pax_global_header00006660000000000000000000000064147461730570014530gustar00rootroot0000000000000052 comment=df6f443ac6ed4c02bacb64a3f1c5c7888dfca5d0 subed-1.2.25/000077500000000000000000000000001474617305700127215ustar00rootroot00000000000000subed-1.2.25/.dir-locals.el000066400000000000000000000000571474617305700153540ustar00rootroot00000000000000((emacs-lisp-mode (indent-tabs-mode . nil))) subed-1.2.25/.gitignore000066400000000000000000000001021474617305700147020ustar00rootroot00000000000000*.elc /subed-autoloads.el /subed-pkg.el /subed/subed-autoloads.el subed-1.2.25/AUTHORS.org000066400000000000000000000011221474617305700145530ustar00rootroot00000000000000#+BEGIN_COMMENT SPDX-FileCopyrightText: 2021-2024 The subed Authors SPDX-License-Identifier: CC0-1.0 #+END_COMMENT This is a list of people who have helped make subed the great Emacs package it is today! If you've contributed to this repository, feel free to leave your name (and optionally email address) below. Please note this shouldn't be taken as a list of copyright holders, nor is it necessarily complete. - Random User (original creator) - Sacha Chua - Sebastian 'seabass' Crane - Marcin Borkowski - Rodrigo Morales subed-1.2.25/LICENSES/000077500000000000000000000000001474617305700141265ustar00rootroot00000000000000subed-1.2.25/LICENSES/CC0-1.0.txt000066400000000000000000000156101474617305700155330ustar00rootroot00000000000000Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. subed-1.2.25/LICENSES/GPL-3.0-or-later.txt000066400000000000000000001035561474617305700173440ustar00rootroot00000000000000GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright © 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. “This License” refers to version 3 of the GNU General Public License. “Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. “The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work. A “covered work” means either the unmodified Program or a work based on the Program. To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. “Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. “Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. Copyright (C) This program 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 of the License, or (at your option) any later version. This program 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 this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . subed-1.2.25/Makefile000066400000000000000000000062531474617305700143670ustar00rootroot00000000000000# SPDX-FileCopyrightText: 2019-2020 The subed Authors # # SPDX-License-Identifier: GPL-3.0-or-later # Putting compile before test results in an invalid-function error about subed-for-each-subtitle all: clean autoloads test compile clean: find . -name "*.elc" -delete rm -f subed/subed-autoloads.el rm -f coverage/.*.json test: autoloads test-coverage package-lint checkdoc test-coverage: mkdir -p coverage UNDERCOVER_FORCE=true emacs -Q -batch -L . -f package-initialize -f buttercup-run-discover test-some: emacs -Q -batch -L . -f package-initialize -f buttercup-run-discover --pattern "${PATTERN}" --no-skip test-only: emacs -Q -batch -f package-initialize -L . -f buttercup-run-discover package-lint: emacs --no-init-file -f package-initialize --batch \ --eval "(require 'package-lint)" \ --file package-lint-batch-and-exit \ ./subed/subed.el checkdoc: emacs --quick --batch --eval "(checkdoc-file \"subed/subed.el\")" emacs --quick --batch --eval "(checkdoc-file \"subed/subed-config.el\")" emacs --quick --batch --eval "(checkdoc-file \"subed/subed-mpv.el\")" emacs --quick --batch --eval "(checkdoc-file \"subed/subed-srt.el\")" emacs --quick --batch --eval "(checkdoc-file \"subed/subed-vtt.el\")" emacs --quick --batch --eval "(checkdoc-file \"subed/subed-ass.el\")" autoloads: emacs --quick --batch --eval "(progn (setq generated-autoload-file (expand-file-name \"subed-autoloads.el\" \"subed\") backup-inhibited t) \ (update-directory-autoloads \"./subed\"))" compile: emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed.el\"))" emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed-config.el\"))" emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed-common.el\"))" emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed-mpv.el\"))" emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed-srt.el\"))" emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed-vtt.el\"))" emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed-ass.el\"))" emacs --quick --batch --eval "(progn (add-to-list 'load-path (expand-file-name \"subed\" default-directory)) \ (byte-compile-file \"subed/subed-debug.el\"))" test-compile: compile clean test-emacs: emacs -Q -L ./subed --eval "(require 'subed-autoloads)" watch: nodemon -w subed -w tests -e el --exec make test subed-1.2.25/NEWS.org000066400000000000000000000352361474617305700142170ustar00rootroot00000000000000#+OPTIONS: toc:nil * subed news ** Version 1.2.25 - 2025-01-28 - Sacha Chua - subed-vtt: Don't require blank lines before timestamps. Support multiple components in a cue. - Document YouTube workflow. ** Version 1.2.24 - 2025-01-24 - Sacha Chua - New command subed-section-comments-as-chapters. - subed-word-data.el: Load word data from Youtube VTT files. - Approximately match word data. - Move CPS overlay more often. - subed-sum-time can take a list of subtitles. - subed-vtt merges comments as well. ** Version 1.2.23 - 2024-12-19 - Sacha Chua - New menu. ** Version 1.2.22 - 2024-12-19 - Sacha Chua - New commands: - subed-sum-time displays the total time for the subtitles. Note that this is the sum of the duration of each subtitle, not the difference between the earliest start time and the latest end time. - subed-retime-subtitles: provide a transient map for setting subtitle times. - subed-align-region: use Aeneas to realign the text within the region using the start and end time in the region. - subed-word-data-fix-subtitle-timing: use WhisperX or SRV2 word data to adjust timing in the region or buffer. - subed-crop-subtitles, subed-crop-media-file - lots of new commands for controlling MPV, starting with the C-c C-f shortcut for subed-mpv-control which uses subed-mpv-control-map as the transient keymap. Use M-x describe-variable subed-mpv-control-map for details. - New hook: subed-region-adjusted-hook - Renamed subed-waveform-ffmpeg-executable to subed-ffmpeg-executable, moved duration from subed-waveform to subed. - Bugfixes and minor improvements: - VTT bugfix: Require subtitle separator before NOTE so that cues can contain the string "Note" - Fix compiler warnings. - subed-word-data-load-from-file: offer directories during completion for easier navigation. - Added more notes to the README.org. ** Version 1.2.20, 1.2.21 - 2024-11-17 - Sacha Chua - subed-word-data.el: Bugfix, whoops. I should write tests for this someday... ** Version 1.2.19 - 2024-11-16 - Sacha Chua - subed-word-data.el: Enhancement: Show transcription confidence based on WhisperX JSON data. - subed-waveform.el: Bugfix: Handle files with invalid duration. ** Version 1.2.18 - 2024-11-06 - Sacha Chua - Bugfix: VTT: Ignore timestamp-like text at the start of subtitle cues. ** Version 1.2.17 - 2024-10-17 - Sacha Chua - subed-word-data: Load JSON data from WhisperX. - Merging should not include the current subtitle if the point is at the beginning of the subtitle. ** Version 1.2.16 - 2024-10-06 - Sacha Chua - subed-merge-with-next separates subtitles with spaces instead of newlines. If you prefer it the other way (keeping line breaks), let me know and we can make it an option. - subed-create-file regenerates IDs. ** Version 1.2.15 - 2024-09-06 - Sacha Chua - Bugfix: Handle extra attributes after VTT timestamps (for example, from YouTube VTTs). Thanks to Jeff Trull for reporting this! ** Version 1.2.14 - 2024-07-05 - Sacha Chua - Bugfix: subed-waveform should now handle the case where the stop time + subed-waveform-preview-msecs-after might extend past the end of the file. Thanks to rodrigomorales1 and rndusr for the bug reports and pull requests! ** Version 1.2.13 - 2024-07-05 - Sacha Chua - Bugfix: Fix the requires in subed-waveform to load subed-common. ** Version 1.2.12 - 2024-07-05 - Sacha Chua - Bugfix: Handle SRT cues that have lines that only contain numbers, as they were getting confused with cue IDs. ** Version 1.2.11 - 2022-12-20 - Sacha Chua - New commands ~subed-shift-subtitles-to-start-at-timestamp~ and ~subed-move-subtitles-to-start-at-timestamp~ should make it easier to adjust timestamps without doing msec math. - Add workflow notes to README.org. ** Version 1.2.10 - 2023-12-20 - Sacha Chua - Add extra line after comments in text output. - ~subed-guess-format~ can now take a filename. ** Version 1.2.9 - 2023-12-19 - Sacha Chua - New ol-subed adds subed: links to Org Mode. To use it, add ~(require 'ol-subed)~ to your configuration. Then you can store links with ~org-store-link~. - ~M-mouse-2~ (M-middle-click) on a subed waveform shifts subtitles. - VTT: msecs are now optional. - New command ~subed-wdiff-subtitle-text-with-file~ compares subtitle text with a script or other subtitles. It uses the external wdiff tool. - subed-subtitle-comment now returns nil when there's no comment. ** Version 1.2.8 - 2023-11-28 - Sacha Chua - subed-waveform: - ~M-mouse-1~ (M-left click) and ~M-mouse-3~ (M-right-click) now set the start or end timestamp and copy it to the previous or next subtitle, respectively. This makes it easier to hold ~M-~ down to change timestamps with the mouse or with ~M-[~, ~M-]~, ~M-{~, and M-}, navigating between subtitles with ~M-n~ and ~M-p~. - You can now show waveforms for all the subtitles using M-x subed-waveform-show-all. Set subed-waveform-show-all to non-nil if you want this to be the default behavior of ~M-x subed-waveform minor-mode~. - Default to keeping MPV open at the end of the file. - New hooks: ~subed-subtitles-sorted-hook~, ~subed-subtitle-merged-hook~ - New function ~subed-media-file~ for things like [[https://github.com/sachac/subed-record][subed-record]] which can refer to multiple sources in one file. ** Version 1.2.7 - 2023-11-10 - Sacha Chua - subed-align: Use current media file even if it's a video, and restore the comments assuming the subtitles are in sequence. - add .m4a to the list of media extensions ** Version 1.2.6 - 2023-11-05 - Sacha Chua New commands and functions: - M-J: subed-mpv-jump-to-current-subtitle-near-end - subed-waveform: - S-mouse-1: subed-waveform-set-start-and-copy-to-previous - S-mouse-3 (right-click): subed-waveform-set-stop-and-copy-to-next - subed-append-subtitle-list New option: - subed-sample-msecs Other changes: - Update loop after adjusting timestamps - Appending or splitting subtitles in VTT files with comments now puts new subtitles before the next comment. ** Version 1.2.5 - 2023-10-15 - Sacha Chua Ignore MPV socket errors when closing files on remote computers. ** Version 1.2.4 - 2023-09-11 - Sacha Chua Guess the format when the generic subed-mode is loaded. Also, require svg when subed-waveform is loaded. ** Version 1.2.3 - 2023-06-18 - Sacha Chua Added subed-waveform, which you can enable with subed-waveform-minor-mode. This makes it easier to review the waveform for the current subtitle so that you can use it to adjust the start or stop time. It requires the ffmpeg executable. Thanks, mbork! ** Version 1.2.2 - 2023-04-09 - Sacha Chua subed should not autoplay media over TRAMP. ** Version 1.2.1 - 2023-03-21 - Sacha Chua Adjusting the starting or stopping timestamp (including via merges) should now also update the looping start and stop times. ** Version 1.2.0 - 2023-03-10 - Sacha Chua I changed ~subed-mode~ so that it doesn't add functions to local hooks, because that seems to mess up configuring hooks from your Emacs init file. Please see README.org for recommended code to add to your ~subed-mode-hook~. I decided to suggest each line separately so that it's easier for people to disable specific behaviors instead of hiding it in ~subed-setup-defaults~. ** Version 1.1.0 - 2023-03-07 - Sacha Chua There are new customizable values for subed-enforce-time-boundaries that affect setting or adjusting the start or stop times if a subtitle will end up overlapping with the previous or next subtitle (based on subed-subtitle-spacing), or if a subtitle will have negative duration. - ='adjust=: the new default. If a subtitle will have invalid times, adjust the other time to resolve the conflict. - ='clip=: set the current time to at most (or at least) the other time, taking spacing into account. - ='error=: report an error when trying to set an invalid time. - =nil=: don't perform any checks, just set the time. By default, you can adjust times with ~M-[~ (~decrease-start-time~), ~M-]~ (~increase-start-time~), ~M-{~ (~decrease-stop-time~), and ~M-}~ (~increase-stop-time~). I've been writing more tests to cover the behavior, but I might've missed stuff, so please let me know if things turn up! ** Version 1.0.29 - 2022-12-29 - Sacha Chua subed-toggle-sync-point-to-player should not confuse subed when it is already looping over a subtitle. Also, subed-loop-seconds-before and subed-loop-seconds-after now default to 0 for less confusion. ** Version 1.0.28 - 2022-12-22 - Sacha Chua subed-parse-file should handle nil filenames now. Also, it should not try to autoplay media. ** Version 1.0.27 - 2022-12-16 - Sacha Chua Bugfix: Actually include VTT comments when inserting subtitles programmatically. ** Version 1.0.26 - 2022-11-30 - Sacha Chua subed-align now keeps VTT comments. It also doesn't remove silences by default now, since aeneas turned out to be a little too aggressive about silence detection. ** Version 1.0.25 - 2022-11-30 - Sacha Chua subed-move-subtitles and subed-scale-subtitles are now interactive commands. The documentation for subed-scale-subtitles now mentions subed-move-subtitles, and I've updated the README to mention them. ** Version 1.0.24 - 2022-11-18 - Sacha Chua subed should compile without checkdoc warnings or obsolete functions now. ** Version 1.0.23 - 2022-11-18 - Sacha Chua You can now use ~subed-copy-region-text~ to copy the text from subtitles in a region. Call it with a prefix argument (~C-u M-x subed-copy-region-text~) to include comments. Calling ~C-u M-x subed-convert~ will retain comments in the TXT output. ** Version 1.0.22 - 2022-11-17 - Sacha Chua VTT comments are now parsed and returned as part of ~subed-subtitle~ and ~subed-subtitle-list~. This makes it easier to build workflows that use the comment information, such as adding NOTE lines for chapters and then creating a new file based on those lines and the subtitles following them. A new function ~subed-create-file~ helps create a file with a list of subtitles. Sanitizing VTT files with ~subed-sanitize~ should retain comments now. ~subed-convert~ should now create a buffer instead of a file if the source is a buffer that isn't a file. ** Version 1.0.21 - 2022-11-16 - Sacha Chua - subed-align-options is a new variable that will be passed to aeneas during execution. - Calling subed-split-subtitle with the C-u prefix will now allow you to specify either an offset or a timestamp. If a timestamp is specified, it will be used as the starting timestamp of the second subtitle. ** Version 1.0.20 - 2022-11-16 - Sacha Chua subed now talks about media files instead of video files, since audio files are fine too. Updating the function names and documentations to refer to media instead of video files can help people think of using subed for audio files as well. Distinguishing between video and audio extensions can be useful for tools like aeneas, which expect audio files. I defined obsolete function and variable aliases for most things, but subed-mpv-media-file (used to be subed-mpv-video-file) uses defvar-local, so it didn't work well with define-obsolete-variable-alias. If you have any code that uses subed-mpv-video-file, please rewrite it to refer to subed-mpv-media-file instead. ** Version 1.0.19 - 2022-11-11 - Sacha Chua New commands subed-merge-dwim, subed-merge-region, subed-merge-region-and-set-text, and subed-set-subtitle-text can help with making chapter files. Added more details to the README.org. This version also includes bugfixes for subed-align and subed-vtt. ** Version 1.0.18 - 2022-11-08 - Sacha Chua New function subed-parse-file. ** Version 1.0.17 - 2022-11-07 - Sacha Chua New command subed-align in the subed-align.el file lets you use aeneas for forced alignment. This can assign timestamps to each line of text. VTT files can now have optional cue identifiers. A cue identifier is a line of text before the timestamps that can identify the cue. It should not contain "-->". ** Version 1.0.16 - 2022-10-26 - Sacha Chua When you load word data, subtitle words that were successfully matched with the word-level timestamps will now be highlighted so that it's easier to split at them. ** Version 1.0.15 - 2022-10-26 - Sacha Chua Added support for SRV2 files in subed-word-data.el. You can use subed-word-data-load-from-file to load word-level timing data from SRV2 files or add subed-word-data-load-maybe to the subed-mode-hook. VTT no longer assumes that the start of the file is part of the first subtitle. VTT and SRT are now less confused by spaces at the end of a subtitle when splitting. ** Version 1.0.14 - 2022-10-25 - Sacha Chua Delete the CPS overlay when disabling it ** Version 1.0.13 - 2022-10-25 - Sacha Chua Fixed TSV fontlocking. Improved subed-convert so that the new buffer is also visiting a file. ** Version 1.0.12 - 2022-10-23 - Sacha Chua Added new command ~subed-convert~. ** Version 1.0.11 - 2022-10-23 - Sacha Chua Added subed-tsv.el for Audacity label exports. Use M-x subed-tsv-mode to load it. ** Version 1.0.10 - 2022-09-20 - Sacha Chua Use - instead of : in mpv socket names to see if that will make it work better on Microsoft Windows. ** Version 1.0.9 - 2022-09-14 - Sacha Chua - Consolidated the different faces to subed-id-face, subed-time-face, and subed-time-separator-face. Added tests for font-locking. Dropped text font-locking for now since we didn't have a good regular expression for it. Obsolete: - subed-srt-id-face - subed-srt-time-face - subed-srt-time-separator-face - subed-srt-text-face - subed-vtt-id-face - subed-vtt-time-face - subed-vtt-time-separator-face - subed-vtt-text-face - subed-ass-id-face - subed-ass-time-face - subed-ass-time-separator-face - subed-ass-text-face Thanks to Igor for the bug report! ** Version 1.0.8 - 2022-09-08 - Sacha Chua - Added support for SRT comment syntax thanks to mbork. http://mbork.pl/2022-09-05_Comments_in_srt_files ** Version 1.0.6 - 2022-07-22 - Sacha Chua - Allow mm:ss.000 (optional hours) when validating VTT files. - Use just the buffer name hash when naming the MPV socket. ** Version 1.0.3 - 2022-02-07 - Sacha Chua subed now tries to avoid sorting already-sorted buffers, which should reduce interference with mark rings and other things. ** Version 1.0.1 - 2022-02-01 - Sacha Chua Added obsolete function aliases in case people are calling format-specific functions in their code. ** Version 1.0.0 - 2022-01-02 - Sacha Chua Format-specific modes are now initialized with =(subed-srt-mode)=, =(subed-vtt-mode)=, or =(subed-ass-mode)= instead of the corresponding =(subed-vtt--init)= functions. I implemented the format-specific functions with =cl-defmethod=, so if you have any code that refers to functions like =subed-vtt--timestamp-to-msecs=, you will need to change your code to use generic functions such as =subed-timestamp-to-msecs=. subed-1.2.25/README.org000066400000000000000000000653171474617305700144030ustar00rootroot00000000000000#+BEGIN_COMMENT SPDX-FileCopyrightText: 2019-2021 The subed Authors SPDX-License-Identifier: GPL-3.0-or-later #+END_COMMENT * subed :PROPERTIES: :CUSTOM_ID: subed :END: subed is an Emacs major mode for editing subtitles while playing the corresponding media file with [[https://mpv.io/][mpv]]. At the moment, the only supported formats are: - SubRip ( ~.srt~) - WebVTT ( ~.vtt~ ) - Advanced SubStation Alpha ( ~.ass~, experimental ) - Tab-separated values ( ~.tsv~, experimental ) - as exported by Audacity for labels. TSVs are not recognized automatically because it's a common data format, but you can use ~subed-tsv-mode~ to turn it on in a buffer. [[file:screenshot.jpg]] #+CAPTION: With word data and waveforms [[file:word-data-and-waveform.png]] ** Features :PROPERTIES: :CUSTOM_ID: subed-features :END: - Jump to next (~M-n~) and previous (~M-p~) subtitle text. - Jump to the beginning (~C-M-a~) and end (~C-M-e~) of the current subtitle's text. - Merge subtitles with ~M-m~ (~subed-merge-dwim~) and split them with ~M-.~ (~subed-split-subtitle~). If the media file is playing in MPV, use the current playback position. If not, use the relative position in the subtitle text, or other functions listed in ~subed-split-subtitle-timestamp-functions~. - Insert subtitles evenly spaced throughout the available space (~M-i~) or right next to the current subtitle (~C-M-i~). A prefix argument controls how many subtitles to insert and whether they are inserted before or after the current subtitle. - Kill subtitles (~M-k~). - Adjust subtitle start (~M-[~ / ~M-]~ or ~C-M-[~ / ~C-M-]~ if Emacs lives in a terminal) and stop (~M-{~ / ~M-}~) time. A prefix argument sets the number of milliseconds for the current session (e.g. ~C-u 1000 M-[ M-[ M-[~ decreases start time by 3 seconds). - Move the current subtitle or all marked subtitles (~subed-move-subtitles~) forward (~C-M-n~) or backward (~C-M-p~) in time without changing subtitle duration. A prefix argument sets the number of milliseconds for the current session (e.g. ~C-u 500 C-M-n C-M-n~ moves the current subtitle 1 second forward). - Shift the current subtitle together with all following subtitles using (~subed-shift-subtitles~), or shift them forward (~C-M-f~) or backward (~C-M-b~). This is basically a convenience shortcut for ~C-SPC M-> C-M-n/p~. This is handy for correcting sync delays where the subtitles are correctly spaced but are offset from the audio. - Scale all subtitles or all marked subtitles forward (~C-M-x~) or backward (~C-M-S-x~) in time without changing subtitle duration. A prefix argument sets the number of milliseconds for the current session (e.g. ~C-u 500 C-M-x~ moves the last [or last marked] subtitle forward 500ms and proportionally scales all [or all marked] subtitles based on this time extension. Similarly, ~C-u 500 C-M-S-x~ moves the last [or last marked] subtitle backward 500ms and proportionally scales all [or all marked] subtitles based on this time contraction). This can be extremely useful to correct synchronization issues in existing subtitle files. First, adjust the starting time if necessary (e.g. ~C-M-f~), then adjust the ending and scale constituent subtitles (e.g. ~C-M-x~). - Show CPS (characters per second) for the current subtitle. - Insert HTML-like tags (~C-c C-t C-t~, with an optional attribute when prefixed by ~C-u~), in particular italics (~C-c C-t C-i~) or boldface (~C-c C-t C-b~). - SRT: Sort and re-number subtitles and remove any extra spaces and newlines (~M-s~). This is done automatically every time the buffer is saved. - Trim subtitle overlaps with ~M-x subed-trim-overlaps~. By default, this adjusts the stop time of overlapping subtitles to ~subed-subtitle-spacing~ milliseconds before the next subtitle starts. Use ~M-x customize-group~ ~subed~ to configure trimming to happen automatically when buffers are loaded or saved, which time is adjusted, and how much time to leave between subtitles. - Convert between formats with ~M-x subed-convert~. - Show the waveform (~M-x subed-waveform-minor-mode~, off by default) extracted from the media file using ~ffmpeg~ with the start/stop positions of the current subtitle and the current position in MPV marked along with the subtitle. Change the "volume" of the waveform (i.e., the /visible/ amplitude) with ~C-c C--~ and ~C-c C-=~. Redisplay the waveform with ~C-c |~. Left/right-click on the waveform to set the start/stop timestamps. If you would like to display the waveform automatically when you open a file, you can add ~(add-hook 'subed-mode-hook 'subed-waveform-minor-mode)~ to your configuration. - Load word timing data (ex: SRV2) using ~M-x subed-word-data-load-from-file~. This will be used for splitting words at timestamps when available. - Use ~M-x subed-align~ and [[https://www.readbeyond.it/aeneas/][aeneas]] to align your text or subtitles with an audio file in order to get timestamps. *** mpv integration (optional) :PROPERTIES: :CUSTOM_ID: subed-features-mpv-integration-optional :END: Using network sockets to control MPV works on Linux and on Mac OS X, but not on Microsoft Windows due to the lack of Unix-style sockets. On Microsoft Windows, you will not be able to synchronize with MPV. - Automatically open the associated media file in MPV based on the filename, open a media file manually with ~C-c C-v~ (~subed-mpv-play-from-file~), or play media directly from a URL with ~C-c C-u~ (~subed-mpv-play-from-url~) . You can customize the automatic detection of files by changing ~subed-video-extensions~ and ~subed-audio-extensions~. - Pause and resume playback without leaving Emacs (~M-SPC~). - Jump to the current subtitle in the MPV player with ~M-j~ (~subed-mpv-jump-to-current-subtitle~). Toggle looping over the current subtitle with ~C-c C-l~ (~subed-toggle-loop-over-current-subtitle~). Control how many seconds to loop before or after the current subtitles by customizing ~subed-loop-seconds-before~ and ~subed-loop-seconds-after~. - Use ~C-c .~ (~subed-toggle-sync-point-to-player~) to toggle whether the point should move to the currently playing subtitle. - Use ~C-c ,~ (~subed-toggle-sync-player-to-point~) to toggle whether mpv should seek to the position of the current subtitle when the point moves between subtitles. - Subtitles are automatically reloaded in mpv when the buffer is saved. - Copy the current playback position as start (~C-c [~) or stop (~C-c ]~) time of the current subtitle. - Playback is paused or slowed down when a subtitle's text is edited (~C-c C-p~, ~subed-toggle-pause-while-typing~). - Loop over the current subtitle in mpv (~C-c C-l~). - When a subtitle's start or stop time changes, mpv seeks to the subtitle's start time (~C-c C-r~, ~subed-toggle-replay-adjusted-subtitle~). - Move one frame forward or backward (~C-c C-f .~ and ~C-c C-f ,~; pressing ~,~ or ~.~ afterwards moves by frames until any other key is pressed). ** Installation :PROPERTIES: :CUSTOM_ID: subed-installation :END: *** Installing the subed package from NonGNU Elpa :PROPERTIES: :CUSTOM_ID: subed-installation-installing-the-subed-package-from-nongnu-elpa :END: ~subed~ is now on [[https://elpa.nongnu.org/nongnu/subed.html][NonGNU ELPA]]. On Emacs 28 and later, you can install it with ~M-x package-install~ ~subed~. To install it on Emacs 27 or earlier, add the following to your Emacs configuration file: #+begin_src emacs-lisp :eval no (with-eval-after-load 'package (add-to-list 'package-archives '("nongnu" . "https://elpa.nongnu.org/nongnu/"))) #+end_src Use ~M-x eval-buffer~ to run the code, use ~M-x package-refresh-contents~ to load the package archives, and then use ~M-x package-install~ ~subed~. Sample configuration: #+begin_src emacs-lisp (with-eval-after-load 'subed-mode ;; Remember cursor position between sessions (add-hook 'subed-mode-hook 'save-place-local-mode) ;; Break lines automatically while typing (add-hook 'subed-mode-hook 'turn-on-auto-fill) ;; Break lines at 40 characters (add-hook 'subed-mode-hook (lambda () (setq-local fill-column 40))) ;; Some reasonable defaults (add-hook 'subed-mode-hook 'subed-enable-pause-while-typing) ;; As the player moves, update the point to show the current subtitle (add-hook 'subed-mode-hook 'subed-enable-sync-point-to-player) ;; As your point moves in Emacs, update the player to start at the current subtitle (add-hook 'subed-mode-hook 'subed-enable-sync-player-to-point) ;; Replay subtitles as you adjust their start or stop time with M-[, M-], M-{, or M-} (add-hook 'subed-mode-hook 'subed-enable-replay-adjusted-subtitle) ;; Loop over subtitles (add-hook 'subed-mode-hook 'subed-enable-loop-over-current-subtitle) ;; Show characters per second (add-hook 'subed-mode-hook 'subed-enable-show-cps)) #+end_src *** Manual installation :PROPERTIES: :CUSTOM_ID: subed-installation-manual-installation :END: If that doesn't work, you can install it manually. To install from the main branch: #+begin_src sh :eval no git clone https://github.com/sachac/subed.git #+end_src This will create a =subed= directory with the code. If you have the =make= utility, you can regenerate the autoload definitions with #+begin_src sh :eval no make autoloads #+end_src If you don't have =make= installed, you can generate the autoloads with: #+begin_src sh :eval no emacs --quick --batch --eval "(progn (setq generated-autoload-file (expand-file-name \"subed-autoloads.el\" \"subed\") backup-inhibited t) \ (update-directory-autoloads \"./subed\"))" #+end_src Then you can add the following to your Emacs configuration (typically =~/.config/emacs/init.el=, =~/.emacs.d/init.el=, or =~/.emacs=; you can create this file if it doesn't exist yet). Here's a configuration example: #+begin_src emacs-lisp ;; Note the reference to the subed subdirectory, instead of the one at the root of the checkout (add-to-list 'load-path "/path/to/subed/subed") (require 'subed-autoloads) (with-eval-after-load 'subed-mode ;; Remember cursor position between sessions (add-hook 'subed-mode-hook 'save-place-local-mode) ;; Break lines automatically while typing (add-hook 'subed-mode-hook 'turn-on-auto-fill) ;; Break lines at 40 characters (add-hook 'subed-mode-hook (lambda () (setq-local fill-column 40))) ;; Some reasonable defaults (add-hook 'subed-mode-hook 'subed-enable-pause-while-typing) ;; As the player moves, update the point to show the current subtitle (add-hook 'subed-mode-hook 'subed-enable-sync-point-to-player) ;; As your point moves in Emacs, update the player to start at the current subtitle (add-hook 'subed-mode-hook 'subed-enable-sync-player-to-point) ;; Replay subtitles as you adjust their start or stop time with M-[, M-], M-{, or M-} (add-hook 'subed-mode-hook 'subed-enable-replay-adjusted-subtitle) ;; Loop over subtitles (add-hook 'subed-mode-hook 'subed-enable-loop-over-current-subtitle) ;; Show characters per second (add-hook 'subed-mode-hook 'subed-enable-show-cps)) #+end_src You can reload your configuration with =M-x eval-buffer= or restart Emacs. If you want to try a branch (ex: =derived-mode=), you can use the following command inside the =subed= directory: #+begin_src sh :eval no git checkout branchname #+end_src *** use-package configuration :PROPERTIES: :CUSTOM_ID: subed-installation-use-package-configuration :END: Here's an example setup if you use [[https://github.com/jwiegley/use-package][use-package]]: #+BEGIN_SRC emacs-lisp (use-package subed :ensure t :config ;; Remember cursor position between sessions (add-hook 'subed-mode-hook 'save-place-local-mode) ;; Break lines automatically while typing (add-hook 'subed-mode-hook 'turn-on-auto-fill) ;; Break lines at 40 characters (add-hook 'subed-mode-hook (lambda () (setq-local fill-column 40))) ;; Some reasonable defaults (add-hook 'subed-mode-hook 'subed-enable-pause-while-typing) ;; As the player moves, update the point to show the current subtitle (add-hook 'subed-mode-hook 'subed-enable-sync-point-to-player) ;; As your point moves in Emacs, update the player to start at the current subtitle (add-hook 'subed-mode-hook 'subed-enable-sync-player-to-point) ;; Replay subtitles as you adjust their start or stop time with M-[, M-], M-{, or M-} (add-hook 'subed-mode-hook 'subed-enable-replay-adjusted-subtitle) ;; Loop over subtitles (add-hook 'subed-mode-hook 'subed-enable-loop-over-current-subtitle) ;; Show characters per second (add-hook 'subed-mode-hook 'subed-enable-show-cps) ) #+END_SRC *** straight configuration :PROPERTIES: :CUSTOM_ID: subed-installation-straight-configuration :END: If you use [[https://github.com/radian-software/straight.el][straight.el]], you can install subed with the following recipe: #+begin_src emacs-lisp (straight-use-package '(subed :type git :host github :repo "sachac/subed" :files ("subed/*.el"))) #+end_src ** Getting started :PROPERTIES: :CUSTOM_ID: subed-getting-started :END: ~C-h f subed-mode~ should get you started. This is the parent mode for ~subed-srt-mode~, ~subed-vtt-mode~, and ~subed-ass-mode~. When manually loading a mode, use those specific format modes instead of ~subed-mode~. ** Some workflow ideas :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas :END: *** Editing subtitles :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-editing-subtitles :END: You can use ~subed-mpv-jump-to-current-subtitle~ (~M-j~) to play the current subtitle and use ~subed-mpv-toggle-pause~ (~M-SPC~) to stop at the right time. Use ~subed-toggle-loop-over-current-subtitle~ (~C-c C-l~) if you want to keep looping automatically. If you have wdiff installed, you can use ~subed-wdiff-subtitle-text-with-file~ to compare the subtitle text with a script or another subtitle file. *** Writing subtitles from scratch :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-writing-subtitles-from-scratch :END: One way is to start with one big subtitle that covers the whole media file. You can create this manually by using the media file duration or a very large ending timestamp (ex: 24:00:000), or you can use ~M-x subed-insert-subtitle-for-whole-file~. Use ~C-c C-p~ (~subed-toggle-pause-while-typing~) to enable pausing while typing. Start playback with ~M-SPC~ (~subed-mpv-toggle-pause~), type as you listen, and split using using ~subed-split-subtitle~ (~M-.~). Another way is to type as much of the text as you can without worrying about timestamps, putting each caption on a separate line. Then you can use ~subed-align~ to convert it into timestamped captions. *** Starting from auto-generated YouTube captions :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-starting-from-auto-generated-youtube-captions :END: To download autogenerated captions for one of the videos you've uploaded to YouTube: 1. Go to https://studio.youtube.com 2. Click on Content in the left-side menu, find your video, and edit it. Clicok on Subtitles in the left menu. If automatic subtitles are available, you will see them in the Subtitles column in the middle. - If you don't see automatic subtitles, set your video's language in the Details tab, wait a while, and check again. 3. Hover over the "Published" label and use the three-dot menu to download the SRT. (The VTT file has a lot of extra mark-up.) 4. Open the SRT file. subed can synchronize playback as you edit the subtitle file. If the media has the same base filename as the subtitle file, it will be opened automatically. If not, use ~subed-mpv-play-from-file~ (~C-c C-v~) or ~subed-mpv-play-from-url~ (~C-c C-u~). You may want to use ~M-x subed-trim-overlaps~ to remove the overlaps between subtitles. If you download the VTT from YouTube, you can load the word timing data from it with ~M-x subed-word-data-load-from-file~. Then those times will be used when splitting subtitles with ~subed-split-subtitle~.. If you want to work in the VTT format so you can use comments, convert the SRT with ~M-x subed-convert~. To upload edited subtitles: 1. Edit your video's details. Click on Subtitles on the right side. 2. Click on the three-dot menu. 3. Click on "Upload file", choose "With timing", and click on "Continue". 4. Select your file. *** Reflowing subtitles into shorter or longer lines :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-reflowing-subtitles-into-shorter-or-longer-lines :END: You may want to use ~set-fill-column~ and ~display-fill-column-indicator-mode~ to show the target number of characters. Use ~subed-split-subtitle~ (~M-.~), ~subed-merge-dwim~ (~M-b~), and ~subed-merge-with-previous~ (~M-M~) to split lines. Splitting will use the current MPV position if available. If not, it will guess where to split based on the the number of characters in the subtitle. You can use ~subed-mpv-jump-to-current-subtitle~ (~M-j~) to play the current subtitle manually and use ~subed-mpv-toggle-pause~ (~M-SPC~) to stop at the right time. Use ~subed-toggle-loop-over-current-subtitle~ (~C-c C-l~) if you want to keep looping. ~subed-waveform-show-current~ can help you fine-tune the split. *** Adjusting timestamps :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-adjusting-timestamps :END: You can use ~subed-mpv-jump-to-current-subtitle~ (~M-j~) to play the current subtitle manually. Use ~subed-mpv-jump-to-current-subtitle-near-end~ (~M-J~) to jump to near the end of the subtitle in order to test it. Use ~subed-toggle-loop-over-current-subtitle~ (~C-c C-l~) if you want to keep looping automatically. Use ~subed-mpv-toggle-pause~ (~M-SPC~) to stop at the right time. You can also manually adjust - subtitle start: ~M-[~ / ~M-]~ - subtitle stop: (~M-{~ / ~M-}~) A prefix argument sets the number of milliseconds (e.g. ~C-u 1000 M-[ M-[ M-[~ decreases start time by 3 seconds). Rodrigo Morales also has some functions for [[https://rodrigo.morales.pe/2024/11/17/my-subed-configuration-for-adding-subtitles-to-emacsconf-2024/][playing part of the subtitles and changing them by a little bit]]. You can shift subtitles to start at a specific timestamp with ~subed-shift-subtitles-to-start-at-timestamp~ . To use a millisecond offset instead, use ~subed-shift-subtitles~. **** Waveforms :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-adjusting-timestamps-waveforms :END: Use ~subed-waveform-show-current~ or ~subed-waveform-show-all~ together with FFmpeg to display waveforms for subtitles. Use ~subed-waveform-set-start~ (~mouse-1~, which is left click) or ~subed-waveform-set-stop~ (~mouse-3~, which is right-click) to adjust only the current subtitle's timestamps, or use ~subed-waveform-set-start-and-copy-to-previous~ (~S-mouse-1~ or ~M-mouse-1~) or ~subed-waveform-set-stop-and-copy-to-next~ (~S-mouse-3~ or ~M-mouse-3~) to adjust adjacent subtitles as well. You can use ~M-mouse-2~ (Meta-middle-click, ~subed-waveform-shift-subtitles~) to shift the current subtitle and succeeding subtitles so that they start at the position you clicked on. **** A transient map for retiming subtitles :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-adjusting-timestamps-a-transient-map-for-retiming-subtitles :END: You can use ~subed-retime-subtitles~ to set new times for subtitles by pressing ~SPC~ when the current subtitle should stop. It will start with the current subtitle and then continue until you press a key that is not in the temporary keymap. Keys: | ~SPC~ | set stop and move forward | | ~~ or ~j~ | replay current subtitle | | ~~ or ~n~ or ~f~ | next | | ~b~ | back | | ~p~ | pause | **** Aeneas forced alignment tool :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-adjusting-timestamps-aeneas-forced-alignment-tool :END: The [[https://www.readbeyond.it/aeneas/][aeneas forced alignment tool]] (Python) can take a media file and a text file (one cue per line) or subtitle file, and create a subtitle file with the timings determined by matching synthesized speech with the waveforms. To use Aeneas to re-time subtitles or text, install Aeneas and its prerequisites, then call ~M-x subed-align~ to align the entire buffer. You can also select a region and then use ~M-x subed-align-region~ to recalculate the timestamps for just that region. One way to use this is: 1. Determine the last correctly-timed subtitle. We'll call this subtitle A. Go to the beginning of subtitle A and use ~C-SPC~ (~set-mark-command~) to set the mark. 2. Pick a subtitle in the incorrectly-timed section. We'll call this subtitle B. Use ~subed-mpv-jump-to-current-subtitle~ to seek to that position. Play it and listen for the words. If you can't figure out which subtitle matches the position currently being played, choose a different subtitle starting point B until you find one that's recognizable. 3. Reset the playback position by using ~subed-mpv-jump-to-current-subtitle~ on subtitle B. 4. Now look for the subtitle that matches the words you heard at the playback position for subtitle B. We'll call that one subtitle D. 5. Go to the subtitle before subtitle D. We'll call that subtitle C. Use ~C-c ]~ (~subed-copy-player-pos-to-stop-time~) to set the stop time of subtitle C (the one immediately before D) to the playback position, which is the same time as the incorrect starting time for subtitle B. 6. Go to the end of subtitle C. 7. Use ~M-x subed-align-region~ to recalculate the timestamps within that section. Aeneas tends to have trouble with subtitle times where there are long silences, background noises, inaccurate transcripts (especially where the speaker has skipped or added many words), overlapping speakers, and non-English languages. It may take several tries to figure out a span of subtitles where Aeneas is more accurate. Doublechecking with the word timing data can help quickly verify if the subtitle times are reasonable. **** Word timing data :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-adjusting-timestamps-word-timing-data :END: To use word timing data from something like WhisperX, load subed-word-data.el and then use ~subed-word-data-load-from-file~. The word times will then be used when you split subtitles with ~subed-split-subtitle~. *** Exporting text for review :PROPERTIES: :CUSTOM_ID: subed-some-workflow-ideas-exporting-text-for-review :END: You can use ~subed-copy-region-text~ to copy the text of the subtitles for pasting into another buffer. Call it with the universal prefix ~C-u~ to copy comments as well. You can also use ~subed-convert~ to convert subtitles to a text file. ** Troubleshooting :PROPERTIES: :CUSTOM_ID: subed-troubleshooting :END: *** subed-mpv: Service name too long :PROPERTIES: :CUSTOM_ID: subed-troubleshooting-subed-mpv-service-name-too-long :END: If =subed-mpv-client= reports =(error "Service name too long")=, this is probably because the path to the socket used to communicate with MPV is too long for your operating system. You can use =M-x customize= to set =subed-mpv-socket-dir= to a shorter path. ** Important change in v1.0.0 :PROPERTIES: :CUSTOM_ID: subed-important-change-in-v1-0-0 :END: ~subed~ now uses ~subed-srt-mode~, ~subed-vtt-mode~, and ~subed-ass-mode~ instead of directly using ~subed-mode~. These modes should be automatically associated with the ~.vtt~, ~.srt~, and ~.ass~ extensions. If the generic ~subed-mode~ is loaded instead of the format-specific mode, you may get an error such as: #+begin_example Error in post-command-hook (subed--post-command-handler): (cl-no-applicable-method subed--subtitle-id) #+end_example If you set ~auto-mode-alist~ manually in your config, please make sure you associate extensions the appropriate format-specific mode instead of ~subed-mode~. The specific backend functions (ex: ~subed-srt--jump-to-subtitle-id~) are also deprecated in favor of using generic functions such as ~subed-jump-to-subtitle-id~. ** Testing :PROPERTIES: :CUSTOM_ID: subed-testing :END: You'll need to install the =buttercup= and =package-lint= Emacs packages. You'll also need =GNU Make= so that you can work with Makefiles. To run the tests, use the command =make test=. ** Contributions :PROPERTIES: :CUSTOM_ID: subed-contributions :END: Contributions would be really appreciated! subed conforms to the [[https://reuse.software/spec/][REUSE Specification]]; this means that every file has copyright and license information. If you modify a file, please update the year shown after ~SPDX-FileCopyrightText~. Thank you! There's a list of authors in the file ~AUTHORS.org~. If you have at any point contributed to subed, you are most welcome to add your name (and email address if you like) to the list. ** License :PROPERTIES: :CUSTOM_ID: subed-license :END: subed 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 of the License, or (at your option) any later version. This program 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 [[https://www.gnu.org/licenses/gpl-3.0.txt][GNU General Public License]] for more details. * Build tips :PROPERTIES: :CUSTOM_ID: build-tips :END: Here's a post-commit hook that will make it easier to remember to tag releases: #+begin_src python :eval no #!/usr/bin/python # place in .git/hooks/post-commit # Based on https://gist.github.com/ajmirsky/1245103 import subprocess import re print("checking for version change...",) output = subprocess.check_output(['git', 'diff', 'HEAD^', 'HEAD', '-U0']).decode("utf-8") version_info = None for d in output.split("\n"): rg = re.compile(r'\+(?:;;\s+)?Version:\s+(?P[0-9]+)\.(?P[0-9]+)\.(?P[0-9]+)') m = rg.search(d) if m: version_info = m.groupdict() break if version_info: tag = "v%s.%s.%s" % (version_info['major'], version_info['minor'], version_info['rev']) existing = subprocess.check_output(['git', 'tag']).decode("utf-8").split("\n") if tag in existing: print("%s is already tagged, not updating" % tag) else: result = subprocess.run(['git', 'tag', '-f', tag]) if result.returncode: raise Exception('tagging not successful: %s %s' % (result.stdout, result.returncode)) print("tagged revision: %s" % tag) else: print("none found.") #+end_src * Other resources :PROPERTIES: :CUSTOM_ID: other-resources :END: - [[https://rodrigo.morales.pe/2024/11/17/my-subed-configuration-for-adding-subtitles-to-emacsconf-2024/][My subed customizations for editing captions of Emacsconf 2024 – Rodrigo Morales]] - [[https://sachachua.com/blog/category/subed][Sacha Chua's subed-related blog posts]] - [[https://mbork.pl/2023-09-18_Making_Anki_flashcards_from_subtitles][Marcin Borkowski: 2023-09-18 Making Anki flashcards from subtitles]] - [[https://github.com/sachac/subed-record][sachac/subed-record: Record audio in segments and compile it into a file]] - [[https://emacsconf.org/captioning/][EmacsConf - Captioning tips]] #+STARTUP: showeverything #+OPTIONS: num:nil #+OPTIONS: ^:{} subed-1.2.25/screenshot.jpg000066400000000000000000003201531474617305700156040ustar00rootroot00000000000000JFIFC       C d e  !1"AQ2a#RVqr358BSTu$4b%6Ccgs&Uet '7EdI!1AQ"aq2BR#5b34r$C%DT6st ?>ؔ[rǪHqQ@;BHI{U:<_TE}{:cMͿe_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5oȤ~Ζ?e_>1Gc^?5Ɲc$ymC}#E'G#Ͻb:.]&y=qq,断4MTf}ԣ*=Uhtn8ߦ",dΠ~sUK1Ѷldjdl~Sh?秽"PW1lSuLbߖ==AGU+toSeGjʼn?>7)a=W|X7)aE3^g%_7ħK_=je16| |Km==E-o_;&9>ʱ/w=NOFz7d{z֛ghg[:^ȸҽ[Sk'ޕ},y䆔1yi'_o{Rm>r|a,>r|aIp\]ůT~A+_|Ay`߷j"Κ^mZ9)G@xw=E?ԋc ?y6OyRHϨ<u߷jo<+S|HY5=EG9i s #,o#yWw.U=}E􃨓cn>Ɠi[$lS,OyQbi`hk,+?zC_槼$կṃ_(ENNOyP̗ƺoE*qD0Ó$%!Je8値(HjH<έOff+u=M&|dl}RV+~Xl*%M\1x:JeZQt$z\njR_oorq8:ݵ_җ.s^wMv>rIp&2b[k w pHҔ=%tմuE׵7qRvHz_ܝ]z_'%C-V;cQ%PM6M`J-'N>}Ϸ#cزxttէ]\>苴X-6:\ǒ $;Qa k>ou(NOM6v&9e `ITNdEK]/) r%@zܞڛݹ6Mrs'mwۑh]g̽Cn !nR$\3enԅ TI '^9?QJ2rn=ԭ5NJfOۻOo_1+WJ3;ѐ-ѭ3 l*j).jJ)?b4iޏN*O|| ڮ8)UwUoP ;׬fpp*xQͲ33Fߕ‹ByKNҝz +q~Kq|0{eJ-E'Z9}~~t9z$ښ}[3%&8 DwiJSNGHPiPJִZʒWyEmSOMQq˄T*~h$1ueCVRD;9(hOxm%J.N Z\c]8QڜSK k~tһˇ񻖞b]baKeAI{撒6k49ux^+J(.e Goo;)|J8]m2Vۯ @ZW lk{#~X`^FZ}9!S^#˿ ޻ZD:11zz&ԖظF޲*R*+qVSɰt'g[ulS>3>>/Z88Iru+%ec-e =ta*Ly+VI: '˽Lދe'IE2)$✫jUoK*m/V?O>KV_s,ݱL3CͪZC&9i#{:֓&icɶ-ljTo|C-]%kD9ݲLvϜ J{d%#@$ hQb䭿s%ݴմUâ=E;.[X8R~üRV yTړ[{dI5mi[]٧$?jek%ů"]L&TgInCQ) mJ$'GaIP: 4ZعbM4M&iMpȔRo-XfM|*g22Vj,$}RKIRi*ב4l4?9({s&|ڏ-4nk ]ɦPg[h]ζ? VT_m.*/ҰT9wN2] c:M6j&I[|q5)N ry}.4,Ț܍rMea.RVBI!A@ZϵFM9IœZjJ.TIZͦNw!796`ąƏ-( J!整;ĝyWfSWgiN[:7'@2[}z8jYWd[j;g{ڀ=btռ?dZ@,q#m\P |!^tnkbȷ5 mmJUV[aF#tS ֩(@feyMڔG 7|p|%ㆭww)EJ*Q$OJFrr}٩J5=Laȯ{}eTv2q(E@P.(l8fn4so2XO"~54Ӗ͵\;YJ.&xI/$ήyzD^9%-b^ڿ; }TaUhSiږֲ4~i礞8l\sI]E[W^ 2hmIӵ7_ZuzWh{/S1l31`x؅32<_%TԖړGlP(D8wv^j9i5c/)\ZrNrRU]X*ԓoioͫ^U`luL;-#K t%-3l>7shqXpKўSm!ji^$)!IMoԺ<6/EW 8$ۭmwt\idt_'/tbņc-~uBnȎ@ԎjB\Ww'i$v=ƚSO=>H8QS*_l'w|/uּ͑_[2ύ=;×lt_z=2c۷|w~*:1d{8.OW^WޘiɃk+qҙiM*]hSTN%:ZOtZ?Sy.%<}6Ӄ7&ibMoL2'_v巳4҉97vQ$/[%,>'ϒ/u}WQunUuupa[2C'2풕}Fji~1n3b[ulKvnӅ-+(?)M\m[WOkW|y^4rVDZ>^.\FLf~Z JQ II{]3@~'&ݽػ%n{2oI+|ޗu_L( Y2߆q;4W>~Uz:S۵Mvܕ>$h9 H:p$KIWCCe+>c7+  b##G|h~ui`opwߞ4X*[PG+[X"lq)i}Qb,JK^h0\u%65j%qnQI)8o|9)kowT\wrOl7^ڱiwAqJ#.9TVPNxǑlnRܒ]81%o侭E;kw+h޷tHҷr QBTG47-UUťI)-V5۬:kDDq=ɍ$4eoХ#K`ƾS{?5Ӟ,njQn ^-'Olb|*vYAiܢO(7U >>'WaD_P'f#U㏛quyK{x^׷{w]m}^{pR9V:P\|W~w$VӞ/dATq >9B#EiJdyv޻ץҴ3s{۔W3k}(1T<,2ꝷq%?Zt1uB-SIn\Gp:vB,(8zFg 4=/R4 MkMۋI>i58G[.*!{$"V ~7ĝ8v%Yo_-bPjRnqI|2ܫ즭:v~/3muWou&߭q}_ֻ_"%<@nw[KiI? `d,:/go @ZoU.pjSHZ_ݵ Id܏61̔}jZhlmZzb3cjȺ*Z\hZU%_MNlr]Ж*`H.%!.$( )Q䘱},)"XY5"!->6ۍ()*AA'&BѮ *RTӍUGO#P=cDn,'Ttԗ(.u|WܚF ]y֨T2k'V~@¤߸}BʯvvCmC}U$-zU h%;wkX=i`SB@6Gԁ%#} -,H 4 WQX-1[Z%*YX+*CK@RPyT4JMJI{:z"G夨* x{AzӶ ]u^jrpeޓR A(k\?vW(B}Y@L:1 .I.@=v$L8ZaK&^}.)} Kचy&*x!I}idBD⶙2%%-pHa)B|DhGMҰ %5^%×*1^Ɏ1pi"~&wiE<|=b:BܗP"Aj.w.7m?7H{m^K&7_1s{ga۹|vZjKXȱ w)K(kִ&.[TK~U|=œ-ɐǐI[Ɯm,) k}ݗ]XʛqiEŴrtҦ85 )'f^-9e->svSSouhV+ iN;C[/]<]V h>L9mwK|l'qbFyzJj8K?!Odq:Ɣzq)n?kK.Xl̮WAvD$k$i^mhrO:nUdž{i)C/{|~97r! sV‥{<kqOS0MVeͤKշI}I鸎:7Y2'oJxmR8]Qq6T#W^ɋWjq11T')qTԚ梹I.N[x?""j3}6Fl̐ñ}m1J N IꗴPxgφԶ9wmw~yuUͭwݷO暮g_s1>" &$M?xԄ2 S^+Bx_j4cԧnkc;rSwqmCmR%k?C>Ȭ7,ZrƯ,ڥ K`%R55XxjpҒ5hf,3xqxt~`z=$>1CHRGп$ {F笔z=#M' Kvp]rkw;M%r::Yrưfer *$d$[Y#Hk@s|p@Mq굳ヸ6Ҿc}?m?70u_'柎|oƭB⸠)G@^2}uzz, o蕙si%mm_{_n#kҧ^TG4WT\C` -{-(- r:HײbZrc UsU5&diK8wE?r}넾+O戦z-=HM($0_[LE|D}z ?w-ki]ߞjUbskcmSx OvBIƉ2SO^4u! BT׶JО hD$ڍ.,X()[ZܤĸT\[P[TzIZ㽾xϲ+ ܱ!n’; u {MV-v.4ZK 9wEm..9-K~䖘A[,%\\R8`RqI{wo|mGqp~%~;z+>Zk=qo%Nmk̖K^ SAB4$,oDwpuYO9I ־ѓN {:[\O~=-[܏K UuZr(w%%CS(i6ڴtv4k&_!(9^߉'kT\I_mVx}km̤c,2m!;44y{4njVImc̬-ha}˫BX kJtmZ9zHNW6%ʴ\GOYal[P*^fg0PPPPPPPPPPPPP &~?qo̟_ɏ5GfH-X?CzU KnbȘtoUHNjQ?IewIL.wiCܴW$U myS3Ga!@f [p 1T[n\^]q=@w&I%qGHޑ6ճmoyDTKraDlQRS̓9OI]n2f+N2[NnVF^<NjYӡڔUJR_j{:WfFoP2K:6șCb/kRVhSyR.#%A$fP3βLK>QyRҖpcVt,))W *ܓI;-ߠ<=<Ԋb2l80#m0{sD24"sgSg̴͂Z>@U4Idx|RI8ޢW^?8m^^^Mdէ 38,9MY܈]%~! C[:w˝4κ+^,[*jcˍK D!O)!MdvJz2R"/޶j u:q%B>[iJԟ.INÈzƸnW~駣[\".z)}=V RO\p/@:|],VKknȡ]n'{Y&"uq2AZؔ&o4C_+8o6{$8rgvbm̶hco)+J:l1"b=q joؒ0eR.*-d`mB 2} :|g͢l3 K yԸIHXf +!ueImЕ JY1u[keźA.{'Z :zۣn,.K)Ӊ#Gzs@=UA$VP V|?4y:H(Gom9>_N@2I¬NJ$%+a z 7jW?j10  7 F1Vh DcGu ]F4;T&ʝLv luc]snz4wNUL~ڑ\Vcz,2 S kMPjrm8ԯP 3,yc>$j`t:Dķs<5m\԰6tJn,-і33UA#^v.n)ڽd e}A-vE EH,VOF^.5+[n]2Y__ZnHekI;[@W{s'du~En7fUE-Ŷ-m,ŔCN{(/_j,p[_gM$ n]NM=gLyË~D$jCXZmI6C}r5hq1gP]%T Wo''__ڻ}n6.M/*JJ2֥-ZJ )GeDMu. ҷwm$o]HFI}ߑdՉ7cwÜUi-뎒;k{u_8~vNWm;K991XO]+\]TISmԴ+q’(dw s~ʌ5R(i+m6W[VNOdv5k_zzb[]&r S*l! x>|6u:I|3~%$<Rw[m|˨-[GeЛ c+. :PC?dyjH>CzKzs{;j[*FE]!N,UϞ{}(2F3%ԴSj=g_MukXzZ\* OH]ԗ-5%uO(mkcHnF=V6$!$+AAM$l8}"hdYai/d- $ro󓯸(rpfj-iSNM%*Q,,l *AWp\='c([I4UJڣ.ܮWΩ?M5k.mXn2~tI[[^ q9gmڭ=iKNjYf*{ve;֫1|辨Lo 1E=ͨ\$\ʒ)M0!$ GDl+[$ۊ{j;NT'&WǓEjiQvc&ˆVeˊ3rĤNiuP6⴩;HuSslӜ䠷7^NPj7k$zMvUͳòmVdؙ/,YZAR+tLPsNr㓫rI%J.IvOlm${K.:wf{lX6'e(;%L>A^s>x1,9% E{Ė*Z.{#(T~=s~F)!lIIM{8 7u))niQZz{cQq+idbkWiAKiWaj%y$Jim(m\\VWz/+Tj}w]FuO#,#m azm}>"vՠ65oAŇtY1y{oM|wiI}w_I/}o2,f֕4R]RFʐw UOgXJ./#Ė}M.-Tv>T~ە'Iur-ޱk ݜ_΃)2PRq ky'q`],qÇ,-U-;IsI^yK3S^x̢z{/|@@@@@@@@@@@@I=f[:o'Ug%dyiJ!a+IINyC/7ϖbP,\^Պ.+gp$Ӓt:B#ιO.D&Z7(ћh< aB@WaHx0Kzّg.\d9vطNYPeRPS QtVԹYB\2ONba$~.^'VG ]R#T~ŭ^It3D^F"ܫVřO49K[Jn\<=j ўt69ԛ_/qtX܇&듄G^YWG-+I<.g=,ñ,.dW%MB^xTL~ umG㨕Ptw)ǺyA`>S{$9^Bۤ4~2]2\}e#jzPo)f(NnE'g7rH1򅵒+ui"85  n=}6u˾ae}˜3ݑC .8; nѮzoP1 S`NKtyZe2Q}G 'K*baS:ՑZAʹ˺!n:AgMd_l}~>1sLCIGP*hIW°.1VoRŊȒyֈ,s; ՓBoI[pK'£pnPQʞA* eeWh XkEߣK\m +OIKiJRni >GQ22Cj iT(gq]2vb{r:="?6DU}BGE &!KJQV(΂)Z (](Ql$R@jD2bJROmMԦr{:nCo-?PMYjQbWٿc PzpŹ|qC8Dć[o ,-XcP~ g+-R Pi"L|*ڋ \))_ߨ r#P\e'f5pb,Ұ6|)0=Zbژv2 RvQaG:[ MJۉh P݊#cۤ:[JUD3ʧmC'$*;$wGaR,z'@xЩe*H<N@zۥo m[JOV@K?*r 'JP2B2l"Kn %*BwZť/;=L!Zth+dEn#x?`t[*Q>GHh̏UPHQ ;UҵU BbN*եNx%#i9 NII15}; : M]tk*{.-C@>4*ձV_6Gj<-Lih# '^bK  O0+qi #hw&ZPЬt$Wr~ҋjm'~&@D̖ %'jr U7k[ q>dynjl=L,8t(c<-$vBX#AH>[րt'@.]6/xT庣JGN[e7kh.vSMa^QNiew&=Rs+UQie5Ӵ uڴ -=Uc3a A~gcSPf#.vn巩v̳ձkiiDd,}>Ӈ>CVJT2uU:q)t&|@ 5 n7m *Omdx:QRv>?zsK! @n@/Ao|kqAfҞD~қm~"ߵJ@&pl *HCnY%sb TOo}Mdm&?'KW4lXY{:m-*(~zś`/PQgw* E(mM{h-Í8|FQ*HPd>P@}IWkʔ)&L0R.-g@:X, lȼd$m d2_OdT5EGV&Th`׃Z ;$;R V:@dˠڠ؃]i8tj>] ;We=ҹ>_rxdZJJ !jjℝ))m! cT}axR[K+II;v&ߔ#eǝpm%JQ;%%rt%ŭ㸵 <TwW6q*@ԥJPJA$<ɩIH7\AQ3\<./PCJL$'i'^-''oڂ Li SHK? ;_}{?trI6aY논O"1^C)筎DFQ1v6røj-[dĶʔP~Ԩy!8Zu BJT G";\((((((((((((((((i3Q4sUثx>gMdcw&П5GbBxd[2r1IBt{TX&qYOΧ nիVLjrƷPP8^q-z@=!z(YmyRRV;ln{ctfC߆OxfYk.#p/-J%|@rxqTG$$6"j԰,#$dmoJ"6*WXmm) g @+>+:5 wn;$Go@bՋ;,H~ ;+'z#t ;]]"JOH8TBZ$IV K,;ihFɞA)$uHa;窱ꮐ5Wpyg[޻wXfu ۏI}B Բ$_0kGFȕ+V%pܗ͟5N-qSV úv$Sw\[ū$kh.+%//wX4JۈWRmɍ\AyV={RoExB2i|NKkj$H7ޥX5ęa`|5^f ;]}$ɳcJ]bqIu;חȪYv in<߅c}jU[Hk=c! g.fr Bcp{vOҪ{߫'',<7n>%ہ$ MK,,4$w.rwZ:5է[+WvnK/<ީ' *UqRV [Ѭ[VH$\VK^^ L?b -ikײ/ƴRmɍ\AyV={RoGNJNRuZ_]W'Fo&پ/ &$΋ bb uJҊOx{=;>j?rtej⚯o e*xk|_~,<PAp~ʂ@NfmII/Ej'\5$&税8-i)ZKͶx\;MX21Y ԩ%nI\@;!i))/qi8[^T~M4wK[O{T;KÝkħS8ݪyǥE]q[-LQ!$hD޵\[\x/̕UG[5?ܴ yhM[`Xm:x\{@k*+M5n( y+jumρH6+ݽ~+y'|IdHu~MW"zT>yPyů]%C@Ls~M|?OE5aՖf#GyhP_ $&:tFqƾ_4]S~Lj0˽5jĸ4)Ҧؽ!3+خ Uy[}md6iZyt%$_UM{b{,8:l3_H>--ɯK[_:f:l)2[8Me,qPen*PPPPPPPPPPPPPPPP &~?qo̟_şO3Е~r6 }T`Vd|E¤wrdVt%5 OT j廒cϳLmőN)K ]s%-]7I@kS$)*Y+Q'U }D~" B{uPH]:>U!aJG@7x]]r+a]eVK[{zl#źM8 uРj.vt5򋱦)ClX#U@,6˃Do΀JJ67@}i'P(wvzv۱H)ުHr7}*mƮvyiE»9U Hl6O!w%MB'.:v@2w }>nw4: V`3B+UqO֫z@ru6hD\mMQK>+Cz>=4\-;2~X:"=D %.PJI:܍{\غ61Ruu">S̔yk[;RIIE.ݮ>!qm͇C\J> vRNδ6{U3fZ^h[j@Pmu<)RJ2dG!īajA$J}(hߑǚe(EOI5ؚ{wxخ D2$7D -),4]V@[B3 SIyҴ5մSvB>1%Ju_ci)n,5L<$!+j+{/CdyU򀟏ws()+𒽑ϲ}gԴ+G)ش!,⮻2Sע>i%aTtA\T{+[זuVו}/o?5ogD-E*IPڈihP/gS.I⃹B^~NIm}&&wCRZ+iiZB>ں9c{JN qq}|۬;$K}$DI$QQ])(bB(((((((((((((((((?u@z8EZMtO/yFQ>5b(h -Y 1OWGU5dspvTBZ:٩5>%ž"t+*Ed,'GT4?x+TD!+R;Hˮeߞbgí{|u9;>urY=8šGA Ϝ3yϜ@`l-VOü*=9<~#r% >T26N2IFNoQ6Z>6P *)R(zkMo@V*ভPՙcU}@/kZ3&=^M LY7;a| ֻd8IШ#}17ST]fJmvV2 8js\<X9hT;\ي}@Aovu(sCup&OoΝҕFтW:[21}2 |ҭFJZA Ͻ#PADch^mT/}+6vLj;hZ-d6QH1E,`[Pd25ϰ~cU׻{+ץ7Ur/eJ¼IrڊB_uDT'>j"XprM''$r<=HGWz.ex U:=H q*RA:Ǽ 8:6 >h5a(2)I5$xguM9S:AbJ]I_Nj:GWWUfo͎dxzeIz"dv]C>etű띮ܔֆ l^R$S掝fqBJ9&IӋW\n:1KPU*NbV|ڟzJHwoz듚M97H[x.LwLAb~1fjG`TLB$HR kV=FM~(yc#j~GnUr-|zcrX_zy\痺r%` جwL"LV^?W+oWwȰܚYɂCxF)I)_AD+/h,z|sƦ䧍o8vZ7OцljvⱋprrשROė`H1s>;f˃|4ڝ'_OS,lp.GGW6.'sTcEĵ̀-*)VS1IƲ9{/3p<-ی*vJ ,6[|*^ivҭwm1zLŠι#ݝɍ2]Gu.դ{'}N>ҵQӂ7'ܩ+so)%ˏ$T'Wm/H}*e{ ~Otp 2 p5!ծ J px=3rյ%/-˲Vq(ʭ+ ?6V24uk5* )ȈDKS G#Jq\6B%1-qmBn7)cinQ?[dZM]B Y^*kJ)g25fT ddR.v+D%g\3>:'ZQVh_D]\nfǕ( ծ  R@RM|iQdTT|AꞞf@a64*;diI7װܶPlv)HW,=]ZTSb>Fa@06 IBp(Ug&,<ЯT; +קeө6m?&UYm>kZt?2(\PJRHFoHۍ5V7e՞z!JOZ +ZSuJw\^q%wqZHןo;:\'dl yZ[p :Pؑ7tt \_ڕx?-\PH@$T7NͬcjrsEzI_ -ҔҤ98(%#d&q\RnW2Tv&Ás̘c0!*YDvMn(FRNNMF)U+ivMiRLb5MK>mD1gLU()h)eyկ62nuM䴲HJt ֛u|>xkW n&r^MaoZ\RSȀ r'w޹dÒjI߭iߛCmIL/`e7.)AEE)=-HIhZ 7޼׫x^ $撓CKKO.ٹ5wNqeZIHI$kϷG$4*H L~ ;3k6*?+?5GaYX9H@ju`߽ެ' XGr V+HتW A/=jE]iRC8R3/w\7q|5^UhJ4\^$%;d1l%\qRIXIȒyh(QH2|Mj(d:vHJ;kURa H'CACno{z*uwBނo(d,M)f[n X II!Uժ,yC|]V!@$5/NKD T0ϭyިVϹbKJWLYi.V7l "q̋XU~x_^ ;C,6$zҞK-/x!S3$ PAQ$śjV<06A[#)dm,(q:I/xN5Wߕ\لeTr Be3?$7<*I_w nAt6إ4®ZV#Q~|sj:8x.k'bRڝ<ͽ p[2G@wF9uӖ xN򶯺^Z$1(||'M%oQ$~KNǭ)a,,QҌvpMFIBUW̿ķ'w>- _LV B9]C͆RN-ޏ4v)^8:J8Gw֚ӊ)}U̽-1P׎%xVŠy R~hgnm߼xpmUN>Hd_VJ>Qlb$G2A]Ƶ{PTTaƐ$Z:JD.MJ%BCm\IjTk15ښ_&}V]5ksqroY1P+-iK"MǨYKkcTm*IZVoqڌ|oqM EJQ$I%H攜͋z;'fDKp\#TI)ifVT%ħ`+BOkvyharw rnWvk$$Iź̚I5 Rt|ƸktOcf ^pc}OnS1%YKa儆X:x2" `Z6%2oUmq}:i4;|Hܳ,=I2 a/okt1n%('Zm.$4T{8bj勨y[pwo{vכ%>cM|&ulrm!L[ݤm`JJReŒFθarKI1J;/uPȜX^MZI.yATyRڟnꍚWD Nm%.;#J<R|ѳaY! OݭJ'I2|{eI~U/'Jk֭rº RϨVE6eY^n[ 62Q+aJi_dG[>F:DVD񥉴=oe%õ!J%9]$_9]gk)&d#+M%924(KRKn8)>ROl}64X0ie$mGVU˾܌٥&ח||}W`@@@@@@@@@@@@@@@@@@@I=f[:o'AUX*#=hQ=e@Z( 8)"2x*-c)%`ʥ[8­%=ե{q*}(@:@Yn\w[s_ARAVA_Y.T->4=T>t~ (ڕ@- :d-ʓ1Ml>5+dU,0PZ&(޼@re^ƪȒCQd*lI_%ZOt clVLcҜ JOz@pBQ[CjN^ qMg`tS ($ ([*u}QqQg)5҂R4vA p]FhhRI HSS[PDԺ>/?Mϧ7tźnp k0WHk/6|Mr|u=5~jP0!~zn~?CK<.APPPPPPPPPPPPPPPPPPPPPPPPPPPP &~?qo̟_ӵœHc釰#>U*wdRTv8"ehꮝIP;L`>t#aUEmU ,V>*MNj&-)JTd(J:7e캉y ٦YS`U]QhB;oPa}MQpq;xnl!@nXdU⑐7P'`2k[3^'NYE*ARm Je-nޒGG%%j q8JH=7 IwCP. QS=@@N]v,6Ry4JujQХYXO~uٌܷ X(XeyM;˩tG Lb4g*};Hߙ%> Lqt*NQP6;RVLOj}"O_!=*2-)gz=Zro_u#C4HnL٪>UuH ?qi4*X".EΎOi5V&Km,'*c"=jfrңfQvĻzRE@{(q'l>v{VVACm論9HG߭6lrRMz$BRjS@Ke$%y$%uKn˷\#NRUרqCc9@gx)$Ԧ!mE~jV ir}q_|s<.APPPPPPPPPPPPPPPPPPPPPPPPPPPP &~?qo̟_O}0y`IVR0}ƞ DMIDII4J@R@^IR$P$ v H0~YDz-J\ZI#mq y0Qw3VT:ǸRLL_oS. nYZ8lreRB,en4qW]@FGm\EXe$8ӝ_}T8κGbMŷڔQ>Y/ܢtqYe*-}fG7ހF|Kt%O`+2 N٠#־`\=8sYhz5d7^5yAmR\Gẕ=EDhT[!c<59'G[4)䅪RH n% c] 7)һT YEM -8O+[!+6<Ԧ d6Л[`|TFmm FlVm"'QI(l{i5cVܖ@\$ޥϓ4&Py6* l۵ܿ<ȭ" ӧqY5m 8[(VW(om~ҦܐRP)Q[y^RM4Lmiq SSg.粒tEV7J)V`-HW5~{PUU̲Z;$(C~g+;"vULÜ{d 99pKq'$?EpiuG VE[tv.45?wuM*VJI6LD2U.]4].ΖnʶC;ZPKJt-cBG#tW4z\>:/to..Mrhc~QUw|];PLb%}Fyp[#GUI⌲JףML18a~xֵ0 G??Vb7R3)#T}Bkob~Oެd)oNҐ(U Bu4RA)Рy,TH'㲆 Uh%>w=XËC(6 Ы沈 gJ,6urnd {ԒUn[N9F o4$uOsF@K =H$50f—ox㰯uH6 c? Ħq 03'd6tV ɀUR brnʝ@2KZVe~[b;jZ@0uYe>``'U m<IөIJ5Pj8d#Q>PI zq}G/ 5*uLZTRT[`bZBJ:rV2q/5ct!ԧJZXp' =HI$U$gNzGƾrTe@(.;o& @)ҁ}UWZt4BDai]u,\k iڣ$#[YbdJR:Z#eu@Q=@-8=,)L!/]z`qke)! UR@\JtRIZ^S 6Oߧ j냑ʬbJ>|D $!<R{ВhڻP%rXzNz;hD{*_cRʳX?p-$RVA#=Fcʚ 4%/cz$}r"AȊG,8Q Jب`zƣ]Z"kPw2_OwWvzc89WWa@̧1"~iO4Җ[֫Ǐz.=[JV|?Zi\d6&o 5 3<[N)RWT>n!):o^LqAJN7eN.nMd*uC#Ĕ[N<$FtmMQJ0xLc_b=nb.P|7Gq$'+ *#oUϵzmnǭ4yp8ӗ .RiM>~8Weȟ \٘B7-JRyl%>ҁ6᜻8«OWni˗-%=ҋ< f.K ix %ߊ p%YԾ@$|(6kڌz(qk +m[ڤ WMumS#61n 2žk#IVJ?b~~ϋ4gSǩ>#TX6H STHIJS)${$w@*3.>TןTϛ>/.ZקϭZ!3l-6:钓!2/ATBJ{*MS,}MMFQHeU5'i:Mq|+ZMp2wA9tvLFK }Ci uIPm)jQoc\X.֗zJԤ|}S[x6.%Y`UaR{Ǹ"rh `Iq}ʉ! T}FYqJHͨOIڌöe]ܚx.+S\jYۋ5 =@rݱozWY-&3ll+C*{\b".Ќ*%R uR# ,azo0%z į[R{|Hު~eJ~~C,YIfSG"cDeAΎrގ[ǽJUu\z%\gM4վwmo4)5 3<[N)RWT>n!):o^Z8'wO7&Q:TJ_ kjw[ݦf< &es/1 (re>_BT•7?mz6R֚mxWwz*t\fQƩ7*'M};G~jeRQqq\imkYFԩ/7$\VZ؇#mglrZ;_zths dz-OS}v-oo\SL0e#N:K YF\M%|L,+.ڤͮsP4S^uǎNPNJc_F~L L~ ;3k6*?+?5V}0f07jɂVL֗N$ʲo*GLׁABunpu%kBI^#CWxl)@}|JU]Yhua-(P;]̖iN>_/+1Mm^R>!K@$|JGz,*GRA#P%k#knxv@./}H-T(I"QuJ!lf4뱒v^}CTRޕQi=Y>E6׌-dU.FYѼs%yX0[oN@-uϽ7L(eok-IV5?CwLTҮsoFT{1jgD cGD@޷7KVxZvlwwiiDRG3Uzk^;9hVXJ^/hv;\: UpqV0}?Ei`d7֓pZ&H O oD 9 FrQF )IUbGVR |G@M٤/kuh-X߽k+ljz}ffӆFmxȐBbj<~HUAHx҉C4CzqZ'p 瘣 .6VWMr }:J}pJx dvdwsj+~u# v.͎z\.L@:$oAa[X60um?IfpU-ߊtPœHժVSM*}kׂ :>~? yu mc"[P[ͺJ %iaǹ3LtPiJ0m-&[B:|YQmN/u;%Nծ$UuuNq|̎Ͱ>mK )H@QPF«˓ۖ9v|%vG B ~f6>dw 1/\ΔO;)HuJJtƐ@{yO~5|_z\qϫݛߩ᪻}||KU0kcUL(SZ̈d(lx-@+Z.G5.~ZSOcgy{U}yWW[~$ǭ c-ŮR|Y\t6'G\Rwګ)~ # ^K_#qplDh)[Í6* ҐFMQjԓMz9xja^Ӵ5N2WGư[H+tbE\\m¯eDHY[SSVڒv1i(-]M:ªU :xާ^ZGTfYRZ 6h%G ({#aUKos.Nn[کpq QmcGpG{%,I󲞔THI,Hl) ڔWXowKz]ٽN74Ysf1uZ;Q…9ŬȉBylj[P*(*2ycRI7娻I:T-8yWWWu|Lz ,1v2Z!gŐIsk)tu){r]v|ץ$tF 96ۿ͖fD幫ucL/;iuzKhR[sW!ni-H+Wo -,rN? WI%߷>="UWk%˦9Y`v6e&E-Suj_'}|v v lkuxd˙Zm|?<":Hd6߅OjKeW߭Fh2 vl6W*y6~"KHl$pȓWOk柩|gA(]Sᦹ^<6bwf/!vtElHLn!d6ACr=X&n4߉;o=k˧bj* v-6חͪiipS.Q{*zTח!n-EJ'\("?H|sm4it-[oȷ?OnͿƟ@"?>E6]|sm4it-[oȷ?OnͿƟ@"?>E6]|sm4it-[oȷ?OnͿƟ@"?>E6]|sm4it-[oȷ?OnͿƟ@"?>E6]tI nB8(( g@ovg*lU32YSw?&>HYؠzY8'VR  #u^JWJ:JA:;mMV6thL#H'\UV,}\?P *y*lK،V17 ޲Oo*#Wĝju?H/ZVɋ,*XjKh .ݬe%\H*er3xqZj3X!K=ԤM3Yv1"iB)[>Udջ)"[&ᲥڦGю}Aû1rL&hiUdɣ͑.r`FeŽ}꿂h*RT$l7d:[mE!'DoΔUQ/j@QV*Y0 (w)%*AQ)!-ۀ2 !JndXu6f,Xũ rij:VmZqn(JG}K`AHV$'EkYʻ* ;Vh| t*I;Pԉ.VW p @ XZNЕeYbӚ_䈟M"9O>,$U;T"LiPJ=Hp|h)uMICJ:-\YkT B/{xWJRQƘ$)*m`P I6*N/˕=2wi -X$٬}G6oGae[*JtIxTM.RٻGq:측I.;fwaRWY5s2 M1!׈oCaד}6C37+u?TK,I*%/K?Bb赖당X$]mTVp1rHBq@;'U&}<5nj_rymj- J-^i쌥I⢒Ac_X9Y,]2nv+VfsonܖƊԗBKgz<]+꣑b悖qFڭE|VqFфe6rԫN|&z-&di.,4˱} X_,N :N Qr c M86QiU[ܹ}q2n*5Y1(=4=nui2"4ڇ Hk5 yӵ:̽S>-RQxQyK|'4u'u$|܂1kn_}U}j؁ǚ*SIT,NajnKΒ}=}O)R'+k)IͪNcgzWUiAIe9!TV]'Yg&T^NWn_:W{SEݩT_$o=.q7Jr~a+Zd-(u*y~ހ+QھruOP?'$6Sj.ƒ)n Z_;j|ϛZ-8ͺ䓣\/pnڗ-!@yJ DzI vǨs꧇M8cNR{V֝).[]!ᶗ׻0Q˼'ʬ@qrPi/%(e $qhX nS n9iN<[W鯝K*u2N}S1\ܐ)A֞ZBI}[ͫ\sģ8[åģ(QW䤡Z}].Mj\nczFLŘ-Kz;"<͇yIΔ Ob=Ѻe"(դ_FQFpqjڿt]L'^SJۀ$!D=%U?ړM8x|t<1ܗ*RzGb?]"tnmr#1=Z*\m+D7˂vw=WQu >L=i_֚äeg9on߄Ok=p/berTԯV5j/=* -4RH^|zKK.8[4ƚ)[wm[-Ɏ1 i}.z[_\`iyn㸭v-pҔ$:4,mIFٯO],:F1rT+;NiC92?Jk~}XWeȍ9lOכ$쬞e%< t`ꚷzN8cN)9>$p}/+W;vӯV6m/WT//<*1>V&푮P*RG';U#z( >gQ)VƵB }o0VB~A'桒O;t4ݚzcHdwPCt׋,+w1ymRBܟ:5X&!2?~O':/U]˺*õ̆:9t~4 Џ~ꏸ nh_)'F"V>>@ned)dJIcfjȴJ~U:DT}F1.hhU ֓ުKwkk ClX%`oUr2- 77@dQmjAuYXXFJth gGfyZ[R+h*̹q6-d 6<$J65V,sQx%ԏh *[![R"@kL.=Vۄ7ᆰ/aS|7SH<&IuRAvߪȔpOcUXILqK!bT I :q X_a0㿽Q/|UtzcSndo͎fĈxBɺ$)qkOOul$u^'=;Q#o4,v

ܤ՞M,tⓩ7Okˬ˅9>dz..SpZRjRJǟrsB08F1<>e8ť񤒾Ƿ6l͚oR\ \qɑN8 2JV ?PƱjc)W;_\>$X(O?Vn;S8VK3meRBT~2T[QHH%:'wF>*4Ԫ2m]դ'uoՈMwqۇE '?]w']K[ l|Qx^ ;}]hiGrvwԡe)_ڞϿ&ێڜ~D2.\i5y.BD7˄%J_NٮL>tR\Ri. x/,e?W뵾{?X/Y|8ӌrɁ=I6)6tMLNiW~GqnKa.$vҕ|aPU.?Bw].ֆ,/CQi])oh28) [L`M6ۭo{mnݲچ=9zݑ\یEe?KD8(!׃HHy@k/Vtx\&qsEQ':_f/DK<:۞,eЋb4lhL!nk6+v7y>SnRr]+nW\ݲeR[_ks lEzʓ8NۥqmŎ湣#[jtx5=odKFNSuiM>~Iz&sbʓ2Q[E%))~Ǟ}A=44{ZiFs72MԹV},R$KőVn G%)ědY,%AR%I;⤀S_Lk=wJQk|Ŧ]tr{諺u]u^:a%$[m18 9v#CVM{~ԣ7Q۵$G⅑޳_f?d_Xo$?q)$ R; h #4\?/r-ڛZI<>;OS[,T'?㇋YuTR (ouU6M4ӴJ٤&_}'[w}i:?$4i@ajl6i$%$@Vݽv|EjP-&mmx5c˄Z\zO:~101XWYKHLflqKPJO}N)E''qndMOt|LzFߡcmзwiwrbFzJY#Jq*S.ҏ4vFA5˨IB *æ%M5͚[n]\vz`Ťa"R,МynwC W=(Ez9:V.:nT\Zڥ+UN3ICu~{_]rwokŏ Jk`Wo[V eZ*NW{G$m/d"6[7T]&Sv|i@8vsNyv.׻9|˷~䓻I݇ݐoS'-a(>8 wXb<8~/~1^8J|Q&fU)WU`FxeA(H(w*$kGá'm[rm]I%OdW/uP>$G(_?Ot~>$G(_?Ot~>$G(_?Ot~>$G(_?Ot~>$G(_?Ot~>$G(_?Ot~>$G(_?Ot~jt:Vh'{tf[:o'q$Q6}M(XTt*{<'g,4lPA`xLMm HFԮI\QETP&ҖRx]&iեr7*(k8CIB@(lt 7t$qDk %[$շX+Gn^I;ZU[I  /G.e;UJtկc|]Vqcy$6~od̿c7Q.2ВĎƢ:%e$G$}Tnn{]ݵ0)"CVWIuJѪQSۜNVhv$W,Gﳪm&v1!$'XuƱ6<&Zilqm-IR=ՓmǐҢ;#: Iit!W1.Y?R~׶׽Ku_?#܆8Qxfߛ-wMI%'_NjxB oKRqrkT)ZU>Uɓ}N1y"~%NKǻ%)ecWT_?5)?m|q5d11rS6k o-WLgc]7NM[Na47esPO%s["WF8}.qU Bqd3pRTSs )N,gTf.E<=ԸE;o5krJwiͿԷ7f\"\a\-al!>i'RR$w|׺($\NOmr<-=W㵙Qqz1˶.r2nB,(u0Ca`9`]=+U,u-9cۊi4VY^(dw&]_}<[r;}o,ķJC B!RsJ46IPj!Ǥ1n3rMRJQ+nK6uh$撮]w~G0!v7/Rdf}@";oM rHq)UEM=S;~,:|yM5|\qOS㔧$ڋiw1wu#βYi.焵#Nҭl} %*UVsrK6]v JSi,=%2\mj.iR9)*_kfm6L4N*|4q^ ;ӳ÷qYaŘq9v@!&^ld%m{'k$Zs2OPYT$5UK̟*wolNj\2ME^%r,wv|6R߈RS9'^[l]7QO-$ӫMkG|>yqb?oԸ%@E/ʲ%Շ箭-amH4 Sz {#I!aFO(G&i$xn;ɨչr}IOW<1Ҩ 뚌dŦtۧ9UC.c[ 51_wiCm|rSوunx0aإ)儤k6˽vru}s pԻEs6wvuZ\{9jxJuF;R{R) P H?_Ud89W-*M[ſR6q?'0E-7`׋YlJm1f\_ [E9Bi=E|sjzVlN>bM6aU>mgK"TSOJͺq\OS_UEY3vmѤX(eŴPhJ%.xlm`#AպW/wA)^mn1Ӕw/Z|.=H~zu_؝Y'SnmC)#JP WvI z^PQbR{s5qq(B_$/ß>qYWRt6 {ɩr*@PPPPPPPPPPPPPPPPPP &~?qo̟_ɏ5V}3mcL|JBu$k҇j^qե.%J:.,-6}vvӣxdVFܚ 0(t a*2f>vFw@ ^]Jw>+T`:BƐhf/ mJ!PUU7Ogi w p#B 'Su 5vQ(um6FH)ggfCʮje. Jᢄr UHĚ,Lq)38=ԒJ^΢7ؚ2UmNyIڌGRVH:>镅Πp(Pe\* (p%;tjUJӴhԮ땙w[zm<z7XU1TO2\E[hHǗ[c#1"_G}&X c`zT .ζ)׾*Z1B:5ZO ͩ\GR) &$3~DϤZ@L@B ;-cO*[rrN.1]]f[yRBQ G;slnm\ 1AlyQ!Z*dT "}hPa-%=0ڏqIv,_eAD;bRҁ>(-Duv?N0QmżH RA 59qc̶kʾSףVy=r]Կ(,̇pdXR*,GqeEJSh (%k%#*VsXfu2㌔v$߫I*}^U^;|ÖJ2zkp}R3!+q[ԕ{AGdKGi*TT 7v? #J:KfvD)^uekqj;RܒI$:8b5Ip$"e)NNRv.uWFomX\;Z."I;d+$y\,VƯڃ 봩`97ܛ{=xNgx]eڤ\^p#N-ʀTaZ <\qaS{]|.GM>_ &>kQ6Ck\R|)+CO5 P(E''nwI[t$[ )%_$Z1BLq{#}SkRZqN?U~D[G:ʞXmjr^O[ SW P%[$(Me>ʠgW]ezI[W뼡5/6&1mz{E pN\pQ[e9B.tϓvWqCŮ5Uޢ8wDI?4:\Y媆8$}e (b|wGC.-OFļ쬡ƜITP GpEtN MSOȣJJ͑8^ۀ JwJP$z>(z4iI[72,O\$^f[$nj F14# (qIm_/O9JNM}q~ ЎyZ@LXe=9#歃KWp\ j>vDU㟽G/Y|X핝xvqQT4H{YYB􏘟T`RWDZmaAI)Vp!JHw>K"a Z$4 )TxO yVв˄tIj.+65ˣx|hB;R$`3+% VpJ[#T$'≶TM/U*\SM@̰KC̨(U4r#Pi`Zg=-\Ӭ8;dȐ[ iwVDb2LWGAGs)0l4y*s68ѩDiNH;߿΢\QԤ&:45TE֔-4ObjQ#2y PSHXȢc)q׉ Gh6/}rYkm\@5PH0 SL^]RP7ᴵq4 ٰnOj {S>Y۪@PӝkixLnr|+6LżZÄ ABⲠuQ>@o{A>U!KMl A$yȢ:EWul{ts]De4GDQFuU4ŕ>gCM'9zN\ΑPmq#(@XMhu\9+(J,[`*RmYCnDy]PgET7;\@05ՉD$ȐEM vHc/kX/H:Bw.8TT`ZmnRvoTCƤf+!AIm((T԰=%),|B˔;#ki- ;YSW<$sDxWE1 !Ccک$IA =C%.[L5Щ2;v-= w媂w9itU Z2>vasԘ ku%zeX:CRBOjXLIPPuK)",oH]MӿxˋJZW~4̬V^D{u U`zWDZhܮ1Tnn|2AP8ZAi`(T:Ы6sKFKS$ lДx).xlVr qP iq]R6y#ʰ×lW/_4NOw_H(tC 6<%{HGz:i*R)ZD麐m{IQ`jN:%HjGXl۱gLK`Н$@!j:gʹ׀;K<4K"h  y $QxlU*\R+ .]1SQ":Ӗ'Lon%'tׁ|O`WбZrehHK7nAU9$8<С@}O(ZCj( -Q1K<[&H5IEbu/j$VA֪I?HuKI =8Gߗ*Eaˎ8JR; '):Hz"oK}kcߏpWCtmU1ex,=<2wO$`7\[K‚{/gG'Cc-n8lg|?<}95ZL+]וۿu֝aհjmP,iIP: k2SJQv,rpiT G??Vb7S1FLDZBk5L&+o[Y^FCMPUjà' %bYn2LX;(C-V\2d'r)Г߽ .²Bca5TkplzsQF 錆3a-%G$@Tו bn~ =*Ȳ {cHj[eG-В@Ty>`~$1X;l|Ru @HĐR}r:|7NO$խR rL}A*Kdu@y1{ІX^My ir u)RVNˍr̚*@z;e%Hz>oIBR V!c UFb((v}(NZY!8<,x]T.?kj 62}Ջ\xo%"-YևƴǟfKCE#75(R仃Ϻ›{[HxQ}tB നu@!Zl ! {@ϰcׄA|>l$qZDZdPq%7yZLuIY ͮ !k]†z*( $ʀR CDpG&z6e"#>= m:]r.$ƖUS5&LdT*+9uu%w"_1W Vig7+Îe)=L"+v ikBMBd884=ItDE:&75Iue+7$XPV$ݔ]Ͻ[?>A^Tt7S9)FtؤHΰr'-;$$Vp } :USsm'÷A?P I**y] ;$Gr%ԥ lUt(q!J&ex$vT+6V?ϫV\_?-#imb(LqKhyEBXR|H-{Cʔˇ/4yrK,?9>k d :]W Ԕ(W@1QԸMG *\[=v, Y"e=Z!{䟟SG6Bx#]H=u:i2dhIHR҄)u)JRJR@|E6·H:VXs*I/VE@&MVWkЉr PH;"]ɹ'ڻWiT'xFjݶIݫ]~C^֗~ϺCIШIHز^:wX$p%-t@ZJ%cUZKOG.=fst}gtӧUk7|aOƜcɅ1,H -N`}JT$l&"lVTIP_\Ya)RF}5C-\ٴڙK_R)JRT)J!)JR Iw^9$z$|"d&MVWkЉr PH;"]V97$W|wm5lջm$WjN(YnxMǙ~h$(A YZ&irNVɒ8|ʙq9!e-)PjPRξ$ <oK.rjq⚄4[o/I~Wmn t:iI /J):&qpQ믻}壞/}.u؇Pne&&fܹ"Ɣ4 jAJBGzT76o;^9q1f-OKtK)nklmP]1 ;ON/>_їKjkkj_T1? Ìr!,AINʕ-#уrK´P >QX?pVT`\exP Uo$4[D%݈\_$IT˰f[Kǝh%y^3yk'YHẰ"l)'誇Hq$ oEOÊ 惱ԃF]j#^~5OUQ<[-&Xed^Ťݥc\[HL}B*,δ>K ڪnG}w ٰVq۬TxjRu敃[ ItM4԰* $_bJmFt/ zᛌ/Dq!Ձ{bè.69BQטt{V-$u '6w-`$v$CIכM䤬Hζ0dHq@@ {WX04J(Uw\ݝHb3 ǐ0#%G'7+`[Y1`kmbH:IˈPXIDԕ{&^i-dDo̝CYҒmZ4d@[-(P-T(I&D!xnv(yvtd'ψz3"%;A`b)* DGzѻvTx۶V=#ŠKMn .,TPj|RVԣumܮvlk-s,TX DfaqkHS6VDaZ !-V1t"tm-]8',^o.Si%T|wmm|²&ug?mUȊPC:NqI -%a<&˿it?~0Yr殚mzWUeu%FWk")] ha 4꜐$&*95[We*KU̽lp)I?[j132I;'fzM,|$TT{ߊĄcK@wCI-Fp= EJ⚵6/jrwSt.owZl. *|qWD2$JB(%*POCjIvY\xRvy'φ=p<8qcm|pnRZWDmnn{{RRXaĩPG7ċlp5̓On\ww=Lуx>6J˥4mk9]/L5GM51.+4]o ]ͩcWt~+A1bQĖRy6)r9%8=RN7bOu|Ҿ)DՖ㻋Ux?uc?o9|f.׆CCO.B4J9$%t+B)¥q~wÔO42o6XԶm>)J7Nm'mY.efx ų[^my8O[pj1oT~"1W|MNRuIr8̼+/XbgV{jC\\p<RVk=>]M+Ϟo~k'S,n^j髋\׭5Ƚj2d9)a-) #rj;MHqPq-9mnֻI&դ[i3Ŏ9"dj6IKV܍d&d{\C%\,gޮ:כ28]Gը L~ ;3k6*?+?5IvgGEs4A Hi[nhlh؊NMI߸VrH-ōS ^#Wp. 5<}āWJ4+Yǀ*J6U9-G*c$* `|j-96ԥb.- tGbdȈP,Ϧ[^bc]s*+O㩱f5 ˜xVUWHYM5JQ k@K6^I ggޒ65Jh K-k  FeqFjlyM,/qBGak-i(m4 GK*[C>|GQo0/kO78xIqAb.%PMdX ֻϮ:~ǃS%2Kk}w_~+8w[~DuyJaW˒c:t4u+Ow}=5=WCڵrJתnN;! %Xde1kx)~z!cA\RAبoU4M,Y%6~i׭?@Mw}F=gDzNoԣ52O( wxxnxQy^bZ帮W2Wi|kUE*_ltW=]ؠd(2֔\B `^[yzh48pfɇ$g,q+{SoֻUqxs|z_PKjW\ok܈Nd#ĂHހ5Һ΁jVXU$[SծRu޽?/zyn'}2 Gؓ8֗R9H O IحKONW/'W_> :ĺatm€-С]}_Z1k.inLc([I- }yV33>EjTbԐJdpO5ӛh .XٶzW?5J77'xfs#שvqf a(F[ۊ7Ti->\ь-I>{qwͪR! e qï~$,F=jS1%Z޶u퓥fYg謷u Orv{금ȶ@nzp%CA'J{[O ')Grn6kax+ `Yu/1.78q/H%\t/gCg}Sj16,cݩ*^yw9XҔ/Dt&#^Mnb`5vLYc<AG9YNXVȲM$J~/jen%\7ܭ+4mx!KgA$6[q@RUv ק6=DLRRͦ+Evocb.c$.+eGOJG?YtZ F=.iO%յ˷|- W_^f_7+_$=pn4T{*>ŝig+#տ?B!d\j*[3.8eٌ'LgYz9l. w yz |2 PxԷ'wn1.UxY1}Ud[]\sa>*m^JTy`kM/R⚒i[W=nxŮd~=xV3b9MLHv1LT3k/2)+q:Е!`ƶͮi)eȢvҦm5KQ&꼺.FW;e=USby )XJ~Z͏Qe%(SN2eAԕf]•r$Lq5E 삯 i4s=FX˲m+B-Vo8q">啶}awp s--%;޶˩衝ie+#uri!,ிrVҼ^#yͮmKVS,!à7)@߆@dsgZ\Zz8IJr֔q}ōv~>F3l%H 4+t6{C^Qn*ߡm. /Pp5 |="ǔƒHuIIבد;u u,O,%)GٓL$cFwvM~Lsֽ1n&BxJ d(v rt㇕2M$||׆͢Te&MeWPfAM K?}hqEJjz&<Ђ\S[tn?RĞ`ojkTP(((((((((((((i3Q4sUثx>gMd,~ H%ٟLHho.-6x6ՁnC`vb۱B?Mc0_mJTfS9sIWjJL&jPUfX])(ڍ@0J4 P;I>uA6$D@WTOV2[A=^l֮%rRx0>îRHI ؛߱ei'}'ovRPu|]K0>qRAѡtM:uPo9*#mnWO:6jȫ"ӎP~t*VX#cүE9Z?j>R`ҮKZpٯ߰ꓽqqw`\oY=՛@>\Tĭ[@N\..@1@KYn+iH Y:,w"S-"RÈ֊Do.Aڈ}7hzMd.&:}CCˊu>@K eEB ޅMmzSe`U`F*D  JMH-JPsUCJ\Uՙ޿]mi@ɫcjQ)=ĚMoW|WX0msid)j*dQzue r+A'M^(zǯ!= - f\2Č%Hڜl+dc[}hΫ{/*o.xזLu3IH;ppGDw.x.]z.Qtڔm%vv=O|m-X>SGV[q۽zo5* :Lf!H([{G*OsuZZ$a-ZqN ;QmH˴Wf+qˬlfvKArVP,oaDTIW~:Х᜚±ߛz'^̟J/vj iO!K5(^|W msQTbHt j*GJm>xw$296Gf Vut#ɵ_mʟ/k|CGkӌ={fcki謦Ga )thRbrb%ZuWJbω=<2]MdnM9+H14ی)y}ׅUA&Kcy>bl٫W(EaoNm@ۃRJ괙t.tJ Rܔ85̟dfǏ&IzoKfJ/|B8Ӫ*<5IiWyz|jk%<])54?>9 3Y4ˆ!rM1$w(-u.-jQOtOlﰮΗ V-,qj)/NJJ?qmFif6u~~K?O8ʹ^W$x^TJ J yr}=47MFY_a «HϞNZ2OcʹM zUs9fN˘/A}ǒ%-:=j$4sfɎP^SFђSKGd{f[G.uEҤm\qBJ_i޹:x?pLK`ji!3ؐL +O;zG8Sn[&%<ܕo R%.\r4Ps)x%qō[EAOKmK >'PE)$tz:h>KZǔ lYv/[ld؞_* Ɇk>qրJ $dSj0j2pjMT]˕]((diE(Jٮeu?fd/v j፳nj3f @}nB)EH O9>vfˎ}\8Km?Uo9 U.}9{_{H+ =teí!uɆe*T˭"1{=,2nVM9;J|Ò0Q8q焓6~G1W)NycćiЍE(*HQzC_U7%i֟w\_,(nͻ>­: 6΅x\뿝yMWr[d)sqUכ 1oW}1._O<+sj k%TӉPou۱m:QU,~&[Wd}L}OvJӜ_,ܮVЗžpʈn6iK| iNYmg ]0!l$*JTI EdmO[qe!E~WƢt۲4Ւ  BZO~Ʈ} nqFeTNڙ`w2McYv8qTHCe)9W]Cu,,OR2*:lY>eJYH,EzyŚ8cQEM՚zCm|A派Bmn9R$6o-z;jE]fYg[CKm)z?*.-ڀre|5mJHLv&1'`GNhP.7FQ m[͢ qjz=a\H=%!娐5=CmsAj"+YY30:Iz[2Vնҽ VJ]/4&=s!q5=λU@=+:%h$YcW"< =@2P'{%DVRڹlb)"IGcU ,{'f{O٠4,o-?46xn"ɻŊW"3`{@X.52XG6H[a."'TKuPI¹]q9 չm} ؏` DβHN+BV,"ލ(|}P^][n \RJ,Lh^|e#A9qYt X1SunIrimo!(JTHYjXxlΒ nIrm$vy/+RCû CɴnUFjBq熄8AQZ@ Wɉ[-^bVڧt$ҳx;%Ms5y_"],'c!$*kZ [k ARTau~iqge5[x;iE? IɎ',侷_wwٔS.) -()$#Hr2ܔ9Gkq~ V ^/j+Ndێ~)JFHOQI#?R̰JmIn~s+nxv}j%2ښWNUqW1?{}Q#KVJpjfǚ%8Lw \V<Ҡ1>qE71s$kK7>\m^_d~=L)D(}7%n1T T6R6j; tjEζ7|NN15o$,roj\.jڜmHM7S7XaZxZ.}o\N_kfݲݺv<%ڮxMѦa9JqlC<6^p%m*@[ dߣQ|kQV) =~U*8:eE38K HR> OQڒ<Ԑg[>{3V2j1M&|\7:8<ꗢ[5u(ޭ(d\B\BQJ\A町8ӶI۶ʻjljS}fIPY҉V)\̬Jkt+(tI;<7bz|O~H/[^I%ڷ5wЎ=أ͵:t8;~GMUqtbr.n>/ꩊ>#M5wkb9kSˤXy>LHqӞ*}pQ䝨{ }wE2ouQWĭ*|wi[>WntC&e[!~M`~txZmrqҠHJUR4IOh-{䱼Q{\W^ZIbr_{쯳o|UQCT[zVˤgK턪4oR$_tIߢ [\b>۶.WI~d;98r\+u^b؞9jf sq3Yi iY  $L^?LY[xo2W6'_ӉJ/"`]{{6IW4)JRJ)D% Hu:Z;JF:>U*m"$YO˻ď+.?p!iq(%*J#D8 nozIBn_J_ iźi>88}R_\NBmfK]"4/4Wu )RO"LqG6'qM?TLd,3x橮t?K9;lCJl*.6s%&;)jЯ#7]?w65TFҴNS΢Koֈvp $6.Bc\="3n8Ja .6 NԑIg< PԝqMm)5볬ړ,uuޯM|3)-TndozŰR,lr:3RqK&ɶ|k}_WK־]_up>~[Av<{SoejHZy'i'zRO=>\Vi(.ө$ӧkegPW/Z5\?f@@@@@@@@@@@@@@49vg*lU32YSw/8yj|S_X.@iP?MGlfF !#@\8vOz%OeⴢuH K  u#oH{^ &s yrO#ހtkHUW%TuT2Vz)i\ÃUʗN5-"G |v~'o)H!)p{ $Xzy&޶J$ʀX!6cA[F &.UlHYN`:eVKk;J3NXMHS,{kuTXB06!$#|*ey=녱QHH0>/6^mu Y2XP;UeIHA&FtT RNm@7TϦrߝٹR!CCG}IVk;E}~ÃB xddZ@ 2Q=8ۭUOwI( ×j{[ %'@5.ǕGL*PB=s+bҕ""U`rȣ_ fJ!yjarSwt=i5ց˵͎uxYzB|+u%4 TVۈ-*I"Ohtz n=kR3n>'Ztm\G0rk9 }c9JnDe C ? (x~]-D##7E+M/$m\g9#ޜZھ/BT" {ȭݺl]CraWΛD2$mqt;dO < 㒚j t6f{<+6>esݚNJule*XJBo`GrZT[\KƣՌaLY"T<)]B^;MA5Zgu~YV𚸷(q{bq#vs]6Uմ* Tܓ6}XnQd+{[s Tq~=5h KnIJ]xϑ,#].u1S9,LW/Xe1 !LTSH쒯>NxSt#XQ4rvԤRT玌+*ㆤ||1:ŎZ`[6$8 xL]UcTh$}ؐ}]Ss){ФG>o]w=Dc=\v_en꽳h2МxXe"501qM--,w;ijpeQi6Mjzs EZYuP SjѲe 4&wpsTMr:nNj"7;>E/UV2qe{ɲv2-12h6T{(PJT#z=2tCUȢT$\)ڧS}85s3O3ǾX"Nw.Ly5_ZpXg,ܦnȞFL|b./"2 )UņK 6)”~h= GelpRXt쮔m--V㓧%Nvך^>Yo74[3;$k*7꾏Klw%*/yۯfVbEhX$ dv>#N^d"K/jmē@I&.Xr`KrOif+BL :틷ćvKb1fRSD;V q>n.JO,nn{)ʢ%|px^(vwUw9Un%k􁷯%;;&[;2q=\EIlgbsi)%%FTK|-|)ťF)_"8jH.MƓvO&DDkLm) H..2s繎/16,fkrn]ٹi}1O+F N6lFK$R=w9Z t}q1~Yqo^n'#Ev#9LoǍ!% (q\Uz:u3k>(J.JוHIrG]FZc _'MaJ.RO J|=i@tvqVP>/wګK/ jIx}ٮ+~{:iq֔FMmXS!| RiQoNl-8)yGa2SOn2'x^+_Z[޼z7G?GIwcuWI+j>_}䪲e6ԙ?]Ngzιz/~[Oskv~=~+{/cOY^_ K녩X½KkV$lPZ*Wr."3lmrwbw6s p w=D '=iA*:K/2̮͝/ڇqT`/96Z!+qgP-YԖ;eO#C%l%eJeq؝Qb~{dɚEu٨+mIj./| ct@nCU dj@Gh$l7IukxúXiA hJ4gV_wl2Ŧ$$bxUR,/[ JBo93TPPPPPPPPPPPPPPPPPPPPPPPPPPPPgh@ٟ_V|ΛeO)|#Ug1O e ?`ٺZ\d!|`\h [߄յ ]s' .2G€Qm!&a=V\)K{㎀wV@} 7"\{S*uGtբ{;)C i@vQOj$ 9!":&@*}[m|x$|&xQ. o>uPg]J%K2,4b-A!R ʖuyM=`JJ帤U@^-U"}}k>2Í9>jU>+Ԙ,3B4 ^$l0'FEL'dX")'[*έYO[*@f|n9-㒱ž޳}5ryHIp8Bo9g#6=p G??Vb7R1FϦdO ə/ef7N*uPh Т (;ʤ|PhaZ@qV%ZʀFTDPؠ!H#YWcTe )c)PTpGj@CgjqJ} FOe{TOwwMg ޛO$aRj u'ϵk\5lcp6򒥀5Q85orMy)* ޫ@LL'H P"[iBSݞ|mGfH Ui/^[k\dGIZkm8L&De.;)jVwBu@op>ᔼ H,0`gZK,Km%2w$h  ()cʤ q,T9V]bRNTyg{,BSމ2pM kT9 r w^DhKɗofs-R<eE JV. u~}?# A3L^zŔ,I J}0B鎇xڶR{$׉׼];lҹIE[ork\}n}ϭ8KjK[Suvmһ7pñQ 합p-m^$t۔"ZAR|hJ) ˥fh7ɩ'QW-ԫwtUǦ[ZqGmյF]Ybcyr5&#+Q(m%;>+ډx(ſVpu=<4̸qɥupPPPPPPPPPPPPPPPPPPPPPPPPPPgh@ٟ_V|ΛeO)];\Y>2B *,V /N@BQ'`", _o@JV8v+^Ee:;bс\w= )8xȌNh'GQ_I:2,P<[uDYh0' $u)cҔZW֝#38A/*,v{]c8~:j=3ɒ>o m, շ;PbXW\^縏ŘMpwj`,iyoݺXRIKtP@*X KbjJ괅۞B%'ґeqLMHe/vjob64 #c{M͌|FqEr곑)Y)-QxLTR&;u,ZV{N#DUWfG*'{Z=p]{ZG wdK'Qiy@)5YT; ԅ-jIFVT ʼn3}{.[h3X>qZvQެL&IᣤMJt@;YS"/WsSb~f(Mz=bR(IN(UQ!H+meZeFyHROkVcčS H⣢FyukKh:7"x\yw۴t"Cn()tqT˖y#/ȗ,-\2_&FÍҹUss:𒔗ӷ^_xm)+Mx^?+!պ+qk;RN$k1"I䜲nV((((((((((((((((((((((((((i3Q4sUثx>gMd, Hc!qsHxH)p5V6H( H${,,G$D qRʕoW>']p{z,{ٛo}dh_{ֽk:VūJX85W+'}SÓ4<('l˦Wt˥i6.C/[޶َm"A;ޫ{Q="S[y޲m+%3+rȶBbswnn,+fzB%+(R\o zMBqާjVܗ>9eQUiqiI_:~&&EE6!_6#X.M̓ Ԅ-祡*H'h28e.E:F(G&xJNa6?Crbvfc 4A]SN6s[A$( Uߤof:~_\`oZrWMܙs5Cn}E3t9V\թ7dCMJJ V}/\5jzxtD#Xrf{q)9u-%='̤7+[J( IRyrFOFt}DrV)+Mt ˃&Y :{,7< ]:*㑮GP.=4q];lY%Gie+v;$ثI'^蓫K+ɕ"rzRi/ר,`pffvUq:]ӫW:7Cۏ m uҥ'O.ZWuj/Ʀ8]1NM/.ç˟qo`-7G2Hu}:\V-9_~~i"{i5/'ZJ|_ y&Z\њc,6C$dLyl%IYC.w5gA4IIT'¾[^<?w33ӌGmzIu*qτsm XVuN׺gF.3U):Vwmi&DLlwO:ѼvCnORX*"=?h|kkz'PɓURwVfeK"^)cx߽mŢFT3$ˆXa#jqi|+u6'%߅*i-0 T|oBsܢӳؓ4r6ԏCȄoG]}sۏg}δWWy;ynfS7Eqi3g[Den74N at2{SISz=x/N=D|.~o|U9skYNctFCծd;:DteL%_5+TwPNlW}smCt\rdI$wkrWefӍ"jzqY=3lβ$6Fi#ai{#]쎛V9uYOrN?xu;]5iŸS( e <%+6+}^:MFq ǧ9[y ĺ)i\>>˖^T}V?jmU٣{ %=1GI;IdI|^d?D2?h"7&ۢ=е##lC]Y`Q?P :mp,}Uog7WoENg_1-{EK^NǃvTuWS{OɡX'1Go$"S4|T32qV_"z4HyҺE˽%ǥKVe4J\rPye~d=2ϻc[g.َm3_9֦Id?J=DX(fy'p"O-7W490-+1 oC}!n_A4$4fq6:#6FwGQLGKz,M''̢j%.4 [.0ʔ ARJN|9E P-y*[zɀA; {BV@Q q %_=꜂!%" &?6$- p(|H ֎z%$cwޖˌ6GFt擮sݮq_1+HO55qzT,80 wnR;\6(/)lM"ld0O{6i&}n|kkLqDuc[g[H39qPڕSt(KPW8\,4J4==$OooM%J^`N^1E3".=նB"4$ H:֞J^T}\} ߎ˙SUy1wQ|sKvM_Dr W`ilHӌ+J 椞Iyrʼn$4ۯxq,iR컾ޥqW#ڹ99ߒM[Ha-HIm@ lvuZi&&.OrO,aQIy|l d ts KG'ޕkdkB<\I Nß/ޭb˞F-1v5?&THyr;J޼{n/y*Ij2r['d0O{6i&}n|kkLqDuc[g[H39qPڕSt(KPW8\,4J4==$OooM%J^`N^1E3".=նB"4$ H:֞J^T}\} ߎ˙SUy1wQ|sKvM_Dr W`ilHӌ+J 椞Iyrʼn$4ۯxq,iR컾ޥVKVڡ=Nխ'm!# @5*MWoʫﳪ.NRRo-ܕɶOmhG&Ҟ+Nbx+$w))ݴ]Is hۺ|A;kb:0 W?߂Kg6#ɻry\ %jKD $xm N :U>Zµ-ӗ۷۾\2[XK|۪=@ɯSKbvqiaż.G׈ Gc@+룯jcYwodMvj<ʏ Y 򥈊92 +Y%.q lW).^bSR7oǷr$ջ~~ϧ-zGIQZ Bx{*H &@Rv{hVߡ'LzRݻߤ)+e8RʋOL7鉓Mq~'X'l~:A÷LϪ? }Muω|tL;͒\RPW)Hvڶ+hhGW2Gt'ۧ\|i7*{_q>tzLNϱgخe 5tیHD#W4xh8Ve$_ >QX5:y(eZqrSIwjGVU\_aF 9gO\I~jwvbsALJ)I>eH9*ɋx&/׻voҊʑ"VI <ɑ E\ZJ}g"?wn :j*zwN*_eX۾R`+*^O"Mt+ݻ"`d*-ܥe)%EXDrBNcʾod:qQ$JW\uQ wkQz)]ʶKm Rvc)hIhWVc{@0xi/9]3#-ϛf==@>YśhwZ7mRz5+}ΆWC]G_M^Jk]GNZ|j fhxI(R_"IߘD+ELK.r|a$SSJ][B_N=SOu(2]k,Ray>Z]D6=!l'-.hTtE@Kn)VݝCְolݒ5q|ew\wns)%U(ܸGm!2&9 )|JN zO'VX'/ RwkO~S{r&g<_0 <γ^bRʒ)PPIJk^Ƀ7[tޫ{")6jMwN<̮f뜧:3kO%4x˨#{J-)ɪa2|h X n)o]kg!#$8E|..m3[snΘO[ؙ"ƣY r1#GdI%߲9+cOaxҌ/km'|sۋ}NZR@щ1q>޸M&B\G^S F{n( o=s~zx( f-|ÔEsDiŗgވ7Hހ6=|asrCN)iiz=\BGWh:}W[W4=4m]6|MCKv+./;->_)kaژJޒ[F$y]>Ǧ%\xb&^#+rAV=b7X\:+#B7NNUXV2~_zQH@o};뜻r2Y+b*3 +HRdC% ( T}&t zΐm4 rUMym{jOOc:Z#7iXIaL!\ڛ% ):>_=yMS;$U7>5@n⁣@lTdjH;5 ^y>-/?_s3:8{vV;*ۍX./ Rvۏۣ <^V|?.联3 sE@{Jz#yV,mJ_zrÊx)E85c묡6{o7-[UB:$A[ҁޫ2"uk]_|˷o^蓙-ʷ^7t^Sb:+Re(I 3qǧL~pN^+#55/+!Xbq)I=%|MWkI_cn=:m~)~B r:%PG]ETs(IqN硺=37.ʾnDc(oθss1ȯLCxs6xPۓl[ËaRO¬޹|^bĚKM>^-ǟ\'Vpaz|]?wkMvBOum&L "_zUȔk6ge{^`Ϲ?-۲mEK `od(<1m4Z2}GQ^_ z~I$6O<_I.zrK!*|דCł:rt䎿M-C)x_E|ɡr #smO 6t-W_7KnmF->{$:W$ώtkA;- U GzJDZ~,9btχ {\WGOI $GYۤlr1:UszTZ>+oGԶ㔫&]|ڽ>*yF)mX|2z"u{߻aNRRWf2l?3/⟪ʼn+9@]PB9@w?EGՋ;ojJaH萭qiVti>-2nuAkL6r BJcLA9@U~bigHq;#i6hlYүS_}ZaVRmkUIHd#E\H+nRX)i͍?wR=!I5v@U~&cz DɑR\nIQJčlAEGʷn/qЂ5>F}`lBT@IZk27W.CGWTвD6)VT-5d g*_O1f Β}fsxlnꞯǓk{]t%8EZMO/yJ7q`|BHc )n+Pݴ`z]}E䗷ƥvlY=Rܤ@K)adHxItBG1eA4^V:elBSٵZ3%BwK.+u֊N@ $vߣ6:Sk&Hv$+gN-^\1"6"݋|Y1}ո[C|uZBJgIH _a=I[mwvCNu .y6> v>"0R#n! ƒ!*lk{'ѵk9pڧrM}IھV"𳞛-WL 1c9 **EKn*9;d0T I+{9հNiIOct%v7R̂a6aeqHv}9q֠\*׵^gtOf5tr)Ns{m=tJ쯂Ys|} oF}'rg7rb[7 [2 ,w{}c'M߇"JJںiSMrfc,N^n]fm6=qswT&*(>$2XsC]kʲ}.ǻO%\ESRy0Ϳשq6om5ˍe*<JWmgPR˥I)MBo [_ݼ#c𜳭fUr͆0Ԟ*erSب$(yч/] ~N(KtWɱKWk-vyO9GNc7>_!\-qyGE)WJ'v`}WNp&$8$|~$'֣ }_ks *T 꾩A$y_VtОݿv뿟GY}罾~u~uJ3lVV=-*%h0#RAV^/gu谷Kk9o&}+2[d _$$6n1|K4mrI?l}䌖ڄd4GkhҲ òc-$% k'Os_n25](X>K P+D҄JS|'(8kzWNK(;\]/{nWWF>GE߰X`¯,"$pH/-<`<U*Խܣڒ7%utCdx%t{9s1Xw9#uC`%3m֢O=w+A1'0M:{{YWo|weGt=ԇdadZa2, F-mv 1{ 4a8\sou9\N [[mWC՞a&8Fks\7$jϴ{#t.g,廜-eKe4V .dyndUDb0:I.+Vɫt^ 3CcزIrUop ʉ}(dѳl.i7"#J̺!rJT<#`f_:O:/y5%MIviŦ/gOpɧn>wp9i͑x,PYajiޙL9[dfGδfQf6ٶVq`ĸClp=Y:Ε9Nrk96OwzM7WPWbawx,zֵKHqTNʽ7A>YA%IxJ6B.dZq|`'-Cۦ;'K'tp=a:Kk5 W QQؕ]/[].\qQjm667!M!d($`Mx=7aM)K)81빬٧;d^}4t8i6bOĽ+;)Pފ褃޾77:_h1yK/5$34һۆZac;57]z˾OjAPhTA)b;I QJH$+g}8v))IRm4JśQ;ݑ ]TN+,ϫ ; ^ J<%Bv@>`⏰~zKok6Z{>;v,Y^_}u7tz!Z6iuXoF$ .$i ٝ冝&9$g)SRگ:--niZoewli6Ly.%k/u~V}.u\k:do[ojJ^Yk|.Ndgb呮V)h̻B*#) ɒ(˜:OLk:doƩU])XyfmS޺HÌ0ܜf%$eZHڼ]7>躴֟ i7'SNNMnZNEGTZa*,z[+Zm yx?Ecؖ;OTG}Ag&\ r[4"iN`)M x{1:$l~K־_e?$(K9x].k'B<Zq^wbaXT ϒ}`g1 qOwׯ8kʤ'}ܒ犷Λ[xVrQtqj,[`Žxz<$0Bd~Ǟ=DG%%RR>ɫϛG)eAKܷqX57ܑ*hC:/-/̓]YV&l4{$8M%tçN<,-)[SR\]U>H6 eY-PTf!)7 ']Oov1)S,Q(Aʸ\G5Xi{xr&0qAz=O`QM߽7\n9/jϨi% kmKmگ7R^b I򐽸O$ցW4wM=cQP\c\.>K&KEwu4"%\ vvR<{Wz8FKR|ԕJOw6k]Q'QiNQLJN5[Uqϗ{]snw-/WAl)WVȐA$Vt:Pfj.rjWR=֯~tGL%ITI4Kbe.ז-,lGUŦHOڏsG}N~OPڣEqn2qO-ĵ}+M W9nPm|6xzx>ݡ@t}O٣^$01"*V7(4rjrGI9\% tێ0KwuIܼ|h-nWR\78\&axuՎqo-@ B?<uZOreS}Mlv6zm49aN>.W+iz67eWض>XU-6 P{Ky{+Pogic4ɑnP|rK[}oEP!)q֋Jk{x*>iaĖ+kw=jo>ϖO*4E(E^N:N_\9G._c¸Ckt!@'jVG~Z[5nr%υ?wɢ·Ṿ^iΈGܬ }zjeqiFB])$U{3 !h0YN_V{sdg\~=ʟ7ʫXM%z~Xm :C(\tDOZdw6IޟGYbٕxu\or1.ywc'.R~<,Gd7put9D\ ֑.֬8O+ 9Ex? [^kXgL#Ű@ڜGcϿqh'.'6)n\㳎So⎎ukV*riI!->82$P̗4؞kVy{oU>\PQ^mק'!t9F{]Gc!0ynK!EaTVqHZt@;*) 'mjMm\e(ոSw«煏aM(ɬOzM;SKx™G86y˛ +nb I:Gd;Z\͞YejjJd )׃>8/IcpIʓr\U\yˎ$MKe]}{AG&C(ɸc)wWW~9}[NQ<2SK|1ӕ~i}t&#9YW a!dotzǡtdfY qqu:SmK8q6T2q{S_/kc*cY`>ZKĝ <}ƾICti8$zIɤILu}N^,;\]*ڵKwB=^=dK+L|r"ƒFd| 59iwOy=''qmm u|zËd^'Tվ*m^Ḝaʖj;_m/mױKSXe/^..+$3v^y֥ ҽ({Z PQJ (w\ɾ3RjWݦ۱:~9)*O줟 x|,n_]@ƭM5 VDg* -{n=6<#sZi}TmFHYn{ZJl.njp;8"i  ܝoiu9ɍF-~MrFXcxciKW}hPS&9o_MUƳ] ghq;'(/T?-QlFU>|].p伎N[Crc]n78 4!8ۜi wO{˲<ە|{3n rᨪO>ZV&{5Ϫn-P)JV -¥RoeTJyfI|˄OM6/Qe62d0eRL}v l|+,K(^b/) q\bUn? &].6<'S"HӁL^eG4L;> ^7kNqqU)wMTI7TcV\lJc4m4{GD_%k[qO3um|9$ZꃸoXgOf܆͆KKR}LxIJˆlUq[O_%w]:kg|%vwgۨs-qG'E il=/z='[(%O}v6o8]M8ׯ&WxN&)jj񛅐"JZ_O,69)E*t۵-RyT} 7_;| ݮ]ض }lӱqZy,&1Ť/[S.GU$&ޓM\+j́^4>¾;a=e#&kT(e)BN7uWO^$XhЮwKu[!Ìo>~*'m*-/_.}RUջ:AA2kTtZ'fc7S~#T}cz]`w>b~g h!JO4(JX-" #7ȇL>BȒbe؍-d#ϞyxZc+ $k*q yQJ_7ڀf6&1gT{70~y=f rϘ4 X;\f`&p4 i0x^=[%2W Qp\mlᶼ2ޠ1ˇ[vxgjBIPǦO'-7[|X<^fD!MࡰAhuy3s ԈZZl HQ JU6&Λ^ ?,QNJy)RRa-Vh駒+$qiIݥv. (%SNu&,ȢUG\Zov.Y [v- |wU(ew>C/'I(Ѱ9(d |ߢ%&#2ud]zi@n6wvϵ+-2dDm^#͠/͡q_hWj6s;1 5;>oqqjX7yxm5P>/͡q_>|?"N>oq>}^^?C@}(P>/͡q_>^5W>oq_>}^^?C@}7 5,]gj|6>o͡q_>^5W_WP\ YN^;QTLDwƯ _ӃsؗO_gՏK'f!@}7tN;vTz~?D(?;lvߪI?YPV>/$DZ. 'f!@=7; w$puc{I?YPkU$EZ YCDA<>m5.ϦUeS $%*r4ʌ-TܩTU!O=ooQ4mĶ{o$ޟ %dz~?WCĀ$ޟ(a{m"YI(yP Om@>D{Pm6 ?;_+UՎd]aȣd,lY1` +Z!kYWZg#퐒jvg*lTx>kMd}L?jf6dfNkVҧTP{dJ~HJV}߭0.+cnA+TXDL xZ)︯{yLZ/.[S֩BVΒ~=6(ª1J)z$.yyRnN؝4Ө=o[W,rЌN^Ctnd%A!$ ʁMW:3iܓya*~c$5S}0S=ۋ3_^5Z}U,s33$T!J\}l>4?Gӥ>JݏB>7'C9MSr*%NZgN#5tm֙#zm^({w$$6WWϥX= ̚6EһS'U7{[{9e-kSqCR^xK\ K3)Fwq|(F&iGᴭټX4`+[>%10e!J! M(BJSdE}Dz^i8. Mwդ9#"1|}`X:kqnO˞#;#ec!H/eZF5F;wk9.UGJ⸩+qndZl}nмmby%gJEAI;u7ҩUo?NE#]n7B/Kn;ϹbS Ű])Kd(]L2O,Ǻj-&ʯjKcI9Uz'mG^[5DVIc(Cq d:^QJ9{kN]*%fHI;qSMkInIαxad>oL׎mW}#5ƌxx̫醦Cm+;*N8~:S]A3'[~Ǎ)%};KAR~*CcA)^feߒfGXa0Yq$)Z)ފvk~:vٌ^i`;WĹN\:gE9gx$類a0Yk.3 z6GgYD+B҉H!Ԅ w>C?f~ɗ?OKI-ƕbI)ܼOɧz6cvܡ[uq ɮJ Dtc_ZGQr٨W)4QogOOe)Lt֮S܎֬bULx(2/PRl!}k4u gO:MXa9E^m_ -xe-$7W~(kj?VwM ecz> )}0a J2I%'G.XB:b_#Թrw&:=4^5,ۋyU8?L[,]Gv7BEoA|R’ O&}C@ì:m 3iUH8Ԫ=7=!j}&}@Ja}EHJC%gA쁡1shtmҩm)?w u]/O9;2)~Vɦ$$;&H8[|ueDh$3Y:OƖ͂7Kg5PNP3X,,=10w='~z`n?CўGE_ڠ€(((((((((((((((((((((((((((m7P??Vb7R1FϦc轥(]R\\ei~|9{赱YcqE֛zB&弎(z],ة-{a:.دdzUEr3IǏ<>,u:Kt:άtVdbX3+S>{q.*bϝs4dmr,xT^ԴgF| R٪t:e . 7{Rko<ڙ*ݻ[]'C7LrsQi?{ˏ8è^8Mb>0Jq$zI\lWoWsaC,Ikt[]?#Ki}IMG<~& e.<ĥŸ2b8R켅w >|zpaw %$SVޏ*I Y6Y?M:|wlpNǮ^qnr@JJZ T ~짵;m]W,.z7 9!:Jc+?7]G=la<$E\}woiSr(+N%rz5i2bK_/vP]{Ndz^Ct$SmWw}WK=K"} }6YjPp@ܪ?C1p{2jGR6IW:_`j!wg>ƭd":/bDFY Rtt^lI%C۽GQɋ$ےu&nIJx#5+Q_.:2by[V 7ޛd0I|pӼf LgBKyIJP7E8[I(js17 .h!K2.|teG.]k_F_Jp Ow ``w!Mb4~$BSDK$Y!F}S{lw)Qת4ǎZRfLsx8[bqIbYmcO2y*ƕ$FmŒ9RI>Wj2.i7yz\EH [F|6?UO, {w3ڳ/rҋ?TWp8zer_1DʕuXқYӋ4+(PIN^4"X&[ԷRm籮K=^_}}C.wܚa, <)ʇ_⸦ex[ί5MKyc7&:ut6X׷_ ZTT(=i#y-Ε ڴUKc_ }T<:6-t=8rP+W"E˯>DhezP:{t7rlNOdd2ؙ3kJRTRȫ]dWΟ}_.|mVˊY#ҩBoٵW‘Պsͧp{_ԣ.ssҗx_-^;zk ʒ";a%({n:OgoYd\sSMtsJOSu9fcsR~EwdU#)EsA%#{ʉĶӿ(񥒎W,Rn#;AT nQ-]Pl֮^hÉ"Tfײ$`^Ш7¿ Uh|Y* *,B dR#rRZp~(حJv Ixa)ZVdʯvrۂ)YJ*'DVs>bxPBF*,v2V;H|cxx.3dt8JJAH#:$v*(kw|H9V+${,m:I䭑;5|L-6I=UɾAW̺ Ρ^>3>[HJ= !kNSdߺ|oݺ]=ҒSom$PBI$;;kOCmzGDuKZYI𖂅=!JJ!DAeJ;]͊t¾k|T ѯmVijm˖,yt=2?O{6:B A @ڸVuhE1³I 56:\'m$VĢh}5en)eYH5U+G%(+D(ADjDu.nyFk `~ԬiD}γ.Lyͺ#dFa ~g>m f%gQnQ.O-qC|pځvI{w=ɮĈg]/Sq:}bMiRb$iEϲNJmpA({`-PY(iR@}h+ LYl92`CCEĤJG'z/k̫%1q>8Ӈ~U<8vKk_;Bɱ)ˀU#T9wKJѪC7^5\ $YbC~[MI QNDxl{T*W2-%GY.Ƽ0YҎ0Lfm#Q#h&mU œ%W}TU%m:hKd(>)GMtƦȐ; )Y>+WPw5}Ǘj<2X*ecΣxwKknʲa L͆Jdr4 ѣH >@r﯀mPoG}MeЇAeв~+ZFɁbVRW῾tY dQGؒJ$I I_r:ω< pNjCRA)K? G{D0|Xi665bL\j:w]i0fBe6+,)H>[!żI٩D%"Y0'9Q t*<&%|<]*hkwwUAlT]Z I{F@5#R:z%y;P T(7_}с,P|5A&dRRUz>(((((((((((((((((((((((((((((m&y6}pPPPPPPPPPPPPPPPPPPPPPPPPPPPPPsubed-1.2.25/screenshot.jpg.license000066400000000000000000000001201474617305700172120ustar00rootroot00000000000000SPDX-FileCopyrightText: 2019 The subed Authors SPDX-License-Identifier: CC0-1.0subed-1.2.25/subed/000077500000000000000000000000001474617305700140235ustar00rootroot00000000000000subed-1.2.25/subed/ol-subed.el000066400000000000000000000043631474617305700160650ustar00rootroot00000000000000;;; ol-subed.el --- Links to subed subtitles -*- lexical-binding: t; -*- ;; Copyright (C) 2023 Sacha Chua ;; Author: Sacha Chua ;; Keywords: multimedia ;; SPDX-License-Identifier: GPL-3.0-or-later ;; This file 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 of the License, ;; or (at your option) any later version. ;; ;; This file 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 this file. If not, see . ;;; Commentary: ;;; Code: ;;;###autoload (defun org-subed-store-link () "Store a link to a subtitle." (when (derived-mode-p 'subed-mode) ;; The value is passed around using variable `org-store-link-plist'. (org-link-store-props :type "subed" :start-ms (subed-subtitle-msecs-start) :stop-ms (subed-subtitle-msecs-stop) :comment (subed-subtitle-comment) :text (subed-subtitle-text) :description (or (subed-subtitle-comment) (subed-subtitle-text))) (org-link-add-props :link (concat "subed:" (buffer-file-name) "::" (number-to-string (subed-subtitle-msecs-start)))) org-store-link-plist)) ;;;###autoload (defun org-subed-open (path _) "Follow a subed link specified by PATH." (let ((parts (split-string path "::"))) (find-file (car parts)) (when (cadr parts) (subed-jump-to-subtitle-id-at-msecs (subed-timestamp-to-msecs (cadr parts)))) (when (elt parts 2) (narrow-to-region (point) (and (subed-jump-to-subtitle-id-at-msecs (subed-timestamp-to-msecs (elt parts 2))) (subed-jump-to-subtitle-end)))))) ;;;###autoload (with-eval-after-load 'org (org-link-set-parameters "subed" :store #'org-subed-store-link :follow #'org-subed-open)) (provide 'ol-subed) ;; Local Variables: ;; indent-tabs-mode: nil ;; End: ;;; ol-subed.el ends here subed-1.2.25/subed/subed-align.el000066400000000000000000000155041474617305700165440ustar00rootroot00000000000000;;; subed-align.el --- use forced alignment tools like aeneas -*- lexical-binding: t; -*- ;; Copyright (C) 2022 Sacha Chua ;; Author: Sacha Chua ;; Keywords: multimedia ;; This program 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 of the License, or ;; (at your option) any later version. ;; This program 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 this program. If not, see . ;;; Commentary: ;; This has some extra support for using Aeneas for forced alignment ;; in order to get VTT or SRT timestamps from a plain text file and an ;; audio file. ;; ;; You will also need aeneas and its dependencies: https://github.com/readbeyond/aeneas ;; ;;; Code: (require 'subed) (defvar subed-align-command '("python3" "-m" "aeneas.tools.execute_task") "Command to run aeneas.") (defvar subed-align-language "eng" "Language code.") (defvar subed-align-options nil "Other options to include in the aeneas invocation. Ex: task_adjust_boundary_nonspeech_min=0.500|task_adjust_boundary_nonspeech_string=REMOVE will remove silence and other non-speech spans.") ;;;###autoload (defun subed-align-region (audio-file beg end) "Align just the given section." (interactive (list (or (subed-media-file) (subed-guess-media-file subed-audio-extensions) (read-file-name "Audio file: ")) (if (region-active-p) (min (point) (mark)) (point-min)) (if (region-active-p) (max (point) (mark)) (point-max)))) (let* ((format (cond ((derived-mode-p 'subed-vtt-mode) "VTT") ((derived-mode-p 'subed-srt-mode) "SRT"))) (input-mode major-mode) (input-subtitles (subed-subtitle-list beg end)) (temp-input-file (make-temp-file "subed-align" nil ".txt" (mapconcat (lambda (o) (elt o 3)) input-subtitles "\n\n"))) (temp-file (concat (make-temp-name "subed-align") "." (if (buffer-file-name) (file-name-extension (buffer-file-name)) (downcase format)))) (ignore-before (save-excursion (goto-char beg) (unless (subed-subtitle-msecs-start) (subed-forward-subtitle-text)) (/ (subed-subtitle-msecs-start) 1000.0))) (process-length (save-excursion (goto-char end) (- (/ (subed-subtitle-msecs-stop) 1000.0) ignore-before))) results) (unwind-protect (progn (apply #'call-process (car subed-align-command) nil (get-buffer-create "*subed-aeneas*") t (append (cdr subed-align-command) (list (expand-file-name audio-file) temp-input-file (format "is_audio_file_head_length=%.3f|is_audio_file_process_length=%.3f|task_language=%s|os_task_file_format=%s|is_text_type=%s%s" ignore-before process-length subed-align-language (downcase format) "subtitles" (if subed-align-options (concat "|" subed-align-options) "")) temp-file))) ;; parse the subtitles from the resulting output (setq results (subed-parse-file temp-file)) (save-excursion (subed-for-each-subtitle beg end nil (when-let* ((current (pop results))) (subed-set-subtitle-time-start (elt current 1)) (subed-set-subtitle-time-stop (elt current 2))))) (run-hook-with-args 'subed-region-adjusted-hook beg end)) (delete-file temp-input-file) (delete-file temp-file)))) ;;;###autoload (defun subed-align (audio-file text-file format) "Align AUDIO-FILE with TEXT-FILE to get timestamps in FORMAT. Return the new filename." (interactive (list (or (subed-media-file) (subed-guess-media-file subed-audio-extensions) (read-file-name "Audio file: ")) (buffer-file-name) (completing-read "Format: " '("AUD" "CSV" "EAF" "JSON" "SMIL" "SRT" "SSV" "SUB" "TEXTGRID" "TSV" "TTML" "TXT" "VTT" "XML")))) (let ((new-file (and (buffer-file-name) (expand-file-name (concat (file-name-sans-extension (buffer-file-name)) "." (downcase format))))) temp-file subtitles) (when (or (null (file-exists-p new-file)) (yes-or-no-p (format "%s exists. Overwrite? " (file-name-nondirectory new-file)))) (when (derived-mode-p 'subed-mode) (setq subtitles (subed-subtitle-list)) (setq temp-file (make-temp-file "subed-align" nil ".txt")) (with-temp-file temp-file (insert (mapconcat (lambda (o) (elt o 3)) subtitles "\n\n")))) (apply #'call-process (car subed-align-command) nil (get-buffer-create "*subed-aeneas*") t (append (cdr subed-align-command) (list (expand-file-name audio-file) (or temp-file (expand-file-name text-file)) (format "task_language=%s|os_task_file_format=%s|is_text_type=%s%s" subed-align-language (downcase format) (if temp-file "subtitles" "plain") (if subed-align-options (concat "|" subed-align-options) "")) new-file))) (when temp-file (delete-file temp-file)) (with-temp-file new-file (insert-file-contents new-file) (subed-guess-format new-file) (when (derived-mode-p 'subed-mode) (subed-trim-overlaps)) (when (derived-mode-p 'subed-vtt-mode) (goto-char (point-min)) (flush-lines "^[0-9]+$") ;; reinsert comments (subed-align-reinsert-comments subtitles))) (when (called-interactively-p 'any) (find-file new-file)) new-file))) (defun subed-align-reinsert-comments (subtitles) "Reinsert the comments from SUBTITLES. Assume that the subtitles are still in the same sequence." (goto-char (point-min)) (mapc (lambda (sub) (subed-forward-subtitle-time-start) (when (elt sub 4) (subed-set-subtitle-comment (elt sub 4)))) subtitles)) (provide 'subed-align) ;;; subed-align.el ends here subed-1.2.25/subed/subed-align.el.license000066400000000000000000000001411474617305700201540ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2022 Sacha Chua ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-ass.el000066400000000000000000000313341474617305700162370ustar00rootroot00000000000000;;; 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) (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-time-face) '("\\(?:[0-9]+:\\)?[0-9]+:[0-9]+\\(?:\\.[0-9]+\\)? *\\(,\\) *\\(?:[0-9]+:\\)?[0-9]+:[0-9]+\\(?:\\.[0-9]+\\)?" 1 'subed-time-separator-face t)) "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") (cl-defmethod subed--timestamp-to-msecs (time-string &context (major-mode subed-ass-mode)) "Find HH:MM:SS.MS pattern in TIME-STRING and convert it to milliseconds. Return nil if TIME-STRING doesn't match the pattern. Use the format-specific function for MAJOR-MODE." (when (string-match subed--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))))) (cl-defmethod subed--msecs-to-timestamp (msecs &context (major-mode subed-ass-mode)) "Convert MSECS to string in the format H:MM:SS.CS. Use the format-specific function for MAJOR-MODE." (concat (format-seconds "%h:%02m:%02s" (/ msecs 1000)) "." (format "%02d" (/ (mod msecs 1000) 10)))) (cl-defmethod subed--subtitle-id (&context (major-mode subed-ass-mode)) "Return the ID of the subtitle at point or nil if there is no ID. Use the format-specific function for MAJOR-MODE." (save-excursion (when (and (subed--jump-to-subtitle-time-start) (looking-at subed--regexp-timestamp)) (match-string 0)))) (cl-defmethod subed--subtitle-id-at-msecs (msecs &context (major-mode subed-ass-mode)) "Return the ID of the subtitle at MSECS milliseconds. Return nil if there is no subtitle at MSECS. Use the format-specific function for MAJOR-MODE." (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--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-subtitle-msecs-start) -1) msecs) ;; If stop time is >= MSECS, we found a match (let ((cur-sub-end (subed-subtitle-msecs-stop))) (when (and cur-sub-end (>= cur-sub-end msecs)) (throw 'subtitle-id (subed-subtitle-id)))) (unless (subed--forward-subtitle-id) (throw 'subtitle-id nil)))))) ;;; Traversing (cl-defmethod subed--jump-to-subtitle-id (&context (major-mode subed-ass-mode) &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. Use the format-specific function for MAJOR-MODE." (if (stringp sub-id) (let* ((orig-point (point)) (find-ms (subed--timestamp-to-msecs sub-id)) (regex (concat "^\\(?:" subed-ass--regexp-start "\\)\\(" subed--regexp-timestamp "\\)")) done) (goto-char (point-min)) (while (not done) (if (re-search-forward regex nil t) (when (= (save-match-data (subed-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--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))))) (cl-defmethod subed--jump-to-subtitle-time-start (&context (major-mode subed-ass-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (when (re-search-forward subed--regexp-timestamp (line-end-position) t) (goto-char (match-beginning 0)) (point)))) (cl-defmethod subed--jump-to-subtitle-time-stop (&context (major-mode subed-ass-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (re-search-forward (concat "\\(?:" subed--regexp-timestamp "\\),") (line-end-position) t) (when (looking-at subed--regexp-timestamp) (point)))) (cl-defmethod subed--jump-to-subtitle-text (&context (major-mode subed-ass-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (beginning-of-line) (when (looking-at ".*?,.*?,.*?,.*?,.*?,.*?,.*?,.*?,.*?,") (goto-char (match-end 0))) (point))) (cl-defmethod subed--jump-to-subtitle-end (&context (major-mode subed-ass-mode) &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. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point))) (when (subed-jump-to-subtitle-text sub-id) (end-of-line) (unless (= orig-point (point)) (point))))) (cl-defmethod subed--forward-subtitle-id (&context (major-mode subed-ass-mode)) "Move point to next subtitle's ID. Return point or nil if there is no next subtitle. Use the format-specific function for MAJOR-MODE." (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))) (cl-defmethod subed--backward-subtitle-id (&context (major-mode subed-ass-mode)) "Move point to previous subtitle's ID. Return point or nil if there is no previous subtitle. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point))) (when (subed-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)))) ;;; Manipulation (cl-defmethod subed--make-subtitle (&context (major-mode subed-ass-mode) &optional _ start stop text _) "Generate new subtitle string. START default to 0. STOP defaults to (+ START `subed-subtitle-spacing') TEXT defaults to an empty string. The ID and comment are ignored. A newline is appended to TEXT, meaning you'll get two trailing newlines if TEXT is nil or empty. Use the format-specific function for MAJOR-MODE." (format "Dialogue: 0,%s,%s,Default,,0,0,0,,%s\n" (subed-msecs-to-timestamp (or start 0)) (subed-msecs-to-timestamp (or stop (+ (or start 0) subed-default-subtitle-length))) (replace-regexp-in-string "\n" "\\n" (or text "")))) (cl-defmethod subed--prepend-subtitle (&context (major-mode subed-ass-mode) &optional id start stop text comment) "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. COMMENT is ignored. Move point to the text of the inserted subtitle. Return new point. Use the format-specific function for MAJOR-MODE." (subed-jump-to-subtitle-id) (insert (subed-make-subtitle id start stop text comment)) (forward-line -1) (subed-jump-to-subtitle-text)) (cl-defmethod subed--append-subtitle (&context (major-mode subed-ass-mode) &optional id start stop text comment) "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. COMMENT is ignored. Move point to the text of the inserted subtitle. Return new point. Use the format-specific function for MAJOR-MODE." (unless (subed-forward-subtitle-id) ;; Point is on last subtitle or buffer is empty (subed-jump-to-subtitle-end) (unless (bolp) (insert "\n"))) (insert (subed-make-subtitle id start stop text comment)) (forward-line -1) (subed-jump-to-subtitle-text)) (cl-defmethod subed--merge-with-next (&context (major-mode subed-ass-mode)) "Merge the current subtitle with the next subtitle. Update the end timestamp accordingly. Use the format-specific function for MAJOR-MODE." (save-excursion (subed-jump-to-subtitle-end) (let ((pos (point)) new-end) (if (subed-forward-subtitle-time-stop) (progn (when (looking-at subed--regexp-timestamp) (setq new-end (subed-timestamp-to-msecs (match-string 0)))) (subed-jump-to-subtitle-text) (delete-region pos (point)) (insert " ") (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-stop new-end)) (run-hooks 'subed-subtitle-merged-hook)) (error "No subtitle to merge into"))))) (cl-defmethod subed--auto-insert (&context (major-mode subed-ass-mode)) "Set up an empty SubStation Alpha file. Use the format-specific function for MAJOR-MODE." (insert "[Script Info] 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\n")) ;;;###autoload (define-derived-mode subed-ass-mode subed-mode "Subed-ASS" "Major mode for editing Advanced SubStation Alpha subtitle files." (setq-local subed--subtitle-format "ass") (setq-local subed--regexp-timestamp subed-ass--regexp-timestamp) (setq-local subed--regexp-separator subed-ass--regexp-separator) (setq-local font-lock-defaults '(subed-ass-font-lock-keywords))) ;;;###autoload (add-to-list 'auto-mode-alist '("\\.ass\\'" . subed-ass-mode)) (provide 'subed-ass) ;;; subed-ass.el ends here subed-1.2.25/subed/subed-ass.el.license000066400000000000000000000001551474617305700176550ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2021-2022 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-common.el000066400000000000000000003535101474617305700167440ustar00rootroot00000000000000;;; subed-common.el --- Subtitle-format agnostic functions -*- 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: ;; ;; The functions in this file do not expect any particular subtitle format. ;; Instead, they expect certain functions to exist that provide navigation and ;; manipulation for whatever format the current buffer contains. ;;; Code: (require 'cl-macs) (require 'subed-config) (require 'subed-debug) (require 'subed-mpv) (declare-function subed-tsv-mode "subed-tsv" ()) (declare-function subed-guess-format "subed" (&optional filename)) ;;; Generic functions and variables (defvar-local subed--regexp-separator nil "Regexp separating subtitles.") (defvar-local subed--regexp-timestamp nil "Regexp matching timestamps.") (defvar-local subed--enable-point-to-player-sync-after-disabling-loop nil) ;;; Macros (defmacro subed-define-generic-function (name args &rest body) "Declare an object method and provide the old way of calling it. NAME is the part of the function name that will go after the subed- prefix. ARGS are the arguments for the function. BODY is the body of the function, and may include a docstring or an interactive form." (declare (indent defun) (debug defun)) (let (is-interactive doc) (when (stringp (car body)) (setq doc (pop body))) (setq is-interactive (eq (caar body) 'interactive)) `(progn (cl-defgeneric ,(intern (concat "subed--" (symbol-name name))) ,args ,doc ,@(if is-interactive (cdr body) body)) ;; Define old internal functions as obsolete aliases ,@(mapcar (lambda (sub-format) `(define-obsolete-function-alias (quote ,(intern (format "subed-%s--%s" sub-format (symbol-name name)))) (function ,(intern (format "subed-%s" (symbol-name name)))) "1.0.0" ,doc)) '("srt" "vtt" "ass")) ,(if is-interactive `(defun ,(intern (concat "subed-" (symbol-name name))) ,args ,(concat doc "\n\nThis function calls the generic function\n`" (concat "subed--" (symbol-name name)) "' for the actual implementation.") ,(car body) (,(intern (concat "subed--" (symbol-name name))) ,@(delq nil (mapcar (lambda (a) (unless (string-match "^&" (symbol-name a)) a)) args)))) `(defalias ',(intern (concat "subed-" (symbol-name name))) #',(intern (concat "subed--" (symbol-name name))) ,doc))))) (defmacro subed-save-excursion (&rest body) "Restore relative point within current subtitle after executing BODY. This also works if the buffer changes (e.g. when sorting subtitles) as long the subtitle IDs don't change." (declare (debug t)) (save-excursion `(let ((sub-id (subed-subtitle-id)) (sub-pos (subed-subtitle-relative-point)) (pos (point))) (progn ,@body) (if sub-id (progn (subed-jump-to-subtitle-id sub-id) ;; Subtitle text may have changed and we may not be able to move to the ;; exact original position (condition-case nil (forward-char sub-pos) (beginning-of-buffer nil) (end-of-buffer nil))) (goto-char pos))))) (defmacro subed-for-each-subtitle (beg end reverse &rest body) "Run BODY for each subtitle between the region specified by BEG and END. If END is nil, it defaults to `point-max'. If BEG and END are both nil, run BODY only on the subtitle at point. If REVERSE is non-nil, start on the subtitle at END and move backwards. Before BODY is run, point is placed on the subtitle's ID." (declare (indent 3) (debug t)) `(atomic-change-group (if (not ,beg) ;; Run body on subtitle at point (save-excursion (subed-jump-to-subtitle-id) ,@body) (let ((begm (make-marker)) (endm (make-marker))) (set-marker begm ,beg) (set-marker endm (or ,end (point-max))) ;; Run body on multiple subtitles (if ,reverse ;; Iterate backwards (save-excursion (goto-char endm) (unless (subed-jump-to-subtitle-id) (subed-backward-subtitle-id)) (catch 'first-subtitle-reached (while t ;; The subtitle includes every character up to the next subtitle's ID (or eob) (let ((sub-end (save-excursion (subed-jump-to-subtitle-end)))) (when (< sub-end begm) (throw 'first-subtitle-reached t))) (progn ,@body) (unless (subed-backward-subtitle-id) (throw 'first-subtitle-reached t))))) ;; Iterate forwards (save-excursion (goto-char begm) (unless (subed-jump-to-subtitle-id) (subed-forward-subtitle-id)) (catch 'last-subtitle-reached (while t (when (> (point) endm) (throw 'last-subtitle-reached t)) (progn ,@body) (unless (subed-forward-subtitle-id) (throw 'last-subtitle-reached t)))))))))) (defmacro subed-with-subtitle-replay-disabled (&rest body) "Run BODY while automatic subtitle replay is disabled." (declare (indent defun)) `(let ((replay-was-enabled-p (subed-replay-adjusted-subtitle-p))) (subed-disable-replay-adjusted-subtitle :quiet) (progn ,@body) (when replay-was-enabled-p (subed-enable-replay-adjusted-subtitle :quiet)))) (defvar-local subed--batch-editing nil "Non-nil means suppress hooks and commands meant for interactive use.") (defmacro subed-batch-edit (&rest body) "Run BODY as a batch edit. Suppress hooks and replays." (declare (indent defun)) `(progn (let ((subed--batch-editing t)) (subed-with-subtitle-replay-disabled (subed-disable-sync-point-to-player-temporarily) (progn ,@body))) (unless subed--batch-editing ;; I wonder if we should do this here or if we should rely on ;; it being in post-command-hook... (when (subed-show-cps-p) (subed--move-cps-overlay-to-current-subtitle) (subed--update-cps-overlay))))) (subed-define-generic-function timestamp-to-msecs (time-string) "Find timestamp pattern in TIME-STRING and convert it to milliseconds. Return nil if TIME-STRING doesn't match the pattern.") (subed-define-generic-function msecs-to-timestamp (msecs) "Convert MSECS to string in the subtitle's timestamp format.") (defun subed-to-msecs (time-string) "Convert TIME-STRING to milliseconds." (or (and (stringp time-string) (subed-timestamp-to-msecs time-string)) (cond ((numberp time-string) time-string) ((string-match "^[0-9\\.]+$" time-string) (string-to-number time-string))))) (subed-define-generic-function subtitle-id () "Return the ID of the subtitle at point or nil if there is no ID.") (subed-define-generic-function subtitle-id-max () "Return the ID of the last subtitle or nil if there are no subtitles." (save-excursion (goto-char (point-max)) (subed-subtitle-id))) (subed-define-generic-function subtitle-id-at-msecs (msecs) "Return the ID of the subtitle at MSECS milliseconds. Return nil if there is no subtitle at MSECS.") (subed-define-generic-function subtitle-start-pos (&optional sub-id) "Return the position of the start of the subtitle. If SUB-ID is not given, use the current subtitle." (interactive) (save-excursion (or (subed-jump-to-subtitle-comment sub-id) (subed-jump-to-subtitle-id sub-id)) (point))) (subed-define-generic-function jump-to-subtitle-start-pos (&optional sub-id) "Move to the beginning of a subtitle and return point. If SUB-ID is not given, focus the current subtitle. Return point or nil if no subtitle could be found." (interactive) (or (subed-jump-to-subtitle-comment sub-id) (subed-jump-to-subtitle-id sub-id))) (subed-define-generic-function 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." (interactive)) (subed-define-generic-function 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)) (subed-define-generic-function 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)) (subed-define-generic-function 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)) (subed-define-generic-function jump-to-subtitle-comment (&optional sub-id) "Move point on the first character of subtitle's comment. If SUB-ID is not given, use subtitle on point. Return point or nil if a the subtitle's comment can't be found." (interactive) (ignore sub-id) nil) (subed-define-generic-function 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)) (subed-define-generic-function 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-subtitle-id-at-msecs'." (let ((current-sub-id (subed-subtitle-id)) (target-sub-id (subed-subtitle-id-at-msecs msecs))) (when (and target-sub-id (not (equal target-sub-id current-sub-id))) (subed-jump-to-subtitle-id target-sub-id)))) (subed-define-generic-function 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-vtt--subtitle-id-at-msecs'." (when (subed-jump-to-subtitle-id-at-msecs msecs) (subed-jump-to-subtitle-text))) (subed-define-generic-function in-header-p () "Return non-nil if the point is in the file header." nil) (subed-define-generic-function in-comment-p () "Return non-nil if the point is in a comment." nil) (subed-define-generic-function forward-subtitle-start-pos () "Move point to the beginning of the next subtitle. Return point or nil if there is no next subtitle." (interactive) (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-start-pos))) (subed-define-generic-function backward-subtitle-start-pos () "Move point to the beginning of the previous subtitle. Return point or nil if there is no previous subtitle." (interactive) (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-start-pos))) (subed-define-generic-function forward-subtitle-id () "Move point to next subtitle's ID. Return point or nil if there is no next subtitle." (interactive)) (subed-define-generic-function backward-subtitle-id () "Move point to previous subtitle's ID. Return point or nil if there is no previous subtitle." (interactive)) (subed-define-generic-function forward-subtitle-text () "Move point to next subtitle's text. Return point or nil if there is no next subtitle." (interactive) (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-text))) (subed-define-generic-function backward-subtitle-text () "Move point to previous subtitle's text. Return point or nil if there is no previous subtitle." (interactive) (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-text))) (subed-define-generic-function forward-subtitle-comment () "Move point to next subtitle's comment. Return point or nil if there is no next subtitle." (interactive) (let ((pos (point))) (if (and (subed-forward-subtitle-id) (subed-jump-to-subtitle-comment)) (point) (goto-char pos) nil))) (subed-define-generic-function backward-subtitle-comment () "Move point to previous subtitle's comment. Return point or nil if there is no previous subtitle." (interactive) (let ((pos (point))) (if (and (subed-backward-subtitle-id) (subed-jump-to-subtitle-comment)) (point) (goto-char pos) nil))) (subed-define-generic-function forward-subtitle-end () "Move point to end of next subtitle. Return point or nil if there is no next subtitle." (interactive) (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-end))) (subed-define-generic-function backward-subtitle-end () "Move point to end of previous subtitle. Return point or nil if there is no previous subtitle." (interactive) (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-end))) (subed-define-generic-function forward-subtitle-time-start () "Move point to next subtitle's start time." (interactive) (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-time-start))) (subed-define-generic-function backward-subtitle-time-start () "Move point to previous subtitle's start time." (interactive) (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-time-start))) (subed-define-generic-function forward-subtitle-time-stop () "Move point to next subtitle's stop time." (interactive) (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-time-stop))) (subed-define-generic-function backward-subtitle-time-stop () "Move point to previous subtitle's stop time." (interactive) (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-time-stop))) (subed-define-generic-function 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-jump-to-subtitle-time-start sub-id) (when (looking-at subed--regexp-timestamp) (match-string 0)))))) (when timestamp (subed-timestamp-to-msecs timestamp)))) (subed-define-generic-function 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-jump-to-subtitle-time-stop sub-id) (when (looking-at subed--regexp-timestamp) (match-string 0)))))) (when timestamp (subed-timestamp-to-msecs timestamp)))) (subed-define-generic-function 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-jump-to-subtitle-text sub-id)) (end (subed-jump-to-subtitle-end sub-id))) (when (and beg end) (buffer-substring beg end)))) "")) (subed-define-generic-function set-subtitle-text (text &optional sub-id) "Set subtitle text to TEXT. If SUB-ID is not given, set the text of the current subtitle." (interactive "MNew text: ") (subed-jump-to-subtitle-text sub-id) (delete-region (point) (or (subed-jump-to-subtitle-end) (point))) (insert text)) (subed-define-generic-function subtitle-relative-point () "Point relative to subtitle's ID or nil if ID can't be found." (let ((start-point (save-excursion (when (subed-jump-to-subtitle-id) (point))))) (when start-point (- (point) start-point)))) (subed-define-generic-function subtitle-comment (&optional _) "Return subtitle comment or nil if none." nil) (subed-define-generic-function set-subtitle-comment (comment) "Set the current subtitle's comment to COMMENT. If COMMENT is nil or the empty string, remove the comment." (interactive "MComment: ") (ignore comment) (error "Not implemented")) (subed-define-generic-function set-subtitle-time-start (msecs &optional sub-id ignore-negative-duration ignore-overlap) "Set subtitle start time to MSECS milliseconds. If SUB-ID is not given, set the start of the current subtitle. If `subed-enforce-time-boundaries' is set to `adjust', adjust the current subtitle's stop time to avoid negative durations (unless IGNORE-NEGATIVE-DURATION is non-nil) and adjust the previous subtitle's stop time to maintain `subed-subtitle-spacing' (unless IGNORE-OVERLAP is non-nil) if needed. If `subed-enforce-time-boundaries' is set to `error', throw an error in those cases. If `subed-enforce-time-boundaries' is nil, make the changes without checking. Return the new subtitle start time in milliseconds." (save-excursion (when (or (not sub-id) (and sub-id (subed-jump-to-subtitle-id sub-id))) (when (< msecs 0) (if (eq subed-enforce-time-boundaries 'error) (error "Start time %d is negative." msecs) (setq msecs 0))) (when (and (not ignore-negative-duration) subed-enforce-time-boundaries (> msecs (subed-subtitle-msecs-stop))) (pcase subed-enforce-time-boundaries ('error (error "Start time %s will be after stop time %s" (subed-msecs-to-timestamp msecs) (subed-msecs-to-timestamp (subed-subtitle-msecs-stop)))) ('clip (setq msecs (subed-subtitle-msecs-stop)) (message "Clipping to %s" (subed-msecs-to-timestamp msecs))) ('adjust (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-stop msecs) (message "Adjusted stop time to %s to avoid negative duration" (subed-msecs-to-timestamp (subed-subtitle-msecs-stop))))))) (when (and subed-enforce-time-boundaries (not ignore-overlap)) (subed-save-excursion (when (and (subed-backward-subtitle-time-stop) subed-subtitle-spacing (> (subed-subtitle-msecs-stop) (- msecs subed-subtitle-spacing))) (pcase subed-enforce-time-boundaries ('error (error "Start time %s will be too close to previous stop time of %s" (subed-msecs-to-timestamp msecs) (subed-msecs-to-timestamp (subed-subtitle-msecs-stop)))) ('clip (setq msecs (+ (subed-subtitle-msecs-stop) subed-subtitle-spacing)) (message "Clipping to %s to maintain spacing from previous stop time of %s" msecs (subed-subtitle-msecs-stop))) ('adjust (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-stop (- msecs subed-subtitle-spacing)) (message "Adjusted previous stop time to %s to maintain spacing" (subed-msecs-to-timestamp (subed-subtitle-msecs-stop))))))))) ;; Update loop start if it's within the current subtitle (when (and subed--subtitle-loop-start (>= subed--subtitle-loop-start (floor (- (subed-subtitle-msecs-start) (* 1000 (or subed-loop-seconds-before 0))))) (< subed--subtitle-loop-start (subed-subtitle-msecs-stop))) (setq subed--subtitle-loop-start (floor (- msecs (* 1000 (or subed-loop-seconds-before 0)))))) (when (and (subed-jump-to-subtitle-time-start sub-id) (looking-at subed--regexp-timestamp)) (replace-match (save-match-data (subed-msecs-to-timestamp msecs))) (subed-subtitle-msecs-start))))) (subed-define-generic-function set-subtitle-time-stop (msecs &optional sub-id ignore-negative-duration ignore-overlap) "Set subtitle stop time to MSECS milliseconds. If SUB-ID is not given, set the stop of the current subtitle. If `subed-enforce-time-boundaries' is set to `adjust', adjust the current subtitle's start time to avoid negative durations (unless IGNORE-NEGATIVE-DURATION is non-nil) and adjust the next subtitle's start time to maintain `subed-subtitle-spacing' (unless IGNORE-OVERLAP is non-nil) if needed. If `subed-enforce-time-boundaries' is set to `error', throw an error in those cases. If `subed-enforce-time-boundaries' is nil, make the changes without checking. Return the new subtitle stop time in milliseconds." (save-excursion (when (or (not sub-id) (and sub-id (subed-jump-to-subtitle-id sub-id))) (let ((current-start (subed-subtitle-msecs-start))) (when (subed-jump-to-subtitle-time-stop sub-id) (when (and subed-enforce-time-boundaries (not ignore-negative-duration) (< msecs current-start)) (pcase subed-enforce-time-boundaries ('error (error "Stop time %s will be before start time %s" (subed-msecs-to-timestamp msecs) (subed-msecs-to-timestamp current-start))) ('clip (message "Clipping time to %s" (subed-msecs-to-timestamp current-start)) (setq msecs (subed-subtitle-msecs-start))) ('adjust (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-start msecs) (message "Adjusted start time to %s to avoid negative duration" (subed-msecs-to-timestamp current-start)))))))) (when (and subed-enforce-time-boundaries (not ignore-overlap)) (subed-save-excursion (when (and (subed-forward-subtitle-time-stop) subed-subtitle-spacing (< (subed-subtitle-msecs-start) (+ msecs subed-subtitle-spacing))) (pcase subed-enforce-time-boundaries ('error (error "Stop time %s will be too close to next start time of %s" (subed-msecs-to-timestamp msecs) (subed-msecs-to-timestamp (subed-subtitle-msecs-start)))) ('clip (setq msecs (- (subed-subtitle-msecs-start) subed-subtitle-spacing)) (message "Clipping to %s to preserve spacing" (subed-msecs-to-timestamp msecs))) ('adjust (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-start (+ msecs subed-subtitle-spacing)) (message "Adjusted next start time to %s to maintain spacing" (subed-msecs-to-timestamp (subed-subtitle-msecs-start))))))))) ;; Update loop end if it's within the current subtitle (when (and subed--subtitle-loop-stop (> subed--subtitle-loop-stop (subed-subtitle-msecs-start)) (<= subed--subtitle-loop-stop (floor (+ (subed-subtitle-msecs-stop) (* 1000 (or subed-loop-seconds-after 0)))))) (setq subed--subtitle-loop-stop (floor (+ msecs (* 1000 (or subed-loop-seconds-after 0)))))) (when (and (subed-jump-to-subtitle-time-stop) (looking-at subed--regexp-timestamp)) (replace-match (save-match-data (subed-msecs-to-timestamp msecs))) (subed-subtitle-msecs-stop))))) (subed-define-generic-function make-subtitle (&optional id start stop text comment) "Generate new subtitle string. ID, START default to 0. STOP defaults to (+ START `subed-subtitle-spacing') TEXT defaults to an empty string. COMMENT defaults to nil." (interactive "P")) (subed-define-generic-function prepend-subtitle (&optional id start stop text comment) "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. COMMENT defaults to nil. Move point to the text of the inserted subtitle. Return new point." (interactive "P")) (subed-define-generic-function append-subtitle (&optional id start stop text comment) "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. COMMENT defaults to nil. Move point to the text of the inserted subtitle. Return new point." (interactive "P")) (subed-define-generic-function kill-subtitle () "Remove subtitle at point." (interactive) (let ((beg (save-excursion (subed-jump-to-subtitle-start-pos))) (end (save-excursion (subed-forward-subtitle-start-pos)))) (if (not end) ;; Removing the last subtitle because forward-subtitle-id returned nil (setq beg (save-excursion (goto-char beg) (subed-backward-subtitle-end) (1+ (point))) end (point-max))) (when beg (remove-overlays beg end) (kill-region beg end)))) ;;;###autoload (defun subed-parse-file (filename &optional mode-func) "Return the subtitles from FILENAME in a list. If MODE-FUNC is non-nil, use that function to initialize the mode. Otherwise, initialize the mode based on the filename." (when (and filename (file-exists-p filename)) (with-temp-buffer (let ((subed-auto-play-media nil)) (insert-file-contents filename) (if mode-func (funcall mode-func) (let ((mode-entry (seq-find (lambda (mode-alist) (string-match (car mode-alist) filename)) auto-mode-alist))) (if mode-entry (funcall (cdr mode-entry)) (subed-tsv-mode)))) (subed-subtitle-list))))) (defun subed-subtitle () "Return the subtitle at point as a list. The list is of the form (id start stop text comment)." (list (subed-subtitle-id) (subed-subtitle-msecs-start) (subed-subtitle-msecs-stop) (subed-subtitle-text) (subed-subtitle-comment))) (defun subed-subtitle-list (&optional beg end) "Return the subtitles from BEG to END as a list. The list will contain entries of the form (id start stop text comment). If BEG and END are not specified, use the whole buffer." (let (result) (subed-for-each-subtitle (or beg (point-min)) (or end (point-max)) nil (when (subed-subtitle-msecs-start) (setq result (cons (subed-subtitle) result)))) (nreverse result))) (defun subed-append-subtitle-list (subtitles) "Append SUBTITLES. SUBTITLES should be a list with entries of the form (id start stop text comment)." (mapc (lambda (sub) (apply #'subed-append-subtitle sub)) subtitles)) (defun subed-subtitle-list-text (subtitles &optional include-comments) "Return the text in SUBTITLES. If INCLUDE-COMMENTS is non-nil, include the comments. If INCLUDE-COMMENTS is a function, call the function on comments before including them." (mapconcat (lambda (sub) (if (and include-comments (elt sub 4) (not (string= (elt sub 4) ""))) (concat "\n" (if (functionp include-comments) (funcall include-comments (elt sub 4)) (elt sub 4)) "\n\n" (elt sub 3) "\n") (concat (elt sub 3) "\n"))) subtitles "")) (defun subed-copy-region-text (&optional beg end include-comments) "Copy the text from BEG to END to the kill ring. If BEG and END are not specified, use the whole buffer. If INCLUDE-COMMENTS is non-nil, include the comments. If INCLUDE-COMMENTS is a function, call the function on comments before including them." (interactive (list (and (use-region-p) (min (point) (mark))) (and (use-region-p) (max (point) (mark))) current-prefix-arg)) (kill-new (subed-subtitle-list-text (subed-subtitle-list beg end) include-comments))) (defun subed-section-comments-as-chapters () "Copy subtitle comments as chapters for video descriptions." (interactive) (let ((result (mapconcat (lambda (sub) (if (elt sub 4) (concat (format-seconds "%02h:%z%02m:%02s" (floor (/ (elt sub 1) 1000))) " " (string-trim (elt sub 4)) "\n") "")) (subed-subtitle-list) ""))) (when (called-interactively-p 'any) (kill-new result)) result)) (subed-define-generic-function sanitize () "Sanitize this file." (interactive) (run-hooks 'subed-sanitize-functions)) (subed-define-generic-function sanitize-format () "Remove surplus newlines and whitespace." nil) (subed-define-generic-function validate () "Move point to the first invalid subtitle and report an error." (interactive) (run-hooks 'subed-validate-functions)) (subed-define-generic-function validate-format () "Validate format-specific rules." nil) (subed-define-generic-function regenerate-ids () "Ensure consecutive, unduplicated subtitle IDs in formats that use them." nil) (defvar-local subed--regenerate-ids-soon-timer nil) (subed-define-generic-function regenerate-ids-soon () "Delay regenerating subtitle IDs for a short amount of time. Run `subed-regenerate-ids' in 100ms unless this function is called again within the next 100ms, in which case the previously scheduled call is canceled and another call is scheduled in 100ms." (interactive) (when subed--regenerate-ids-soon-timer (cancel-timer subed--regenerate-ids-soon-timer)) (setq subed--regenerate-ids-soon-timer (run-at-time 0.1 nil (lambda () (setq subed--regenerate-ids-soon-timer nil) (subed-regenerate-ids))))) ;;; Utilities (defun subed--right-pad (string length fillchar) "Use FILLCHAR to make STRING LENGTH characters long." (concat string (make-string (- length (length string)) fillchar))) ;;; Hooks for point motion and subtitle motion (defvar-local subed--current-point -1) (defvar-local subed--buffer-chars-modified-tick -1) (defvar-local subed--current-subtitle-id -1) (defun subed--post-command-handler () "Detect point motion and user entering text and signal hooks." ;; Check for point motion first to avoid expensive calls to subed-subtitle-id ;; as often as possible. (save-match-data (let ((new-point (point)) (new-buffer-chars-modified-tick (buffer-chars-modified-tick))) (when (and new-point subed--current-point (not (and (= new-point subed--current-point) (= new-buffer-chars-modified-tick subed--buffer-chars-modified-tick)))) ;; If point is synced to playback position, temporarily disable that so ;; that manual moves aren't cancelled immediately by automated moves. (subed-disable-sync-point-to-player-temporarily) ;; Store new point and fire signal. (setq subed--current-point new-point subed--buffer-chars-modified-tick new-buffer-chars-modified-tick) (run-hooks 'subed-point-motion-hook) ;; Check if point moved across subtitle boundaries. (let ((new-sub-id (subed-subtitle-id))) (when (and new-sub-id subed--current-subtitle-id (not (funcall (if (stringp subed--current-subtitle-id) 'string= 'equal) new-sub-id subed--current-subtitle-id))) ;; Store new ID and fire signal. (setq subed--current-subtitle-id new-sub-id) (run-hooks 'subed-subtitle-motion-hook))))))) ;;; Adjusting start/stop time individually (defun subed-adjust-subtitle-time-start (msecs &optional ignore-negative-duration ignore-overlap) "Add MSECS milliseconds to start time (use negative value to subtract). Unless either IGNORE-NEGATIVE-DURATION is non-nil or `subed-enforce-time-boundaries' is nil, adjust MSECS so that the stop time isn't smaller than the start time. Zero-length subtitles are always allowed. Unless either IGNORE-OVERLAP is non-nil or `subed-enforce-time-boundaries' is nil, ensure that there are no gaps between subtitles smaller than `subed-subtitle-spacing' milliseconds by adjusting MSECS if necessary. Return the number of milliseconds the start time was adjusted or nil if nothing changed." (subed-disable-sync-point-to-player-temporarily) (let* ((msecs-start (subed-subtitle-msecs-start)) (msecs-new (when msecs-start (+ msecs-start msecs)))) (when msecs-new ;; MSECS-NEW must be bigger than the current start time if we are adding ;; or smaller if we are subtracting. (setq msecs-new (subed-set-subtitle-time-start msecs-new nil ignore-negative-duration ignore-overlap)) (subed--run-subtitle-time-adjusted-hook) (- msecs-new msecs-start)))) (defun subed-adjust-subtitle-time-stop (msecs &optional ignore-negative-duration ignore-overlap) "Add MSECS milliseconds to stop time (use negative value to subtract). Unless either IGNORE-NEGATIVE-DURATION or `subed-enforce-time-boundaries' are non-nil, adjust MSECS so that the stop time isn't smaller than the start time. Zero-length subtitles are always allowed. Unless either IGNORE-OVERLAP or `subed-enforce-time-boundaries' are non-nil, ensure that there are no gaps between subtitles smaller than `subed-subtitle-spacing' milliseconds by adjusting MSECS if necessary. Return the number of milliseconds the stop time was adjusted or nil if nothing changed." (subed-disable-sync-point-to-player-temporarily) (let* ((msecs-stop (subed-subtitle-msecs-stop)) (msecs-new (when msecs-stop (+ msecs-stop msecs)))) ;; MSECS-NEW must be bigger than the current stop time if we are adding or ;; smaller if we are subtracting. (when (and (>= msecs-new 0) ;; Ignore negative times (or (and (> msecs 0) (> msecs-new msecs-stop)) ;; Adding (and (< msecs 0) (< msecs-new msecs-stop)))) ;; Subtracting (setq msecs-new (subed-set-subtitle-time-stop msecs-new nil ignore-negative-duration ignore-overlap)) (subed--run-subtitle-time-adjusted-hook) (- msecs-new msecs-stop)))) (defun subed-increase-start-time (&optional arg) "Add `subed-milliseconds-adjust' milliseconds to start time. Return new start time in milliseconds or nil if it didn't change. If prefix argument ARG is given, it is used to set `subed-milliseconds-adjust' before moving subtitles. If the prefix argument is given but not numerical, `subed-milliseconds-adjust' is reset to its default value. Example usage: \\[universal-argument] 1000 \\[subed-increase-start-time] Increase start time by 1000ms \\[subed-increase-start-time] Increase start time by 1000ms again \\[universal-argument] 500 \\[subed-increase-start-time] Increase start time by 500ms \\[subed-increase-start-time] Increase start time by 500ms again \\[universal-argument] \\[subed-increase-start-time] Increase start time by 100ms (the default) \\[subed-increase-start-time] Increase start time by 100ms (the default) again" (interactive "P") (subed-adjust-subtitle-time-start (subed-get-milliseconds-adjust arg)) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop))) (defun subed-decrease-start-time (&optional arg) "Subtract `subed-milliseconds-adjust' milliseconds from start time. Return new start time in milliseconds or nil if it didn't change. See `subed-increase-start-time' about ARG." (interactive "P") (subed-adjust-subtitle-time-start (* -1 (subed-get-milliseconds-adjust arg))) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop))) (defun subed-increase-stop-time (&optional arg) "Add `subed-milliseconds-adjust' milliseconds to stop time. Return new stop time in milliseconds or nil if it didn't change. See `subed-increase-start-time' about ARG." (interactive "P") (subed-adjust-subtitle-time-stop (subed-get-milliseconds-adjust arg)) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop))) (defun subed-decrease-stop-time (&optional arg) "Subtract `subed-milliseconds-adjust' milliseconds from stop time. Return new stop time in milliseconds or nil if it didn't change. See `subed-increase-start-time' about ARG." (interactive "P") (subed-adjust-subtitle-time-stop (* -1 (subed-get-milliseconds-adjust arg))) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop))) (defun subed-copy-player-pos-to-start-time () "Replace current subtitle's start time with current playback time." (interactive) (when (and subed-mpv-playback-position (subed-subtitle-msecs-start)) (subed-set-subtitle-time-start subed-mpv-playback-position) (subed--run-subtitle-time-adjusted-hook) subed-mpv-playback-position)) (defun subed-copy-player-pos-to-stop-time () "Replace current subtitle's stop time with current playback time." (interactive) (when (and subed-mpv-playback-position (subed-subtitle-msecs-stop)) (subed-set-subtitle-time-stop subed-mpv-playback-position) (subed--run-subtitle-time-adjusted-hook) subed-mpv-playback-position)) (defun subed-copy-player-pos-to-start-time-and-copy-to-previous () "Replace current subtitle's start time with current playback time. Update the previous subtitle's stop time." (interactive) (when (and subed-mpv-playback-position (subed-subtitle-msecs-start)) (subed-set-subtitle-time-start subed-mpv-playback-position) (subed--run-subtitle-time-adjusted-hook) (save-excursion (when (subed-backward-subtitle-time-stop) (subed-set-subtitle-time-stop (- subed-mpv-playback-position subed-subtitle-spacing))) (subed--run-subtitle-time-adjusted-hook)) subed-mpv-playback-position)) (defun subed-copy-player-pos-to-stop-time-and-copy-to-next () "Replace current subtitle's stop time with current playback time. Update the next subtitle's start time." (interactive) (when (and subed-mpv-playback-position (subed-subtitle-msecs-stop)) (subed-set-subtitle-time-stop subed-mpv-playback-position) (subed--run-subtitle-time-adjusted-hook) (save-excursion (when (subed-forward-subtitle-time-start) (subed-set-subtitle-time-start (+ subed-mpv-playback-position subed-subtitle-spacing))) (subed--run-subtitle-time-adjusted-hook)) subed-mpv-playback-position)) ;;; Moving subtitles ;;; (adjusting start and stop time by the same amount) (defun subed--get-move-subtitle-func (msecs) "Return subtitle moving function. When moving subtitles forward (MSECS > 0), we must adjust the stop time first and adjust the start time by the same amount the stop time was adjusted. This ensures that subtitle length doesn't change if we can't move MSECS milliseconds forward because we'd overlap with the next subtitle. When moving subtitles backward (MSECS < 0), it's the same thing but we move the start time first." (if (> msecs 0) ;; Moving forward (lambda (msecs &optional ignore-overlap) (let ((msecs (subed-adjust-subtitle-time-stop msecs :ignore-negative-duration ignore-overlap))) (when msecs (subed-adjust-subtitle-time-start msecs :ignore-negative-duration ignore-overlap)))) ;; Moving backward (lambda (msecs &optional ignore-overlap) (let ((msecs (subed-adjust-subtitle-time-start msecs :ignore-negative-duration ignore-overlap))) (when msecs (subed-adjust-subtitle-time-stop msecs :ignore-negative-duration ignore-overlap)))))) (defun subed--move-current-subtitle (msecs) "Move subtitle on point by MSECS milliseconds." (unless (= msecs 0) (subed-with-subtitle-replay-disabled (cl-flet ((move-subtitle (subed--get-move-subtitle-func msecs))) (move-subtitle msecs))))) (defun subed--scale-subtitles-in-region (msecs beg end) "Scale subtitles between BEG and END after moving END milliseconds. BEG and END specify a region. This stretches the subtitles so that they cover the new timespan. If you want to shift all the subtitles by the same offset, use `subed-move-subtitles' instead." (let* ((beg-point (save-excursion ; normalized to fixed location over BEG (goto-char beg) (subed-jump-to-subtitle-end) (point))) (beg-next-point (save-excursion (goto-char beg-point) (subed-forward-subtitle-end) (point))) (end-point (save-excursion ; normalized to fixed location over END (goto-char end) (subed-jump-to-subtitle-end) (point))) (end-prev-point (save-excursion (goto-char end-point) (subed-backward-subtitle-end) (point))) (beg-start-msecs (save-excursion (goto-char beg-point) (subed-subtitle-msecs-start))) (old-end-start-msecs (save-excursion (goto-char end-point) (subed-subtitle-msecs-start)))) ;; check for improper range (BEG after END) (unless (<= beg end) (user-error "Can't scale with improper range")) ;; check for 0 or 1 subtitle scenario (unless (/= beg-point end-point) (user-error "Can't scale with fewer than 3 subtitles")) ;; check for 2 subtitle scenario (unless (/= beg-point end-prev-point) (user-error "Can't scale with only 2 subtitles")) ;; check for missing timestamps (unless beg-start-msecs (user-error "Can't scale when first subtitle timestamp missing")) (unless old-end-start-msecs (user-error "Can't scale when last subtitle timestamp missing")) ;; check for range with 0 time interval (unless (/= beg-start-msecs old-end-start-msecs) (user-error "Can't scale subtitle range with 0 time interval")) ;; check for overlap (when (and (> msecs 0) (eq subed-enforce-time-boundaries 'error)) (subed-save-excursion (goto-char end-point) (let ((old-stop (subed-subtitle-msecs-stop))) (when (and (subed-forward-subtitle-time-start) (> (+ old-stop msecs subed-subtitle-spacing) (subed-subtitle-msecs-start))) (user-error "Can't scale when extension would overlap subsequent subtitles"))))) ;; check for non-chronological: last will start before previous subtitle stops (let ((list (subed-subtitle-list beg end))) ;; make sure each subtitle ends before the next subtitle starts (while list (when (and (cdr list) (> (elt (car list) 2) (elt (cadr list) 1))) (user-error "Can't scale when nonchronological subtitles exist")) (setq list (cdr list)))) (unless (= msecs 0) (atomic-change-group (subed-with-subtitle-replay-disabled (cl-flet ((move-subtitle (subed--get-move-subtitle-func msecs))) (let* ((new-end-start-msecs (+ old-end-start-msecs msecs)) (scale-factor (/ (float (- new-end-start-msecs beg-start-msecs)) (float (- old-end-start-msecs beg-start-msecs)))) (scale-subtitles (lambda (&optional reverse) (subed-for-each-subtitle beg-next-point end-prev-point reverse (let ((old-start-msecs (subed-subtitle-msecs-start))) (unless old-start-msecs (user-error "Can't scale when subtitle timestamp missing")) (let* ((new-start-msecs (+ beg-start-msecs (round (* (- old-start-msecs beg-start-msecs) scale-factor)))) (delta-msecs (- new-start-msecs old-start-msecs))) (unless (and (<= beg-start-msecs old-start-msecs) (>= old-end-start-msecs old-start-msecs)) (user-error "Can't scale when nonchronological subtitles exist")) (move-subtitle delta-msecs :ignore-negative-duration))))))) (atomic-change-group (if (> msecs 0) (save-excursion ;; Moving forward - Start on last subtitle to see if we ;; can move forward. (goto-char end) (move-subtitle msecs) (funcall scale-subtitles :reverse)) (save-excursion ;; Moving backward - Make sure the last subtitle will not ;; precede the first subtitle. (unless (> new-end-start-msecs beg-start-msecs) (user-error "Can't scale when contraction would eliminate region")) (goto-char end) (move-subtitle msecs :ignore-negative-duration) (funcall scale-subtitles) (run-hook-with-args 'subed-region-adjusted-hook beg (point)))))))))))) (defun subed--move-subtitles-in-region (msecs beg end) "Move subtitles in region specified by BEG and END by MSECS milliseconds." (unless (= msecs 0) (atomic-change-group (subed-with-subtitle-replay-disabled (cl-flet ((move-subtitle (subed--get-move-subtitle-func msecs))) ;; When moving subtitles forward, the first step is to move the last ;; subtitle because: ;; a) We need to check if we can move at all and abort if not. ;; b) We may have to reduce MSECS if we can move but not by the full ;; amount. The goal is that all subtitles are moved by the same ;; amount and the spacing between subtitles doesn't change. ;; All other subtitles must be moved without any checks because we only ;; ensure that the active region as a whole can be moved, not it's ;; individual parts, which may be too close together or even overlap. ;; Moving subtitles backward is basically the same thing but vice versa. (catch 'bumped-into-subtitle (if (> msecs 0) (save-excursion ;; Moving forward - Start on last subtitle to see if/how far ;; we can move forward. (goto-char end) (unless (setq msecs (move-subtitle msecs)) (throw 'bumped-into-subtitle t)) (subed-backward-subtitle-id) (subed-for-each-subtitle beg (point) :reverse (move-subtitle msecs :ignore-negative-duration))) ;; Start on first subtitle to see if/how far we can move backward. (save-excursion (goto-char beg) (unless (setq msecs (move-subtitle msecs)) (throw 'bumped-into-subtitle t)) (subed-forward-subtitle-id) (subed-for-each-subtitle (point) end nil (move-subtitle msecs :ignore-negative-duration))))) (run-hook-with-args 'subed-region-adjusted-hook beg (point))))))) (defun subed-scale-subtitles (msecs &optional beg end) "Scale subtitles between BEG and END after moving END MSECS. Use a negative MSECS value to move END backward. If END is nil, END will be the last subtitle in the buffer. If BEG is nil, BEG will be the first subtitle in the buffer. If you want to shift all the subtitles by the same offset, use `subed-move-subtitles' instead." (interactive (list (if current-prefix-arg (prefix-numeric-value current-prefix-arg) (read-number "Milliseconds: ")) (when (region-active-p) (point)) (when (region-active-p) (mark)))) (let ((beg (or beg (point-min))) (end (or end (point-max)))) (subed--scale-subtitles-in-region msecs beg end) (when (subed-replay-adjusted-subtitle-p) (save-excursion (goto-char end) (subed-jump-to-subtitle-id) (subed-mpv-jump (subed-subtitle-msecs-start)))))) (defun subed-scale-subtitles-forward (&optional arg) "Scale subtitles after region is extended `subed-milliseconds-adjust'. Scaling adjusts start and stop by the same amount, preserving subtitle duration. All subtitles that are fully or partially in the active region are moved so they are placed proportionally in the new range. If prefix argument ARG is given, it is used to extend the end of the region `subed-milliseconds-adjust' before proportionally adjusting subtitles. If the prefix argument is given but not numerical, `subed-milliseconds-adjust' is reset to its default value. Example usage: \\[universal-argument] 1000 \\[subed-scale-subtitles-forward] Extend region 1000ms forward in time and scale subtitles in region. \\[subed-scale-subtitles-forward] Extend region another 1000ms forward in time and scale subtitles again. \\[universal-argument] 500 \\[subed-scale-subtitles-forward] Extend region 500ms forward in time and scale subtitles in region. \\[subed-scale-subtitles-forward] Extend region another 500ms forward in time and scale subtitles again. \\[universal-argument] \\[subed-scale-subtitles-forward] Extend region 100ms (the default) forward in time and scale subtitles in region. \\[subed-scale-subtitles-forward] Extend region another 100ms (the default) forward in time and scale subtitles again. If you want to shift all the subtitles by the same offset, use `subed-move-subtitles' instead." (interactive "P") (let ((deactivate-mark nil) (msecs (subed-get-milliseconds-adjust arg)) (beg (when mark-active (region-beginning))) (end (when mark-active (region-end)))) (subed-scale-subtitles msecs beg end))) (defun subed-scale-subtitles-backward (&optional arg) "Scale subtitles after region is shortened `subed-milliseconds-adjust'. See `subed-scale-subtitles-forward' about ARG. If you want to shift all the subtitles by the same offset, use `subed-move-subtitles' instead." (interactive "P") (let ((deactivate-mark nil) (msecs (* -1 (subed-get-milliseconds-adjust arg))) (beg (when mark-active (region-beginning))) (end (when mark-active (region-end)))) (subed-scale-subtitles msecs beg end))) (defun subed-move-subtitles (msecs &optional beg end) "Move subtitles between BEG and END MSECS milliseconds forward. Use a negative MSECS value to move subtitles backward. If END is nil, move all subtitles from BEG to end of buffer. If BEG is nil, move only the current subtitle. After subtitles are moved, replay the first moved subtitle if replaying is enabled. To move to a specific timestamp, use `subed-move-subtitles-to-start-at-timestamp'. To move the current subtitle and following subtitles by default, use `subed-shift-subtitles', `subed-shift-subtitle-forward', or `subed-shift-subtitle-backward'." (interactive (list (if current-prefix-arg (prefix-numeric-value current-prefix-arg) (read-number "Milliseconds: ")) (when (region-active-p) (point)) (when (region-active-p) (mark)))) (cond ((and beg end) (subed--move-subtitles-in-region msecs beg end)) (beg (subed--move-subtitles-in-region msecs beg (point-max))) (t (subed--move-current-subtitle msecs))) (when (subed-replay-adjusted-subtitle-p) (save-excursion (when beg (goto-char beg)) (subed-mpv-jump (subed-subtitle-msecs-start))))) (defun subed-move-subtitles-to-start-at-timestamp (timestamp &optional beg end) "Move subtitles between BEG and END to start at TIMESTAMP. If END is nil, move all subtitles from BEG to end of buffer. If BEG is nil, move only the current subtitle. After subtitles are moved, replay the first moved subtitle if replaying is enabled. To move the current subtitle and following subtitles by default, use `subed-shift-subtitles', `subed-shift-subtitles-to-start-at-timestamp', `subed-shift-subtitle-forward', or `subed-shift-subtitle-backward'." (interactive (list (read-string "New start: ") (when (region-active-p) (point)) (when (region-active-p) (mark)))) (subed-move-subtitles (- (subed-timestamp-to-msecs timestamp) (subed-subtitle-msecs-start)) beg end)) (defun subed-move-subtitle-forward (&optional arg) "Move subtitle `subed-milliseconds-adjust' forward. Moving adjusts start and stop time by the same amount, preserving subtitle duration. All subtitles that are fully or partially in the active region are moved. If prefix argument ARG is given, it is used to set `subed-milliseconds-adjust' before moving subtitles. If the prefix argument is given but not numerical, `subed-milliseconds-adjust' is reset to its default value. Example usage: \\[universal-argument] 1000 \\[subed-move-subtitle-forward] Move subtitle 1000ms forward in time \\[subed-move-subtitle-forward] Move subtitle 1000ms forward in time again \\[universal-argument] 500 \\[subed-move-subtitle-forward] Move subtitle 500ms forward in time \\[subed-move-subtitle-forward] Move subtitle 500ms forward in time again \\[universal-argument] \\[subed-move-subtitle-forward] Move subtitle 100ms (the default) forward in time \\[subed-move-subtitle-forward] Move subtitle 100ms (the default) forward in time again" (interactive "P") (let ((deactivate-mark nil) (msecs (subed-get-milliseconds-adjust arg)) (beg (when mark-active (region-beginning))) (end (when mark-active (region-end)))) (subed-move-subtitles msecs beg end))) (defun subed-move-subtitle-backward (&optional arg) "Move subtitle `subed-milliseconds-adjust' backward. See `subed-move-subtitle-forward' about ARG." (interactive "P") (let ((deactivate-mark nil) (msecs (* -1 (subed-get-milliseconds-adjust arg))) (beg (when mark-active (region-beginning))) (end (when mark-active (region-end)))) (subed-move-subtitles msecs beg end))) ;;; Shifting subtitles ;;; (same as moving, but follow-up subtitles are also moved) (defun subed-shift-subtitles (&optional arg) "Move this and following subtitles by ARG milliseconds. To shift to a specific timestamp, use `subed-shift-subtitles-to-start-at-timestamp'." (interactive (list (if current-prefix-arg (prefix-numeric-value current-prefix-arg) (read-number "Milliseconds: ")))) (let ((deactivate-mark nil) (msecs (subed-get-milliseconds-adjust arg))) (subed-move-subtitles msecs (point)))) (defun subed-shift-subtitles-to-start-at-timestamp (timestamp) "Move this and following subtitles to starts at TIMESTAMP. To shift by a millisecond offset, use `subed-shift-subtitles'. If TIMESTAMP is a number or a numeric string, treat it as the time in milliseconds." (interactive (list (read-string "New start: "))) (subed-shift-subtitles (- (subed-to-msecs timestamp) (or (subed-subtitle-msecs-start) (and (subed-forward-subtitle-time-start) (subed-subtitle-msecs-start)))))) (defun subed-shift-subtitle-forward (&optional arg) "Shift subtitle `subed-milliseconds-adjust' forward. Shifting is like moving, but it always moves the subtitles between point and the end of the buffer. See `subed-move-subtitle-forward' about ARG." (interactive "P") (let ((deactivate-mark nil) (msecs (subed-get-milliseconds-adjust arg))) (subed-move-subtitles msecs (point)))) (defun subed-shift-subtitle-backward (&optional arg) "Shift subtitle `subed-milliseconds-adjust' backward. Shifting is like moving, but it always moves the subtitles between point and the end of the buffer. See `subed-move-subtitle-forward' about ARG." (interactive "P") (let ((deactivate-mark nil) (msecs (* -1 (subed-get-milliseconds-adjust arg)))) (subed-move-subtitles msecs (point)))) ;;; Inserting (defun subed--insert-subtitle-info (arg) "Provide information for inserting subtitles. ARG is the user-given argument. Return a list of values in the following order: number-of-subs insert-before-current (t or nil) buffer-is-empty msecs-min msecs-max msecs-avail msecs-per-sub msecs-between insert-subtitle-func" (let* ((number-of-subs (cond ((not arg) 1) ;; M-i ((integerp arg) arg) ;; C-u N M-i / C-u - N M-i ;; C-u [C-u ...] M-i / C-u - [C-u ...] M-i ((consp arg) (* (truncate (log (abs (car arg)) 4)) ;; ([-]64) -> 3 (/ (car arg) (abs (car arg))))) ;; Restore sign (t 1))) ;; C-u - M-i (insert-before-current (or (< number-of-subs 0) ;; C-u - N M-i (eq arg '-) ;; C-u - M-i (consp arg))) ;; C-u [C-u ...] M-i ;; Ensure number-of-subs is positive, now that we figured out `insert-before-current' (number-of-subs (abs number-of-subs)) (buffer-is-empty (if (subed-subtitle-id) nil t)) ;; Find out how much time there is available (msecs-min (save-excursion (if insert-before-current (if (subed-backward-subtitle-id) (subed-subtitle-msecs-stop) 0) (subed-subtitle-msecs-stop)))) (msecs-max (save-excursion (if insert-before-current (subed-subtitle-msecs-start) (when (subed-forward-subtitle-id) (subed-subtitle-msecs-start))))) (msecs-avail (cond ((and msecs-min msecs-max) (- msecs-max msecs-min)) (msecs-max msecs-max) (t nil))) ;; Unlimited (msecs-per-sub (if msecs-avail (min subed-default-subtitle-length (max 0 (/ (- msecs-avail (* (1+ number-of-subs) subed-subtitle-spacing)) number-of-subs))) subed-default-subtitle-length)) (msecs-between (if (or (not msecs-avail) (>= msecs-avail (* (1+ number-of-subs) subed-subtitle-spacing))) subed-subtitle-spacing 0)) (insert-subtitle-func (if insert-before-current #'subed-prepend-subtitle #'subed-append-subtitle))) (subed-debug "Inserting %s subtitle(s) %s the current in %sempty buffer" number-of-subs (if insert-before-current "before" "after") (if buffer-is-empty "" "non-")) (subed-debug " Available time: min=%S max=%S avail=%S sublen=%S/%S" msecs-min msecs-max msecs-avail msecs-per-sub subed-default-subtitle-length) (list number-of-subs insert-before-current buffer-is-empty msecs-min msecs-max msecs-avail msecs-per-sub msecs-between insert-subtitle-func))) (subed-define-generic-function insert-subtitle (&optional arg) "Insert subtitle(s) evenly spaced. The inserted subtitles are `subed-default-subtitle-length' milliseconds long. Subtitles are spread out evenly over the available time. ARG, usually provided by `universal-argument', is used in the following manner: \\[subed-insert-subtitle] Insert 1 subtitle after the current subtitle \\[universal-argument] \\[subed-insert-subtitle] Insert 1 subtitle before the current subtitle \\[universal-argument] 5 \\[subed-insert-subtitle] Insert 5 subtitles after the current subtitle \\[universal-argument] - 5 \\[subed-insert-subtitle] Insert 5 subtitles before the current subtitle \\[universal-argument] \\[universal-argument] \\[subed-insert-subtitle] Insert 2 subtitles before the current subtitle" (interactive "P") (atomic-change-group (cl-multiple-value-bind (number-of-subs insert-before-current _buffer-is-empty msecs-min msecs-max msecs-avail msecs-per-sub msecs-between insert-subtitle-func) (subed--insert-subtitle-info arg) (dotimes (i number-of-subs) ;; Value constellations: ;; empty buffer, append : min=0 max=nil avail=nil ;; empty buffer, prepend : min=0 max=nil avail=nil ;; non-empty buffer, prepend, betwixt : min=non-nil max=non-nil avail=non-nil ;; non-empty buffer, append, betwixt : min=non-nil max=non-nil avail=non-nil ;; non-empty buffer, prepend to first : min=0 max=non-nil avail=non-nil ;; non-empty buffer, append to last : min=non-nil max=nil avail=nil (let* ((multiplier (if insert-before-current (- number-of-subs i) (1+ i))) (msecs-start (if msecs-avail ;; Inserting anywhere before the last subtitle (+ msecs-min (if (< msecs-per-sub subed-default-subtitle-length) ;; Use all available space between subtitles (+ msecs-between (* (1- multiplier) (+ msecs-between msecs-per-sub))) ;; Leave extra space between subtitles (* multiplier (/ msecs-avail (1+ number-of-subs))))) (if (and msecs-min (not msecs-max)) ;; Appending to last subtitle (+ msecs-min ;; If buffer is empty, start first subtitle at 0 (if (> msecs-min 0) msecs-between 0) (* (1- multiplier) (+ msecs-per-sub msecs-between))) ;; Appending in empty buffer (* i (+ msecs-per-sub msecs-between))))) (msecs-stop (+ msecs-start msecs-per-sub))) (subed-debug " Inserting new subtitle at %S - %S" msecs-start msecs-stop) (funcall insert-subtitle-func nil msecs-start msecs-stop nil))) (unless insert-before-current (dotimes (_ (1- number-of-subs)) (subed-backward-subtitle-text))))) (point)) (subed-define-generic-function insert-subtitle-adjacent (&optional arg) "Insert subtitle(s) close to each other. The inserted subtitles are `subed-default-subtitle-length' milliseconds long. Subtitles are inserted `subed-subtitle-spacing' milliseconds before or after the current subtitle. When inserting multiple subtitles, the gap between them is also `subed-subtitle-spacing' milliseconds long. ARG, usually provided by `universal-argument', is used in the following manner: \\[subed-insert-subtitle] Insert 1 subtitle after the current subtitle \\[universal-argument] \\[subed-insert-subtitle] Insert 1 subtitle before the current subtitle \\[universal-argument] 5 \\[subed-insert-subtitle] Insert 5 subtitles after the current subtitle \\[universal-argument] - 5 \\[subed-insert-subtitle] Insert 5 subtitles before the current subtitle \\[universal-argument] \\[universal-argument] \\[subed-insert-subtitle] Insert 2 subtitles before the current subtitle" (interactive "P") (atomic-change-group (cl-multiple-value-bind (number-of-subs insert-before-current buffer-is-empty msecs-min msecs-max _msecs-avail msecs-per-sub msecs-between insert-subtitle-func) (subed--insert-subtitle-info arg) (dotimes (i number-of-subs) ;; Value constellations: ;; empty buffer, append : min=0 max=nil avail=nil ;; empty buffer, prepend : min=0 max=nil avail=nil ;; non-empty buffer, prepend, betwixt : min=non-nil max=non-nil avail=non-nil ;; non-empty buffer, append, betwixt : min=non-nil max=non-nil avail=non-nil ;; non-empty buffer, prepend to first : min=0 max=non-nil avail=non-nil ;; non-empty buffer, append to last : min=non-nil max=nil avail=nil (let* ((multiplier (if insert-before-current (- number-of-subs i 1) i)) (msecs-start (if buffer-is-empty (* multiplier (+ msecs-between msecs-per-sub)) (if insert-before-current (- msecs-max (* (1+ i) (+ msecs-between msecs-per-sub))) (+ msecs-min msecs-between (* i (+ msecs-per-sub msecs-between)))))) (msecs-stop (+ msecs-start msecs-per-sub))) (subed-debug " Inserting new subtitle at %S - %S" msecs-start msecs-stop) (funcall insert-subtitle-func nil msecs-start msecs-stop nil))) (unless insert-before-current (dotimes (_ (1- number-of-subs)) (subed-backward-subtitle-text))))) (point)) (defvar subed-split-subtitle-timestamp-functions '(subed-split-subtitle-based-on-mpv-playback-position subed-split-subtitle-based-on-point-ratio) "Functions to call to get the timestamp to split at. Functions will be called with one argument. They should return a timestamp in milliseconds. The first non-nil value will be used for the split. Functions should preserve the point.") (defun subed-split-subtitle-based-on-mpv-playback-position () "Return a timestamp based on the current MPV position. Do this only if the position is within the start and end of the current subtitle." (when (and subed-mpv-playback-position (>= subed-mpv-playback-position (subed-subtitle-msecs-start)) (<= subed-mpv-playback-position (subed-subtitle-msecs-stop))) subed-mpv-playback-position)) (defun subed-split-subtitle-based-on-point-ratio () "Return a timestamp based on the relative position in the subtitle text." (let* ((pos (point)) (text-beg (or (save-excursion (subed-jump-to-subtitle-text)) pos)) (text-end (or (save-excursion (subed-jump-to-subtitle-end)) pos))) ;; Ensure point is on subtitle text (when (and (>= pos text-beg) (<= pos text-end)) (let* ((orig-start (subed-subtitle-msecs-start)) (orig-end (subed-subtitle-msecs-stop)) (text-fraction (if (= text-beg text-end) 1 (/ (* 1.0 (- (point) text-beg)) (- text-end text-beg)))) (time-fraction (floor (* text-fraction (- orig-end orig-start))))) (+ orig-start time-fraction))))) (subed-define-generic-function split-subtitle (&optional offset) "Split current subtitle at point. The subtitle text after point is moved to a new subtitle that is inserted after the current subtitle. If OFFSET is a number, it is used as the offset in milliseconds from the starting timestamp if positive or from the ending timestamp if negative. If OFFSET is a timestamp, it is used as the starting timestamp of the second subtitle. Otherwise, if `subed-mpv-playback-position' is within the current subtitle, it is used as the new stop time of the current subtitle. Otherwise, the timestamp proportional to the point's position between start and stop timestamp of the current subtitle is used. If called interactively, calling it with one prefix argument (e.g. \\[universal-argument] \\[subed-split-subtitle]) prompts for the offset in milliseconds. Calling it with two prefix arguments (e.g. \\[universal-argument] \\[universal-argument] \\[subed-split-subtitle]) uses the relative position of the point even if the media is playing in MPV. The newly inserted subtitle starts `subed-subtitle-spacing' milliseconds after the current subtitle's new end timestamp. Move to the beginning of the new subtitle's text and return the position of the point." (interactive (list (cond ((equal current-prefix-arg '(4)) (read-string "Offset (ms or timestamp): ")) ((equal current-prefix-arg '(16)) t)))) (let ((text-beg (save-excursion (subed-jump-to-subtitle-text))) (text-end (save-excursion (or (subed-jump-to-subtitle-end) (point))))) ;; Ensure point is on subtitle text (unless (and text-beg (>= (point) text-beg)) (subed-jump-to-subtitle-text)) (let* ((orig-end (subed-subtitle-msecs-stop)) (offset-ms (cond ((null offset) nil) ((numberp offset) offset) ((and (stringp offset) (string-match "^-?[0-9]*\\.[0-9]*" offset)) (string-to-number offset)))) (split-timestamp (cond (offset-ms (if (> offset-ms 0) (+ (subed-subtitle-msecs-start) offset-ms) (+ orig-end offset-ms))) ((stringp offset) (- (subed-timestamp-to-msecs offset) subed-subtitle-spacing)) (t (run-hook-with-args-until-success 'subed-split-subtitle-timestamp-functions)))) (new-text (string-trim (buffer-substring (point) text-end))) (new-start-timestamp (and split-timestamp (+ split-timestamp subed-subtitle-spacing)))) (if split-timestamp (progn (subed-set-subtitle-time-stop split-timestamp) (skip-chars-backward "\r\n") (delete-region (point) (progn (subed-jump-to-subtitle-end) (skip-chars-forward " \t") (point))) (when (looking-at "[ \t]+") (replace-match "")) (subed-append-subtitle nil new-start-timestamp orig-end (string-trim new-text))) (error "Could not determine timestamp for splitting"))) (point))) ;;; Merging (defun subed-merge-dwim () "Merge the subtitles in the region if the region is active. If the region is not active, merge the current subtitle with the next one." (interactive) (if (region-active-p) (subed-merge-region (min (point) (mark)) (max (point) (mark))) (subed-merge-with-next))) (subed-define-generic-function merge-with-next () "Merge the current subtitle with the next subtitle. Update the end timestamp accordingly." (interactive)) (subed-define-generic-function merge-with-previous () "Merge the current subtitle with the previous subtitle. Update the end timestamp accordingly." (interactive) (if (subed-backward-subtitle-id) (subed-merge-with-next) (error "No previous subtitle to merge into"))) (subed-define-generic-function merge-region (beg end) "Merge the subtitles in the region defined by BEG and END." (interactive "r") (save-restriction (narrow-to-region (progn (goto-char beg) (or (subed-jump-to-subtitle-id) (point))) (progn (goto-char end) (if (= (point) (subed-subtitle-start-pos)) (point) (or (subed-jump-to-subtitle-end) (point))))) (goto-char beg) (while (save-excursion (subed-forward-subtitle-id)) (subed-merge-with-next)))) (subed-define-generic-function merge-region-and-set-text (beg end text) "Merge the subtitles in the region defined by BEG and END. Replace the subtitle text with TEXT. If the region is not specified, set the current subtitle's text." (interactive (list (when (region-active-p) (min (point) (mark))) (when (region-active-p) (max (point) (mark))) (read-string "Text: "))) (when (and beg end) (subed-merge-region beg end)) (subed-set-subtitle-text text)) ;;; Replay time-adjusted subtitle (defun subed-replay-adjusted-subtitle-p () "Whether the player jumps to start time when start or stop time is adjusted." (member #'subed--replay-adjusted-subtitle subed-subtitle-time-adjusted-hook)) (defun subed-enable-replay-adjusted-subtitle (&optional quiet) "Automatically replay a subtitle when its start/stop time is adjusted. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (unless (subed-replay-adjusted-subtitle-p) (add-hook 'subed-subtitle-time-adjusted-hook #'subed--replay-adjusted-subtitle :append :local) (subed-debug "Enabled replaying adjusted subtitle: %s" subed-subtitle-time-adjusted-hook) (unless quiet (message "Enabled replaying adjusted subtitle")))) (defun subed-disable-replay-adjusted-subtitle (&optional quiet) "Do not replay a subtitle automatically when its start/stop time is adjusted. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (when (subed-replay-adjusted-subtitle-p) (remove-hook 'subed-subtitle-time-adjusted-hook #'subed--replay-adjusted-subtitle :local) (subed-debug "Disabled replaying adjusted subtitle: %s" subed-subtitle-time-adjusted-hook) (unless quiet (message "Disabled replaying adjusted subtitle")))) (defun subed-toggle-replay-adjusted-subtitle () "Enable/disable subtitle replay when start/stop time is adjusted." (interactive) (if (subed-replay-adjusted-subtitle-p) (subed-disable-replay-adjusted-subtitle) (subed-enable-replay-adjusted-subtitle))) (defun subed--replay-adjusted-subtitle (msecs-start) "Seek player to MSECS-START." (subed-debug "Replaying subtitle at: %s" (subed-msecs-to-timestamp msecs-start)) (subed-mpv-jump msecs-start)) ;;; Sync point-to-player (defun subed-sync-point-to-player-p () "Whether point is automatically moved to currently playing subtitle." (member #'subed--sync-point-to-player subed-mpv-playback-position-hook)) (defun subed-enable-sync-point-to-player (&optional quiet) "Automatically move point to the currently playing subtitle. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) ;; If looping over a subtitle, don't immediately enable it. Set it to be enabled after the loop ends. (if (subed-loop-over-current-subtitle-p) (setq subed--enable-point-to-player-sync-after-disabling-loop t) (unless (subed-sync-point-to-player-p) (add-hook 'subed-mpv-playback-position-hook #'subed--sync-point-to-player :append :local) (subed-debug "Enabled syncing point to playback position: %s" subed-mpv-playback-position-hook) (unless quiet (message "Enabled syncing point to playback position"))))) (defun subed-disable-sync-point-to-player (&optional quiet) "Do not move point automatically to the currently playing subtitle. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (when (subed-loop-over-current-subtitle-p) (setq subed--enable-point-to-player-sync-after-disabling-loop nil)) (when (subed-sync-point-to-player-p) (remove-hook 'subed-mpv-playback-position-hook #'subed--sync-point-to-player :local) (subed-debug "Disabled syncing point to playback position: %s" subed-mpv-playback-position-hook) (unless quiet (message "Disabled syncing point to playback position")))) (defun subed-toggle-sync-point-to-player () "Enable/disable moving point to the currently playing subtitle." (interactive) (if (subed-sync-point-to-player-p) (subed-disable-sync-point-to-player) (subed-enable-sync-point-to-player))) (defun subed--sync-point-to-player (msecs) "Move point to subtitle at MSECS." (when (and (not (use-region-p)) ;; Don't sync with active-mark in transient-mark-mode (subed-jump-to-subtitle-text-at-msecs msecs)) (subed-debug "Synchronized point to playback position: %s -> #%s" (subed-msecs-to-timestamp msecs) (subed-subtitle-id)) ;; post-command-hook is not triggered because we didn't move interactively, ;; but there shouldn't be a difference between automatic movement and manual ;; movement. E.g. the minor mode `hl-line' breaks because its post-command ;; function is not called. ;; But it's also important NOT to call our own post-command function because ;; that causes player-to-point syncing, which would get hairy. (remove-hook 'post-command-hook #'subed--post-command-handler) (run-hooks 'post-command-hook) (add-hook 'post-command-hook #'subed--post-command-handler :append :local))) (defvar-local subed--point-sync-delay-after-motion-timer nil) (defun subed-disable-sync-point-to-player-temporarily () "Temporarily disable syncing point to player. After `subed-point-sync-delay-after-motion' seconds point is re-synced." (if subed--point-sync-delay-after-motion-timer (when (timerp subed--point-sync-delay-after-motion-timer) (cancel-timer subed--point-sync-delay-after-motion-timer)) (setq subed--point-was-synced (subed-sync-point-to-player-p))) (when subed--point-was-synced (subed-disable-sync-point-to-player :quiet)) (when subed--point-was-synced (setq subed--point-sync-delay-after-motion-timer (run-at-time subed-point-sync-delay-after-motion nil (lambda () (setq subed--point-sync-delay-after-motion-timer nil) (subed-enable-sync-point-to-player :quiet)))))) ;;; Sync player-to-point (defun subed-sync-player-to-point-p () "Whether playback position jumps to subtitle at point." (member #'subed--sync-player-to-point subed-subtitle-motion-hook)) (defun subed-enable-sync-player-to-point (&optional quiet) "Automatically seek player to subtitle at point. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (unless (subed-sync-player-to-point-p) (subed--sync-player-to-point) (add-hook 'subed-subtitle-motion-hook #'subed--sync-player-to-point :append :local) (subed-debug "Enabled syncing playback position to point: %s" subed-subtitle-motion-hook) (unless quiet (message "Enabled syncing playback position to point")))) (defun subed-disable-sync-player-to-point (&optional quiet) "Do not automatically seek player to subtitle at point. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (when (subed-sync-player-to-point-p) (remove-hook 'subed-subtitle-motion-hook #'subed--sync-player-to-point :local) (subed-debug "Disabled syncing playback position to point: %s" subed-subtitle-motion-hook) (unless quiet (message "Disabled syncing playback position to point")))) (defun subed-toggle-sync-player-to-point () "Enable or disable automatically seeking player to subtitle at point." (interactive) (if (subed-sync-player-to-point-p) (subed-disable-sync-player-to-point) (subed-enable-sync-player-to-point))) (defun subed--sync-player-to-point () "Seek player to currently focused subtitle." (subed-debug "Seeking player to subtitle at point %s" (point)) (let ((cur-sub-start (subed-subtitle-msecs-start)) (cur-sub-stop (subed-subtitle-msecs-stop))) (when (and subed-mpv-playback-position cur-sub-start cur-sub-stop (or (< subed-mpv-playback-position cur-sub-start) (> subed-mpv-playback-position cur-sub-stop))) (subed-mpv-jump cur-sub-start) (subed-debug "Synchronized playback position to point: #%s -> %s" (subed-subtitle-id) cur-sub-start)))) ;;; Loop over single subtitle (defun subed-loop-over-current-subtitle-p () "Whether the player is looping over the current subtitle." (or subed--subtitle-loop-start subed--subtitle-loop-stop)) (defun subed-enable-loop-over-current-subtitle (&optional quiet) "Enable looping over the current subtitle in the player. If enabled, point-to-player synchronization is disabled and re-enabled again when `subed-disable-loop-over-current-subtitle' is called. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (unless (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop (subed-subtitle-id)) (add-hook 'subed-mpv-playback-position-hook #'subed--ensure-subtitle-loop :append :local) (add-hook 'subed-subtitle-motion-hook #'subed--set-subtitle-loop :append :local) (subed-debug "Enabling loop: %s - %s" subed--subtitle-loop-start subed--subtitle-loop-stop) (when (subed-sync-point-to-player-p) (subed-disable-sync-point-to-player quiet) (setq subed--enable-point-to-player-sync-after-disabling-loop t)) (unless quiet (message "Enabled looping over current subtitle")))) (defun subed-disable-loop-over-current-subtitle (&optional quiet) "Disable looping over the current subtitle in the player. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (when (subed-loop-over-current-subtitle-p) (remove-hook 'subed-mpv-playback-position-hook #'subed--ensure-subtitle-loop :local) (remove-hook 'subed-subtitle-motion-hook #'subed--set-subtitle-loop :local) (setq subed--subtitle-loop-start nil subed--subtitle-loop-stop nil) (subed-debug "Disabling loop: %s - %s" subed--subtitle-loop-start subed--subtitle-loop-stop) (when subed--enable-point-to-player-sync-after-disabling-loop (subed-enable-sync-point-to-player) (setq subed--enable-point-to-player-sync-after-disabling-loop nil)) (unless quiet (message "Disabled looping over current subtitle")))) (defun subed-toggle-loop-over-current-subtitle (&optional quiet) "Enable or disable looping over the current subtitle in the player. If QUIET is non-nil, do not display a message in the minibuffer." (interactive) (if (subed-loop-over-current-subtitle-p) (subed-disable-loop-over-current-subtitle quiet) (subed-enable-loop-over-current-subtitle quiet))) (defun subed--set-subtitle-loop (&optional sub-id) "Set loop positions to start/stop time of SUB-ID or current subtitle." (let ((msecs-start (subed-subtitle-msecs-start sub-id)) (msecs-stop (subed-subtitle-msecs-stop sub-id))) (when (and msecs-start msecs-stop) (setq subed--subtitle-loop-start (- msecs-start (* subed-loop-seconds-before 1000)) subed--subtitle-loop-stop (+ msecs-stop (* subed-loop-seconds-after 1000))) (subed-debug "Set loop: %s - %s" (subed-msecs-to-timestamp subed--subtitle-loop-start) (subed-msecs-to-timestamp subed--subtitle-loop-stop)) (message "Looping over %s - %s" (subed-msecs-to-timestamp subed--subtitle-loop-start) (subed-msecs-to-timestamp subed--subtitle-loop-stop))))) (defun subed--ensure-subtitle-loop (cur-msecs) "Jump to current subtitle start time if CUR-MSECS is after stop time." (when (and subed--subtitle-loop-start subed--subtitle-loop-stop subed-mpv-is-playing) (when (or (< cur-msecs subed--subtitle-loop-start) (> cur-msecs subed--subtitle-loop-stop)) (subed-debug "%s -> Looping over %s - %s" (subed-msecs-to-timestamp cur-msecs) (subed-msecs-to-timestamp subed--subtitle-loop-start) (subed-msecs-to-timestamp subed--subtitle-loop-stop)) (subed-mpv-jump subed--subtitle-loop-start)))) ;;; Pause player while the user is editing (defun subed-pause-while-typing-p () "Whether player is automatically paused or slowed down during editing. See `subed-playback-speed-while-typing' and `subed-playback-speed-while-not-typing'." (member #'subed--pause-while-typing post-command-hook)) (defun subed-enable-pause-while-typing (&optional quiet) "Pause player while the user is editing a subtitle. After `subed-unpause-after-typing-delay' seconds, playback is resumed automatically unless the player was paused already. If QUIET is non-nil, do not display a message in the minibuffer." (unless (subed-pause-while-typing-p) (add-hook 'post-command-hook #'subed--pause-while-typing :append :local) (unless quiet (subed-debug "%S" subed-playback-speed-while-typing) (if (<= subed-playback-speed-while-typing 0) (message "Playback will pause while subtitle texts are edited") (message "Playback will slow down by %s while subtitle texts are edited" subed-playback-speed-while-typing))))) (defun subed-disable-pause-while-typing (&optional quiet) "Do not automatically pause player while the user is editing the buffer. If QUIET is non-nil, do not display a message in the minibuffer." (when (subed-pause-while-typing-p) (remove-hook 'post-command-hook #'subed--pause-while-typing :local) (unless quiet (message "Playback speed will not change while subtitle texts are edited")))) (defun subed-toggle-pause-while-typing () "Enable or disable auto-pausing while the user is editing the buffer." (interactive) (if (subed-pause-while-typing-p) (subed-disable-pause-while-typing) (subed-enable-pause-while-typing))) (defvar-local subed--unpause-after-typing-timer nil) (defun subed--pause-while-typing (&rest _args) "Pause or slow down playback for `subed-unpause-after-typing-delay' seconds. This function is meant to be an item in `after-change-functions' and therefore gets ARGS, which is ignored." (when subed--unpause-after-typing-timer (cancel-timer subed--unpause-after-typing-timer)) (when (or subed-mpv-is-playing subed--player-is-auto-paused) (if (<= subed-playback-speed-while-typing 0) ;; Pause playback (progn (subed-mpv-pause) (setq subed--player-is-auto-paused t) (setq subed--unpause-after-typing-timer (run-at-time subed-unpause-after-typing-delay nil (lambda () (setq subed--player-is-auto-paused nil) (subed-mpv-unpause))))) ;; Slow down playback (progn (subed-mpv-playback-speed subed-playback-speed-while-typing) (setq subed--player-is-auto-paused t) (setq subed--unpause-after-typing-timer (run-at-time subed-unpause-after-typing-delay nil (lambda () (setq subed--player-is-auto-paused nil) (subed-mpv-playback-speed subed-playback-speed-while-not-typing)))))))) (defvar subed-media-file-functions '(subed-media-file-from-cache subed-guess-media-file) "Functions to use for getting the media file.") (defun subed-media-file () "Return the current media file. Uses the functions listed in `subed-media-file-functions'." (run-hook-with-args-until-success 'subed-media-file-functions)) (defun subed-media-file-from-cache () "Return the media file from the variable." subed-mpv-media-file) (defun subed-guess-media-file (&optional extensions) "Find media file with same base name as the opened file in the buffer. The optional EXTENSIONS argument can be a list of extensions to look for. If not, check against the extensions in `subed-video-extensions' and `subed-audio-extensions'. The file extension of the function `buffer-file-name' is replaced with each item in the extension list and the first existing file is returned. Language codes are also handled; e.g. \"foo.en.srt\" or \"foo.estonian.srt\" -> \"foo.{mkv,mp4,...}\" (this actually simply removes the extension from the extension-stripped file name). Return nil if function `buffer-file-name' returns nil." (when (buffer-file-name) (catch 'found-file (let* ((file-base (file-name-sans-extension (buffer-file-name))) (file-stem (file-name-sans-extension file-base))) (dolist (extension (or extensions (append subed-video-extensions subed-audio-extensions))) (let ((file-base-media (format "%s.%s" file-base extension)) (file-stem-media (format "%s.%s" file-stem extension))) (when (file-exists-p file-base-media) (throw 'found-file file-base-media)) (when (file-exists-p file-stem-media) (throw 'found-file file-stem-media)))))))) (define-obsolete-function-alias 'subed-guess-video-file 'subed-guess-media-file "1.0.20") ;;; Inserting HTML-like tags (defvar subed--html-tag-history nil "History of HTML-like tags in subtitles.") (defvar subed--html-attr-history nil "History of HTML-like attributes in subtitles.") (defun subed-insert-html-tag (begin end tag &optional attributes) "Insert a pair of HTML-like tags around the region using TAG. BEGIN and END specify the start of the region. If region is not active, insert a pair of tags and put the point between them. If called with a prefix argument, also ask for ATTRIBUTE(s)." (interactive (let* ((region-p (use-region-p)) (begin (if region-p (region-beginning) (point))) (end (if region-p (region-end) (point))) (tag (read-string "Tag: " nil 'subed--html-tag-history)) (attributes (when current-prefix-arg (read-string "Attribute(s): " nil 'subed--html-attr-history)))) (list begin end tag attributes))) (save-excursion (push (point) buffer-undo-list) (goto-char end) (insert "") (goto-char begin) (insert-before-markers "<" tag) (when attributes (insert-before-markers " " attributes)) (insert-before-markers ">"))) (defun subed-insert-html-tag-italic (begin end) "Insert a pair of tags at point or around the region. The region is defined by BEGIN and END." (interactive (let* ((region-p (use-region-p)) (begin (if region-p (region-beginning) (point))) (end (if region-p (region-end) (point)))) (list begin end))) (subed-insert-html-tag begin end "i")) (defun subed-insert-html-tag-bold (begin end) "Insert a pair of tags at point or around the region. The region is defined by BEGIN and END." (interactive (let* ((region-p (use-region-p)) (begin (if region-p (region-beginning) (point))) (end (if region-p (region-end) (point)))) (list begin end))) (subed-insert-html-tag begin end "b")) ;;; Characters per second computation (defvar-local subed--cps-overlay nil) (defun subed-show-cps-p () "Whether CPS is shown for the current subtitle." (member #'subed--update-cps-overlay post-command-hook)) (defun subed-enable-show-cps (&optional quiet) "Enable showing CPS next to the subtitle heading. If QUIET is nil, show a message." (interactive "p") ;; FIXME: Consider displaying CPS on all cues (via jit-lock) rather than the current one? (add-hook 'post-command-hook #'subed--move-cps-overlay-to-current-subtitle nil t) (add-hook 'subed-subtitle-motion-hook #'subed--move-cps-overlay-to-current-subtitle nil t) (add-hook 'after-save-hook #'subed--move-cps-overlay-to-current-subtitle nil t) (unless quiet (message "Enabled showing characters per second"))) (defun subed-disable-show-cps (&optional quiet) "Disable showing CPS next to the subtitle heading. If QUIET is nil, show a message." (interactive) (remove-hook 'post-command-hook #'subed--move-cps-overlay-to-current-subtitle t) (remove-hook 'subed-subtitle-motion-hook #'subed--move-cps-overlay-to-current-subtitle t) (remove-hook 'after-save-hook #'subed--move-cps-overlay-to-current-subtitle t) (when subed--cps-overlay (remove-overlays (point-min) (point-max) 'subed 'cps) (setq subed--cps-overlay nil)) (unless quiet (message "Disabled showing characters per second"))) (defun subed-toggle-show-cps () "Enable or disable showing CPS next to the subtitle heading." (interactive) (if (subed-show-cps-p) (subed-disable-show-cps) (subed-enable-show-cps))) (defvar subed-transform-for-cps #'subed--strip-tags) (defun subed--strip-tags (string) "Strip HTML-like tags from STRING." (with-temp-buffer (insert string) (goto-char 1) (while (re-search-forward "]+>" nil t) (delete-region (match-beginning 0) (match-end 0))) (buffer-string))) (defun subed-calculate-cps (&optional print-message) "Calculate characters per second of the current subtitle. if PRINT-MESSAGE is non-nil, display a message." (interactive "p") (let* ((msecs-start (ignore-errors (subed-subtitle-msecs-start))) (msecs-stop (ignore-errors (subed-subtitle-msecs-stop))) (text (if (fboundp subed-transform-for-cps) (funcall subed-transform-for-cps (subed-subtitle-text)) (subed-subtitle-text))) (length (length text)) (cps (when (and (numberp msecs-stop) (numberp msecs-start)) (/ length 0.001 (- msecs-stop msecs-start))))) (if (and print-message cps) (message "%.1f characters per second" cps) cps))) (defun subed--move-cps-overlay-to-current-subtitle () "Move the CPS overlay to the current subtitle." (unless subed--batch-editing (let* ((begin (save-excursion (subed-jump-to-subtitle-time-start) (point))) (end (save-excursion (goto-char begin) (line-end-position)))) (if subed--cps-overlay (move-overlay subed--cps-overlay begin end (current-buffer)) (setq subed--cps-overlay (make-overlay begin end)) (overlay-put subed--cps-overlay 'subed 'cps)) (subed--update-cps-overlay)))) (defun subed--update-cps-overlay (&rest _rest) "Update the CPS overlay." (when subed--cps-overlay (let ((cps (subed-calculate-cps))) (when cps (overlay-put subed--cps-overlay 'after-string (propertize (format " %.1f CPS" cps) 'face 'shadow 'display '(height 0.9))))))) (defun subed-wpm (&optional subtitles) "Display words per minute. Use SUBTITLES if specified." (interactive) (setq subtitles (or subtitles (subed-subtitle-list))) (let (word-count (minutes (/ (apply '+ (mapcar (lambda (o) (- (elt o 2) (elt o 1))) subtitles)) 60000.0))) (with-temp-buffer (insert (mapconcat (lambda (o) (replace-regexp-in-string "]+>" "" (elt o 3))) subtitles " ")) (setq word-count (count-words (point-min) (point-max)))) (if (called-interactively-p 'any) (message "%d wpm (%d words / %.1f minutes)" (/ (* 1.0 word-count) minutes) word-count minutes) (list (/ (* 1.0 word-count) minutes) word-count minutes)))) ;;; Trimming overlaps (defun subed--identify-overlaps () "Return a list of IDs for subtitles that overlap the next one. This observes `subed-subtitle-spacing'." (let (overlap-ids next-sub-start-time) (save-excursion (subed-for-each-subtitle (point-min) (point-max) t (when (and next-sub-start-time (> (+ (subed-subtitle-msecs-stop) subed-subtitle-spacing) next-sub-start-time)) (push (subed-subtitle-id) overlap-ids)) (setq next-sub-start-time (subed-subtitle-msecs-start))) overlap-ids))) ;; NB: runs in a hook, so this version cannot send prefix arg to ;; subed-trim-overlaps. Doesn't actually use the whole list of ;; overlaps, so it may be more efficient to just find the first overlap. (defun subed-trim-overlap-check () "Test all subtitles for overlapping timecodes. Creates a list of the ids of overlapping subtitles, moves point to the end time of the first one, and prompts to trim them. Designed to be run in `subed-mode-hook'." (interactive) (let ((overlap-ids (subed--identify-overlaps))) (when overlap-ids (subed-jump-to-subtitle-time-stop (car overlap-ids)) (when (yes-or-no-p "Overlapping subtitles found. Trim them? ") (subed-trim-overlaps))))) (defun subed-trim-overlaps (&optional msecs) "Adjust all overlapping times in current file. Change the stop times to start MSECS before the next subtitle, or `subed-subtitle-spacing' if not specified. If `subed-trim-overlap-use-start' is non-nil, change the start times instead." (interactive "P") (subed-batch-edit (save-excursion (subed-for-each-subtitle (point-min) (point-max) nil (if subed-trim-overlap-use-start (subed-trim-overlap-next-start msecs) (subed-trim-overlap-stop msecs)))))) (defun subed-trim-overlap-maybe-sanitize () "Trim overlaps if specified by `subed-trim-overlap-on-save'." (when subed-trim-overlap-on-save (subed-trim-overlaps))) (defun subed-trim-overlap-maybe-check () "Check overlaps if specified by `subed-trim-overlap-check-on-save'." (when subed-trim-overlap-check-on-save (subed-trim-overlap-check))) (defun subed-trim-overlap-stop (&optional msecs ignore-negative-duration) "Trim the current subtitle so that it stops before the next one. Trim the end time of the current subtitle to MSECS or `subed-subtitle-spacing' less than the start time of the next subtitle, if needed. Unless either IGNORE-NEGATIVE-DURATION or `subed-enforce-time-boundaries' are non-nil, adjust MSECS so that the stop time isn't smaller than the start time. Return the new stop time." (interactive "P") (let ((next-sub-start-time (save-excursion (and (subed-forward-subtitle-time-start) (subed-subtitle-msecs-start)))) (this-sub-stop-time (subed-subtitle-msecs-stop))) (when (and next-sub-start-time this-sub-stop-time (> this-sub-stop-time (- next-sub-start-time (if (numberp msecs) msecs subed-subtitle-spacing)))) (subed-set-subtitle-time-stop (- next-sub-start-time (if (numberp msecs) msecs subed-subtitle-spacing)) nil ignore-negative-duration t)))) (defun subed-trim-overlap-next-start (&optional msecs ignore-negative-duration) "Trim the next subtitle to start after the current one. Trim the start time of the next subtitle to MSECS or `subed-subtitle-spacing' greater than the end time of the current subtitle. Unless either IGNORE-NEGATIVE-DURATION or `subed-enforce-time-boundaries' are non-nil, adjust MSECS so that the stop time isn't smaller than the start time. Return the new start time." (interactive "P") (save-excursion (let ((this-sub-stop-time (subed-subtitle-msecs-stop)) (next-sub-start-time (and (subed-forward-subtitle-time-start) (subed-subtitle-msecs-start)))) (when (and this-sub-stop-time next-sub-start-time (or msecs subed-subtitle-spacing) (< next-sub-start-time (+ this-sub-stop-time (if (numberp msecs) msecs subed-subtitle-spacing)))) (subed-set-subtitle-time-start (+ this-sub-stop-time (or msecs subed-subtitle-spacing)) nil ignore-negative-duration t))))) ;;; Sorting and sanitizing (defun subed-prepare-to-save () "Sanitize and validate this buffer." (interactive) (atomic-change-group (subed-sanitize) (subed-validate))) (defun subed--sorted-p (&optional list) "Return non-nil if LIST is sorted by start time. If LIST is nil, use the subtitles in the current buffer." (let ((subtitles (or list (subed-subtitle-list))) (sorted t)) (while (cdr subtitles) (if (and (numberp (elt (car subtitles) 1)) (numberp (elt (cadr subtitles) 1)) (> (elt (car subtitles) 1) (elt (cadr subtitles) 1))) ; starts later than the next one (setq sorted nil subtitles nil) (setq subtitles (cdr subtitles)))) sorted)) (subed-define-generic-function sort () "Sort subtitles." (interactive) (subed-sanitize-format) (subed-validate-format) (unless (subed--sorted-p) (subed-batch-edit (atomic-change-group (subed-save-excursion (goto-char (point-min)) (unless (subed-jump-to-subtitle-id) (subed-forward-subtitle-id)) (sort-subr nil ;; nextrecfun (move to next record/subtitle or to end-of-buffer ;; if there are no more records) (lambda () (unless (subed-forward-subtitle-id) (goto-char (point-max)))) ;; endrecfun (move to end of current record/subtitle) #'subed-jump-to-subtitle-end ;; startkeyfun (return sort value of current record/subtitle) #'subed-subtitle-msecs-start)) (subed-regenerate-ids) (run-hooks 'subed-subtitles-sorted-hook))))) ;;; Conversion (subed-define-generic-function auto-insert () "Add header text for the current format." (interactive) nil) (defun subed-create-file (filename subtitles &optional ok-if-exists init-func) "Create FILENAME and prepopulate it with SUBTITLES. If OK-IF-EXISTS is non-nil, overwrite existing files. If INIT-FUNC is non-nil, call that function to initialize." (when (and (file-exists-p filename) (not ok-if-exists)) (error "File %s already exists" filename)) (let ((subed-auto-play-media nil)) (with-temp-file filename (subed-guess-format filename) (if init-func (funcall init-func)) (subed-auto-insert) (subed-append-subtitle-list subtitles) (subed-regenerate-ids)))) (defun subed-convert (format &optional include-comments) "Create a buffer with the current subtitles converted to FORMAT. You may need to add some extra information to the buffer. If INCLUDE-COMMENTS is non-nil or `subed-convert' is called with a prefix argument, include comments in TXT output." (interactive (list (completing-read "To format: " '("VTT" "SRT" "ASS" "TSV" "TXT")) current-prefix-arg)) (let* ((subtitles (mapcar (lambda (sub) (cons nil (cdr sub))) ; remove ID (subed-subtitle-list))) (new-filename (concat (file-name-base (or (buffer-file-name) (buffer-name))) "." (downcase format))) (mode-func (pcase format ("VTT" (require 'subed-vtt) 'subed-vtt-mode) ("SRT" (require 'subed-srt) 'subed-srt-mode) ("ASS" (require 'subed-ass) 'subed-ass-mode) ("TSV" (require 'subed-tsv) 'subed-tsv-mode)))) (if (buffer-file-name) ;; Create a new file (when (or (not (file-exists-p new-filename)) (yes-or-no-p (format "%s exists. Overwrite? " new-filename))) (if (string= format "TXT") (progn (with-temp-file new-filename (insert (subed-subtitle-list-text subtitles include-comments))) (find-file new-filename)) (subed-create-file new-filename subtitles t mode-func)) (current-buffer)) ;; Create a temporary buffer (switch-to-buffer (get-buffer-create new-filename)) (erase-buffer) (when (functionp mode-func) (funcall mode-func) (subed-auto-insert)) (if (string= format "TXT") (insert (subed-subtitle-list-text subtitles include-comments)) (mapc (lambda (sub) (apply #'subed-append-subtitle nil (cdr sub))) subtitles)) (current-buffer)))) ;;; Wdiff ;;;###autoload (defvar subed-wdiff-executable "wdiff" "Command for word-based diffs.") (defun subed-wdiff-subtitle-text-with-file (script-file) "Use wdiff to compare the captions with SCRIPT-FILE by word. The wdiff program must be installed. Set `subed-wdiff-executable' if needed." (interactive (list (read-file-name "Script: "))) (let ((subtitle-text (subed-subtitle-list-text (subed-subtitle-list))) (subtitle-text-filename (make-temp-file "subed-wdiff-subtitles" nil ".txt")) (one-line-script-filename (make-temp-file "subed-wdiff-script" nil ".txt")) result) (with-temp-file subtitle-text-filename (insert (replace-regexp-in-string "[ \n]+" " " subtitle-text))) (with-temp-file one-line-script-filename (if (member (file-name-extension script-file) '("vtt" "srt" "tsv" "ass")) (insert (mapconcat (lambda (o) (concat (elt o 3) " ")) (subed-parse-file script-file))) (insert-file-contents script-file)) (goto-char (point-min)) (while (re-search-forward "[ \n]+" nil t) (replace-match " "))) (setq result (shell-command-to-string (format "wdiff %s %s" subtitle-text-filename one-line-script-filename))) (delete-file subtitle-text-filename) (delete-file one-line-script-filename) (with-current-buffer (get-buffer-create "*wdiff*") (erase-buffer) (insert result) (goto-char (point-min)) (while (re-search-forward "\\(\\[-\\|{\\+\\)\\(.*?\\)\\(-\\]\\|\\+}\\)" nil t) (add-text-properties (match-beginning 0) (match-end 0) (list 'face (if (string= (match-string 1) "[-") 'diff-removed 'diff-added)))) (display-buffer (current-buffer))) result)) ;;; Misc (defun subed-sum-time (&optional beg end) "Display the total time of the subtitles. Does not yet take overlapping subtitles into account. BEG can also be a subtitle list." (interactive (list (and (region-active-p) (min (point) (mark))) (and (region-active-p) (max (point) (mark))))) (let ((sum (seq-reduce ;; TODO: Handle overlapping subtitles (lambda (prev val) (+ prev (- (elt val 2) (elt val 1)))) (if (listp beg) beg (subed-subtitle-list beg end)) 0))) (when (called-interactively-p 'any) (message "%s" (subed-msecs-to-timestamp sum))) sum)) (defun subed-forward-word (&optional arg) "Skip timestamps." (interactive "^p") (setq arg (or arg 1)) (let ((end (or (save-excursion (subed-jump-to-subtitle-end)) (point)))) (loop while (> arg 0) do (forward-word 1) (skip-syntax-forward "^\s") (setq arg (1- arg)) (when (> (point) end) (subed-jump-to-subtitle-text) (forward-word 1) (skip-syntax-forward "^\s") (setq end (or (save-excursion (subed-jump-to-subtitle-end)) (point))))))) ;;; Experimental retiming workflow (defvar subed-retime-subtitles-adjustment-msecs 100 "Number of msecs to adjust the MPV playback position. This accounts for reaction time.") (defun subed-retime-set-stop-and-move-forward () "Set the current subtitle's stop time and the next subtitle's start time. Move to the next subtitle. Take into account `subed-subtitle-spacing' and `subed-retime-subtitles-adjustment-msecs'." (interactive) (subed-set-subtitle-time-stop (- subed-mpv-playback-position subed-subtitle-spacing subed-retime-subtitles-adjustment-msecs)) (subed-forward-subtitle-text) (subed-set-subtitle-time-start (- subed-mpv-playback-position subed-retime-subtitles-adjustment-msecs))) (defun subed-retime-play-previous () "Go backward one subtitle and replay." (interactive) (subed-backward-subtitle-text) (subed-mpv-jump-to-current-subtitle)) (defun subed-retime-play-next () "Go backward one subtitle and replay." (interactive) (subed-forward-subtitle-text) (subed-mpv-jump-to-current-subtitle)) (defvar subed-retime-subtitles-map (define-keymap "SPC" #'subed-retime-set-stop-and-move-forward "" #'subed-mpv-jump-to-current-subtitle "j" #'subed-mpv-jump-to-current-subtitle "" #'subed-retime-play-next "b" #'subed-retime-play-previous "f" #'subed-retime-play-next "n" #'subed-retime-play-next "p" #'subed-mpv-toggle-pause) "Some shortcuts for subtitle retiming.") ;;;###autoload (defun subed-retime-subtitles () "Set new stop times for subtitles by pressing SPC when the next subtitle starts." (interactive) (subed-disable-loop-over-current-subtitle) (subed-mpv-unpause) (subed-mpv-jump-to-current-subtitle) (set-transient-map subed-retime-subtitles-map t nil ;; todo: support substitute-command-keys "SPC: set new stop, : replay current, : forward, (b)ack, (f)orward, (p)ause")) ;;; ffprobe (defvar-local subed-file-duration-ms-cache nil "If non-nil, duration of current file in milliseconds.") (defun subed-convert-ffprobe-tags-duration-to-ms (duration) "Return milliseconds as an integer for DURATION. DURATION must be a string of the format HH:MM:SS.MMMM. Example: 00:00:03.003000000 -> 3003 00:00:03.00370000 -> 3004" (unless (string-match "\\([0-9]\\{2\\}\\):\\([0-9]\\{2\\}\\):\\([0-9]\\{2\\}\\)\\.\\([0-9]+\\)" duration) (error "The duration is not well formatted.")) (let ((hour (match-string 1 duration)) (minute (match-string 2 duration)) (seconds (match-string 3 duration)) (milliseconds (match-string 4 duration))) (+ (* (string-to-number hour) 3600000) (* (string-to-number minute) 60000) (* (string-to-number seconds) 1000) (* (string-to-number (concat "0." milliseconds)) 1000)))) (defun subed-ffprobe-duration-ms (filename) "Use ffprobe to get duration of audio stream in milliseconds of FILENAME." (let ((json (json-read-from-string (with-temp-buffer (call-process subed-ffprobe-executable nil t nil "-v" "error" "-print_format" "json" "-show_streams" "-show_format" filename) (buffer-string))))) ;; Check that the file has at least one audio stream. (when (eq (seq-find (lambda (stream) (equal (alist-get 'codec_type stream) "audio")) (alist-get 'streams json)) 0) (error "The provided file doesn't have an audio stream.")) (cond ;; If the file has one stream and it is an audio stream, we can ;; get the duration from format=duration ;; ;; nb_streams equals the number of streams in the media file. ((and (eq (alist-get 'nb_streams (alist-get 'format json)) 1) (equal (alist-get 'codec_type (seq-first (alist-get 'streams json))) "audio")) (* 1000 (string-to-number (alist-get 'duration (alist-get 'format json))))) ;; If the file has more than one stream and only one audio ;; stream, return the duration of the audio stream. ((and (> (alist-get 'nb_streams (alist-get 'format json)) 1) (eq (length (seq-filter (lambda (stream) (equal (alist-get 'codec_type stream) "audio")) (alist-get 'streams json))) 1)) (cond ((or (string-match "\\.mkv\\'" filename) (string-match "\\.webm\\'" filename)) (subed-convert-ffprobe-tags-duration-to-ms (alist-get 'DURATION (alist-get 'tags (seq-find (lambda (stream) (equal (alist-get 'codec_type stream) "audio")) (alist-get 'streams json)))))) (t (* 1000 (string-to-number (alist-get 'duration (seq-find (lambda (stream) (equal (alist-get 'codec_type stream) "audio")) (alist-get 'streams json)))))))) ;; TODO: Some media files might have multiple audio streams ;; (e.g. multiple languages). When the media file has multiple ;; audio streams, prompt the user for the audio stream. The audio ;; stream selected by the user must be stored in a buffer-local ;; variable so that ffmpeg knows the audio stream from which the ;; waveforms are created. ))) (defun subed-clear-file-duration-ms-cache (&rest _) "Clear `subed-file-duration-ms-cache'." (setq subed-file-duration-ms-cache nil)) (defun subed-file-duration-ms (&optional filename refresh-cache) "Return the duration of FILENAME in milliseconds." (setq filename (or filename (subed-media-file))) (if refresh-cache (setq subed-file-duration-ms-cache nil)) (cond ((numberp subed-file-duration-ms-cache) (when (> subed-file-duration-ms-cache 0) subed-file-duration-ms-cache)) (subed-ffprobe-executable (setq subed-file-duration-ms-cache (subed-ffprobe-duration-ms filename)) (if (and (numberp subed-file-duration-ms-cache) (> subed-file-duration-ms-cache 0)) subed-file-duration-ms-cache ;; mark as invalid (warn "Could not get file duration for %s" filename) (setq subed-file-duration-ms-cache -1) nil)))) (defun subed-insert-subtitle-for-whole-file () "Insert a subtitle that starts at 0 until the end of the current file. This might make it easier to type subtitles from scratch. Use this function to start with a subtitle for the whole duration. It may be a good idea to enable pausing while typing with `subed-toggle-pause-while-typing'. As you type each subtitle's worth of text, use `subed-split-subtitle' to start a new subtitle at the current playback position. If there is an error running `subed-ffprobe-executable', use one day as the duration instead." (interactive) (when (string= (string-trim (buffer-string)) "") (subed-auto-insert)) (subed-append-subtitle nil 0 (condition-case nil (and (subed-media-file) (subed-file-duration-ms (subed-media-file))) (error (* 24 60 60 1000))))) (defun subed-crop-subtitles (beg end) "Crop subtitles to region. Delete subtitles before and after the region (including outside any narrowing) and shift subtitles to start at 0." (interactive (list (if (region-active-p) (min (point) (mark)) (point-min)) (if (region-active-p) (max (point) (mark)) (point-max)))) (save-restriction (widen) (goto-char end) (subed-jump-to-subtitle-end) (delete-region (point) (point-max)) (goto-char (point-min)) (unless (subed-subtitle-msecs-start) (subed-forward-subtitle-start-pos)) (delete-region (point) beg) (subed-shift-subtitles-to-start-at-timestamp 0))) (defun subed-crop-media-file (beg end &optional new-file) "Crop media file to the specified region using ffmpeg. This reencodes the media file if the starting time is not 0. Use the start time of the first subtitle in the region and the stop time of the last subtitle. Call with \\[universal-argument] to prompt for a file to write the extracted segment to. Adjusted subtitles will also be written alongside the file." (interactive (list (if (region-active-p) (min (point) (mark)) (point-min)) (if (region-active-p) (max (point) (mark)) (point-max)) (when current-prefix-arg (read-file-name "New file: ")))) (unless (subed-media-file) (error "Must have associated media file")) (let* ((start-ms (save-excursion (goto-char beg) (or (subed-subtitle-msecs-start) (and (subed-forward-subtitle-time-start) (subed-subtitle-msecs-start))))) (stop-ms (save-excursion (goto-char end) (or (subed-subtitle-msecs-stop) (and (subed-backward-subtitle-time-start) (subed-subtitle-msecs-stop))))) (input (subed-media-file)) (input-mode major-mode) (input-subtitle-file (buffer-file-name)) (subtitles (mapcar (lambda (cue) (setf (elt cue 1) (- (elt cue 1) start-ms)) (setf (elt cue 2) (- (elt cue 2) start-ms)) cue) (subed-subtitle-list beg end))) (temp-file (make-temp-file "subed-record-crop" nil (concat "." (file-name-extension input))))) (unless (and start-ms stop-ms) (error "Could not find start and stop time")) (let ((args (append (list "-i" input) (if (> start-ms 0) (list "-ss" (number-to-string (/ start-ms 1000.0))) nil) (list "-to" (number-to-string (/ stop-ms 1000.0))) (list "-y" "-c:a" "copy" temp-file)))) (with-current-buffer (get-buffer-create "*subed-record*") (erase-buffer) (apply #'call-process subed-ffmpeg-executable nil t t args)) (when (= (file-attribute-size (file-attributes temp-file)) 0) (error "Error processing media file")) (rename-file temp-file (or new-file input) t) (cond ((and new-file input-subtitle-file) ;; file (with-temp-file (concat (file-name-sans-extension temp-file) "." (file-name-extension input-subtitle-file)) (funcall input-mode) (subed-auto-insert) (subed-append-subtitle-list subtitles))) (new-file ;; temporary buffer? (with-current-buffer (generate-new-buffer "*subed*") (funcall input-mode) (subed-auto-insert) (subed-append-subtitle-list subtitles))) (nil ;; clean up current buffer (subed-crop-subtitles beg end)))))) (provide 'subed-common) ;;; subed-common.el ends here subed-1.2.25/subed/subed-common.el.license000066400000000000000000000001551474617305700203570ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2019-2021 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-config.el000066400000000000000000000262311474617305700167160ustar00rootroot00000000000000;;; subed-config.el --- Customization variables and hooks 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: ;; Customization variables, hooks, keybindings, etc for subed-mode. ;;; Code: (defgroup subed nil "Major mode for editing subtitles." :group 'files :group 'multimedia :prefix "subed-") (defvar-local subed--subtitle-format nil "Short form of the subtitle format in the current buffer (e.g. \"srt\").") ;; This variable is set in subed.el to avoid compiler warnings because it uses ;; functions defined in subed-common.el, and (require 'subed-common) results in ;; recursive requires. (defvar subed-mode-map nil "Keymap for ‘subed-mode’.") ;; Syntax highlighting (defface subed-id-face '((t (:inherit 'font-lock-constant-face))) "Each subtitle's consecutive number.") (defface subed-time-face '((t (:inherit 'font-lock-string-face))) "Start and stop times of subtitles.") (defface subed-time-separator-face '((t (:inherit 'font-lock-comment-face))) "Separator between the start and stop time (\" --> \").") (define-obsolete-face-alias 'subed-srt-id-face 'subed-id-face "2022-09-14") (define-obsolete-face-alias 'subed-srt-time-face 'subed-time-face "2022-09-14") (define-obsolete-face-alias 'subed-srt-time-separator-face 'subed-time-separator-face "2022-09-14") (defface subed-srt-text-face nil "Obsolete, do not use.") (define-obsolete-face-alias 'subed-vtt-id-face 'subed-id-face "2022-09-14") (define-obsolete-face-alias 'subed-vtt-time-face 'subed-time-face "2022-09-14") (define-obsolete-face-alias 'subed-vtt-time-separator-face 'subed-time-separator-face "2022-09-14") (defface subed-vtt-text-face nil "Obsolete, do not use.") (define-obsolete-face-alias 'subed-ass-id-face 'subed-id-face "2022-09-14") (define-obsolete-face-alias 'subed-ass-time-face 'subed-time-face "2022-09-14") (define-obsolete-face-alias 'subed-ass-time-separator-face 'subed-time-separator-face "2022-09-14") (defface subed-ass-text-face nil "Obsolete, do not use.") ;; Variables (defvar-local subed-debugging-enabled-p nil "Whether debugging messages are displayed.") (defcustom subed-debug-buffer "*subed-debug*" "Name of the buffer that contains debugging messages." :type 'string :group 'subed) (defcustom subed-mode-hook nil "Functions to call when entering subed mode." :type 'hook :group 'subed) (defcustom subed-video-extensions '("mkv" "mp4" "webm" "avi" "ts" "ogv" "mov") "Video file name extensions." :type '(repeat string) :group 'subed) (defcustom subed-audio-extensions '("wav" "ogg" "mp3" "opus" "m4a") "Audio file name extensions." :type '(repeat string) :group 'subed) (define-obsolete-variable-alias 'subed-auto-find-video 'subed-auto-play-media "1.20") (defcustom subed-auto-play-media t "Whether to open the video or audio automatically when opening a subtitle file." :type 'boolean :group 'subed) (defcustom subed-milliseconds-adjust 100 "Milliseconds to add or subtract from start/stop time. This variable is used when adjusting, moving or shifting subtitles without a prefix argument. This variable is set when adjusting, moving or shifting subtitles with a prefix argument. See `subed-increase-start-time' for details. Use `setq-default' to change the default value of this variable." :type 'float :group 'subed) (defun subed-get-milliseconds-adjust (arg) "Set `subed-milliseconds-adjust' to ARG if it's a number. If ARG is non-nil, reset `subed-milliseconds-adjust' to its default. Return new `subed-milliseconds-adjust' value." (cond ((integerp arg) (setq subed-milliseconds-adjust arg)) ;; Custom adjustment (arg (custom-reevaluate-setting 'subed-milliseconds-adjust))) ;; Reset to default subed-milliseconds-adjust) (defcustom subed-playback-speed-while-typing 0 "Playback speed while the user is editing the buffer. If set to zero or smaller, playback is paused." :type 'float :group 'subed) (defcustom subed-playback-speed-while-not-typing 1.0 "Playback speed while the user is not editing the buffer." :type 'float :group 'subed) (defcustom subed-unpause-after-typing-delay 1.0 "Number of seconds to wait after typing stopped before unpausing the player." :type 'float :group 'subed) (defvar-local subed--player-is-auto-paused nil "Whether the player was paused by the user or automatically.") (defcustom subed-subtitle-spacing 100 "Minimum time in milliseconds between subtitles when start/stop time is changed." :type 'integer :group 'subed) (defcustom subed-default-subtitle-length 1000 "How long to make inserted subtitles in milliseconds." :type 'float :group 'subed) (defcustom subed-enforce-time-boundaries 'adjust "How to manage changes that cause overlapping subtitles or negative durations. - `adjust' means adjust the stop or start time of the current subtitle to keep duration >= 0, and adjust the previous or next subtitle as needed to maintain `subed-subtitle-spacing'. - `clip' means limit the change to the maximum it can be within the boundaries. - `error' means report an error if there will be overlaps or negative duration. - nil means perform the change without checking. Other values aside from the ones specified above will be treated as nil." :type '(choice (const :tag "Adjust other times as needed" adjust) (const :tag "Limit changes" clip) (const :tag "Report an error" error) (const :tag "Do not check" nil)) :group 'subed) (defcustom subed-sanitize-functions '(subed-sanitize-format subed-sort subed-trim-overlap-maybe-sanitize) "Functions to call when sanitizing subtitles." :type '(repeat function) :local t :group 'subed) (defcustom subed-validate-functions '(subed-validate-format subed-trim-overlap-maybe-check) "Functions to validate this buffer. Validation functions should throw an error or prompt the user for action." :type '(repeat function) :local t :group 'subed) (defcustom subed-loop-seconds-before 0 "Prelude in seconds when looping over subtitle(s)." :type 'float :group 'subed) (defcustom subed-loop-seconds-after 0 "Addendum in seconds when looping over subtitle(s)." :type 'float :group 'subed) (defcustom subed-sample-msecs 2000 "Number of milliseconds to play when jumping near the end of a subtitle." :type 'integer :group 'subed) (defvar-local subed--subtitle-loop-start nil "Start position of loop in player in milliseconds.") (defvar-local subed--subtitle-loop-stop nil "Stop position of loop in player in milliseconds.") (defcustom subed-point-sync-delay-after-motion 1.0 "Player sync point delay in seconds after the user moves the point. This prevents the player from moving the point while the user is doing so." :type 'float :group 'subed) (defvar-local subed--point-was-synced nil "Remembers whether point-to-player was originally enabled by the user. Used when temporarily disabling point-to-player sync.") (defcustom subed-ffmpeg-executable "ffmpeg" "Path to the FFmpeg executable." :type 'file :group 'subed) (defcustom subed-ffprobe-executable "ffprobe" "Path to the FFprobe executable used for measuring file duration." :type 'file :group 'subed) (defcustom subed-mpv-executable "mpv" "Path or filename of mpv executable." :type 'file :group 'subed) (defcustom subed-mpv-large-step-seconds 5 "Number of seconds to move for a large step." :type 'number :group 'subed) (defcustom subed-mpv-small-step-seconds 1 "Number of seconds to move for a large step." :type 'number :group 'subed) (defcustom subed-mpv-arguments '("--osd-level=2" "--osd-fractions" "--keep-open=yes") "Additional arguments for \"mpv\". The options --input-ipc-server=SRTEDIT-MPV-SOCKET and --idle are hardcoded." :type '(repeat string) :group 'subed) (defcustom subed-mpv-socket-dir (concat (temporary-file-directory) "subed") "Path to Unix IPC socket that is passed to mpv's --input-ipc-server option." :type 'file :group 'subed) (defun subed--buffer-file-name () "Return base name of buffer file name or a default name." (file-name-nondirectory (or (buffer-file-name) "unnamed"))) ;;; Trim overlaps ;; checked by subed-sort (defcustom subed-trim-overlap-on-save nil "Non-nil means trim all overlapping subtitles when saving. Subtitles are trimmed according to `subed-trim-overlap-use-start'." :type '(choice (const :tag "Trim" nil) (const :tag "Do not trim" t)) :group 'subed) (defcustom subed-trim-overlap-check-on-save nil "Non-nil means check for overlapping subtitles when saving." :type '(choice (const :tag "Check" nil) (const :tag "Do not check" t)) :group 'subed) ;; checked by subed mode hook (defcustom subed-trim-overlap-check-on-load nil "Non-nil means check for overlapping subtitles on entering subed mode. Subtitles are trimmed according to `subed-trim-overlap-use-start'." :type '(choice (const :tag "Check" t) (const :tag "Do not check" nil)) :group 'subed) (defcustom subed-trim-overlap-use-start nil "Non-nil means adjust the start time of the following subtitle for overlaps. Otherwise, adjust the stop time of the current subtitle." :type '(choice (const :tag "Adjust stop time of the current subtitle" nil) (const :tag "Adjust start time of the next subtitle" t)) :group 'subed) ;;; Hooks (defvar-local subed-subtitle-time-adjusted-hook nil "Functions to call when a subtitle's start or stop time has changed. The functions are called with the subtitle's start time.") (defvar-local subed-region-adjusted-hook nil "Functions to call when the times for subtitles in a region have changed. The functions are called with BEG and END for the region.") (defvar-local subed-subtitle-merged-hook nil "Functions to call when a subtitle has been merged.") (declare-function subed-subtitle-msecs-start "subed-common" (&optional id)) (defun subed--run-subtitle-time-adjusted-hook () "Run `subed-subtitle-time-adjusted-hook' functions. The functions are called with the subtitle's start time." (when subed-subtitle-time-adjusted-hook (run-hook-with-args 'subed-subtitle-time-adjusted-hook (subed-subtitle-msecs-start)))) (defvar-local subed-point-motion-hook nil "Functions to call after point changed.") (defvar-local subed-subtitle-motion-hook nil "Functions to call after current subtitle changed.") (defvar-local subed-subtitles-sorted-hook nil "Functions to call after subtitles are sorted.") (provide 'subed-config) ;;; subed-config.el ends here subed-1.2.25/subed/subed-config.el.license000066400000000000000000000001551474617305700203340ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2019-2021 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-debug.el000066400000000000000000000054571474617305700165460ustar00rootroot00000000000000;;; subed-debug.el --- Debugging functions -*- 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: ;; ;; Debugging-related functions. ;;; Code: (require 'subed-config) (defun subed-enable-debugging () "Hide debugging messages and set `debug-on-error' to nil." (interactive) (unless subed-debugging-enabled-p (setq subed-debugging-enabled-p t debug-on-error t) (let ((debug-buffer (get-buffer-create subed-debug-buffer)) (debug-window (or (get-buffer-window subed-debug-buffer) (split-window-horizontally (max 40 (floor (* 0.3 (window-width)))))))) (set-window-buffer debug-window debug-buffer) (with-current-buffer debug-buffer (buffer-disable-undo) (setq-local buffer-read-only t))) (add-hook 'kill-buffer-hook #'subed-disable-debugging :append :local))) (defun subed-disable-debugging () "Display debugging messages in separate window and set `debug-on-error' to t." (interactive) (when subed-debugging-enabled-p (setq subed-debugging-enabled-p nil debug-on-error nil) (let ((debug-window (get-buffer-window subed-debug-buffer))) (when debug-window (delete-window debug-window))) (remove-hook 'kill-buffer-hook #'subed-disable-debugging :local))) (defun subed-toggle-debugging () "Display or hide debugging messages in separate window. Set `debug-on-error' to t or nil." (interactive) (if subed-debugging-enabled-p (subed-disable-debugging) (subed-enable-debugging))) (defun subed-debug (msg &rest args) "Pass MSG and ARGS to `format'. Show the result in debugging buffer if it exists." (when (get-buffer subed-debug-buffer) (with-current-buffer (get-buffer-create subed-debug-buffer) (setq-local buffer-read-only nil) (insert (apply #'format (concat msg "\n") args)) (setq-local buffer-read-only t) (let ((debug-window (get-buffer-window subed-debug-buffer))) (when debug-window (set-window-point debug-window (goto-char (point-max)))))))) (provide 'subed-debug) ;;; subed-debug.el ends here subed-1.2.25/subed/subed-debug.el.license000066400000000000000000000001501474617305700201500ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2019 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-menu.el000066400000000000000000000135161474617305700164170ustar00rootroot00000000000000;;; subed-menu.el --- Menu for subed -*- lexical-binding: t; -*- ;;; License: ;; Copyright (C) 2024 Sacha Chua ;; Author: Sacha Chua ;; Keywords: multimedia ;; ;; 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: ;; ;; Thanks to Mohsen Banan for the nudge. ;;; Code: (when (< emacs-major-version 28) ; preloaded in Emacs 28 (require 'easymenu)) (easy-menu-define subed-menu subed-mode-map "Subed menu." `("Subed" ("MPV" ["Load file" subed-mpv-play-from-file t] ["Load URL" subed-mpv-play-from-url t] ["Jump to current subtitle" subed-mpv-jump-to-current-subtitle t] ["Control" subed-mpv-control t]) ("Navigate" ("Backward" ["Start pos" subed-backward-subtitle-start-pos t] ["Comment" subed-backward-subtitle-comment t :visible (and (featurep 'subed-vtt) subed-vtt-mode)] ["Start time" subed-backward-subtitle-start t] ["Stop time" subed-backward-subtitle-stop t] ["Text" subed-backward-subtitle-text t] ["End of subtitle" subed-backward-subtitle-end t]) ("Forward" ["Start pos" subed-forward-subtitle-start-pos t] ["Comment" subed-forward-subtitle-comment t :visible (and (featurep 'subed-vtt) subed-vtt-mode)] ["Start time" subed-forward-subtitle-start t] ["Stop time" subed-forward-subtitle-stop t] ["Text" subed-forward-subtitle-text t] ["End of subtitle" subed-forward-subtitle-end t]) ("Jump" ["Start pos" subed-jump-to-subtitle-start-pos t] ["Comment" subed-jump-to-subtitle-comment t :visible (and (featurep 'subed-vtt) subed-vtt-mode)] ["Start time" subed-jump-to-subtitle-start t] ["Stop time" subed-jump-to-subtitle-stop t] ["Text" subed-jump-to-subtitle-text t] ["End of subtitle" subed-jump-to-subtitle-end t])) ["Insert" subed-insert-subtitle t] ["Insert adjacent" subed-insert-subtitle-adjacent t] ["Prepend" subed-prepend-subtitle t] ["Delete current" subed-kill-subtitle] ("Change time" ("Current subtitle" ("Copy player pos to..." ["Start time" subed-copy-player-pos-to-start-time] ["Start time and previous subtitle" subed-copy-player-pos-to-start-time-and-copy-to-previous] ["Stop time" subed-copy-player-pos-to-stop-time] ["Stop time and next subtitle" subed-copy-player-pos-to-stop-time-and-copy-to-next]) ["Set start" subed-set-subtitle-time-start t] ["Set stop" subed-set-subtitle-time-stop t] ["Decrease start time" subed-decrease-start-time t] ["Increase start time" subed-increase-start-time t] ["Decrease stop time" subed-decrease-stop-time t] ["Increase stop time" subed-increase-stop-time t] ["Move backward" subed-move-subtitle-backward t] ["Move forward" subed-move-subtitle-forward t]) ("Current and following subtitles" ["Shift to start at time" subed-shift-subtitles-to-start-at-timestamp t] ["Shift by msecs" subed-shift-subtitles t] ["Shift backward" subed-shift-subtitle-backward t] ["Shift forward" subed-shift-subtitle-forward t] ["Scale backward" subed-scale-subtitles-backward t] ["Scale forward" subed-scale-subtitles-forward t]) ["Trim overlaps" subed-trim-overlaps t] ["Retime" subed-retime-subtitles t]) ["Set text" subed-set-subtitle-text t] ["Set comment" subed-set-subtitle-comment :visible (and (featurep 'subed-vtt) subed-vtt-mode)] ["Split" subed-split-subtitle t] ["Merge" subed-merge-dwim t] ["Merge with previous" subed-merge-with-previous t] ["Copy region text" subed-copy-region-text t] ["Calculate WPM" subed-wpm t] ["Sort" subed-sort t] ["Convert" subed-convert t] ("Crop" ["Subtitles" subed-crop-subtitles t] ["Media file" subed-crop-media-file t]) ("Waveforms" ["Show for all subtitles" subed-waveform-show-all t] ["Show for current" subed-waveform-show-current t] ["Hide" subed-waveform-minor-mode :active subed-waveform-minor-mode :visible subed-waveform-minor-mode]) ("Align with aeneas" ["Region" subed-align-region t] ["Buffer" subed-align t]) ["Load word data from file" subed-word-data-load-from-file t] ["Adjust timing using word data" subed-word-data-fix-subtitle-timing t] ["Word diff" subed-wdiff-subtitle-text-with-file t] ("Options" ["Loop over current subtitle" subed-toggle-loop-over-current-subtitle :style toggle :selected (subed-loop-over-current-subtitle-p)] ["Pause when typing" subed-toggle-pause-while-typing :style toggle :selected (subed-pause-while-typing-p)] ["Replay adjusted subtitle" subed-toggle-replay-adjusted-subtitle :style toggle :selected (subed-replay-adjusted-subtitle-p)] ["Show characters per second" subed-toggle-show-cps :style toggle :selected (subed-show-cps-p)] ["Sync player -> point" subed-toggle-sync-player-to-point :style toggle :selected (subed-sync-player-to-point-p)] ["Sync point -> player" subed-toggle-sync-point-to-player :style toggle :selected (subed-sync-point-to-player-p)] )) ) ;;; Code: (provide 'subed-menu) ;;; subed-menu.el ends here subed-1.2.25/subed/subed-menu.el.license000066400000000000000000000001411474617305700200260ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2024 Sacha Chua ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-mpv.el000066400000000000000000000540641474617305700162600ustar00rootroot00000000000000;;; subed-mpv.el --- mpv integration 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: ;; ;; Based on: ;; https://github.com/mk-fg/emacs-setup/blob/master/extz/emms-player-mpv.el ;;; Code: (require 'subed-config) (require 'subed-debug) (require 'json) (declare-function subed-subtitle-id "subed-common" ()) (declare-function subed-subtitle-msecs-start "subed-common" (&optional id)) (declare-function subed-subtitle-msecs-stop "subed-common" (&optional id)) (defvar subed-mpv-frame-step-map) (defvar-local subed-mpv-is-playing nil "Whether mpv is currently playing or paused.") (defvar-local subed-mpv-playback-speed nil "How fast mpv is playing the media file. 1.0 is normal speed, 0.5 is half speed, etc.") (defvar-local subed-mpv-playback-position nil "Current playback position in milliseconds.") (defvar-local subed-mpv-playback-position-hook nil "Functions to call when mpv changes playback position.") (defvar-local subed-mpv-file-loaded-hook '(subed-mpv-pause subed-mpv-jump-to-current-subtitle) "Functions to call when mpv has loaded a file and starts playing.") (defvar-local subed-mpv-media-file nil "Current file or URL.") (defvar-local subed-mpv--server-proc nil "Running mpv process.") (defvar-local subed-mpv--client-proc nil "IPC socket process that communicates over `subed-mpv--socket'.") (defconst subed-mpv--client-test-request (json-encode (list :command '(get_property mpv-version))) "Request as a string to send to check whether IPC connection is working.") (defconst subed-mpv--retry-delays ;; Sums up to 5 seconds in total before failing '(0.1 0.1 0.1 0.1 0.2 0.2 0.3 0.4 0.5 0.5 0.5 0.5 0.5 0.5 0.5) "List of delays between attemps to connect to `subed-mpv--socket'.") (defvar-local subed-mpv--client-command-queue nil "Commands to call when connection to `subed-mpv--socket' is established.") ;;; Server (mpv process that provides an IPC socket) (defun subed-mpv--socket (&optional skip-create) "Path to mpv's RPC socket for a particular buffer. See also `subed-mpv-socket-dir'. If SKIP-CREATE is non-nil, don't create it if it doesn't exist." (unless (or skip-create (file-exists-p subed-mpv-socket-dir)) (condition-case err (make-directory subed-mpv-socket-dir :create-parents) (file-error (error "%s" (mapconcat #'identity (cdr err) ": ")) nil))) (when (file-exists-p subed-mpv-socket-dir) (expand-file-name (format "subed-%s" (md5 (subed--buffer-file-name))) subed-mpv-socket-dir))) (defun subed-mpv--server-start (&rest args) "Run mpv in JSON IPC mode. Pass ARGS as command line arguments. \"--idle\" and \"--input-ipc-server\" are hardcoded." (subed-mpv--server-stop) (let* ((socket-file (subed-mpv--socket)) (argv (append (list subed-mpv-executable (format "--input-ipc-server=%s" socket-file) "--idle") args))) (when (file-exists-p socket-file) (error "An mpv instance for %s is already running: %s" (subed--buffer-file-name) socket-file)) (subed-debug "Running %s" argv) (condition-case err (setq subed-mpv--server-proc (make-process :command argv :name "subed-mpv-server" :buffer nil :noquery t)) (error (error "%s" (mapconcat #'identity (cdr (cdr err)) ": ")))))) (defun subed-mpv--server-stop () "Kill a running mpv process." (when (and subed-mpv--server-proc (process-live-p subed-mpv--server-proc)) (delete-process subed-mpv--server-proc) (subed-debug "Killed mpv process")) (ignore-errors (let ((socket-file (subed-mpv--socket t))) (when (file-exists-p socket-file) (subed-debug "Removing IPC socket: %s" socket-file) (ignore-errors (delete-file socket-file))))) (setq subed-mpv--server-proc nil)) (defun subed-mpv--server-started-p () "Whether `subed-mpv--server-proc' is a running process." (if subed-mpv--server-proc t nil)) ;;; Client (elisp process that connects to server's IPC socket) (defun subed-mpv--client-buffer () "Unique name of buffer that store RPC responses." (let ((buffer-name (format "*subed-mpv-buffer:%s-%s*" (file-name-base (or (buffer-file-name) "unnamed")) (buffer-hash)))) (if subed-debugging-enabled-p buffer-name (concat " " buffer-name)))) (defun subed-mpv--client-connect (delays) "Try to connect to `subed-mpv--socket'. If a connection attempt fails, wait (car DELAYS) seconds and try again with (cdr DELAYS) as arguments." (subed-debug "Attempting to connect to IPC socket: %s" (subed-mpv--socket)) (subed-mpv--client-disconnect) ;; NOTE: make-network-process doesn't fail when the socket file doesn't exist (let ((proc (make-network-process :name "subed-mpv-client" :family 'local :service (subed-mpv--socket) :coding '(utf-8 . utf-8) :buffer (subed-mpv--client-buffer) :filter #'subed-mpv--client-filter :noquery t :nowait t))) ;; Test connection by sending a test request (condition-case err (progn (process-send-string proc (concat subed-mpv--client-test-request "\n")) (subed-debug "Connected to %s (%s)" proc (process-status proc)) (setq subed-mpv--client-proc proc)) (error (if delays (progn (subed-debug "Failed to connect (trying again in %s seconds)" (car delays)) (run-at-time (car delays) nil #'subed-mpv--client-connect (cdr delays))) (progn (subed-debug "Connection failed: %s" err)))))) ;; Run commands that were sent while the connection wasn't up yet (when (subed-mpv--client-connected-p) (while subed-mpv--client-command-queue (let ((cmd (pop subed-mpv--client-command-queue))) (subed-debug "Running queued command: %s" cmd) (apply #'subed-mpv--client-send (list cmd)))))) (defun subed-mpv--client-disconnect () "Close connection to mpv process, if there is one." (when (subed-mpv--client-connected-p) (delete-process subed-mpv--client-proc) (subed-debug "Closed connection to mpv process")) (setq subed-mpv--client-proc nil subed-mpv-is-playing nil subed-mpv-playback-position nil)) (defun subed-mpv--client-connected-p () "Whether the server connection has been established and tested successfully." (process-live-p subed-mpv--client-proc)) (defun subed-mpv--client-send (cmd) "Send JSON IPC command. If we're not connected yet but the server has been started, add CMD to `subed-mpv--client-command-queue' which is evaluated by `subed-mpv--client-connect' when the connection is up." (if (subed-mpv--client-connected-p) (let ((request-data (concat (json-encode (list :command cmd))))) (subed-debug "Sending request: %s" request-data) (condition-case err (process-send-string subed-mpv--client-proc (concat request-data "\n")) (error (subed-mpv-kill) (error "Unable to send commands via %s: %s" (subed-mpv--socket) (cdr err)))) t) (when (subed-mpv--server-started-p) (subed-debug "Queueing command: %s" cmd) (setq subed-mpv--client-command-queue (append subed-mpv--client-command-queue (list cmd))) t))) (defun subed-mpv--client-filter (proc response) "Handle response from the server. PROC is the mpv process and RESPONSE is the response as a JSON string." ;; JSON-STRING contains zero or more lines with JSON encoded objects, e.g.: ;; {"data":"mpv 0.29.1","error":"success"} ;; {"data":null,"request_id":1,"error":"success"} ;; {"event":"start-file"}{"event":"tracks-changed"} ;; JSON-STRING can also contain incomplete JSON, e.g. `{"error":"succ'. ;; Therefore we maintain a buffer and process only complete lines. (when (buffer-live-p (process-buffer proc)) (let ((orig-buffer (current-buffer))) (when (derived-mode-p 'subed-mode) (with-current-buffer (process-buffer proc) ;; Insert new response where previous response ended (let* ((proc-mark (process-mark proc)) (moving (= (point) proc-mark))) (save-excursion (goto-char proc-mark) (insert response) (set-marker proc-mark (point))) (if moving (goto-char proc-mark))) ;; Process and remove all complete lines of JSON (lines are complete if ;; they end with \n) (let ((p0 (point-min))) (while (progn (goto-char p0) (end-of-line) (equal (following-char) ?\n)) (let* ((p1 (point)) (line (buffer-substring p0 p1))) (delete-region p0 (+ p1 1)) ;; Return context to the subtitle file buffer because we're using ;; buffer-local variables to store player state. (with-current-buffer orig-buffer (subed-mpv--client-handle-json line)))))))))) (defun subed-mpv--client-handle-json (json-string) "Process server response JSON-STRING." (let* ((json-data (condition-case nil (json-read-from-string json-string) (error (subed-debug "Unable to parse JSON response:\n%S" json-string) nil))) (event (when json-data (alist-get 'event json-data)))) (when event (subed-mpv--client-handle-event json-data)))) (defun subed-mpv--client-handle-event (json-data) "Handler for relevant mpv events. JSON-DATA is mpv's JSON response in the form of an association list. See \"List of events\" in mpv(1)." (let ((event (alist-get 'event json-data))) (pcase event ("property-change" (when (string= (alist-get 'name json-data) "time-pos") (let ((pos-msecs (* 1000 (or (alist-get 'data json-data) 0)))) (setq subed-mpv-playback-position (round pos-msecs)) (run-hook-with-args 'subed-mpv-playback-position-hook subed-mpv-playback-position)))) ("file-loaded" (setq subed-mpv-is-playing t) ;; TODO: Remove this code. It seems unnecessary now. Not sure why, but ;; I can't reproduce the issue. ;; Because mpv can report the player position AFTER the file was loaded ;; we disable automatic movement of point for a while so that the effect ;; of `subed-mpv-jump-to-current-subtitle' isn't cancelled immediately. ;; (subed-disable-sync-point-to-player-temporarily) (run-hooks 'subed-mpv-file-loaded-hook)) ("unpause" (setq subed-mpv-is-playing t)) ((or "pause" "end-file" "shutdown" "idle") (setq subed-mpv-is-playing nil))))) ;;; High-level functions (defun subed-mpv-pause () "Stop playback." (interactive) (when subed-mpv-is-playing (when (subed-mpv--client-send `(set_property pause yes)) (subed-mpv--client-handle-event '((event . "pause")))))) (defun subed-mpv-unpause () "Start playback." (interactive) (unless subed-mpv-is-playing (when (subed-mpv--client-send `(set_property pause no)) (subed-mpv--client-handle-event '((event . "unpause")))))) (defun subed-mpv-toggle-pause () "Start or stop playback." (interactive) (if subed-mpv-is-playing (subed-mpv-pause) (subed-mpv-unpause))) (defun subed-mpv-playback-speed (factor) "Play slower (FACTOR < 1) or faster (FACTOR > 1)." (interactive "NFactor: ") (unless (eq subed-mpv-playback-speed factor) (when (subed-mpv--client-send `(set_property speed ,factor)) (setq subed-mpv-playback-speed factor)))) (defun subed-mpv-seek (msec) "Move playback position MSEC milliseconds relative to current position." (interactive "NOffset in ms: ") (subed-mpv--client-send `(seek ,(/ msec 1000.0) relative+exact))) (defun subed-mpv-jump (msec) "Move playback position to absolute position MSEC milliseconds." (interactive "MTimestamp or msecs: ") (setq msec (subed-to-msecs msec)) (subed-mpv--client-send `(seek ,(/ msec 1000.0) absolute+exact))) (defun subed-mpv-jump-to-current-subtitle () "Move playback position to start of currently focused subtitle if possible." (interactive) (let ((cur-sub-start (subed-subtitle-msecs-start))) (when cur-sub-start (subed-debug "Seeking player to focused subtitle: %S" cur-sub-start) (subed-mpv-jump cur-sub-start)))) (defun subed-mpv-jump-to-current-subtitle-near-end () "Move playback position to near the end of the current subtitle." (interactive) (let ((cur-sub-end (subed-subtitle-msecs-stop))) (when cur-sub-end (setq cur-sub-end (- cur-sub-end subed-sample-msecs)) (subed-debug "Seeking player to end of focused subtitle: %S" cur-sub-end) (subed-mpv-jump cur-sub-end)))) (defun subed-mpv-jump-to-before-current-subtitle () "Move playback position before the current subtitle." (interactive) (let ((cur-sub-start (subed-subtitle-msecs-start))) (when cur-sub-start (setq cur-sub-start (- cur-sub-start subed-sample-msecs)) (subed-debug "Seeking player to before focused subtitle: %S" cur-sub-start) (subed-mpv-jump cur-sub-start)))) (defun subed-mpv-jump-to-end-of-current-subtitle () "Move playback position to the end of current subtitle." (interactive) (let ((cur-sub-end (subed-subtitle-msecs-stop))) (when cur-sub-end (subed-debug "Seeking player to end of focused subtitle: %S" cur-sub-end) (subed-mpv-jump cur-sub-end)))) (defun subed-mpv-back-large-step () "Move back one large step." (interactive) (subed-mpv--client-send `(seek ,(- 0 subed-mpv-large-step-seconds) relative+exact))) (defun subed-mpv-back-small-step () "Move back one small step." (interactive) (subed-mpv--client-send `(seek ,(- 0 subed-mpv-small-step-seconds) relative+exact))) (defun subed-mpv-small-step () "Move forward one small step." (interactive) (subed-mpv--client-send `(seek ,subed-mpv-small-step-seconds relative+exact))) (defun subed-mpv-large-step () "Move forward one large step." (interactive) (subed-mpv--client-send `(seek ,subed-mpv-large-step-seconds relative+exact))) (defun subed-mpv-undo-seek () "Undo a seek." (interactive) (subed-mpv--client-send `(revert-seek))) (defun subed-mpv-frame-step () "Step one frame forward. Set up keybindings so that repeatedly pressing `,' and `.' moves by frames until any other key is pressed." (interactive) (subed-mpv--client-send `(frame-step))) (defun subed-mpv-frame-back-step () "Step one frame backward. Set up keybindings so that repeatedly pressing `,' and `.' moves by frames until any other key is pressed." (interactive) (subed-mpv--client-send `(frame-back-step))) (defun subed-mpv-backward-subtitle-and-jump () "Go to the previous subtitle and jump to its beginning." (interactive) (subed-backward-subtitle-text) (subed-mpv-jump-to-current-subtitle)) (defun subed-mpv-forward-subtitle-and-jump () "Go to the next subtitle and jump to its beginning." (interactive) (subed-forward-subtitle-text) (subed-mpv-jump-to-current-subtitle)) (defun subed-mpv-add-subtitles (file) "Load FILE as subtitles in mpv." (subed-mpv--client-send `(sub-add ,file select))) (defun subed-mpv-reload-subtitles () "Reload subtitle file from disk." (subed-mpv--client-send '(sub-reload))) (defun subed-mpv-screenshot (&optional file flags) "Take a screenshot without subtitles. Save to FILE if specified. Use \\[universal-argument] to save to a specific file. Passes FLAGS to MPV for taking the screenshot. Returns the filename." (setq flags (or flags 'video)) (interactive (list (if current-prefix-arg (read-file-name "Filename: ")))) (if file (subed-mpv--client-send `(screenshot-to-file ,file ,flags)) (let ((old-latest (car (sort (seq-remove #'file-directory-p (directory-files default-directory 'full "mpv-shot[0-9]+\\.jpg" t)) #'file-newer-than-file-p)))) (subed-mpv--client-send `(screenshot ,flags)) (while (string= (car (sort (seq-remove #'file-directory-p (directory-files default-directory 'full "mpv-shot[0-9]+\\.jpg" t)) #'file-newer-than-file-p)) old-latest) (sit-for 0.5) (setq file (car (sort (seq-remove #'file-directory-p (directory-files default-directory 'full "mpv-shot[0-9]+\\.jpg" t)) #'file-newer-than-file-p)))))) (kill-new file) (message "Copied %s" file) file) (defun subed-mpv-screenshot-with-subtitles (&optional file) "Take a screenshot including subtitles. Save to FILE if specified. Use \\[universal-argument] to save to a specific file. Returns the filename." (interactive (list (if current-prefix-arg (read-file-name "Filename: ")))) (subed-mpv-screenshot file 'subtitles)) (defun subed-mpv-copy-position-as-timestamp () "Copy current playback position as a timestamp." (interactive) (if subed-mpv-playback-position (progn (let ((pos (subed-msecs-to-timestamp subed-mpv-playback-position))) (kill-new pos) (message "Copied %s" pos))) (error "No playback position yet"))) (defun subed-mpv-copy-position-as-seconds () "Copy current playback position as seconds. This might be helpful for ffmpeg." (interactive) (if subed-mpv-playback-position (let ((pos (number-to-string (/ subed-mpv-playback-position 1000.0)))) (kill-new pos) (message "Copied %s" pos)) (error "No playback position yet"))) (defun subed-mpv-copy-position-as-msecs () "Copy current playback position as msecs." (interactive) (if subed-mpv-playback-position (let ((pos (number-to-string subed-mpv-playback-position))) (kill-new pos) (message "Copied %s" pos)) (error "No playback position yet"))) (define-obsolete-function-alias 'subed-mpv--is-video-file-p 'subed-mpv--is-media-file-p "1.20") (defun subed-mpv--is-media-file-p (filename) "Return non-nil if FILENAME is a media file. Files should match `subed-video-extensions' or `subed-audio-extensions'." (and (not (or (string= filename ".") (string= filename ".."))) (let ((filepath (expand-file-name filename))) (or (file-directory-p filepath) (member (file-name-extension filename) subed-video-extensions) (member (file-name-extension filename) subed-audio-extensions))))) (defun subed-mpv--play (file) "Open FILE and play it in mpv." (when (subed-mpv--server-started-p) (subed-mpv-kill)) (when (apply #'subed-mpv--server-start subed-mpv-arguments) (subed-debug "Opening file: %s" file) (subed-mpv--client-connect subed-mpv--retry-delays) (subed-mpv--client-send `(loadfile ,file replace)) ;; mpv won't add the subtitles if the file doesn't exist yet, so we add it ;; via after-save-hook. (if (file-exists-p (buffer-file-name)) (subed-mpv-add-subtitles (buffer-file-name)) (add-hook 'after-save-hook #'subed-mpv--add-subtitle-after-first-save :append :local)) (subed-mpv--client-send `(observe_property 1 time-pos)) (subed-mpv-playback-speed subed-playback-speed-while-not-typing))) (define-obsolete-function-alias 'subed-mpv-play-video-from-url 'subed-mpv-play-from-url "1.20") (defun subed-mpv-play-from-url (url) "Open file from URL in mpv. See the mpv manual for a list of supported URL types. If you have youtube-dl or yt-dlp installed, mpv can open videos from a variety of hosting providers." (interactive "MURL: ") (setq subed-mpv-media-file url) (subed-mpv--play url)) (defvar subed-mpv-play-from-file-hook nil "Functions to run after a media file is loaded. Called with FILE as argument.") (defun subed-mpv-play-from-file (file) "Open FILE in mpv. Files are expected to have any of the extensions listed in `subed-video-extensions' or `subed-audio-extensions'." (interactive (list (read-file-name "Find media: " nil nil t nil #'subed-mpv--is-media-file-p))) (setq subed-mpv-media-file (expand-file-name file)) (subed-clear-file-duration-ms-cache) (subed-mpv--play (expand-file-name file)) (run-hook-with-args subed-mpv-play-from-file-hook)) (define-obsolete-function-alias 'subed-mpv-find-video 'subed-mpv-play-from-file "1.20") (defun subed-mpv--add-subtitle-after-first-save () "Tell mpv to load subtitles from function `buffer-file-name'. Don't send the load command to mpv if `subed-subtitle-id' returns nil because that likely means the file is empty or invalid. This function is supposed to be added to `after-save-hook', and it removes itself from it so mpv doesn't add the same file every time the buffer is saved." (when (subed-subtitle-id) (subed-mpv-add-subtitles (buffer-file-name)) (remove-hook 'after-save-hook #'subed-mpv--add-subtitle-after-first-save :local))) (defun subed-mpv-kill () "Close connection to mpv process and kill the process." (subed-mpv--client-disconnect) (subed-mpv--server-stop)) (defun subed-mpv-control () "Use keyboard shortcuts to control MPV. See `subed-mpv-control-map'." (interactive) (set-transient-map subed-mpv-control-map t)) (provide 'subed-mpv) ;;; subed-mpv.el ends here subed-1.2.25/subed/subed-mpv.el.license000066400000000000000000000001551474617305700176710ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2019-2020 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-srt.el000066400000000000000000000441351474617305700162640ustar00rootroot00000000000000;;; subed-srt.el --- SubRip/srt 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: ;; SubRip/srt implementation for subed-mode. ;;; Code: (require 'subed) (require 'subed-config) (require 'subed-debug) (require 'subed-common) ;;; Syntax highlighting (defconst subed-srt-font-lock-keywords (list '("^[0-9]+$" . 'subed-id-face) '("[0-9]+:[0-9]+:[0-9]+,[0-9]+" . 'subed-time-face) '(",[0-9]+ +\\(-->\\) +[0-9]+:" 1 'subed-time-separator-face t)) "Highlighting expressions for `subed-mode'.") ;;; Parsing (defconst subed-srt--regexp-timestamp "\\([0-9]+\\):\\([0-9]+\\):\\([0-9]+\\),\\([0-9]+\\)") (defconst subed-srt--regexp-separator "\\(?:[[:blank:]]*\n\\)+[[:blank:]]*\n") (cl-defmethod subed--timestamp-to-msecs (time-string &context (major-mode subed-srt-mode)) "Find HH:MM:SS,MS pattern in TIME-STRING and convert it to milliseconds. Return nil if TIME-STRING doesn't match the pattern. Use the format-specific function for MAJOR-MODE." (when (string-match subed--regexp-timestamp time-string) (let ((hours (string-to-number (match-string 1 time-string))) (mins (string-to-number (match-string 2 time-string))) (secs (string-to-number (match-string 3 time-string))) (msecs (string-to-number (subed--right-pad (match-string 4 time-string) 3 ?0)))) (+ (* (truncate hours) 3600000) (* (truncate mins) 60000) (* (truncate secs) 1000) (truncate msecs))))) (cl-defmethod subed--msecs-to-timestamp (msecs &context (major-mode subed-srt-mode)) "Convert MSECS to string in the format HH:MM:SS,MS. Use the format-specific function for MAJOR-MODE." (concat (format-seconds "%02h:%02m:%02s" (/ (floor msecs) 1000)) "," (format "%03d" (mod (floor msecs) 1000)))) (cl-defmethod subed--subtitle-id (&context (major-mode subed-srt-mode)) "Return the ID of the subtitle at point or nil if there is no ID. Use the format-specific function for MAJOR-MODE." (save-excursion (when (subed-jump-to-subtitle-id) (string-to-number (current-word))))) (cl-defmethod subed--subtitle-id-at-msecs (msecs &context (major-mode subed-srt-mode)) "Return the ID of the subtitle at MSECS milliseconds. Return nil if there is no subtitle at MSECS. Use the format-specific function for MAJOR-MODE." (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\\|\\`\\)[0-9]+\n%02d:" subed--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\\|\\`\\)[0-9]+\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-subtitle-msecs-start) -1) msecs) ;; If stop time is >= MSECS, we found a match (let ((cur-sub-end (subed-subtitle-msecs-stop))) (when (and cur-sub-end (>= cur-sub-end msecs)) (throw 'subtitle-id (subed-subtitle-id)))) (unless (subed-forward-subtitle-id) (throw 'subtitle-id nil)))))) ;;; Traversing (cl-defmethod subed--jump-to-subtitle-id (&context (major-mode subed-srt-mode) &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. Use the format-specific function for MAJOR-MODE." (if sub-id ;; Look for a line that contains only the ID, preceded by one or more ;; blank lines or the beginning of the buffer. (let* ((orig-point (point)) (regex (format "\\(%s\\|\\`\\)\\(%d\\)$" subed--regexp-separator sub-id)) (match-found (progn (goto-char (point-min)) (re-search-forward regex nil t)))) (if match-found (goto-char (match-beginning 2)) (goto-char orig-point))) ;; Find one or more blank lines. (re-search-forward "\\([[:blank:]]*\n\\)+" nil t) ;; Find two or more blank lines or the beginning of the buffer, followed ;; by line composed of only digits. (let* ((regex (concat "\\(" subed--regexp-separator "\\|\\`\\)\\([0-9]+\\)$")) (match-found (re-search-backward regex nil t))) (when match-found (goto-char (match-beginning 2))))) ;; Make extra sure we're on an ID, return nil if we're not (when (looking-at "^\\([0-9]+\\)$") (point))) (cl-defmethod subed--jump-to-subtitle-time-start (&context (major-mode subed-srt-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (forward-line) (when (looking-at subed--regexp-timestamp) (point)))) (cl-defmethod subed--jump-to-subtitle-time-stop (&context (major-mode subed-srt-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (forward-line 1) (re-search-forward " *--> *" (line-end-position) t) (when (looking-at subed--regexp-timestamp) (point)))) (cl-defmethod subed--jump-to-subtitle-text (&context (major-mode subed-srt-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (forward-line 2) (point))) (cl-defmethod subed--jump-to-subtitle-end (&context (major-mode subed-srt-mode) &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. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point))) (subed-jump-to-subtitle-text sub-id) (unless (looking-at "\n") ; handle empty subtitle text ;; Look for next separator or end of buffer. (let ((regex (concat "\\(" subed--regexp-separator "[0-9]+\n\\|\\([[:blank:]]*\n*\\)\\'\\)"))) (when (re-search-forward regex nil t) (goto-char (match-beginning 0))))) (unless (= (point) orig-point) (point)))) (cl-defmethod subed--forward-subtitle-id (&context (major-mode subed-srt-mode)) "Move point to next subtitle's ID. Return point or nil if there is no next subtitle. Use the format-specific function for MAJOR-MODE." (let ((pos (point))) (when (and (bolp) (> (point) (point-min))) (forward-char -1)) (if (re-search-forward (concat subed--regexp-separator "[0-9]+\n") nil t) (subed-jump-to-subtitle-id) (goto-char pos) nil))) (cl-defmethod subed--backward-subtitle-id (&context (major-mode subed-srt-mode)) "Move point to previous subtitle's ID. Return point or nil if there is no previous subtitle. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point))) (when (subed-jump-to-subtitle-id) (if (re-search-backward (concat "\\(" subed--regexp-separator "\\|\\`[[:space:]]*\\)" "\\([0-9]+\\)\n") nil t) (progn (goto-char (match-beginning 2)) (point)) (goto-char orig-point) nil)))) ;;; Manipulation (cl-defmethod subed--make-subtitle (&context (major-mode subed-srt-mode) &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. COMMENT is ignored. A newline is appended to TEXT, meaning you'll get two trailing newlines if TEXT is nil or empty. Use the format-specific function for MAJOR-MODE." (format "%s\n%s --> %s\n%s\n" (or id 0) (subed-msecs-to-timestamp (or start 0)) (subed-msecs-to-timestamp (or stop (+ (or start 0) subed-default-subtitle-length))) (or text ""))) (cl-defmethod subed--prepend-subtitle (&context (major-mode subed-srt-mode) &optional id start stop text comment) "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. COMMENT is ignored. Move point to the text of the inserted subtitle. Return new point. Use the format-specific function for MAJOR-MODE." (subed-jump-to-subtitle-id) (insert (subed-make-subtitle id start stop text comment)) (when (looking-at "\\([[:space:]]*\\|^\\)[0-9]+$") (insert "\n")) (forward-line -2) (subed-jump-to-subtitle-text)) (cl-defmethod subed--append-subtitle (&context (major-mode subed-srt-mode) &optional id start stop text comment) "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. COMMENT is ignored. Move point to the text of the inserted subtitle. Return new point. Use the format-specific function for MAJOR-MODE." (unless (subed-forward-subtitle-id) ;; Point is on last subtitle or buffer is empty (subed-jump-to-subtitle-end) (when (looking-at "[[:space:]]+") (replace-match "")) ;; Moved point to end of last subtitle; ensure separator exists (while (not (looking-at "\\(\\`\\|[[:blank:]]*\n[[:blank:]]*\n\\)")) (save-excursion (insert ?\n))) ;; Move to end of separator (goto-char (match-end 0))) (insert (subed-make-subtitle id start stop text comment)) ;; Complete separator with another newline unless we inserted at the end (when (looking-at "\\([[:space:]]*\\|^\\)[0-9]+$") (insert ?\n)) (forward-line -2) (subed-jump-to-subtitle-text)) (cl-defmethod subed--kill-subtitle :after (&context (major-mode subed-srt-mode)) "Remove subtitle at point. Use the format-specific function for MAJOR-MODE." (subed-regenerate-ids-soon)) (cl-defmethod subed--split-subtitle :after (&context (major-mode subed-srt-mode) &optional _) "Split current subtitle at point. Use the format-specific function for MAJOR-MODE." (subed-regenerate-ids-soon)) (cl-defmethod subed--merge-with-next (&context (major-mode subed-srt-mode)) "Merge the current subtitle with the next subtitle. Update the end timestamp accordingly. Use the format-specific function for MAJOR-MODE." (save-excursion (subed-jump-to-subtitle-end) (let ((pos (point)) new-end) (if (subed-forward-subtitle-time-stop) (progn (when (looking-at subed--regexp-timestamp) (setq new-end (subed-timestamp-to-msecs (match-string 0)))) (subed-jump-to-subtitle-text) (delete-region pos (point)) (insert " ") (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-stop new-end)) (run-hooks 'subed-subtitle-merged-hook) (subed-regenerate-ids-soon)) (error "No subtitle to merge into"))))) ;;; Maintenance (cl-defmethod subed--regenerate-ids (&context (major-mode subed-srt-mode)) "Ensure consecutive, unduplicated subtitle IDs. Format-specific for MAJOR-MODE." (atomic-change-group (save-excursion (goto-char (point-min)) (subed-jump-to-subtitle-id) (when (looking-at "^[[:digit:]]+$") (unless (string= (current-word) "1") (delete-region (point) (progn (forward-word 1) (point))) (insert "1"))) (let ((id 2)) (while (subed-forward-subtitle-id) (let ((id-str (number-to-string id))) (unless (string= (current-word) id-str) (delete-region (point) (progn (forward-word 1) (point))) (insert id-str))) (setq id (1+ id))))))) (cl-defmethod subed--sanitize-format (&context (major-mode subed-srt-mode)) "Remove surplus newlines and whitespace. Use the format-specific function for MAJOR-MODE." (atomic-change-group (subed-save-excursion ;; Remove trailing whitespace from each line (delete-trailing-whitespace (point-min) (point-max)) ;; Remove leading spaces and tabs from each line (goto-char (point-min)) (while (re-search-forward "^[[:blank:]]+" nil t) (replace-match "")) ;; Remove leading newlines (goto-char (point-min)) (while (looking-at "\\`\n+") (replace-match "")) ;; Replace separators between subtitles with double newlines (goto-char (point-min)) (while (subed-forward-subtitle-id) (let ((prev-sub-end (save-excursion (when (subed-backward-subtitle-end) (point))))) (when (and prev-sub-end (not (string= (buffer-substring prev-sub-end (point)) "\n\n"))) (delete-region prev-sub-end (point)) (insert "\n\n")))) ;; Two trailing newline if last subtitle text is empty, one trailing ;; newline otherwise; do nothing in empty buffer (no graphical ;; characters) (goto-char (point-min)) (when (re-search-forward "[[:graph:]]" nil t) (goto-char (point-max)) (subed-jump-to-subtitle-end) (unless (looking-at "\n\\'") (delete-region (point) (point-max)) (insert "\n"))) ;; One space before and after " --> " (goto-char (point-min)) (while (re-search-forward (format "^%s" subed--regexp-timestamp) nil t) (when (looking-at "[[:blank:]]*-->[[:blank:]]*") (unless (= (length (match-string 0)) 5) (replace-match " --> "))))))) (cl-defmethod subed--validate-format (&context (major-mode subed-srt-mode)) "Move point to the first invalid subtitle and report an error. Use the format-specific function for MAJOR-MODE." (when (> (buffer-size) 0) (atomic-change-group (let ((orig-point (point))) (goto-char (point-min)) (while (and (re-search-forward (format "\\(%s\\|\\`\\)" subed--regexp-separator) nil t) (looking-at "[[:alnum:]]")) (unless (looking-at "^[0-9]+$") (error "Found invalid subtitle ID: %S" (substring (or (thing-at-point 'line :no-properties) "\n") 0 -1))) (forward-line) ;; This regex is stricter than `subed-srt--regexp-timestamp' (unless (looking-at "^[0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\},[0-9]\\{,3\\}") (error "Found invalid start time: %S" (substring (or (thing-at-point 'line :no-properties) "\n") 0 -1))) (when (re-search-forward "[[:blank:]]" (line-end-position) t) (goto-char (match-beginning 0))) (unless (looking-at " --> ") (error "Found invalid separator between start and stop time: %S" (substring (or (thing-at-point 'line :no-properties) "\n") 0 -1))) (condition-case nil (forward-char 5) (error nil)) (unless (looking-at "[0-9]\\{2\\}:[0-9]\\{2\\}:[0-9]\\{2\\},[0-9]\\{,3\\}$") (error "Found invalid stop time: %S" (substring (or (thing-at-point 'line :no-properties) "\n") 0 -1)))) (goto-char orig-point))))) (cl-defmethod subed--insert-subtitle :after (&context (major-mode subed-srt-mode) &optional _) "Renumber afterwards. Format-specific for MAJOR-MODE." (subed-regenerate-ids-soon) (point)) (cl-defmethod subed--insert-subtitle-adjacent :after (&context (major-mode subed-srt-mode) &optional _) "Renumber afterwards. Format-specific for MAJOR-MODE." (subed-regenerate-ids-soon) (point)) ;;;###autoload (define-derived-mode subed-srt-mode subed-mode "Subed-SRT" "Major mode for editing SubRip subtitle files." (setq-local subed--subtitle-format "srt") (setq-local subed--regexp-timestamp subed-srt--regexp-timestamp) (setq-local subed--regexp-separator subed-srt--regexp-separator) (setq-local font-lock-defaults '(subed-srt-font-lock-keywords)) (setq-local comment-start "{\\") (setq-local comment-end "}") (modify-syntax-entry ?\{ ". 1") (modify-syntax-entry ?\\ ". 2") (modify-syntax-entry ?\} ">") ;; Support for fill-paragraph (M-q) (let ((timestamps-regexp (concat subed--regexp-timestamp " *--> *" subed--regexp-timestamp))) (setq-local paragraph-separate (concat "^\\(" (mapconcat 'identity `("[[:blank:]]*" "[[:digit:]]+" ,timestamps-regexp) "\\|") "\\)$")) (setq-local paragraph-start (concat "\\(" ;; Mulitple speakers in the same ;; subtitle are often distinguished with ;; a "-" at the start of the line. (mapconcat 'identity '("^-" "[[:graph:]]*$") "\\|") "\\)"))) (add-hook 'subed-sanitize-functions #'subed-regenerate-ids t t)) ;;;###autoload (add-to-list 'auto-mode-alist '("\\.srt\\'" . subed-srt-mode)) (provide 'subed-srt) ;;; subed-srt.el ends here subed-1.2.25/subed/subed-srt.el.license000066400000000000000000000001551474617305700176770ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2019-2020 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-tsv.el000066400000000000000000000443431474617305700162710ustar00rootroot00000000000000;;; subed-tsv.el --- Tab-separated subtitles, such as Audacity labels -*- 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: ;; This file supports tab-separated values such as labels exported from Audacity. ;; Example: ;; ;; 6.191196 27.488912 This is a test ;; 44.328966 80.733201 This is another line, a little longer than the first. ;;; Code: (require 'subed) (require 'subed-config) (require 'subed-debug) (require 'subed-common) ;;; Syntax highlighting (defconst subed-tsv-font-lock-keywords (list '("^\\([0-9]+\\.[0-9]+\\)\t\\([0-9]+\\.[0-9]+\\)" (0 'subed-time-face))) "Highlighting expressions for `subed-mode'.") ;;; Parsing (defconst subed-tsv--regexp-timestamp "\\([0-9]+\\)\\(\\.\\([0-9]+\\)\\)?") (defconst subed-tsv--regexp-separator "\n") (cl-defmethod subed--timestamp-to-msecs (time-string &context (major-mode subed-tsv-mode)) "Find SS.MS pattern in TIME-STRING and convert it to milliseconds. Return nil if TIME-STRING doesn't match the pattern. Use the format-specific function for MAJOR-MODE." (save-match-data (when (string-match subed-tsv--regexp-timestamp time-string) (* 1000 (string-to-number (match-string 0 time-string)))))) (cl-defmethod subed--msecs-to-timestamp (msecs &context (major-mode subed-tsv-mode)) "Convert MSECS to string in the format H:MM:SS.CS. Use the format-specific function for MAJOR-MODE." ;; We need to wrap format-seconds in save-match-data because it does regexp ;; stuff and we need to preserve our own match-data. (format "%f" (/ msecs 1000.0))) (cl-defmethod subed--subtitle-id (&context (major-mode subed-tsv-mode)) "Return the ID of the subtitle at point or nil if there is no ID. Use the format-specific function for MAJOR-MODE." (save-excursion (when (subed-jump-to-subtitle-id) (when (looking-at subed-tsv--regexp-timestamp) (match-string 0))))) (cl-defmethod subed--subtitle-id-max (&context (major-mode subed-tsv-mode)) "Return the ID of the last subtitle or nil if there are no subtitles. Use the format-specific function for MAJOR-MODE." (save-excursion (goto-char (point-max)) (subed-subtitle-id))) (cl-defmethod subed--subtitle-id-at-msecs (msecs &context (major-mode subed-tsv-mode)) "Return the ID of the subtitle at MSECS milliseconds. Return nil if there is no subtitle at MSECS. Use the format-specific function for MAJOR-MODE." (save-match-data (save-excursion (goto-char (point-min)) ;; Move to first subtitle that starts at or after MSECS (catch 'subtitle-id (while (<= (or (subed-subtitle-msecs-start) -1) msecs) ;; If stop time is >= MSECS, we found a match (let ((cur-sub-end (subed-subtitle-msecs-stop))) (when (and cur-sub-end (>= cur-sub-end msecs)) (throw 'subtitle-id (subed-subtitle-id)))) (unless (subed-forward-subtitle-id) (throw 'subtitle-id nil))))))) (cl-defmethod subed--subtitle-msecs-start (&context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (let ((timestamp (save-excursion (when (subed-jump-to-subtitle-time-start sub-id) (when (looking-at subed-tsv--regexp-timestamp) (match-string 0)))))) (when timestamp (subed-timestamp-to-msecs timestamp)))) (cl-defmethod subed--subtitle-msecs-stop (&context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (let ((timestamp (save-excursion (when (subed-jump-to-subtitle-time-stop sub-id) (when (looking-at subed-tsv--regexp-timestamp) (match-string 0)))))) (when timestamp (subed-timestamp-to-msecs timestamp)))) (cl-defmethod subed--subtitle-text (&context (major-mode subed-tsv-mode) &optional sub-id) "Return subtitle's text or an empty string. If SUB-ID is not given, use subtitle on point. Use the format-specific function for MAJOR-MODE." (or (save-excursion (let ((beg (subed-jump-to-subtitle-text sub-id)) (end (subed-jump-to-subtitle-end sub-id))) (when (and beg end) (buffer-substring beg end)))) "")) (cl-defmethod subed--subtitle-relative-point (&context (major-mode subed-tsv-mode)) "Point relative to subtitle's ID or nil if ID can't be found. Use the format-specific function for MAJOR-MODE." (let ((start-point (save-excursion (when (subed-jump-to-subtitle-id) (point))))) (when start-point (- (point) start-point)))) ;;; Traversing (cl-defmethod subed--jump-to-subtitle-id (&context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (save-match-data (if (stringp sub-id) (let* ((orig-point (point)) (find-ms (subed-timestamp-to-msecs sub-id)) done) (goto-char (point-min)) ;; Find the first timestamp that ends after the time we're looking for (catch 'found-ending-after (while (not (eobp)) (when (and (looking-at (concat "^[^\t]+\t\\(" subed-tsv--regexp-timestamp "\\)\t")) (> (subed-timestamp-to-msecs (match-string 1)) find-ms )) (throw 'found-ending-after "Found ending")) (forward-line 1))) ;; Does the time fit in the current one? (if (>= find-ms (subed-subtitle-msecs-start)) (progn (beginning-of-line) (point)) (goto-char orig-point) nil)) (beginning-of-line) (when (looking-at (concat subed-tsv--regexp-timestamp "\t" subed-tsv--regexp-timestamp "\t.*")) (point))))) (cl-defmethod subed--jump-to-subtitle-id-at-msecs (msecs &context (major-mode subed-tsv-mode)) "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-tsv--subtitle-id-at-msecs'. Use the format-specific function for MAJOR-MODE." (let ((current-sub-id (subed-subtitle-id)) (target-sub-id (subed-subtitle-id-at-msecs msecs))) (when (and target-sub-id current-sub-id (not (equal target-sub-id current-sub-id))) (subed-jump-to-subtitle-id target-sub-id)))) (cl-defmethod subed--jump-to-subtitle-text-at-msecs (msecs &context (major-mode subed-tsv-mode)) "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-tsv--subtitle-id-at-msecs'. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id-at-msecs msecs) (subed-jump-to-subtitle-text))) (cl-defmethod subed--jump-to-subtitle-time-start (&context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (save-match-data (when (subed-jump-to-subtitle-id sub-id) (when (re-search-forward subed-tsv--regexp-timestamp (line-end-position) t) (goto-char (match-beginning 0)) (point))))) (cl-defmethod subed--jump-to-subtitle-time-stop (&context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (save-match-data (when (subed-jump-to-subtitle-id sub-id) (re-search-forward (concat "\\(?:" subed-tsv--regexp-timestamp "\\)\t") (point-at-eol) t) (when (looking-at subed-tsv--regexp-timestamp) (point))))) (cl-defmethod subed--jump-to-subtitle-text (&context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (beginning-of-line) (when (looking-at ".*?\t.*?\t") (goto-char (match-end 0))) (point))) (cl-defmethod subed--jump-to-subtitle-end (&context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (save-match-data (let ((orig-point (point))) (when (subed-jump-to-subtitle-text sub-id) (end-of-line) (unless (= orig-point (point)) (point)))))) (cl-defmethod subed--forward-subtitle-id (&context (major-mode subed-tsv-mode)) "Move point to next subtitle's ID. Return point or nil if there is no next subtitle. Use the format-specific function for MAJOR-MODE." (save-match-data (let ((pos (point))) (forward-line 1) (if (eobp) (prog1 nil (goto-char pos)) (beginning-of-line) (if (looking-at subed-tsv--regexp-timestamp) (point) (goto-char pos) nil))))) (cl-defmethod subed--backward-subtitle-id (&context (major-mode subed-tsv-mode)) "Move point to previous subtitle's ID. Return point or nil if there is no previous subtitle. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point))) (if (bobp) nil (when (subed-jump-to-subtitle-id) (if (bobp) (progn (goto-char orig-point) nil) (forward-line -1) (while (not (or (bobp) (looking-at subed-tsv--regexp-timestamp))) (forward-line -1)) (if (looking-at subed-tsv--regexp-timestamp) (point) (goto-char orig-point) nil)))))) (cl-defmethod subed--forward-subtitle-text (&context (major-mode subed-tsv-mode)) "Move point to next subtitle's text. Return point or nil if there is no next subtitle. Use the format-specific function for MAJOR-MODE." (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-text))) (cl-defmethod subed--backward-subtitle-text (&context (major-mode subed-tsv-mode)) "Move point to previous subtitle's text. Return point or nil if there is no previous subtitle. Use the format-specific function for MAJOR-MODE." (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-text))) (cl-defmethod subed--forward-subtitle-end (&context (major-mode subed-tsv-mode)) "Move point to end of next subtitle. Return point or nil if there is no next subtitle. Use the format-specific function for MAJOR-MODE." (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-end))) (cl-defmethod subed--backward-subtitle-end (&context (major-mode subed-tsv-mode)) "Move point to end of previous subtitle. Return point or nil if there is no previous subtitle. Use the format-specific function for MAJOR-MODE." (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-end))) (cl-defmethod subed--forward-subtitle-time-start (&context (major-mode subed-tsv-mode)) "Move point to next subtitle's start time. Use the format-specific function for MAJOR-MODE." (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-time-start))) (cl-defmethod subed--backward-subtitle-time-start (&context (major-mode subed-tsv-mode)) "Move point to previous subtitle's start time. Use the format-specific function for MAJOR-MODE." (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-time-start))) (cl-defmethod subed--forward-subtitle-time-stop (&context (major-mode subed-tsv-mode)) "Move point to next subtitle's stop time. Use the format-specific function for MAJOR-MODE." (when (subed-forward-subtitle-id) (subed-jump-to-subtitle-time-stop))) (cl-defmethod subed--backward-subtitle-time-stop (&context (major-mode subed-tsv-mode)) "Move point to previous subtitle's stop time. Use the format-specific function for MAJOR-MODE." (when (subed-backward-subtitle-id) (subed-jump-to-subtitle-time-stop))) ;;; Manipulation (cl-defmethod subed--set-subtitle-time-start (msecs &context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (save-excursion (when (or (not sub-id) (and sub-id (subed-jump-to-subtitle-id sub-id))) (subed-jump-to-subtitle-time-start) (when (looking-at subed-tsv--regexp-timestamp) (replace-match (subed-msecs-to-timestamp msecs)))))) (cl-defmethod subed--set-subtitle-time-stop (msecs &context (major-mode subed-tsv-mode) &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. Use the format-specific function for MAJOR-MODE." (save-excursion (when (or (not sub-id) (and sub-id (subed-jump-to-subtitle-id sub-id))) (subed-jump-to-subtitle-time-stop) (when (looking-at subed-tsv--regexp-timestamp) (replace-match (subed-msecs-to-timestamp msecs)))))) (cl-defmethod subed--make-subtitle (&context (major-mode subed-tsv-mode) &optional id start stop text comment) "Generate new subtitle string. ID, START default to 0. STOP defaults to (+ START `subed-subtitle-spacing') TEXT defaults to an empty string. COMMENT is ignored. A newline is appended to TEXT, meaning you'll get two trailing newlines if TEXT is nil or empty. Use the format-specific function for MAJOR-MODE." (format "%s\t%s\t%s\n" (subed-msecs-to-timestamp (or start 0)) (subed-msecs-to-timestamp (or stop (+ (or start 0) subed-default-subtitle-length))) (replace-regexp-in-string "\n" " " (or text "")))) (cl-defmethod subed--prepend-subtitle (&context (major-mode subed-tsv-mode) &optional id start stop text comment) "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. COMMENT is ignored. Move point to the text of the inserted subtitle. Return new point. Use the format-specific function for MAJOR-MODE." (subed-jump-to-subtitle-id) (insert (subed-make-subtitle id start stop text comment)) (forward-line -1) (subed-jump-to-subtitle-text)) (cl-defmethod subed--append-subtitle (&context (major-mode subed-tsv-mode) &optional id start stop text comment) "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. COMMENT is ignored. Move point to the text of the inserted subtitle. Return new point. Use the format-specific function for MAJOR-MODE." (unless (subed-forward-subtitle-id) ;; Point is on last subtitle or buffer is empty (subed-jump-to-subtitle-end) (unless (bolp) (insert "\n"))) (insert (subed-make-subtitle id start stop text comment)) (forward-line -1) (subed-jump-to-subtitle-text)) (cl-defmethod subed--kill-subtitle (&context (major-mode subed-tsv-mode)) "Remove subtitle at point. Use the format-specific function for MAJOR-MODE." (let ((beg (save-excursion (subed-jump-to-subtitle-id) (point))) (end (save-excursion (subed-jump-to-subtitle-id) (when (subed-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-backward-subtitle-end) (1+ (point))) end (save-excursion (goto-char (point-max))))) (delete-region beg end))) (cl-defmethod subed--merge-with-next (&context (major-mode subed-tsv-mode)) "Merge the current subtitle with the next subtitle. Update the end timestamp accordingly. Use the format-specific function for MAJOR-MODE." (save-excursion (subed-jump-to-subtitle-end) (let ((pos (point)) new-end) (if (subed-forward-subtitle-time-stop) (progn (when (looking-at subed-tsv--regexp-timestamp) (setq new-end (subed-timestamp-to-msecs (match-string 0)))) (subed-jump-to-subtitle-text) (delete-region pos (point)) (insert " ") (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-stop new-end)) (run-hooks 'subed-subtitle-merged-hook)) (error "No subtitle to merge into"))))) ;;; Initialization ;;;###autoload (define-derived-mode subed-tsv-mode subed-mode "Subed-TSV" "Tab-separated subtitles, such as from exporting text labels from Audacity." (setq-local subed--subtitle-format "tsv") (setq-local subed--regexp-timestamp subed-tsv--regexp-timestamp) (setq-local subed--regexp-separator subed-tsv--regexp-separator) (setq-local font-lock-defaults '(subed-tsv-font-lock-keywords))) (provide 'subed-tsv) ;;; subed-tsv.el ends here subed-1.2.25/subed/subed-vtt.el000066400000000000000000000636141474617305700162740ustar00rootroot00000000000000;;; subed-vtt.el --- WebVTT 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: ;; WebVTT implementation for subed-mode. ;; Since WebVTT doesn't use IDs, we'll use the starting timestamp. ;;; Code: (require 'subed) (require 'subed-config) (require 'subed-debug) (require 'subed-common) ;;; Syntax highlighting (defconst subed-vtt-font-lock-keywords (list '("\\([0-9]+:\\)?[0-9]+:[0-9]+\\.[0-9]+" . 'subed-time-face) '("\\.[0-9]+ +\\(-->\\) +[0-9]+:" 1 'subed-time-separator-face t)) "Highlighting expressions for `subed-mode'.") ;;; Parsing (defconst subed-vtt--regexp-timestamp "\\(?:\\(?:[0-9]+\\):\\)?\\(?:[0-9]+\\):\\(?:[0-9]+\\)\\(?:\\.\\(?:[0-9]+\\)\\)?") (defconst subed-vtt--regexp-separator "\\(?:\\(?:[ \t]*\n\\)+\\(?:NOTE[ \t\n]*[ \t]*\n[ \t]*\n\\)?\\)" "Blank lines and possibly a comment.") (defconst subed-vtt--regexp-blank-separator "\\(?:[ \t]*\n[ \t]*\n\\|\\`\\(?:[ \t\n]*\\)\\)") (defconst subed-vtt--regexp-note "\\(NOTE[ \t\n]\\)") (defconst subed-vtt--regexp-identifier ;; According to https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API ;; Cues can start with an identifier which is a non empty line that does ;; not contain "-->". "[ \t]*[^ \t\n-]\\(?:[^\n-]\\|-[^\n-]\\|--[^\n>]\\)*[ \t]*") (defconst subed-vtt--regexp-start-of-line "\\(?:\\`\\|\n\\)") (defconst subed-vtt--regexp-timing (concat "[ \t]*" subed-vtt--regexp-timestamp "[ \t]*-->[ \t]*" subed-vtt--regexp-timestamp)) (defconst subed-vtt--regexp-maybe-identifier-and-timing (format "%s\\(%s%s\\|%s\\)" subed-vtt--regexp-blank-separator subed-vtt--regexp-identifier subed-vtt--regexp-timing subed-vtt--regexp-timing)) (cl-defmethod subed--timestamp-to-msecs (time-string &context (major-mode subed-vtt-mode)) "Find HH:MM:SS.MS pattern in TIME-STRING and convert it to milliseconds. Return nil if TIME-STRING doesn't match the pattern. Use the format-specific function for MAJOR-MODE." (when (string-match "\\(\\([0-9]+\\):\\)?\\([0-9]+\\):\\([0-9]+\\)\\(?:\\.\\([0-9]+\\)\\)?" 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 (string-to-number (subed--right-pad (or (match-string 5 time-string) "0") 3 ?0)))) (+ (* (truncate hours) 3600000) (* (truncate mins) 60000) (* (truncate secs) 1000) (truncate msecs))))) (cl-defmethod subed--msecs-to-timestamp (msecs &context (major-mode subed-vtt-mode)) "Convert MSECS to string in the format HH:MM:SS.MS. Use the format-specific function for MAJOR-MODE." (concat (format-seconds "%02h:%02m:%02s" (/ (floor msecs) 1000)) "." (format "%03d" (mod (floor msecs) 1000)))) (cl-defmethod subed--subtitle-id (&context (major-mode subed-vtt-mode)) "Return the ID of the subtitle at point or nil if there is no ID. Use the format-specific function for MAJOR-MODE." (save-excursion (when (subed-jump-to-subtitle-id) (cond ((looking-at (concat "[ \t]*\\(" subed-vtt--regexp-timestamp "\\)")) (match-string 1)) ((looking-at subed-vtt--regexp-identifier) (string-trim (match-string 0))))))) (cl-defmethod subed--subtitle-id-at-msecs (msecs &context (major-mode subed-vtt-mode)) "Return the ID of the subtitle at MSECS milliseconds. Return nil if there is no subtitle at MSECS. Use the format-specific function for MAJOR-MODE." (save-excursion (goto-char (point-min)) (unless (subed-subtitle-id) (subed-forward-subtitle-time-start)) ;; Move to first subtitle that starts at or after MSECS (catch 'subtitle-id (while (<= (or (subed-subtitle-msecs-start) -1) msecs) ;; If stop time is >= MSECS, we found a match (let ((cur-sub-end (subed-subtitle-msecs-stop))) (when (and cur-sub-end (>= cur-sub-end msecs)) (throw 'subtitle-id (subed-subtitle-id)))) (unless (subed-forward-subtitle-id) (throw 'subtitle-id nil)))))) ;;; Traversing (cl-defmethod subed--in-header-p (&context (major-mode subed-vtt-mode)) "Return non-nil if the point is in the file header. Use the format-specific function for MAJOR-MODE." (save-excursion (let ((orig-point (point))) (goto-char (line-beginning-position)) (cond ((and (looking-at subed-vtt--regexp-timing) (looking-back subed-vtt--regexp-start-of-line)) nil) ;; not looking right at a timestamp; check the previous line to see if it's blank ((and (looking-back subed-vtt--regexp-blank-separator) (looking-at (format "[ \t]*\\(%s\n%s\\|%s\\)" subed-vtt--regexp-identifier subed-vtt--regexp-timing subed-vtt--regexp-note))) nil) ((re-search-backward (concat "^" subed-vtt--regexp-timing) nil t) nil) ((re-search-backward (concat subed-vtt--regexp-blank-separator subed-vtt--regexp-note) nil t) nil) (t t))))) (cl-defmethod subed--in-comment-p (&context (major-mode subed-vtt-mode)) "Return non-nil if the point is in a comment. Use the format-specific function for MAJOR-MODE." (save-excursion (goto-char (line-beginning-position)) (cond ((looking-at subed-vtt--regexp-timing) nil) ((and (looking-at (format "%s\n%s" subed-vtt--regexp-identifier subed-vtt--regexp-timing)) (looking-back subed-vtt--regexp-blank-separator)) nil) ;; looking at a comment ((and (looking-back "^[ \t]*\n\\|\\`\\(?:[ \t\n]*\\)") (looking-at (concat "[ \t]*" subed-vtt--regexp-note))) t) ((re-search-backward (format "\\(%s%s\\)\\|%s%s" subed-vtt--regexp-blank-separator subed-vtt--regexp-note subed-vtt--regexp-start-of-line subed-vtt--regexp-timing) nil t) ;; found the note instead of the timestamp (when (match-string 1) t))))) (cl-defmethod subed--jump-to-subtitle-id (&context (major-mode subed-vtt-mode) &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. WebVTT IDs are optional. If an ID is not specified, use the timestamp instead. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point)) found (timestamp-line (format "\\(?:\\`\\|\n[ \t]*\n+\\)\\(\\(%s\\)\n%s\\|\\(%s\\)\\)" subed-vtt--regexp-identifier subed-vtt--regexp-timing subed-vtt--regexp-timing))) (if (stringp sub-id) ;; Look for a line that contains the timestamp, preceded by one or more ;; blank lines or the beginning of the buffer. (let* ((regex (concat "\\(" subed-vtt--regexp-blank-separator "\\|\\`\\)\\(" (regexp-quote sub-id) "\\)[ \n]"))) (goto-char (point-min)) (setq found (re-search-forward regex nil t)) (if found (goto-char (match-beginning 2)) (goto-char orig-point) nil)) (catch 'done (goto-char (line-beginning-position)) (cond ((subed-in-header-p) (throw 'done nil)) ;; are we looking at a timestamp? Stay here, check for ID later ((looking-at subed-vtt--regexp-timing) (point)) ;; are we at an ID? return that ((and (or (looking-back subed-vtt--regexp-blank-separator) (bobp)) (not (looking-at "[ \t]*\n")) (not (save-excursion (re-search-forward "-->" (line-end-position) t))) (save-excursion (forward-line) (looking-at subed-vtt--regexp-timing))) (throw 'done (point))) ;; are we in a comment? Search forward ((subed-in-comment-p) (if (re-search-forward (concat subed-vtt--regexp-start-of-line "\\(" subed-vtt--regexp-timing "\\)") nil t) (goto-char (match-beginning 1)) (throw 'done nil))) ;; scan backwards for a timestamp ((re-search-backward (concat subed-vtt--regexp-start-of-line "\\(" subed-vtt--regexp-timing "\\)") nil t) (goto-char (match-beginning 1)))) ;; We are at a timestamp; check backwards for an ID (cond ((bobp) (throw 'done (point))) ((progn (previous-line) (goto-char (line-beginning-position)) (and (or (looking-back subed-vtt--regexp-blank-separator) (bobp)) (not (looking-at "[ \t]*\n")) (save-excursion (not (re-search-forward "-->" (line-end-position) t))))) (throw 'done (point))) (t ;; go back (forward-line 1) (point))))))) (cl-defmethod subed--jump-to-subtitle-time-start (&context (major-mode subed-vtt-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-id sub-id) (if (looking-at subed-vtt--regexp-timing) (point) (when (re-search-forward subed-vtt--regexp-timing nil t) (goto-char (match-beginning 0)) (point))))) (cl-defmethod subed--jump-to-subtitle-time-stop (&context (major-mode subed-vtt-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-time-start sub-id) (re-search-forward "[ \t]*-->[ \t]*" (line-end-position) t) (when (looking-at subed--regexp-timestamp) (point)))) (cl-defmethod subed--jump-to-subtitle-text (&context (major-mode subed-vtt-mode) &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. Use the format-specific function for MAJOR-MODE." (when (subed-jump-to-subtitle-time-start sub-id) (forward-line 1) (point))) (cl-defmethod subed--jump-to-subtitle-end (&context (major-mode subed-vtt-mode) &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. Use the format-specific function for MAJOR-MODE." (let* ((orig-point (point)) (case-fold-search nil) text-point) (catch 'done ;; go back to the text (if (subed-in-comment-p) (if (re-search-forward (concat subed-vtt--regexp-start-of-line subed-vtt--regexp-timing) nil t) (progn (unless (eobp) (forward-line 1) (goto-char (line-beginning-position))) (setq text-point (point))) (throw 'done nil)) (setq text-point (or (subed-jump-to-subtitle-text sub-id) (point)))) (goto-char (line-beginning-position)) (cond ;; are we in the header? ((subed-in-header-p) (goto-char orig-point) (throw 'done nil)) ;; are we looking at the next timing? ((looking-at (concat "[ \t\n]*" subed-vtt--regexp-timing)) (point)) ;; are we looking at an ID and then the next timing? ((looking-at (format "\\(?:[ \t]*\n\\)+%s\n%s" subed-vtt--regexp-identifier subed-vtt--regexp-timing)) (point)) ((re-search-forward (format "\\(%s%s\\)\\|\\(%s%s\n%s\\)\\|\\(%s%s\\)\\|%s.*-->" subed-vtt--regexp-blank-separator subed-vtt--regexp-note subed-vtt--regexp-blank-separator subed-vtt--regexp-identifier subed-vtt--regexp-timing subed-vtt--regexp-start-of-line subed-vtt--regexp-timing subed-vtt--regexp-start-of-line) nil t) (goto-char (max text-point (match-beginning 0)))) (t (goto-char (point-max)))) (skip-chars-backward " \t\n") (goto-char (max (point) text-point)) (unless (= (point) orig-point) (point))))) (cl-defmethod subed--forward-subtitle-id (&context (major-mode subed-vtt-mode)) "Move point to next subtitle's ID. Return point or nil if there is no next subtitle. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point))) (when (subed-subtitle-id) (subed-jump-to-subtitle-end)) (if (re-search-forward (format "\\(?:%s\\(%s\n%s\\)\\|%s\\(%s\\)\\)" subed-vtt--regexp-blank-separator subed-vtt--regexp-identifier subed-vtt--regexp-timing subed-vtt--regexp-start-of-line subed-vtt--regexp-timing) nil t) (goto-char (or (match-beginning 1) (match-beginning 2))) (goto-char orig-point) nil))) (cl-defmethod subed--backward-subtitle-id (&context (major-mode subed-vtt-mode)) "Move point to previous subtitle's ID. Return point or nil if there is no previous subtitle. Use the format-specific function for MAJOR-MODE." (let ((orig-point (point))) (subed-jump-to-subtitle-id) (or (catch 'found (while (re-search-backward (concat "^\\(.*?\\n\\)?" subed--regexp-timestamp " *--> *" subed--regexp-timestamp) nil t) (when (subed-jump-to-subtitle-id) (throw 'found (point))))) (progn (goto-char orig-point) nil)))) ;;; Manipulation (defun subed-vtt--format-comment (comment) "Return COMMENT formatted for insertion. If COMMENT starts with NOTE, keep it as is. If not, add a NOTE header to it. Make sure COMMENT ends with a blank line." (cond ((null comment) "") ((string-match "\\`NOTE" (concat comment (if (string-match "\n\n\\'" comment) "" "\n\n"))) comment) ((string-match "\n" comment) (concat "NOTE\n" comment "\n\n")) (t (concat "NOTE " comment "\n\n")))) (cl-defmethod subed--jump-to-subtitle-comment (&context (major-mode subed-vtt-mode) &optional sub-id) "Move point to subtitle's comment. If SUB-ID is not given, use subtitle on point. Return point, or nil if no comment could be found. Use the format-specific function for MAJOR-MODE." (let ((pos (point))) (if (and (subed-jump-to-subtitle-id sub-id) (re-search-backward (concat subed-vtt--regexp-blank-separator "\\(NOTE[ \t\n]\\)") (or (save-excursion (subed-backward-subtitle-end)) (point-min)) t)) (progn (goto-char (match-beginning 1)) (point)) (goto-char pos) nil))) (cl-defmethod subed--subtitle-comment (&context (major-mode subed-vtt-mode) &optional sub-id) "Return subtitle comment or nil if none. If SUB-ID is not given, use the subtitle on point. Use the format-specific function for MAJOR-MODE." (save-excursion (let ((comment-start (subed-jump-to-subtitle-comment sub-id))) (if comment-start (string-trim (replace-regexp-in-string "^NOTE[ \n]+" "" (buffer-substring comment-start (subed-jump-to-subtitle-id sub-id)))) nil)))) (cl-defmethod subed--set-subtitle-comment (comment &context (major-mode subed-vtt-mode) &optional sub-id) "Set the current subtitle's comment to COMMENT. If COMMENT is nil or the empty string, remove the comment. If SUB-ID is not given, use the subtitle on point. Use the format-specific function for MAJOR-MODE." (let ((comment-start (subed-jump-to-subtitle-comment sub-id))) ;; remove previous comment (if comment-start (delete-region comment-start (subed-jump-to-subtitle-id sub-id)) (subed-jump-to-subtitle-id sub-id)) (when (and comment (not (string= comment ""))) (insert (subed-vtt--format-comment comment))))) (cl-defmethod subed--make-subtitle (&context (major-mode subed-vtt-mode) &optional _ start stop text comment) "Generate new subtitle string. START defaults to 0. STOP defaults to (+ START `subed-subtitle-spacing') TEXT defaults to an empty string. ID is ignored. A newline is appended to TEXT, meaning you'll get two trailing newlines if TEXT is nil or empty. Use the format-specific function for MAJOR-MODE." (format "%s%s --> %s\n%s\n" (subed-vtt--format-comment comment) (subed-msecs-to-timestamp (or start 0)) (subed-msecs-to-timestamp (or stop (+ (or start 0) subed-default-subtitle-length))) (or text ""))) (cl-defmethod subed--prepend-subtitle (&context (major-mode subed-vtt-mode) &optional id start stop text comment) "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. Use the format-specific function for MAJOR-MODE." (or (subed-jump-to-subtitle-comment) (subed-jump-to-subtitle-id)) (insert (subed-make-subtitle id start stop text comment)) (when (looking-at (concat "\\([[:space:]]*\\|^\\)" subed--regexp-timestamp)) (insert "\n")) (skip-chars-backward " \t\n") (subed-jump-to-subtitle-text) (point)) (cl-defmethod subed--append-subtitle (&context (major-mode subed-vtt-mode) &optional id start stop text comment) "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. Use the format-specific function for MAJOR-MODE." (unless (or (subed-forward-subtitle-comment) (subed-forward-subtitle-id)) ;; Point is on last subtitle or buffer is empty (subed-jump-to-subtitle-end) (when (looking-at "[[:space:]]+") (replace-match "")) ;; Moved point to end of last subtitle; ensure separator exists (while (not (looking-at "\\(\\`\\|[[:blank:]]*\n[[:blank:]]*\n\\)")) (save-excursion (insert ?\n))) ;; Move to end of separator (goto-char (match-end 0))) (insert (subed-make-subtitle id start stop text comment)) (unless (eolp) ;; Complete separator with another newline unless we inserted at the end (insert ?\n)) (forward-line -2) (subed-jump-to-subtitle-text)) (cl-defmethod subed--merge-with-next (&context (major-mode subed-vtt-mode)) "Merge the current subtitle with the next subtitle. Update the end timestamp accordingly. Use the format-specific function for MAJOR-MODE." (save-excursion (subed-jump-to-subtitle-end) (let ((pos (point)) cur-comment next-comment new-end) (if (subed-forward-subtitle-time-stop) (progn (setq next-comment (subed-subtitle-comment)) (when (looking-at subed--regexp-timestamp) (setq new-end (subed-timestamp-to-msecs (match-string 0)))) (subed-jump-to-subtitle-text) (delete-region pos (point)) (insert " ") (when next-comment (setq cur-comment (subed-subtitle-comment)) (subed-set-subtitle-comment (if cur-comment (concat cur-comment "\n" next-comment) next-comment))) (let ((subed-enforce-time-boundaries nil)) (subed-set-subtitle-time-stop new-end)) (run-hooks 'subed-subtitle-merged-hook)) (error "No subtitle to merge into"))))) ;;; Maintenance (cl-defmethod subed--sanitize-format (&context (major-mode subed-vtt-mode)) "Remove surplus newlines and whitespace. Use the format-specific function for MAJOR-MODE." (atomic-change-group (subed-save-excursion ;; Remove trailing whitespace from each line (delete-trailing-whitespace (point-min) (point-max)) ;; Remove leading spaces and tabs from each line (goto-char (point-min)) (while (re-search-forward "^[[:blank:]]+" nil t) (replace-match "")) ;; Remove leading newlines (goto-char (point-min)) (while (looking-at "\\`\n+") (replace-match "")) ;; Replace blank separators between subtitles with double newlines (goto-char (point-min)) (while (subed-forward-subtitle-id) (let ((prev-sub-end (save-excursion (when (subed-backward-subtitle-end) (point))))) (when (and prev-sub-end (not (string= (buffer-substring prev-sub-end (point)) "\n\n")) (string-match "\\`\n+\\'" (buffer-substring prev-sub-end (point)))) (delete-region prev-sub-end (point)) (insert "\n\n")))) ;; Two trailing newline if last subtitle text is empty, one trailing ;; newline otherwise; do nothing in empty buffer (no graphical ;; characters) (goto-char (point-min)) (when (re-search-forward "[[:graph:]]" nil t) (goto-char (point-max)) (skip-chars-backward " \t\n") (subed-jump-to-subtitle-end) (unless (looking-at "\n\\'") (delete-region (point) (point-max)) (insert "\n"))) ;; One space before and after " --> " (goto-char (point-min)) (while (re-search-forward (format "^%s" subed--regexp-timestamp) nil t) (when (looking-at "[[:blank:]]*-->[[:blank:]]*") (unless (= (length (match-string 0)) 5) (replace-match " --> "))))))) (cl-defmethod subed--validate-format (&context (major-mode subed-vtt-mode)) "Move point to the first invalid subtitle and report an error. Use the format-specific function for MAJOR-MODE." (when (> (buffer-size) 0) (atomic-change-group (let ((orig-point (point))) (goto-char (point-min)) (while (subed-forward-subtitle-id) ;; This regex is stricter than `subed--regexp-timestamp' (unless (looking-at "^\\([0-9]\\{2\\}:\\)?[0-9]\\{2\\}:[0-9]\\{2\\}\\(\\.[0-9]\\{0,3\\}\\)") (error "Found invalid start time: %S" (substring (or (thing-at-point 'line :no-properties) "\n") 0 -1))) (when (re-search-forward "[[:blank:]]" (line-end-position) t) (goto-char (match-beginning 0))) (unless (looking-at " --> ") (error "Found invalid separator between start and stop time: %S" (substring (or (thing-at-point 'line :no-properties) "\n") 0 -1))) (condition-case nil (forward-char 5) (error nil)) (unless (looking-at "\\([0-9]\\{2\\}:\\)?[0-9]\\{2\\}:[0-9]\\{2\\}\\(\\.[0-9]\\{0,3\\}\\)$") (error "Found invalid stop time: %S" (substring (or (thing-at-point 'line :no-properties) "\n") 0 -1)))) (goto-char orig-point))))) (cl-defmethod subed--auto-insert (&context (major-mode subed-vtt-mode)) "Set up an empty WebVTT file. Use the format-specific function for MAJOR-MODE." (insert "WEBVTT\n")) ;;;###autoload (define-derived-mode subed-vtt-mode subed-mode "Subed-VTT" "Major mode for editing WebVTT subtitle files." (setq-local subed--subtitle-format "vtt") (setq-local subed--regexp-timestamp subed-vtt--regexp-timestamp) (setq-local subed--regexp-separator subed-vtt--regexp-separator) (setq-local font-lock-defaults '(subed-vtt-font-lock-keywords)) (setq-local syntax-propertize-function (syntax-propertize-rules ("^\n" (0 ">")) ("^\\(N\\)\\(O\\)TE\\_>" (1 "w 1") (2 "w 2")))) ;; Support for fill-paragraph (M-q) (let ((timestamps-regexp (concat subed--regexp-timestamp " *--> *" subed--regexp-timestamp))) (setq-local paragraph-separate (concat "^\\(" (mapconcat #'identity `("[[:blank:]]*" "[[:digit:]]+" ,timestamps-regexp) "\\|") "\\)$")) (setq-local paragraph-start (concat "\\(" ;; Multiple speakers in the same ;; subtitle are often distinguished with ;; a "-" at the start of the line. (mapconcat #'identity '("^-" "[[:graph:]]*$") "\\|") "\\)")))) ;;;###autoload (add-to-list 'auto-mode-alist '("\\.vtt\\'" . subed-vtt-mode)) (provide 'subed-vtt) ;;; subed-vtt.el ends here subed-1.2.25/subed/subed-vtt.el.license000066400000000000000000000001551474617305700177040ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2019-2020 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-waveform.el000066400000000000000000001002051474617305700172710ustar00rootroot00000000000000;;; subed-waveform.el --- display waveforms in subed buffers -*- lexical-binding: t; -*- ;; Copyright (C) 2023-2024 Sacha Chua, Marcin Borkowski, Rodrigo Morales ;; Author: Sacha Chua , Marcin Borkowski ;; Keywords: multimedia ;; This program 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 of the License, or ;; (at your option) any later version. ;; This program 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 this program. If not, see . ;;; Commentary: ;; This file contains variables, options, functions and commands for ;; displaying a waveform along with the current subtitle. Use ;; `subed-waveform-minor-mode' to turn the waveform display on or off. ;; Press `C-x C-=' and `C-x C--' to make the amplitude of the ;; displayed waveform larger and smaller. ;; To set the start time, click the waveform with `mouse-1' (left-click). ;; To set the stop time, use `mouse-3' (right-click). ;; You can also adjust start/stop times with the following ;; keybindings from `subed-mode-map': ;; M-[ - `subed-decrease-start-time' ;; M-] - `subed-increase-start-time' ;; M-{ - `subed-decrease-stop-time' ;; M-} - `subed-increase-stop-time' ;; To play a sample from the middle of a waveform, middle-click on the ;; position you would like to play. This plays ;; `subed-waveform-sample-msecs' milliseconds and then sets the ;; playing position to that point. ;; You can shift-drag with `mouse-1' (left mouse button) to the left ;; of a subtitle's waveform in order to extend the view earlier and ;; set the start time. `Shift-drag-mouse-3' (right mouse button) to ;; extend the view later and set the stop time. ;; To split a subtitle in the middle using the text at point, use ;; S-C-mouse-2 (control-shift middle-click), which is bound to ;; `subed-waveform-split'. ;; Customization: ;; Use `M-x customize-group subed-waveform' to configure options. To ;; change how much time you see before or after the current subtitle, ;; set `subed-waveform-preview-msecs-before' and ;; `subed-waveform-preview-msecs-after'. You may also want to adjust ;; `subed-loop-seconds-before' and `subed-loop-seconds-after' if you ;; want this to match the looping behavior toggled with ;; `subed-toggle-loop-over-current-subtitle'. (Note the switch from ;; milliseconds to seconds.) The boundaries of the current subtitle ;; as well as the current playing position are indicated with the ;; colors set in `subed-waveform-bar-params'. ;; ;; To change how your adjustments affect previous/next subtitles, ;; customize the `subed-enforce-time-boundaries' and ;; `subed-subtitle-spacing' variables. ;; To automatically display subtitles whenever you open a subed file, ;; add the following to your configuration: ;; ;; (with-eval-after-load 'subed ;; (add-hook 'subed-mode-hook 'subed-waveform-minor-mode)) ;; ;; Troubleshooting: ;; If the waveform becomes corrupted or is out of sync (this may ;; happen for example when you modify the start/stop timestamp(s) ;; using Subed mode commands but then undo your changes), press `C-c ;; |' to redisplay it. ;; If images are not displayed, you may want to make sure that ;; `max-image-size' is set to a value that allows short, wide images. ;; The following code may help: ;; ;; (with-eval-after-load 'subed ;; (add-hook 'subed-mode-hook (lambda () (setq-local max-image-size nil)))) ;; ;; The bar is positioned using a percentage, so it gets a little ;; tricky for subtitles with short durations. ;;; Code: (require 'svg) (require 'subed-common) (defgroup subed-waveform nil "Minor mode for viewing subtitle waveforms while in `subed-mode'." :group 'subed :prefix "subed-waveform") (defcustom subed-waveform-show-all nil "Non-nil means show the waveforms for all subtitles. Nil means show only the waveform for the current subtitle." :type 'boolean :group 'subed-waveform) (defcustom subed-waveform-ffmpeg-filter-args ":colors=gray" "Additional arguments for the showwavespic filter. The background is black by default and the foreground gray. To change the foreground color, use something like \":colors=white\". To invert the colors (for example to obtain black on white), use \":colors=white,negate\". You can also set it to a function. The function will be called with WIDTH and HEIGHT as parameters, and should return a string to include in the filter. See `subed-waveform-fancy-filter' for an example." :type '(choice (string :tag "Extra arguments to include") (function :tag "Function to call with the width and height")) :group 'subed-waveform) (defcustom subed-waveform-bar-params '((:start . (:id "start" :stroke-color "darkgreen" :stroke-width "3")) (:stop . (:id "stop" :stroke-color "darkred" :stroke-width "3")) (:current . (:id "current" :stroke-color "orange" :stroke-width "2"))) "An alist of bar types and parameters. The keys in it are `:start', `:stop' and `:current'. The values are SVG parameters of the displayed bars. Every bar must have a unique `:id' parameter." :type '(alist :key-type (choice (const :tag "Start" :start) (const :tag "Stop" :stop) (const :tag "Current" :current)) :value-type (plist :key-type symbol :value-type string)) :group 'subed-waveform) (defcustom subed-waveform-preview-msecs-before 2000 "Prelude in milliseconds displaying subtitle waveform." :type 'integer :group 'subed-waveform) (defcustom subed-waveform-preview-msecs-after 2000 "Addendum in seconds when displaying subtitle waveform." :type 'integer :group 'subed-waveform) (defcustom subed-waveform-sample-msecs 2000 "Number of milliseconds to play when jumping around a waveform. 0 or nil means don't play a sample." :type 'integer :group 'subed-waveform) (defcustom subed-waveform-volume 2.0 "A multiplier of the volume. Set it to more than 1.0 if the voice is too quiet and the moments when people speak are indistinguishable from silence." :type 'number :group 'subed-waveform) (defcustom subed-waveform-timestamp-resolution 20 "Resolution of the timestamps. When the user clicks on the waveform, the timestamp set will be rounded to the nearest multiple of this number." :type 'integer :group 'subed-waveform) (defvar subed-waveform--overlay nil "Overlay if only a single waveform is displayed.") (defvar subed-waveform--svg nil "SVG if only a single waveform is displayed.") (defun subed-waveform-remove () "Remove waveform overlays." (interactive) (remove-overlays (point-min) (point-max) 'subed-waveform t)) (defvar subed-waveform-minor-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "C-c C-=") #'subed-waveform-volume-increase) (define-key map (kbd "C-c C--") #'subed-waveform-volume-decrease) (define-key map (kbd "C-c |") #'subed-waveform-put-svg) map) "Keymap for `subed-waveform-minor-mode'.") ;;;###autoload (define-minor-mode subed-waveform-minor-mode "Display waveforms for subtitles. Update on motion." :keymap subed-waveform-minor-mode-map :lighter "w" :require 'subed (if subed-waveform-minor-mode (progn (add-hook 'subed-subtitle-motion-hook #'subed-waveform-put-svg nil t) (add-hook 'after-change-motion-hook #'subed-waveform-put-svg nil t) (add-hook 'subed-mpv-playback-position-hook #'subed-waveform--update-current-bar t) (add-hook 'subed-subtitle-time-adjusted-hook #'subed-waveform--after-time-adjusted nil t) (add-hook 'subed-subtitle-merged-hook 'subed-waveform-subtitle-merged nil t) (add-hook 'subed-subtitles-sorted-hook 'subed-waveform-refresh nil t) (subed-waveform-refresh)) (subed-waveform-remove) (remove-hook 'subed-subtitle-motion-hook #'subed-waveform-put-svg t) (remove-hook 'subed-subtitle-time-adjusted-hook #'subed-waveform--after-time-adjusted t) (remove-hook 'subed-mpv-playback-position-hook #'subed-waveform--update-current-bar t) (remove-hook 'after-change-motion-hook #'subed-waveform-put-svg t) (remove-hook 'subed-subtitle-merged-hook 'subed-waveform-subtitle-merged t) (remove-hook 'subed-subtitles-sorted-hook 'subed-waveform-refresh t))) (with-eval-after-load 'subed (add-hook 'subed-region-adjusted-hook #'subed-waveform-refresh-region)) (defconst subed-waveform-volume-map (let ((map (make-sparse-keymap))) (define-key map (kbd "C-=") #'subed-waveform-volume-increase) (define-key map (kbd "C--") #'subed-waveform-volume-decrease) map) "A keymap for manipulating waveform \"volume\".") (defun subed-waveform-volume-increase (amount) "Increase `subed-waveform-value' by AMOUNT/2." (interactive "p") (setq subed-waveform-volume (+ subed-waveform-volume (/ amount 2.0))) (message "Waveform volume multiplier is now set to %s" subed-waveform-volume) (subed-waveform-put-svg) (set-transient-map subed-waveform-volume-map)) (defun subed-waveform-volume-decrease (amount) "Increase `subed-waveform-value' by AMOUNT/2." (interactive "p") (setq subed-waveform-volume (max 1.0 (- subed-waveform-volume (/ amount 2.0)))) (message "Waveform volume multiplier is now set to %s" subed-waveform-volume) (subed-waveform-put-svg) (set-transient-map subed-waveform-volume-map)) (defun subed-waveform-fancy-filter (width height) "Display green waveforms on a dark green background with a grid. WIDTH and HEIGHT are given in pixels." (concat ":colors=#9cf42f[fg];" (format "color=s=%dx%d:color=#44582c,drawgrid=width=iw/10:height=ih/5:color=#9cf42f@0.1[bg];" width height) "[bg][fg]overlay=format=auto,drawbox=x=(iw-w)/2:y=(ih-h)/2:w=iw:h=1:color=#9cf42f")) (make-obsolete-variable 'subed-waveform-ffmpeg-executable 'subed-ffmpeg-executable "1.2.22") (make-obsolete-variable 'subed-waveform-ffprobe-executable 'subed-ffprobe-executable "1.2.22") (make-obsolete-variable 'subed-waveform-file-duration-ms-cache 'subed-file-duration-ms-cache "1.2.22") (make-obsolete 'subed-waveform-convert-ffprobe-tags-duration-to-ms 'subed-convert-ffprobe-tags-duration-to-ms "1.2.22") (make-obsolete 'subed-waveform-ffprobe-duration-ms 'subed-ffprobe-duration-ms "1.2.22") (make-obsolete 'subed-waveform-file-duration-ms 'subed-file-duration-ms "1.2.22") (make-obsolete 'subed-waveform-clear-file-duration-ms-cache 'subed-clear-file-duration-ms-cache "1.2.22") (defun subed-waveform--from-file (filename from to width height) "Returns a string representing the image data in PNG format. FILENAME is the input file, FROM and TO are time positions, WIDTH and HEIGHT are dimensions in pixels." (let* ((args (append (list "-accurate_seek" "-ss" (format "%s" from) "-to" (format "%s" to)) (list "-i" filename) (list "-loglevel" "0" "-filter_complex" (format "volume=%s,showwavespic=s=%dx%d%s" subed-waveform-volume width height (cond ((functionp subed-waveform-ffmpeg-filter-args) (funcall subed-waveform-ffmpeg-filter-args width height)) ((stringp subed-waveform-ffmpeg-filter-args) subed-waveform-ffmpeg-filter-args) (t ""))) "-frames:v" "1" "-c:v" "png" "-f" "image2" "-")))) (with-temp-buffer (apply 'call-process subed-ffmpeg-executable nil t nil args) (encode-coding-string (buffer-string) 'binary)))) (defun subed-waveform--msecs-to-ffmpeg (msecs) "Convert MSECS to string in the format HH:MM:SS.MS." (concat (format-seconds "%02h:%02m:%02s" (/ (floor msecs) 1000)) "." (format "%03d" (mod (floor msecs) 1000)))) (defun subed-waveform--position-to-percent (pos start stop) "Return a percentage of POS relative to START/STOP." (when pos (format "%.2f%%" (/ (* 100.0 (- pos start)) (- stop start))))) (defun subed-waveform--image-parameters (&optional width height) "Return a plist of media-file, start, stop, width, height. Use WIDTH and HEIGHT if specified." (let* ((duration (subed-file-duration-ms (subed-media-file))) (start (floor (max 0 (- (subed-subtitle-msecs-start) subed-waveform-preview-msecs-before)))) (stop (min (floor (+ (subed-subtitle-msecs-stop) subed-waveform-preview-msecs-after)) (or duration most-positive-fixnum))) (width-ratio (/ (* 100.0 (- stop start)) (- (+ (subed-subtitle-msecs-stop) subed-waveform-preview-msecs-after) start))) (width (or width (/ (* width-ratio (string-pixel-width (make-string fill-column ?*))) (face-attribute 'default :height)))) (height (or height (save-excursion ;; don't count the current waveform towards the ;; line height (forward-line -1) (* 2 (line-pixel-height)))))) (list :file (or (subed-media-file) (error "No media file found")) :start start :stop stop :width width :height height))) (defun subed-waveform--make-overlay (&optional width height) "Make an overlay at point for the current subtitle." (let* ((overlay (make-overlay (point) (point))) (params (subed-waveform--image-parameters width height)) (image (subed-waveform--from-file (plist-get params :file) (subed-waveform--msecs-to-ffmpeg (plist-get params :start)) (subed-waveform--msecs-to-ffmpeg (plist-get params :stop)) (plist-get params :width) (plist-get params :height))) (svg (svg-create (plist-get params :width) (plist-get params :height)))) (svg-embed svg image "image/png" t :x 0 :y 0 :width "100%" :height "100%" :preserveAspectRatio "none") (overlay-put overlay 'subed-waveform t) (overlay-put overlay 'after-string "\n") (overlay-put overlay 'waveform-start (plist-get params :start)) (overlay-put overlay 'waveform-stop (plist-get params :stop)) (overlay-put overlay 'before-string (propertize " " 'display (svg-image svg) 'svg svg 'pointer 'arrow 'keymap subed-waveform-svg-map 'waveform-start (plist-get params :start) 'waveform-stop (plist-get params :stop) 'waveform-pixels-per-second (/ (plist-get params :width) (* 0.001 (- (plist-get params :stop) (plist-get params :start)))))) (unless subed-waveform-show-all (setq subed-waveform--overlay overlay) (setq subed-waveform--svg svg)) (subed-waveform--update-bars overlay) (subed-waveform--update-overlay-svg overlay) overlay)) (defun subed-waveform--move-bar (bar-type position &optional overlay) "Update the SVG in OVERLAY, moving bar BAR-TYPE to POSITION. BAR-TYPE should be a symbol, one of :start, :stop, :current. POSITION should be a percentage as a string. If POSITION is nil, remove the bar." (let ((svg (if subed-waveform-show-all (get-text-property 0 'svg (overlay-get (or overlay (subed-waveform--get-current-overlay)) 'before-string)) subed-waveform--svg))) (svg-remove svg (plist-get (alist-get bar-type subed-waveform-bar-params) ":id" #'string=)) (when position (apply #'svg-line svg position 0 position "100%" (alist-get bar-type subed-waveform-bar-params))) (unless subed-waveform-show-all (setq subed-waveform--svg svg)))) (defun subed-waveform--get-current-overlay () "Return the subed-waveform overlay for this subtitle." (when subed-waveform-minor-mode (save-excursion (if (or subed-waveform-show-all (null subed-waveform--overlay)) (when (subed-jump-to-subtitle-text) (seq-find (lambda (o) (overlay-get o 'subed-waveform)) (overlays-in (point) (or (subed-jump-to-subtitle-end) (point))))) subed-waveform--overlay)))) (defun subed-waveform--update-bars (&optional overlay) "Update the bars in OVERLAY." (setq overlay (or overlay (subed-waveform--get-current-overlay))) (let* ((start (subed-subtitle-msecs-start)) (stop (min (subed-subtitle-msecs-stop) (or (subed-file-duration-ms) most-positive-fixnum))) (start-pos (subed-waveform--position-to-percent start (overlay-get overlay 'waveform-start) (overlay-get overlay 'waveform-stop))) (stop-pos (subed-waveform--position-to-percent stop (overlay-get overlay 'waveform-start) (overlay-get overlay 'waveform-stop)))) (subed-waveform--move-bar :start start-pos overlay) (subed-waveform--move-bar :stop stop-pos overlay)) (subed-waveform--update-current-bar subed-mpv-playback-position overlay)) (defun subed-waveform--update-current-bar (subed-mpv-playback-position &optional overlay) "Update the \"current\" bar in the overlay. Assume all necessary variables are already set. This function is meant to be as fast as possible so that it can be called many times per second." (when subed-mpv-playback-position (dolist (overlay (overlays-in (point-min) (point-max))) (cond ((null (overlay-get overlay 'subed-waveform)) nil) ((and (>= subed-mpv-playback-position (overlay-get overlay 'waveform-start)) (<= subed-mpv-playback-position (overlay-get overlay 'waveform-stop))) (subed-waveform--move-bar :current (subed-waveform--position-to-percent subed-mpv-playback-position (overlay-get overlay 'waveform-start) (overlay-get overlay 'waveform-stop)) overlay) (subed-waveform--update-overlay-svg overlay) (overlay-put overlay 'waveform-current t)) ((overlay-get overlay 'waveform-current) (subed-waveform--move-bar :current nil overlay) (subed-waveform--update-overlay-svg overlay) (overlay-put overlay 'waveform-current nil)))))) (defvar subed-waveform-svg-map (let ((subed-waveform-svg-map (make-keymap))) (define-key subed-waveform-svg-map [mouse-1] #'subed-waveform-set-start) (define-key subed-waveform-svg-map [mouse-2] #'subed-waveform-jump-to-timestamp) (define-key subed-waveform-svg-map [mouse-3] #'subed-waveform-set-stop) (define-key subed-waveform-svg-map [down-mouse-3] #'ignore) (define-key subed-waveform-svg-map [C-mouse-2] #'ignore) (define-key subed-waveform-svg-map [S-mouse-1] #'subed-waveform-set-start-and-copy-to-previous) (define-key subed-waveform-svg-map [S-mouse-3] #'subed-waveform-set-stop-and-copy-to-next) (define-key subed-waveform-svg-map [M-mouse-1] #'subed-waveform-set-start-and-copy-to-previous) (define-key subed-waveform-svg-map [M-mouse-2] #'subed-waveform-shift-subtitles) (define-key subed-waveform-svg-map [M-mouse-3] #'subed-waveform-set-stop-and-copy-to-next) (define-key subed-waveform-svg-map [C-mouse-3] #'ignore) (define-key subed-waveform-svg-map [S-down-mouse-1] #'ignore) (define-key subed-waveform-svg-map [S-drag-mouse-1] #'subed-waveform-reduce-start-time) (define-key subed-waveform-svg-map [S-down-mouse-3] #'ignore) (define-key subed-waveform-svg-map [M-down-mouse-1] #'ignore) (define-key subed-waveform-svg-map [M-down-mouse-3] #'ignore) (define-key subed-waveform-svg-map [S-drag-mouse-3] #'subed-waveform-increase-stop-time) (define-key subed-waveform-svg-map [S-C-down-mouse-2] #'subed-waveform-split) (define-key subed-waveform-svg-map [C-mouse-1] #'ignore) subed-waveform-svg-map) "A keymap for clicking on the waveform.") (defun subed-waveform--update-overlay-svg (&optional overlay) "Update the SVG in the overlay." (setq overlay (or overlay (subed-waveform--get-current-overlay))) (when overlay (let ((s (overlay-get overlay 'before-string))) (overlay-put overlay 'before-string (propertize " " 'display (svg-image (if subed-waveform-show-all (get-text-property 0 'svg s) subed-waveform--svg)) 'svg (if subed-waveform-show-all (get-text-property 0 'svg s) subed-waveform--svg) 'pointer 'arrow 'waveform-start (get-text-property 0 'waveform-start s) 'waveform-stop (get-text-property 0 'waveform-stop s) 'waveform-pixels-per-second (get-text-property 0 'waveform-pixels-per-second s) 'keymap subed-waveform-svg-map))))) (defvar subed-waveform--update-timer nil) (defun subed-waveform--after-time-adjusted (&rest _) "Update the bars or the waveform image as needed." (when subed-waveform-minor-mode (when (timerp subed-waveform--update-timer) (cancel-timer subed-waveform--update-timer)) (setq subed-waveform--update-timer (run-with-idle-timer 0 nil 'subed-waveform-put-svg)))) (defalias 'subed-waveform-refresh-current-subtitle #'subed-waveform-put-svg) (defun subed-waveform-put-svg (&rest _) "Put or update an overlay with the SVG in the current subtitle. Set the relevant variables if necessary. This function ignores arguments and can be used in hooks." (interactive) (save-excursion (when subed-waveform-minor-mode (unless subed-waveform-show-all (let ((min (or (subed-jump-to-subtitle-comment) (subed-jump-to-subtitle-id)))) (when min (remove-overlays min (subed-jump-to-subtitle-end) 'subed-waveform t)))) (when (subed-jump-to-subtitle-text) (let ((overlay (subed-waveform--get-current-overlay))) (when overlay (delete-overlay overlay)) (setq overlay (subed-waveform--make-overlay))))))) (defun subed-waveform-add-to-all (&optional beg end) "Update subtitles from BEG to END." (interactive (list (if (region-active-p) (min (point) (mark))) (if (region-active-p) (max (point) (mark))))) (setq beg (or beg (point-min))) (setq end (or end (point-max))) (remove-overlays beg end 'subed-waveform t) (subed-for-each-subtitle beg end nil (subed-jump-to-subtitle-text) (subed-waveform--make-overlay))) (defun subed-waveform-refresh-region (beg end) "Refresh waveforms after modifying region." (when subed-waveform-minor-mode (save-excursion (if subed-waveform-show-all (subed-waveform-add-to-all beg end) (subed-waveform-remove) (subed-waveform-put-svg))))) (defun subed-waveform-refresh () "Add all waveforms or just the current one. Controlled by `subed-waveform-show-all`." (interactive) (if subed-waveform-show-all (subed-waveform-add-to-all) (subed-waveform-remove) (subed-waveform-put-svg))) (defun subed-waveform-toggle-show-all () "Toggle between showing all waveforms and showing only the current one." (interactive) (setq subed-waveform-show-all (null subed-waveform-show-all)) (subed-waveform-refresh)) ;;;###autoload (defun subed-waveform-show-all () "Turn on `subed-waveform-minor-mode' and show all the subtitles." (interactive) (setq subed-waveform-show-all t) (unless subed-waveform-minor-mode (subed-waveform-minor-mode 1))) ;;;###autoload (defun subed-waveform-show-current () "Turn on `subed-waveform-minor-mode' and show the waveform for the current subtitle.." (interactive) (setq subed-waveform-show-all nil) (unless subed-waveform-minor-mode (subed-waveform-minor-mode 1))) ;;; Adjusting based on the mouse (defmacro subed-waveform--with-event-subtitle (event &rest body) "Run BODY with the point at the subtitle for EVENT." (declare (indent defun) (debug t)) `(with-selected-window (caadr ,event) (save-excursion (goto-char (elt (elt event 1) 1)) ,@body (goto-char (elt (elt event 1) 1)) (subed-waveform-put-svg)))) (defun subed-waveform--mouse-event-to-ms (event) "Return the millisecond position of EVENT." (let* ((obj (car (elt (cadr event) 4))) (start (get-text-property 0 'waveform-start obj)) (stop (get-text-property 0 'waveform-stop obj)) (resolution (get-text-property 0 'waveform-resolution obj)) (x (car (elt (cadr event) 8))) (width (car (elt (cadr event) 9)))) (* subed-waveform-timestamp-resolution (round (+ (* (/ (* 1.0 x) width) (- stop start)) start) subed-waveform-timestamp-resolution)))) (defun subed-waveform-set-start (event) "Set the start timestamp in the place clicked." (interactive "e") (subed-waveform--with-event-subtitle event (subed-set-subtitle-time-start (subed-waveform--mouse-event-to-ms event)) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop)) (subed--run-subtitle-time-adjusted-hook))) (defun subed-waveform-set-start-and-copy-to-previous (event) "Set the start timestamp in the place clicked. Copy it to the stop time of the previous subtitle, leaving a gap of `subed-subtitle-spacing'." (interactive "e") (subed-waveform--with-event-subtitle event (subed-set-subtitle-time-start (subed-waveform--mouse-event-to-ms event)) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop)) (save-excursion (when (subed-backward-subtitle-time-stop) (subed-set-subtitle-time-stop (- (subed-waveform--mouse-event-to-ms event) subed-subtitle-spacing))) (subed--run-subtitle-time-adjusted-hook)) (subed--run-subtitle-time-adjusted-hook))) (defun subed-waveform-set-stop-and-copy-to-next (event) "Set the stop timestamp in the place clicked. Copy it to the start time of the next subtitle, leaving a gap of `subed-subtitle-spacing'." (interactive "e") (subed-waveform--with-event-subtitle event (subed-set-subtitle-time-stop (subed-waveform--mouse-event-to-ms event)) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop)) (save-excursion (when (subed-forward-subtitle-time-start) (subed-set-subtitle-time-start (+ (subed-waveform--mouse-event-to-ms event) subed-subtitle-spacing))) (subed--run-subtitle-time-adjusted-hook)) (subed--run-subtitle-time-adjusted-hook))) (defun subed-waveform-set-stop (event) "Set the start timestamp in the place clicked." (interactive "e") (subed-waveform--with-event-subtitle event (subed-set-subtitle-time-stop (subed-waveform--mouse-event-to-ms event)) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop)) (subed--run-subtitle-time-adjusted-hook))) (defun subed-waveform-reduce-start-time (event) "Make this subtitle start `subed-milliseconds-adjust' milliseconds earlier." (interactive "e") (subed-waveform--with-event-subtitle event (let ((obj (car (elt (cadr event) 4)))) (when (get-text-property 0 'waveform-pixels-per-second obj) (let* ((x1 (car (elt (elt event 2) 2))) (x2 (car (elt (elt event 1) 2))) (msecs (floor (* 1000 (/ (- x1 x2) ; pixels moved (get-text-property 0 'waveform-pixels-per-second obj)))))) (subed-adjust-subtitle-time-start msecs)))))) (defun subed-waveform-increase-stop-time (event) "Make this subtitle stop later. If called from a mouse drag EVENT, adjust it proportionally to what is displayed. If not, adjust it by `subed-milliseconds-adjust' milliseconds." (interactive "e") (subed-waveform--with-event-subtitle event (let* ((obj (car (elt (cadr event) 4))) (pixels-per-second (get-text-property 0 'waveform-pixels-per-second obj))) (goto-char (elt (elt event 1) 1)) (when pixels-per-second (let* ((x1 (car (elt (elt event 2) 2))) (x2 (car (elt (elt event 1) 2))) (msecs (floor (* 1000 (/ (- x1 x2) ; pixels moved pixels-per-second))))) (subed-adjust-subtitle-time-stop msecs) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop))))))) (defun subed-waveform-shift-subtitles (event) "Shift this and succeeding subtitles. The current subtitle will start at the selected time and other timestamps will be adjusted accordingly." (interactive "e") (subed-waveform--with-event-subtitle event (subed-shift-subtitles (- (subed-waveform--mouse-event-to-ms event) (subed-subtitle-msecs-start))) (when (subed-loop-over-current-subtitle-p) (subed--set-subtitle-loop)))) (defun subed-waveform-split (event) "Split the current subtitle at point. Use the selected timestamp as the start time of the next subtitle, leaving a gap of `subed-subtitle-spacing'." (interactive "e") (let ((pos (point))) (subed-waveform--with-event-subtitle event (let ((ms (subed-waveform--mouse-event-to-ms event))) (goto-char pos) (subed-split-subtitle (- ms (subed-subtitle-msecs-start))))))) ;;; Hooks (defun subed-waveform-subtitle-merged () "Clean up waveforms in subtitle text and update subtitle." (remove-overlays (subed-jump-to-subtitle-text) (or (subed-jump-to-subtitle-end) (point)) 'subed-waveform t) (subed-waveform-put-svg)) ;;; Sampling (defvar-local subed-waveform--sample-timer nil "Timer used for sampling. Resets MPV position when done.") (defun subed-waveform-jump-to-timestamp (event) "Jump to the timestamp at EVENT and play a short sample. The `subed-waveform-sample-msecs' variable specifies the duration of the sample. Jump to the specified position afterwards so that you can use it in `subed-split-subtitle' and other commands." (interactive "e") (with-selected-window (caadr event) (let* ((ms (subed-waveform--mouse-event-to-ms event)) (ts (subed-msecs-to-timestamp ms)) (obj (car (elt (cadr event) 4))) (stop (get-text-property 0 'waveform-stop obj))) (subed-mpv-jump ms) (message "%s" ts) (if (> (or subed-waveform-sample-msecs 0) 0) (subed-waveform-play-sample ms (min (- stop ms) subed-waveform-sample-msecs)) (subed-mpv-jump ms))))) (defvar subed-waveform--enable-point-to-player-sync-after-sample nil "Non-nil means need to re-enable point to player sync.") (defvar subed-waveform--enable-loop-over-current-subtitle-after-sample nil "Non-nil means need to loop over current subtitle.") (defun subed-waveform--restore-mpv-position (reset-msecs) "Jump back to RESET-MSECS." (subed-mpv-pause) (subed-mpv-jump reset-msecs) (when subed-waveform--enable-point-to-player-sync-after-sample (subed-enable-sync-point-to-player t)) (when subed-waveform--enable-loop-over-current-subtitle-after-sample (subed-enable-loop-over-current-subtitle t)) (setq subed-waveform--enable-loop-over-current-subtitle-after-sample nil subed-waveform--enable-point-to-player-sync-after-sample nil)) (defun subed-waveform-play-sample (msecs &optional duration-ms) "Play starting at MSECS position for DURATION-MS seconds. If DURATION is unspecified, use `subed-waveform-sample-msecs.'" (subed-mpv-jump msecs) (subed-mpv-unpause) (when (subed-loop-over-current-subtitle-p) (setq subed-waveform--enable-loop-over-current-subtitle-after-sample t) (subed-disable-loop-over-current-subtitle t)) (when (subed-sync-point-to-player-p) (setq subed-waveform--enable-point-to-player-sync-after-sample t) (subed-disable-sync-point-to-player t)) (if (timerp subed-waveform--sample-timer) (cancel-timer subed-waveform--sample-timer)) (setq subed-waveform--sample-timer (run-at-time (/ (or duration-ms subed-waveform-sample-msecs) 1000.0) nil #'subed-waveform--restore-mpv-position msecs))) (advice-add 'subed-move-subtitles :around (lambda (old-fn &rest args) (let ((subed-waveform-minor-mode nil)) (apply old-fn args)))) (provide 'subed-waveform) ;;; subed-waveform.el ends here subed-1.2.25/subed/subed-waveform.el.license000066400000000000000000000002121474617305700207070ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2023, 2024 Sacha Chua, Marcin Borkowski, Rodrigo Morales ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed-word-data.el000066400000000000000000000707441474617305700173430ustar00rootroot00000000000000;;; subed-word-data.el --- Use word-level timing data when splitting subtitles -*- lexical-binding: t; -*- ;;; License: ;; ;; Copyright (C) 2022 Sacha Chua ;; Author: Sacha Chua ;; Keywords: multimedia ;; This program 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 of the License, or ;; (at your option) any later version. ;; This program 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 this program. If not, see . ;;; Commentary: ;; This file parses timing data such as the ones you get from YouTube ;; .srv2 or WhisperX JSON and tries to match the timing data with the remaining text in ;; the current subtitle in order to determine the word timestamp for ;; splitting the subtitle. ;; To try to automatically load word data from a similarly-named file ;; in the buffer, add this to your configuration: ;; (with-eval-after-load 'subed ;; (add-hook 'subed-mode-hook 'subed-word-data-load-maybe)) ;;; Code: (require 'xml) (require 'dom) (defvar-local subed-word-data--cache nil "Word-level timing in the form ((start . ms) (end . ms) (text . ms))") (defcustom subed-word-data-score-faces '((0.8 . compilation-info) (0.4 . compilation-warning) (0 . compilation-error)) "Alist of score thresholds and faces to use." :type '(alist :key-type float :value-type face)) (defface subed-word-data-face '((((class color) (background light)) :foreground "darkgreen") (((class color) (background dark)) :foreground "lightgreen")) "Face used for words with word data available.") (defun subed-word-data--extract-words-from-srv2 (data) "Extract the timing from DATA in SRV2 format. Return a list of ((start . ?), (end . ?) (text . ?))." (when (stringp data) (with-temp-buffer (insert data) (setq data (xml-parse-region)))) (let* ((text-elements (reverse (dom-by-tag data 'text))) (last-start (and text-elements (+ (string-to-number (alist-get 't (xml-node-attributes (car text-elements)))) (string-to-number (alist-get 'd (xml-node-attributes (car text-elements)))))))) (reverse (mapcar #'(lambda (element) (let ((rec (list (cons 'start (string-to-number (alist-get 't (xml-node-attributes element)))) (cons 'end (min (+ (string-to-number (alist-get 't (xml-node-attributes element))) (string-to-number (alist-get 'd (xml-node-attributes element)))) last-start)) (cons 'text (replace-regexp-in-string "'" "'" (car (xml-node-children element))) )))) (setq last-start (alist-get 'start rec)) rec)) text-elements)))) (defun subed-word-data--extract-words-from-youtube-vtt (file &optional from-string) "Extract the timing from FILE which is a VTT from YouTube. Return a list of ((start . ?), (end . ?) (text . ?)). If FROM-STRING is non-nil, treat FILE as the data itself." (with-temp-buffer (subed-vtt-mode) (if from-string (insert file) (insert-file-contents file)) (let ((list (subed-subtitle-list)) results s start stop i) (dolist (sub list) (when (string-match "" (elt sub 3)) (setq s (elt sub 3)) (setq i 0) (setq start (elt sub 1)) (while (and (< i (length s)) (string-match "\\(.+?\\)<\\([0-9]+:[0-9]+:[0-9]+\\.[0-9]+\\)>" s i)) (setq stop (1- (save-match-data (subed-timestamp-to-msecs (match-string 2 s))))) (push `((text . ,(save-match-data (string-trim (replace-regexp-in-string "" "" (match-string 1 s))))) (start . ,start) (end . ,stop)) results) (setq i (match-end 0) start (1+ stop))) (if (and (< i (length s)) (not (string= "" (string-trim (substring s i))))) (push `((text . ,(string-trim (save-match-data (replace-regexp-in-string "" "" (substring s i))))) (start . ,start) (end . ,(elt sub 2))) results)))) (nreverse results)))) (defun subed-word-data--extract-words-from-whisperx-json (file &optional from-string) "Extract the timing from FILE in WhisperX's JSON format. Return a list of ((start . ?), (end . ?) (text . ?) (score . ?)). If FROM-STRING is non-nil, treat FILE as the data itself." (let* ((json-object-type 'alist) (json-array-type 'list) (data (if from-string (json-read-from-string file) (json-read-file file))) (base (seq-mapcat (lambda (segment) (seq-map (lambda (info) (let-alist info `((start . ,(and .start (* 1000 .start))) (end . ,(and .end (* 1000 .end))) (text . ,(identity .word)) (score . ,(identity .score))))) (alist-get 'words segment))) (alist-get 'segments data))) last-end current) ;; numbers at the end of a sentence sometimes don't end up with times ;; so we need to fix them (while current (unless (alist-get 'start (car current)) ; start (set-cdr (assoc 1 'start (car current)) (1+ last-end))) (unless (alist-get 'end (car current)) ; start (set-cdr (assoc 1 'end (car current)) (1- (alist-get 'start (cadr current))))) (setq last-end (alist-get 'end (car current)) current (cdr current))) base)) (defun subed-word-data--load (data) "Load word-level timing from DATA. Supports WhisperX JSON, YouTube VTT, and Youtube SRV2 files." (when data (setq-local subed-word-data--cache data) (add-hook 'subed-split-subtitle-timestamp-functions #'subed-word-data-split-at-word-timestamp -5 t) (subed-word-data-refresh-text-properties) data)) ;;;###autoload (defun subed-word-data-load-from-file (file) "Load word-level timing from FILE. Supports WhisperX JSON, YouTube VTT, and Youtube SRV2 files." (interactive (list (read-file-name "JSON, VTT, or srv2: " nil nil nil nil (lambda (f) (or (file-directory-p f) (string-match "\\.\\(json\\|srv2\\|vtt\\)\\'" f)))))) (subed-word-data--load (pcase (file-name-extension file) ("json" (subed-word-data--extract-words-from-whisperx-json file)) ("srv2" (subed-word-data--extract-words-from-srv2 (xml-parse-file file))) ("vtt" (subed-word-data--extract-words-from-youtube-vtt file))))) (defun subed-word-data-load-from-string (string) "Load word-level timing from STRING. For now, only JSON or SRV2 files are supported." (subed-word-data--load (cond ((string-match "^{" string) (subed-word-data--extract-words-from-whisperx-json string t)) ((string-match "^WEBVTT" string) (subed-word-data--extract-words-from-youtube-vtt string t)) (t (subed-word-data--extract-words-from-srv2 string))))) (defvar subed-word-data-extensions '(".en.srv2" ".srv2" ".json" ".vtt") "Extensions to search for word data.") ;;;###autoload (defun subed-word-data-load-maybe () "Load word data if available. Suitable for adding to `subed-mode-hook'." (when (buffer-file-name) (let (file) (catch 'found (mapc (lambda (ext) (when (file-exists-p (concat (file-name-sans-extension (buffer-file-name)) ext)) (setq file (concat (file-name-sans-extension (buffer-file-name)) ext)) (throw 'found))) subed-word-data-extensions)) (when (and file (subed-word-data-load-from-file file)) (message "Word data loaded."))))) (defvar subed-word-data-normalizing-functions '(subed-word-data-normalize-word-default) "Functions to run to normalize words before comparison.") (defun subed-word-data-normalize-word-default (s) "Downcase S and remove non-alphanumeric characters for comparison." (replace-regexp-in-string "[^[:alnum:]]" "" (downcase s))) (defun subed-word-data-normalize-word (word) "Normalize WORD to make it easier to compare." (mapc (lambda (func) (setq word (funcall func word))) subed-word-data-normalizing-functions) word) (defun subed-word-data-compare-normalized-string= (word1 word2) "Compare two words and return t if they are the same after normalization." (string= (subed-word-data-normalize-word word1) (subed-word-data-normalize-word word2))) (defvar subed-word-data-compare-function 'subed-word-data-compare-normalized-string= "Function to use to compare.") (defun subed-word-data-compare (word1 word2) "Use the `subed-word-data-compare' function to compare WORD1 and WORD2. Return non-nil if they are the same after normalization." (funcall subed-word-data-compare-function word1 word2)) (defun subed-word-data--look-up-word () "Find the word timing that matches the one at point (approximately)." (save-excursion (skip-syntax-backward "w") (let* ((end (subed-subtitle-msecs-stop)) (start (subed-subtitle-msecs-start)) (remaining-words (split-string (buffer-substring (point) (or (subed-jump-to-subtitle-end) (point))))) (words (if remaining-words (reverse (seq-filter (lambda (o) (and (or (not (alist-get 'end o)) (<= (alist-get 'end o) end)) (or (not (alist-get 'start o)) (>= (alist-get 'start o) start)) (not (string-match "^\n*$" (alist-get 'text o))))) subed-word-data--cache)))) (offset 0) (done (null remaining-words)) candidate) (while (not done) (setq candidate (elt words (+ (1- (length remaining-words)) offset))) (cond ((and candidate (subed-word-data-compare (car remaining-words) (alist-get 'text candidate))) (setq done t)) ((> offset (length words)) (setq done t)) ((> offset 0) (setq offset (- offset))) (t (setq offset (1+ (- offset)))))) candidate))) (defun subed-word-data-split-at-word-timestamp () "Return the starting timestamp if the word is found." (cond ((get-text-property (point) 'subed-word-start) (- time subed-subtitle-spacing)) (subed-word-data--cache (let ((time (assoc-default 'start (subed-word-data--look-up-word)))) (when time (- time subed-subtitle-spacing)))))) (defun subed-word-data-subtitle-entries () "Return the entries that start and end within the current subtitle." (let ((start (subed-subtitle-msecs-start)) (stop (+ (subed-subtitle-msecs-stop) subed-subtitle-spacing))) (seq-filter (lambda (o) (and (<= (or (alist-get 'end o) most-positive-fixnum) stop) (>= (or (alist-get 'start o) 0) start) (not (string-match "^\n*$" (alist-get 'text o))))) subed-word-data--cache))) (defvar subed-word-data-threshold 5 "Number of words to consider for matching.") (defun subed-word-data-refresh-text-properties-for-subtitle () "Refresh the text properties for the current subtitle." (interactive) (remove-text-properties (subed-jump-to-subtitle-text) (subed-jump-to-subtitle-end) '(subed-word-data-start subed-word-data-end font-lock-face)) (let* ((text-start (progn (subed-jump-to-subtitle-text) (point))) pos (word-data (reverse (subed-word-data-subtitle-entries))) candidate cand-count) (subed-jump-to-subtitle-end) (while (> (point) text-start) ;; Work our way backwards, matching against remaining words (setq pos (point)) (backward-word) (let ((try-list word-data) candidate) (setq candidate (car try-list) cand-count 0) (setq try-list (cdr try-list)) (while (and candidate (< cand-count subed-word-data-threshold) (not (subed-word-data-compare (buffer-substring (point) pos) (alist-get 'text candidate)))) (setq candidate (car try-list) cand-count (1+ cand-count)) (when (> cand-count subed-word-data-threshold) (setq candidate nil)) (setq try-list (cdr try-list))) (when (and candidate (subed-word-data-compare (buffer-substring (point) pos) (alist-get 'text candidate))) (subed-word-data--add-word-properties (point) pos candidate) (setq word-data try-list)))))) (defun subed-word-data-refresh-region (beg end) "Refresh text properties in region." (when subed-word-data--cache (subed-for-each-subtitle beg end nil (subed-word-data-refresh-text-properties-for-subtitle)))) (defsubst subed-word-data--candidate-face (candidate) "Return the face to use for CANDIDATE." (if (and (alist-get 'score candidate) subed-word-data-score-faces) (cdr (seq-find (lambda (threshold) (>= (alist-get 'score candidate) (car threshold))) subed-word-data-score-faces)) 'subed-word-data-face)) (defsubst subed-word-data--add-word-properties (start end candidate) "Add properties from START to END for CANDIDATE." (let ((face (subed-word-data--candidate-face candidate))) (add-text-properties start end (list 'subed-word-data-start (assoc-default 'start candidate) 'subed-word-data-end (assoc-default 'end candidate) 'subed-word-data-score (assoc-default 'score candidate) 'font-lock-face face)) (add-face-text-property start end face))) (defun subed-word-data-refresh-text-properties () "Add word data properties and face when available." (interactive) (save-excursion (remove-text-properties (point-min) (point-max) '(subed-word-data-start subed-word-data-end font-lock-face)) (when subed-word-data--cache (goto-char (point-min)) (unless (subed-jump-to-subtitle-id) (subed-forward-subtitle-id)) (while (not (eobp)) (let* ((text-start (progn (subed-jump-to-subtitle-text) (point))) pos (word-data (reverse (subed-word-data-subtitle-entries))) candidate) (subed-jump-to-subtitle-end) (while (> (point) text-start) ;; Work our way backwards, matching against remaining words (setq pos (point)) (backward-word) (let ((try-list word-data) candidate) (setq candidate (car try-list)) (setq try-list (cdr try-list)) (while (and candidate (not (subed-word-data-compare (buffer-substring (point) pos) (alist-get 'text candidate)))) (setq candidate (car try-list)) (setq try-list (cdr try-list))) (when (and candidate (subed-word-data-compare (buffer-substring (point) pos) (alist-get 'text candidate))) ( subed-word-data--add-word-properties (point) pos candidate) (setq word-data try-list))))) (or (subed-forward-subtitle-id) (goto-char (point-max))))))) (defun subed-word-data-pause-msecs () "Return the number of milliseconds between this word and the previous word. Requires the text properties to be set." (let ((current (get-text-property (point) 'subed-word-data-start))) (save-excursion (skip-syntax-backward "w") (backward-word) (when (get-text-property (point) 'subed-word-data-end) (- current (get-text-property (point) 'subed-word-data-end)))))) (defun subed-word-data-jump-to-longest-pause-in-current-subtitle () "Jump to the word after the longest pause in the current subtitle. Requires the text properties to be set." (interactive) (let ((start (or (subed-jump-to-subtitle-text) (point))) (end (or (subed-jump-to-subtitle-end) (point))) pos last-start-time pause (max-pause 0) max-pos) (backward-word) (setq max-pos (point)) (while (> (point) start) (setq pos (point) last-start-time (get-text-property (point) 'subed-word-data-start)) (backward-word) (if (get-text-property (point) 'subed-word-data-end) (progn (setq pause (and last-start-time (- last-start-time (get-text-property (point) 'subed-word-data-end)))) (when (and pause (> pause max-pause)) (setq max-pos pos max-pause pause))) (setq last-start-time nil))) (goto-char max-pos))) (defun subed-word-data-find-minimum-distance (from-n to-n distance-fn &optional short-circuit low-threshold) "Return (number distance) that minimizes DISTANCE-FN. Check the range FROM (inclusive) to TO (exclusive). If SHORT-CIRCUIT is specified, call that function with i as the argument and stop when it returns true. If LOW-THRESHOLD is specified, stop when the distance is less than or equal to that number." (setq low-threshold (or low-threshold 0)) (catch 'found (let* (min-distance current-distance i) (setq i from-n) (while (< i to-n) (setq current-distance (funcall distance-fn i)) (when (or (null min-distance) (< current-distance (cdr min-distance))) (setq min-distance (cons i current-distance)) (when (<= current-distance low-threshold) (throw 'found min-distance))) (when (and short-circuit (funcall short-circuit i)) (throw 'found min-distance)) (setq i (1+ i))) min-distance))) (defun subed-word-data-find-approximate-match (phrase list-of-words &optional short-circuit) "Match PHRASE against the beginning of LIST-OF-WORDS. LIST-OF-WORDS is a list of strings or a list of alists that have 'text. If SHORT-CIRCUIT is non-nil, use it as a regexp that short-circuits recognition and stops there. Return (distance . list of words) that minimizes the string distance from PHRASE. distance is expressed as a ratio of number of edits / maximum length of phrase or words. " (let ((min-distance (subed-word-data-find-minimum-distance 1 (+ (length (split-string phrase " ")) 8) (lambda (num-words) (let ((cand (mapconcat (lambda (o) (if (stringp o) o (alist-get 'text o))) (seq-take list-of-words num-words) " "))) (/ (* 1.0 (string-distance phrase cand)) (max (length phrase) (length cand))))) (if (and short-circuit (not (string-match short-circuit phrase))) (lambda (num-words) (string-match short-circuit (mapconcat (lambda (o) (if (stringp o) o (alist-get 'text o))) (seq-take list-of-words num-words) " "))))))) (cons (cdr min-distance) (seq-take list-of-words (car min-distance))))) ;; (subed-word-data-find-approximate-match "Go into the room." (split-string "Go in to the room. There you will" " ")) ;; (subed-word-data-find-approximate-match "The quick brown fox jumps over the lazy dog" (split-string "The quick, oops, the quick brown fox jumps over the lazy dog and goes all sorts of places" " ") "\\") ;; (subed-word-data-find-approximate-match "I already talk pretty quickly," (split-string "I already talk pretty quickly. Oops. I already talk pretty quickly, so I'm not going" " ") "\\") (defun subed-word-data-fix-subtitle-timing (beg end) "Sets subtitle starts and stops based on the word data. Assumes words haven't been edited." (interactive (list (if (region-active-p) (min (point) (mark))) (if (region-active-p) (max (point) (mark))))) (unless subed-word-data--cache (call-interactively #'subed-word-data-load-from-file)) (setq beg (or beg (point-min))) (setq end (if end (save-excursion (goto-char end) (subed-jump-to-subtitle-end) (point)) (point-max))) (goto-char beg) (if (subed-subtitle-msecs-start) (subed-jump-to-subtitle-text) (subed-forward-subtitle-text)) (let* ((start-ms (save-excursion (goto-char beg) (or (subed-subtitle-msecs-start) (progn (subed-forward-subtitle-time-start) (subed-subtitle-msecs-start))))) (data (seq-drop-while (lambda (o) (< (or (alist-get 'start o) 0) start-ms)) subed-word-data--cache)) candidate) (while (and (not (> (point) end)) data) (setq current-sub (replace-regexp-in-string "\n" " " (subed-subtitle-text))) (let ((candidate (subed-word-data-find-approximate-match current-sub data))) (subed-set-subtitle-time-start (alist-get 'start (seq-find (lambda (o) (alist-get 'start o)) (cdr candidate)))) (subed-set-subtitle-time-stop (alist-get 'end (seq-find (lambda (o) (alist-get 'end o)) (reverse (cdr candidate))))) (subed-word-data-refresh-text-properties-for-subtitle) (setq data (seq-drop data (length (cdr candidate))))) (unless (subed-forward-subtitle-text) (goto-char (point-max)))))) (defun subed-word-data-move-untimed-words-from-previous () "Move untimed words from previous subtitle to current one." (interactive) (save-excursion (subed-backward-subtitle-end) (text-property-search-backward 'subed-word-data-end) (goto-char (next-single-property-change (point) 'subed-word-data-end)) (let* ((start (point)) (text (buffer-substring start (subed-jump-to-subtitle-end)))) (delete-region start (point)) (subed-forward-subtitle-text) (insert text " ")))) (defvar subed-word-data-script-difference-threshold 0.2 "*If string difference is above this threshold, include original text as a comment.") (defvar subed-word-data-oops-regexp "\\" "*Regular expression matching the signal used after a false start.") (defun subed-word-data-combine-script-and-transcript (phrases bag-of-words &optional oops-regexp keep-transcript-words) "Use PHRASES to split the words in BAG-OF-WORDS. If OOPS-REGEXP is non-nil, use that as the regular expression that signals a false start. If KEEP-TRANSCRIPT-WORDS is non-nil, don't correct transcript words. Return a list of subtitles and comments." (let* ((phrase-length (length phrases)) (phrase-cursor 0) (case-fold-search t) lookback min-candidate result) (while (and (< phrase-cursor phrase-length) bag-of-words) (when (and oops-regexp (string-match oops-regexp (car bag-of-words))) ;; discard that word and figure out where we're restarting (setf (elt (cdr (car result)) 3) (concat (elt (cdr (car result)) 3) " " (car bag-of-words))) (setf (elt (cdr (car result)) 4) (string-trim (concat (or (elt (cdr (car result)) 4) "") "\n#+SKIP"))) (setq bag-of-words (cdr bag-of-words))) (setq phrase-cursor (- phrase-cursor (car (subed-word-data-find-minimum-distance 0 (1+ (min phrase-cursor 4)) (lambda (i) (if (< (- phrase-cursor i) 0) most-positive-fixnum (car (subed-word-data-find-approximate-match (elt phrases (- phrase-cursor i)) bag-of-words oops-regexp)))))))) ;; mark the previous ones as oopses also (dolist (o result) (when (>= (car o) phrase-cursor) (unless (string-match "#\\+SKIP" (or (elt (cdr o) 4) "")) (setf (elt (cdr o) 4) (string-trim (concat (or (elt (cdr o) 4) "") "\n" "#+SKIP")))))) (setq candidate (subed-word-data-find-approximate-match (elt phrases phrase-cursor) bag-of-words oops-regexp)) (setq result (cons (cons phrase-cursor (if (and oops-regexp (string-match oops-regexp (string-join (cdr candidate) " ")) (not (string-match oops-regexp (elt phrases phrase-cursor)))) (list nil 0 0 (string-join (cdr candidate) " ") "#+SKIP") (setq phrase-cursor (1+ phrase-cursor)) (list nil 0 0 (if (or keep-transcript-words (> (car candidate) subed-word-data-script-difference-threshold)) (string-join (cdr candidate) " ") (elt phrases (1- phrase-cursor))) (cond ((and keep-transcript-words (> (car candidate) 0)) (concat "#+SCRIPT: " (elt phrases (1- phrase-cursor)) "\n" "#+DISTANCE: " (format "%.2f" (car candidate)))) ((= (car candidate) 0) nil) ((> (car candidate) subed-word-data-script-difference-threshold) (concat "#+SCRIPT: " (elt phrases (1- phrase-cursor)) "\n" "#+DISTANCE: " (format "%.2f" (car candidate)))) ((< (car candidate) subed-word-data-script-difference-threshold) (concat "#+TRANSCRIPT: " (string-join (cdr candidate) " ") "\n" "#+DISTANCE: " (format "%.2f" (car candidate)))) )))) result)) ;; found a good match, move to the next phrase (setq bag-of-words (seq-drop bag-of-words (length (cdr candidate))))) (mapcar 'cdr (reverse result)))) (defun subed-word-data-use-script-file (script-file output-file &optional oops-regexp keep-transcript-words) "Use the info from SCRIPT-FILE to correct the current transcript. Write OUTPUT-FILE so that it uses the words and phrasing from SCRIPT-FILE, but includes any extra phrases from TRANSCRIPT-FILE (such as oopses). If OOPS-REGEXP is non-nil, use that as the regular expression that signals a false start. If KEEP-TRANSCRIPT-WORDS is non-nil, don't correct current transcript words. When called with `\\[universal-argument]', don't correct current transcript words." (interactive (list (read-file-name "Script: ") (read-file-name "Output file: ") subed-word-data-oops-regexp current-prefix-arg)) (let* ((phrases (if (string= "vtt" (file-name-extension script-file)) (mapcar (lambda (o) (elt o 3)) (subed-parse-file script-file)) (with-temp-buffer (insert-file script-file) (split-string (string-trim (buffer-string)) "\n")))) (bag-of-words (split-string (if (derived-mode-p 'subed-mode) (mapconcat (lambda (o) (elt o 3)) (subed-subtitle-list) " ") (string-trim (buffer-string))) "[ \n]+")) (result (subed-word-data-combine-script-and-transcript phrases bag-of-words oops-regexp))) (subed-create-file output-file result t) (find-file output-file))) (with-eval-after-load 'subed (add-hook 'subed-region-adjusted-hook #'subed-word-data-refresh-region)) (provide 'subed-word-data) ;;; subed-word-data.el ends here subed-1.2.25/subed/subed-word-data.el.license000066400000000000000000000001411474617305700207440ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2022 Sacha Chua ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/subed/subed.el000066400000000000000000000237011474617305700154520ustar00rootroot00000000000000;;; subed.el --- A major mode for editing subtitles -*- lexical-binding: t; -*- ;; Version: 1.2.25 ;; Maintainer: Sacha Chua ;; Author: Random User ;; Keywords: convenience, files, hypermedia, multimedia ;; URL: https://github.com/sachac/subed ;; Package-Requires: ((emacs "25.1")) ;;; 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: ;; ;; subed is a major mode for editing subtitles with Emacs and mpv. See C-h f ;; subed-mode, the README.org file or https://github.com/rndusr/subed for more ;; information. ;;; Code: (require 'subed-config) (require 'subed-debug) (require 'subed-common) (require 'subed-mpv) (require 'subed-menu) (declare-function tramp-tramp-file-p "tramp") (define-obsolete-variable-alias 'subed-mpv-frame-step-map 'subed-mpv-control-map "2024-12-18") (defvar-keymap subed-mpv-control-map :doc "Shortcuts for focusing on controlling MPV." :name "MPV" "." #'subed-mpv-frame-step "," #'subed-mpv-frame-back-step "" #'subed-mpv-back-large-step "S-" #'subed-mpv-back-small-step "" #'subed-mpv-large-step "S-" #'subed-mpv-small-step "SPC" #'subed-mpv-toggle-pause "u" #'subed-mpv-undo-seek "S-" #'subed-mpv-undo-seek "j" #'subed-mpv-jump-to-current-subtitle "J" #'subed-mpv-jump-to-current-subtitle-near-end "s" #'subed-mpv-seek "S" #'subed-mpv-jump "l" #'subed-toggle-loop-over-current-subtitle ;; hmm, should we change these to work with the playback speed instead? "[" #'subed-copy-player-pos-to-start-time "]" #'subed-copy-player-pos-to-stop-time "{" #'subed-copy-player-pos-to-start-time-and-copy-to-previous "}" #'subed-copy-player-pos-to-stop-time-and-copy-to-next "b" #'subed-backward-subtitle-text "f" #'subed-forward-subtitle-text "p" #'subed-backward-subtitle-text "n" #'subed-forward-subtitle-text "i" #'subed-mpv-screenshot "I" #'subed-mpv-screenshot-with-subtitles ; note that this is the opposite of MPV's s/S "t" #'subed-mpv-copy-position-as-timestamp "T" #'subed-mpv-copy-position-as-seconds "M-t" #'subed-mpv-copy-position-as-msecs "" #'subed-backward-subtitle-text "" #'subed-forward-subtitle-text "S-" #'subed-mpv-backward-subtitle-and-jump "S-" #'subed-mpv-forward-subtitle-and-jump ;; aegisub-inspired keyboard shortcuts "q" #'subed-mpv-jump-to-before-current-subtitle "d" #'subed-mpv-jump-to-current-subtitle-near-end "e" #'subed-mpv-jump-to-current-subtitle "w" #'subed-mpv-jump-to-end-of-current-subtitle "x" #'subed-backward-subtitle-text "z" #'subed-forward-subtitle-text "X" #'subed-mpv-backward-subtitle-and-jump "Z" #'subed-mpv-forward-subtitle-and-jump) (defconst subed-mode-map (let ((subed-mode-map (make-keymap))) (define-key subed-mode-map (kbd "M-n") #'subed-forward-subtitle-text) (define-key subed-mode-map (kbd "M-p") #'subed-backward-subtitle-text) (define-key subed-mode-map (kbd "C-M-a") #'subed-jump-to-subtitle-text) (define-key subed-mode-map (kbd "C-M-e") #'subed-jump-to-subtitle-end) ;; Binding M-[ when Emacs runs in a terminal emulator inserts "O" and "I" ;; every time the terminal window looses/gains focus. ;; https://emacs.stackexchange.com/questions/48738 ;; https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-FocusIn_FocusOut ;; https://www.gnu.org/software/emacs/manual/html_node/elisp/Input-Focus.html (if (display-graphic-p) (progn (define-key subed-mode-map (kbd "M-[") #'subed-decrease-start-time) (define-key subed-mode-map (kbd "M-]") #'subed-increase-start-time)) (progn (define-key subed-mode-map (kbd "C-M-[") #'subed-decrease-start-time) (define-key subed-mode-map (kbd "C-M-]") #'subed-increase-start-time))) (define-key subed-mode-map (kbd "M-{") #'subed-decrease-stop-time) (define-key subed-mode-map (kbd "M-}") #'subed-increase-stop-time) (define-key subed-mode-map (kbd "C-M-n") #'subed-move-subtitle-forward) (define-key subed-mode-map (kbd "C-M-p") #'subed-move-subtitle-backward) (define-key subed-mode-map (kbd "C-M-f") #'subed-shift-subtitle-forward) (define-key subed-mode-map (kbd "C-M-b") #'subed-shift-subtitle-backward) (define-key subed-mode-map (kbd "C-M-x") #'subed-scale-subtitles-forward) (define-key subed-mode-map (kbd "C-M-S-x") #'subed-scale-subtitles-backward) (define-key subed-mode-map (kbd "M-i") #'subed-insert-subtitle) (define-key subed-mode-map (kbd "C-M-i") #'subed-insert-subtitle-adjacent) (define-key subed-mode-map (kbd "M-k") #'subed-kill-subtitle) (define-key subed-mode-map (kbd "M-m") #'subed-merge-dwim) (define-key subed-mode-map (kbd "M-M") #'subed-merge-with-previous) (define-key subed-mode-map (kbd "M-.") #'subed-split-subtitle) (define-key subed-mode-map (kbd "M-s") #'subed-sort) (define-key subed-mode-map (kbd "M-SPC") #'subed-mpv-toggle-pause) (define-key subed-mode-map (kbd "M-j") #'subed-mpv-jump-to-current-subtitle) (define-key subed-mode-map (kbd "M-J") #'subed-mpv-jump-to-current-subtitle-near-end) (define-key subed-mode-map (kbd "C-c C-d") #'subed-toggle-debugging) (define-key subed-mode-map (kbd "C-c C-v") #'subed-mpv-play-from-file) (define-key subed-mode-map (kbd "C-c C-u") #'subed-mpv-play-from-url) (define-key subed-mode-map (kbd "C-c C-f") #'subed-mpv-control) (define-key subed-mode-map (kbd "C-c C-p") #'subed-toggle-pause-while-typing) (define-key subed-mode-map (kbd "C-c C-l") #'subed-toggle-loop-over-current-subtitle) (define-key subed-mode-map (kbd "C-c C-r") #'subed-toggle-replay-adjusted-subtitle) (define-key subed-mode-map (kbd "C-c [") #'subed-copy-player-pos-to-start-time) (define-key subed-mode-map (kbd "C-c ]") #'subed-copy-player-pos-to-stop-time) (define-key subed-mode-map (kbd "C-c .") #'subed-toggle-sync-point-to-player) (define-key subed-mode-map (kbd "C-c ,") #'subed-toggle-sync-player-to-point) (define-key subed-mode-map (kbd "C-c C-t") (let ((html-tag-keymap (make-sparse-keymap))) (define-key html-tag-keymap (kbd "C-t") #'subed-insert-html-tag) (define-key html-tag-keymap (kbd "C-i") #'subed-insert-html-tag-italic) (define-key html-tag-keymap (kbd "C-b") #'subed-insert-html-tag-bold) html-tag-keymap)) subed-mode-map) "A keymap for editing subtitles.") (defun subed-auto-play-media-maybe () "Load media file associated with this subtitle file. Do not autoplay media over TRAMP." (unless (and (featurep 'tramp) (buffer-file-name) (tramp-tramp-file-p (buffer-file-name))) (let ((file (subed-guess-media-file))) (when file (subed-debug "Auto-discovered media file: %s" file) (condition-case err (subed-mpv-play-from-file file) (error (message "%s -- Set subed-auto-find-media to nil to avoid this error." (car (cdr err))))))))) (define-obsolete-function-alias 'subed-auto-find-video-maybe 'subed-auto-play-media-maybe "1.20") ;; TODO: Make these more configurable. (defun subed-set-up-defaults () "Quietly enable some recommended defaults." (subed-enable-pause-while-typing :quiet) (subed-enable-sync-point-to-player :quiet) (subed-enable-sync-player-to-point :quiet) (subed-enable-replay-adjusted-subtitle :quiet) (subed-enable-loop-over-current-subtitle :quiet) (subed-enable-show-cps :quiet)) ;;;###autoload (define-derived-mode subed-mode text-mode "subed" "Major mode for editing subtitles. subed uses the following terminology when it comes to changes in subtitles' timestamps: Adjust - Increase or decrease start or stop time of a subtitle Move - Increase or decrease start and stop time of a subtitle by the same amount Shift - Increase or decrease start and stop time of the current and all following subtitles by the same amount Key bindings: \\{subed-mode-map}" :group 'subed (add-hook 'post-command-hook #'subed--post-command-handler :append :local) (add-hook 'before-save-hook #'subed-prepare-to-save :append :local) (add-hook 'after-save-hook #'subed-mpv-reload-subtitles :append :local) (add-hook 'kill-buffer-hook #'subed-mpv-kill :append :local) (add-hook 'kill-emacs-hook #'subed-mpv-kill :append :local) (add-hook 'after-change-major-mode-hook #'subed-guess-format :append :local) (when subed-trim-overlap-check-on-load (subed-trim-overlap-check)) (when subed-auto-play-media (subed-auto-play-media-maybe))) (declare-function subed-ass-mode "subed-ass" (&optional arg)) (declare-function subed-vtt-mode "subed-vtt" (&optional arg)) (declare-function subed-srt-mode "subed-srt" (&optional arg)) (defun subed-guess-format (&optional filename) "Set this buffer's format to a more specific subed mode format. This is a workaround for the transition to using format-specific modes such as `subed-srt-mode' while `auto-mode-alist' might still refer to `subed-mode'. It will also switch to the format-specific mode if `subed-mode' is called directly. If FILENAME is specified, use that." (when (or filename (and (eq major-mode 'subed-mode) (buffer-file-name))) (pcase (file-name-extension (or filename (buffer-file-name))) ("vtt" (subed-vtt-mode)) ("srt" (subed-srt-mode)) ("ass" (subed-ass-mode))))) (provide 'subed) ;;; subed.el ends here subed-1.2.25/subed/subed.el.license000066400000000000000000000001551474617305700170710ustar00rootroot00000000000000;;;; SPDX-FileCopyrightText: 2019-2021 The subed Authors ;;;; ;;;; SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/tests/000077500000000000000000000000001474617305700140635ustar00rootroot00000000000000subed-1.2.25/tests/test-subed-ass.el000066400000000000000000000456011474617305700172560ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (load-file "./tests/undercover-init.el") (require 'subed) (require 'subed-ass) (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) "Initialize temporary buffer with `subed-ass-mode' and run BODY." `(with-temp-buffer (subed-ass-mode) (progn ,@body))) (describe "subed-ass" (describe "Getting" (describe "the subtitle start/stop time" (it "returns the time in milliseconds." (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-id "0:00:14.00") (expect (subed-subtitle-msecs-start) :to-equal (* 14 1000)) (expect (subed-subtitle-msecs-stop) :to-equal (+ (* 16 1000) 800)))) (it "returns nil if time can't be found." (with-temp-ass-buffer (expect (subed-subtitle-msecs-start) :to-be nil) (expect (subed-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-jump-to-subtitle-text "0:00:11.12") (kill-line) (expect (subed-subtitle-text) :to-equal ""))))) (describe "when text is not empty" (it "and has no linebreaks." (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-text "0:00:14.00") (expect (subed-subtitle-text) :to-equal "This is a test."))))) (describe "Converting to msecs" (it "works with numbers." (expect (with-temp-ass-buffer (subed-to-msecs 5123)) :to-equal 5123)) (it "works with numbers as strings." (expect (with-temp-ass-buffer (subed-to-msecs "5123")) :to-equal 5123)) (it "works with timestamps." (expect (with-temp-ass-buffer (subed-to-msecs "00:00:05.12")) :to-equal 5120))) (describe "Jumping" (describe "to current subtitle timestamp" (it "can handle different formats of timestamps." (with-temp-ass-buffer (insert mock-ass-data) (expect (subed-jump-to-subtitle-id "00:00:11.120") :to-equal 564) (expect (subed-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-jump-to-subtitle-id "0:00:11.12") (expect (subed-jump-to-subtitle-time-start) :to-equal (point)) (expect (looking-at subed--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-jump-to-subtitle-time-start) :to-equal 640) (expect (looking-at subed--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-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-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-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-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-jump-to-subtitle-time-start) :to-equal 576) (expect (looking-at subed--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-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-jump-to-subtitle-time-stop) :to-equal 651) (expect (looking-at subed--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-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-jump-to-subtitle-text) :to-equal 614) (expect (looking-at "Hello, world!") :to-equal t) (forward-line 1) (expect (subed-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-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-jump-to-subtitle-end) :to-be 627) (expect (looking-back "Hello, world!") :to-be t) (forward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 693) (expect (looking-back "This is a test.") :to-be t) (forward-char 2) (expect (subed-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-jump-to-subtitle-end) :to-be nil))) (it "returns nil if point did not move." (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-text "0:00:11.12") (subed-jump-to-subtitle-end) (expect (subed-jump-to-subtitle-end) :to-be nil))) (it "works if text is empty." (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-text "00:00:11.12") (kill-line) (backward-char) (expect (subed-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-jump-to-subtitle-id "00:00:11.12") (expect (subed-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-forward-subtitle-id) :to-be nil)) (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-text "0:00:17.00") (expect (subed-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-jump-to-subtitle-text "00:00:14.00") (expect (subed-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-backward-subtitle-id) :to-be nil)) (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-id "00:00:11.12") (expect (subed-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-jump-to-subtitle-id "00:00:14.00") (expect (subed-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-jump-to-subtitle-id "00:00:17.00") (expect (subed-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-jump-to-subtitle-id "00:00:14.00") (expect (subed-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-forward-subtitle-time-start) (expect (looking-at (regexp-quote "0:00:11.12")) :to-be t) (expect (subed-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-jump-to-subtitle-text "00:00:14.00") (expect (thing-at-point 'word) :to-equal "This") (expect (subed-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-jump-to-subtitle-text "00:00:17.00") (expect (subed-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-jump-to-subtitle-id "00:00:14.00") (expect (subed-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-forward-subtitle-id) (expect (looking-at (regexp-quote "Dialogue: 0,0:00:11.12")) :to-be t) (expect (subed-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-jump-to-subtitle-id "00:00:14.00") (expect (subed-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-jump-to-subtitle-id "00:00:17.00") (let ((pos (point))) (expect (subed-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-jump-to-subtitle-id "00:00:14.00") (expect (subed-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-forward-subtitle-id) (expect (subed-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-jump-to-subtitle-id "00:00:14.00") (expect (subed-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-jump-to-subtitle-id "00:00:17.00") (let ((pos (point))) (expect (subed-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-jump-to-subtitle-id "00:00:14.00") (subed-set-subtitle-time-start (+ (* 15 1000) 400)) (expect (subed-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-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-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-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-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-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-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-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-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-jump-to-subtitle-time-stop) (subed-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-jump-to-subtitle-time-stop "0:00:14.00") (subed-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-jump-to-subtitle-time-stop "0:00:14.00") (subed-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-subtitle-msecs-start) :to-be 14000)))))) (describe "Killing a subtitle" (it "removes the first subtitle." (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-text "0:00:11.12") (subed-kill-subtitle) (expect (subed-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-jump-to-subtitle-text "00:00:14.00") (subed-kill-subtitle) (expect (subed-subtitle-msecs-start) :to-be 17000))) (it "removes the last subtitle." (with-temp-ass-buffer (insert mock-ass-data) (subed-jump-to-subtitle-text "00:00:17.00") (subed-kill-subtitle) (expect (subed-subtitle-msecs-start) :to-be 14000))) (describe "Converting msecs to timestamp" (it "uses the right format" (with-temp-ass-buffer (expect (subed-msecs-to-timestamp 1410) :to-equal "0:00:01.41")))) (describe "Font-locking" (it "recognizes ASS syntax." (with-temp-ass-buffer (insert mock-ass-data) (font-lock-fontify-buffer) (goto-char (point-min)) (re-search-forward "00:11") (expect (face-at-point) :to-equal 'subed-time-face) (re-search-forward ",") (backward-char 1) (expect (face-at-point) :to-equal 'subed-time-separator-face))))) subed-1.2.25/tests/test-subed-ass.el.license000066400000000000000000000001321474617305700206650ustar00rootroot00000000000000SPDX-FileCopyrightText: 2020 The subed Authors SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/tests/test-subed-common.el000066400000000000000000006141301474617305700177570ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (load-file "./tests/undercover-init.el") (require 'subed-srt) (require 'subed-mpv) (defvar mock-srt-data "1 00:01:01,000 --> 00:01:05,123 Foo. 2 00:02:02,234 --> 00:02:10,345 Bar. 3 00:03:03,45 --> 00:03:15,5 Baz. ") (defmacro with-temp-srt-buffer (&rest body) "Call `subed-srt--init' in temporary buffer before running BODY." (declare (indent defun)) `(with-temp-buffer ;; subed--init uses file extension to detect format (subed-srt-mode) (progn ,@body))) (cl-defun create-sample-media-file (&key path duration-video-stream duration-audio-stream) "Create a sample media file. PATH is the absolute path for the output file. It must be a string. AUDIO-DURATION is the duration in seconds for the audio stream. It must be a number. VIDEO-DURATION is the duration in seconds for the video stream. It must be a number." (apply 'call-process ;; The ffmpeg command shown below can create files with the ;; extensions shown below (tested using ffmpeg version ;; 4.4.2-0ubuntu0.22.04.1) ;; + audio extensions: wav ogg mp3 opus m4a ;; + video extensions: mkv mp4 webm avi ts ogv" "ffmpeg" nil nil nil "-v" "error" "-y" (append ;; Create the video stream (when duration-video-stream (list "-f" "lavfi" "-i" (format "testsrc=size=100x100:duration=%d" duration-video-stream))) ;; Create the audio stream (when duration-audio-stream (list "-f" "lavfi" "-i" (format "sine=frequency=1000:duration=%d" duration-audio-stream))) (list path))) path) (defmacro test-subed-extension (extension &optional has-video) `(it ,(if has-video (format "reports the duration of %s even with a longer video stream" extension) (format "reports the duration of %s" extension)) (let* (;; `duration-audio-stream' is the duration in seconds for ;; the media file that is used inside the tests. When ;; `duration-audio-stream' is an integer, ffprobe might ;; report a duration that is slightly greater, so we can't ;; expect the duration reported by ffprobe to be equal to ;; the duration that we passed to ffmpeg when creating the ;; sample media file. For this reason, we define the ;; variables `duration-lower-boundary' and ;; `duration-upper-boundary' to set a tolerance to the ;; reported value by ffprobe. ;; ;; When `duration-audio-stream' changes, the variables ;; `duration-lower-boundary' and ;; `duration-upper-boundary' should be set accordingly." (duration-audio-stream 3) (duration-video-stream 5) (duration-lower-boundary 3000) (duration-upper-boundary 4000) (filename (make-temp-file "test-subed-a" nil ,extension)) (file (create-sample-media-file :path filename :duration-audio-stream duration-audio-stream :duration-video-stream ,(if has-video 'duration-video-stream nil))) (duration-ms (subed-ffprobe-duration-ms filename))) (expect duration-ms :to-be-weakly-greater-than duration-lower-boundary) (expect duration-ms :to-be-less-than duration-upper-boundary) (delete-file filename)))) (describe "subed-common" (describe "Iterating over subtitles" (describe "without providing beginning and end" (it "goes through each subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 1) (subed-for-each-subtitle nil nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert "Hello.")) (expect (subed-subtitle-text 1) :to-equal "Hello.") (expect (subed-subtitle-text 2) :to-equal "Bar.") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 20) (subed-jump-to-subtitle-time-stop 2) (subed-for-each-subtitle nil nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert "HEllo.")) (expect (subed-subtitle-text 1) :to-equal "Hello.") (expect (subed-subtitle-text 2) :to-equal "HEllo.") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 60) (subed-jump-to-subtitle-time-stop 3) (subed-for-each-subtitle nil nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert "HELlo.")) (expect (subed-subtitle-text 1) :to-equal "Hello.") (expect (subed-subtitle-text 2) :to-equal "HEllo.") (expect (subed-subtitle-text 3) :to-equal "HELlo.") (expect (point) :to-equal 99)))) (describe "providing only the beginning" (it "goes forward." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-start 1) (expect (point) :to-equal 3) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 71 nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "A") (expect (subed-subtitle-text 3) :to-equal "B") (expect (point) :to-equal 3))) (it "goes backward." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 3) (expect (point) :to-equal 95) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 75 nil :reverse (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "B") (expect (subed-subtitle-text 3) :to-equal "A") (expect (point) :to-equal 92))) ) (describe "providing beginning and end," (describe "excluding subtitles above" (it "goes forward." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 1) (expect (point) :to-equal 20) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 71 79 nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "A") (expect (subed-subtitle-text 3) :to-equal "B") (expect (point) :to-equal 20))) (it "goes backward." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-start 3) (expect (point) :to-equal 79) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 39 77 :reverse (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "B") (expect (subed-subtitle-text 3) :to-equal "A") (expect (point) :to-equal 76)))) (describe "excluding subtitles below" (it "goes forward." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (expect (point) :to-equal 106) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 5 76 nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "A") (expect (subed-subtitle-text 2) :to-equal "B") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 100))) (it "goes backward." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 2) (expect (point) :to-equal 58) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 20 76 :reverse (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "B") (expect (subed-subtitle-text 2) :to-equal "A") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 55))) ) )) (describe "Getting the maximum subtitle ID" (it "returns nil in an empty buffer." (with-temp-srt-buffer (expect (subed-subtitle-id-max) :to-be nil))) (it "returns the subtitle ID at the end." (with-temp-srt-buffer (insert mock-srt-data) (expect (subed-subtitle-id-max) :to-be 3)))) (describe "Setting subtitle start time" (it "continues when setting the first subtitle's start time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (subed-set-subtitle-time-start 30000) (expect (subed-subtitle-msecs-start) :to-equal 30000))) (it "ignores the previous subtitle's stop time if there's enough spacing." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (subed-set-subtitle-time-start (subed-timestamp-to-msecs "00:01:10,000"))) (expect (subed-subtitle-msecs-start) :to-equal (subed-timestamp-to-msecs "00:01:10,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123")))) (describe "when it overlaps with the previous subtitle" (it "ignores the previous subtitle's stop time if spacing is unspecified." (let ((subed-subtitle-spacing nil) (subed-enforce-time-boundaries 'error)) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (subed-set-subtitle-time-start (subed-timestamp-to-msecs "00:01:05,000")) (expect (subed-subtitle-msecs-start) :to-equal (subed-timestamp-to-msecs "00:01:05,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123"))))) (describe "when time boundaries are enforced by errors" (it "reports an error if the change violates spacing." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (expect (subed-set-subtitle-time-start (subed-timestamp-to-msecs "00:01:05,000")) :to-throw 'error))))) (describe "when time boundaries are enforced by clipping" (before-each (setq subed-enforce-time-boundaries 'clip subed-subtitle-spacing 100)) (it "clips to preserve spacing based on the previous subtitle's stop time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (subed-set-subtitle-time-start (subed-timestamp-to-msecs "00:01:05,000")) (expect (subed-subtitle-msecs-start) :to-equal (subed-timestamp-to-msecs "00:01:05,223")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123"))))) (describe "when time boundaries are enforced by adjusting" (before-each (setq subed-enforce-time-boundaries 'adjust subed-subtitle-spacing 100)) (it "adjusts the previous subtitle's stop time to maintain spacing." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (subed-set-subtitle-time-start (subed-timestamp-to-msecs "00:01:05,000")) (expect (subed-subtitle-msecs-start) :to-equal (subed-timestamp-to-msecs "00:01:05,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:04,900")))) (it "adjusts the previous subtitle's stop time, but not the one before it." ;; TODO: Decide if we want to change this expectation (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (subed-set-subtitle-time-start (subed-timestamp-to-msecs "00:01:05,000")) (expect (subed-subtitle-msecs-start) :to-equal (subed-timestamp-to-msecs "00:01:05,000")) (expect (subed-subtitle-msecs-stop 2) :to-equal (subed-timestamp-to-msecs "00:01:04,900")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123")))) (it "adjusts the current subtitle's stop time to at least the start time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'adjust) (subed-subtitle-spacing 100)) (subed-set-subtitle-time-start (subed-timestamp-to-msecs "00:02:11,000"))) (expect (subed-subtitle-msecs-start) :to-equal (subed-timestamp-to-msecs "00:02:11,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123")))))) (describe "when it will result in invalid duration" :var ((temp-time (+ (* 3 60 1000) (* 17 1000)))) (it "throws an error when enforcing time boundaries." (let ((subed-enforce-time-boundaries 'error)) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (expect (subed-set-subtitle-time-start temp-time) :to-throw 'error)))) (it "clips the current subtitle's start time to at most the stop time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (let ((subed-enforce-time-boundaries 'clip) (subed-subtitle-spacing 100)) (subed-set-subtitle-time-start temp-time)) (expect (subed-subtitle-msecs-start) :to-equal (subed-timestamp-to-msecs "00:03:15,500")))) (it "changes it when ignoring time boundaries." (let ((subed-enforce-time-boundaries nil)) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (subed-set-subtitle-time-start temp-time) (expect (subed-subtitle-msecs-start) :to-equal temp-time)))) (it "changes it when negative durations are allowed." (let ((subed-enforce-time-boundaries 'error)) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (subed-set-subtitle-time-start temp-time nil t) (expect (subed-subtitle-msecs-start) :to-equal temp-time)))))) (describe "Setting subtitle stop time" (it "continues when setting the last subtitle's stop time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (let ((subed-enforce-time-boundaries 'error)) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:03:30,000"))) (expect (subed-subtitle-msecs-stop) :to-equal (subed-timestamp-to-msecs "00:03:30,000")))) (it "ignores the next subtitle's start time if there's enough spacing." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error)) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:03:01,000"))) (expect (subed-subtitle-msecs-stop) :to-equal (subed-timestamp-to-msecs "00:03:01,000")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,45")))) (describe "when it overlaps with the next subtitle" (it "ignores the next subtitle's start time if spacing is unspecified." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing nil)) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:03:05,000"))) (expect (subed-subtitle-msecs-stop) :to-equal (subed-timestamp-to-msecs "00:03:05,000")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,45")))) (describe "when time boundaries are enforced by errors" (before-each (setq subed-subtitle-spacing 100 subed-enforce-time-boundaries 'error)) (it "reports an error if the change violates spacing." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (expect (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:03:05,000")) :to-throw 'error)))) (describe "when time boundaries are enforced by clipping" (before-each (setq subed-subtitle-spacing 100 subed-enforce-time-boundaries 'clip)) (it "clips to the next subtitle's start time to maintain spacing." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:03:05,000")) (expect (subed-subtitle-msecs-stop) :to-equal (subed-timestamp-to-msecs "00:03:03,350")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,450"))))) (describe "when time boundaries are enforced by adjusting" (before-each (setq subed-subtitle-spacing 100 subed-enforce-time-boundaries 'adjust)) (it "adjusts the next subtitle's start time to maintain spacing." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:03:05,000")) (expect (subed-subtitle-msecs-stop) :to-equal (subed-timestamp-to-msecs "00:03:05,000")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:05,100")))) (it "adjusts the next subtitle's start time, but not the one after it." ;; TODO: Decide if we want to change this expectation (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:03:05,000")) (expect (subed-subtitle-msecs-stop) :to-equal (subed-timestamp-to-msecs "00:03:05,000")) (expect (subed-subtitle-msecs-start 2) :to-equal (subed-timestamp-to-msecs "00:03:05,100")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,45")))))) (describe "when it will result in invalid duration" (it "adjusts the start time as needed." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (let ((subed-enforce-time-boundaries 'adjust) (temp-time (subed-timestamp-to-msecs "00:03:01,000"))) (subed-set-subtitle-time-stop temp-time) (expect (subed-subtitle-msecs-start) :to-equal temp-time) (expect (subed-subtitle-msecs-stop) :to-equal temp-time)))) (it "throws an error when enforcing time boundaries." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (let ((subed-enforce-time-boundaries 'error) (temp-time (subed-timestamp-to-msecs "00:03:01,000"))) (expect (subed-set-subtitle-time-stop temp-time) :to-throw 'error)))) (it "changes it when ignoring time boundaries." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (let ((subed-enforce-time-boundaries nil) (temp-time (subed-timestamp-to-msecs "00:03:01,000"))) (subed-set-subtitle-time-stop temp-time) (expect (subed-subtitle-msecs-stop) :to-equal temp-time)))) (it "changes it when negative durations are allowed." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (let ((subed-enforce-time-boundaries 'error) (temp-time (subed-timestamp-to-msecs "00:03:01,000"))) (subed-set-subtitle-time-stop temp-time nil t) (expect (subed-subtitle-msecs-stop) :to-equal temp-time))))) (describe "when looping" (it "updates the loop stop time for the current subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (let ((subed-loop-seconds-before 1) (subed-loop-seconds-after 1)) (subed--set-subtitle-loop) (expect subed--subtitle-loop-start :to-equal (subed-timestamp-to-msecs "00:02:01,234")) (expect subed--subtitle-loop-stop :to-equal (subed-timestamp-to-msecs "00:02:11,345")) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:02:13,345")) (expect subed--subtitle-loop-start :to-equal (subed-timestamp-to-msecs "00:02:01,234")) (expect subed--subtitle-loop-stop :to-equal (subed-timestamp-to-msecs "00:02:14,345"))))) (it "leaves other subtitle loops alone." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (let ((subed-loop-seconds-before 1) (subed-loop-seconds-after 1)) (subed--set-subtitle-loop) (expect subed--subtitle-loop-start :to-equal (subed-timestamp-to-msecs "00:02:01,234")) (expect subed--subtitle-loop-stop :to-equal (subed-timestamp-to-msecs "00:02:11,345")) (subed-jump-to-subtitle-text 1) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "00:01:04,123")) (expect subed--subtitle-loop-start :to-equal (subed-timestamp-to-msecs "00:02:01,234")) (expect subed--subtitle-loop-stop :to-equal (subed-timestamp-to-msecs "00:02:11,345"))))))) (describe "Adjusting subtitle start/stop time" :var (subed-subtitle-time-adjusted-hook) (it "runs the appropriate hook." (let ((foo (setf (symbol-function 'foo) (lambda (msecs) ())))) (spy-on 'foo) (with-temp-srt-buffer (add-hook 'subed-subtitle-time-adjusted-hook 'foo) (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (expect (subed-adjust-subtitle-time-start 100) :to-equal 100) (expect 'foo :to-have-been-called-with 61100) (expect 'foo :to-have-been-called-times 1) (expect (subed-adjust-subtitle-time-stop 123) :to-equal 123) (expect 'foo :to-have-been-called-with 61100) (expect 'foo :to-have-been-called-times 2) (subed-jump-to-subtitle-id 2) (expect (subed-adjust-subtitle-time-start 6) :to-equal 6) (expect 'foo :to-have-been-called-with 122240) (expect 'foo :to-have-been-called-times 3) (expect (subed-adjust-subtitle-time-stop 123) :to-equal 123) (expect 'foo :to-have-been-called-with 122240) (expect 'foo :to-have-been-called-times 4)))) (it "adjusts the start/stop time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (expect (subed-adjust-subtitle-time-start 100) :to-equal 100) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "00:01:01,100 --> 00:01:05,123\n") (expect (subed-adjust-subtitle-time-start -200) :to-equal -200) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "00:01:00,900 --> 00:01:05,123\n") (expect (subed-adjust-subtitle-time-stop 200) :to-equal 200) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "00:01:00,900 --> 00:01:05,323\n") (expect (subed-adjust-subtitle-time-stop -100) :to-equal -100) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "00:01:00,900 --> 00:01:05,223\n"))) (describe "when enforcing boundaries with errors" (describe "when decreasing start time" (it "handles the first subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n")) (let ((subed-enforce-time-boundaries 'error)) (expect subed-enforce-time-boundaries :to-equal 'error) (expect (subed-adjust-subtitle-time-start -999) :to-be -999) (expect (subed-subtitle-msecs-start) :to-be 1) (expect (subed-adjust-subtitle-time-start -1) :to-be -1) (expect (subed-subtitle-msecs-start) :to-be 0) (expect (subed-adjust-subtitle-time-start -1) :to-throw 'error)))) (it "handles a non-first subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n")) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (expect (subed-adjust-subtitle-time-start -899) :to-be -899) (expect (subed-subtitle-msecs-start) :to-be 2101) (expect (subed-adjust-subtitle-time-start -1) :to-be -1) (expect (subed-subtitle-msecs-start) :to-be 2100) ;; report an error if it bumps up against a previous subtitle (expect (subed-adjust-subtitle-time-start -1) :to-throw 'error) (expect (subed-subtitle-msecs-start) :to-be 2100))))) (it "increases start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n")) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (expect (subed-adjust-subtitle-time-start 999) :to-be 999) (expect (subed-subtitle-msecs-start) :to-be 3999) (expect (subed-adjust-subtitle-time-start 1) :to-be 1) (expect (subed-subtitle-msecs-start) :to-be 4000) (expect (subed-adjust-subtitle-time-start 1) :to-throw 'error) (expect (subed-subtitle-msecs-start) :to-be 4000)))) (it "decreases stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n")) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (expect (subed-adjust-subtitle-time-stop -999) :to-be -999) (expect (subed-subtitle-msecs-stop) :to-be 3001) (expect (subed-adjust-subtitle-time-stop -1) :to-be -1) (expect (subed-subtitle-msecs-stop) :to-be 3000) (expect (subed-adjust-subtitle-time-stop -1) :to-throw 'error) (expect (subed-subtitle-msecs-stop) :to-be 3000)))) (describe "when increasing stop time" (it "increases the last subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n")) (subed-jump-to-subtitle-id 2) (expect (subed-adjust-subtitle-time-stop 1000000):to-be 1000000) (expect (subed-subtitle-msecs-stop) :to-be 1004000))) (it "increases a non-last subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n")) (subed-jump-to-subtitle-id 1) (expect (subed-adjust-subtitle-time-stop 899) :to-be 899) (expect (subed-subtitle-msecs-stop) :to-be 2899) (expect (subed-adjust-subtitle-time-stop 1) :to-be 1) (expect (subed-subtitle-msecs-stop) :to-be 2900) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (expect (subed-adjust-subtitle-time-stop 1) :to-throw 'error) (expect (subed-subtitle-msecs-stop) :to-be 2900))))) (it "increases without undershooting the target time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,000 --> 00:00:03,000\n" "Bar.\n")) (subed-jump-to-subtitle-id 1) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (expect (subed-adjust-subtitle-time-stop 1) :to-throw 'error) (expect (subed-subtitle-msecs-stop) :to-equal 2000)))) (it "increases without overshooting the target time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,000 --> 00:00:03,000\n" "Bar.\n")) (subed-jump-to-subtitle-id 2) (let ((subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (expect (subed-adjust-subtitle-time-start -1) :to-throw 'error) (expect (subed-subtitle-msecs-start) :to-equal 2000)))) ) (describe "ignores negative duration if the second argument is truthy" (it "when adjusting start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n")) (expect (subed-adjust-subtitle-time-start 2000 t) :to-be 2000) (expect (subed-subtitle-msecs-start) :to-be 3000) (expect (subed-subtitle-msecs-stop) :to-be 2000) (expect (subed-adjust-subtitle-time-start -500 t) :to-be -500) (expect (subed-subtitle-msecs-start) :to-be 2500) (expect (subed-subtitle-msecs-stop) :to-be 2000))) (it "when adjusting stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n")) (expect (subed-adjust-subtitle-time-stop -1500 t) :to-be -1500) (expect (subed-subtitle-msecs-stop) :to-be 500) (expect (subed-subtitle-msecs-start) :to-be 1000) (expect (subed-adjust-subtitle-time-stop 200 t) :to-be 200) (expect (subed-subtitle-msecs-stop) :to-be 700) (expect (subed-subtitle-msecs-start) :to-be 1000))) ) (describe "ignores subtitle spacing if the second argument is truthy" (it "when adjusting start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,200 --> 00:00:03,000\n" "Bar.\n")) (subed-jump-to-subtitle-id 2) (expect (subed-adjust-subtitle-time-start -150 nil t) :to-be -150) (expect (subed-subtitle-msecs-start 2) :to-be 2050) (expect (subed-subtitle-msecs-stop 1) :to-be 2000) (expect (subed-adjust-subtitle-time-start -51 nil t) :to-be -51) (expect (subed-subtitle-msecs-start 2) :to-be 1999) (expect (subed-subtitle-msecs-stop 1) :to-be 2000))) (it "when adjusting stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,200 --> 00:00:03,000\n" "Bar.\n")) (subed-jump-to-subtitle-id 1) (expect (subed-adjust-subtitle-time-stop 150 nil t) :to-be 150) (expect (subed-subtitle-msecs-stop 1) :to-be 2150) (expect (subed-subtitle-msecs-start 2) :to-be 2200) (expect (subed-adjust-subtitle-time-stop 51 nil t) :to-be 51) (expect (subed-subtitle-msecs-stop 1) :to-be 2201) (expect (subed-subtitle-msecs-start 2) :to-be 2200))) ) (describe "ignores negative duration if subed-enforce-time-boundaries is falsy" (it "when adjusting start time." (with-temp-srt-buffer (setq-local subed-enforce-time-boundaries nil) (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n")) (expect (subed-adjust-subtitle-time-start 2000) :to-be 2000) (expect (subed-subtitle-msecs-start) :to-be 3000) (expect (subed-subtitle-msecs-stop) :to-be 2000) (expect (subed-adjust-subtitle-time-start -500) :to-be -500) (expect (subed-subtitle-msecs-start) :to-be 2500) (expect (subed-subtitle-msecs-stop) :to-be 2000))) (it "when adjusting stop time." (with-temp-srt-buffer (setq-local subed-enforce-time-boundaries nil) (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n")) (expect (subed-adjust-subtitle-time-stop -1500) :to-be -1500) (expect (subed-subtitle-msecs-stop) :to-be 500) (expect (subed-subtitle-msecs-start) :to-be 1000) (expect (subed-adjust-subtitle-time-stop 200) :to-be 200) (expect (subed-subtitle-msecs-stop) :to-be 700) (expect (subed-subtitle-msecs-start) :to-be 1000))) ) (describe "ignores subtitle spacing if subed-enforce-time-boundaries is falsy" (it "when adjusting start time." (with-temp-srt-buffer (setq-local subed-enforce-time-boundaries nil) (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,200 --> 00:00:03,000\n" "Bar.\n")) (subed-jump-to-subtitle-id 2) (expect (subed-adjust-subtitle-time-start -150) :to-be -150) (expect (subed-subtitle-msecs-start 2) :to-be 2050) (expect (subed-subtitle-msecs-stop 1) :to-be 2000) (expect (subed-adjust-subtitle-time-start -51) :to-be -51) (expect (subed-subtitle-msecs-start 2) :to-be 1999) (expect (subed-subtitle-msecs-stop 1) :to-be 2000))) (it "when adjusting stop time." (with-temp-srt-buffer (setq-local subed-enforce-time-boundaries nil) (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,200 --> 00:00:03,000\n" "Bar.\n")) (subed-jump-to-subtitle-id 1) (expect (subed-adjust-subtitle-time-stop 150) :to-be 150) (expect (subed-subtitle-msecs-stop 1) :to-be 2150) (expect (subed-subtitle-msecs-start 2) :to-be 2200) (expect (subed-adjust-subtitle-time-stop 51) :to-be 51) (expect (subed-subtitle-msecs-stop 1) :to-be 2201) (expect (subed-subtitle-msecs-start 2) :to-be 2200))) ) (describe "prevents negative time even if subed-enforce-time-boundaries is falsy" (it "when adjusting start time." (with-temp-srt-buffer (setq-local subed-enforce-time-boundaries nil) (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n")) (expect (subed-adjust-subtitle-time-start -1000) :to-be -1000) (expect (subed-subtitle-msecs-start) :to-be 0) (expect (subed-adjust-subtitle-time-start -1) :to-be 0) (expect (subed-subtitle-msecs-start) :to-be 0))) (it "when adjusting stop time." (with-temp-srt-buffer (setq-local subed-enforce-time-boundaries nil) (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n")) (expect (subed-adjust-subtitle-time-stop -2000) :to-be -2000) (expect (subed-subtitle-msecs-stop) :to-be 0) (expect (subed-adjust-subtitle-time-stop -1) :to-be nil) (expect (subed-subtitle-msecs-stop) :to-be 0))) ) (it "does nothing if no timestamp can be found." (with-temp-srt-buffer (insert "foo") (goto-char (point-min)) (expect (subed-adjust-subtitle-time-start 123) :to-be nil) (expect (buffer-string) :to-equal "foo") (expect (subed-adjust-subtitle-time-start -123) :to-be nil) (expect (buffer-string) :to-equal "foo"))) ) (describe "Copy start/stop time from player" :var (subed-mpv-playback-position) (it "does nothing in an empty buffer." (with-temp-srt-buffer (let ((subed-mpv-playback-position 12345)) (expect (subed-copy-player-pos-to-start-time) :to-be nil) (expect (subed-copy-player-pos-to-stop-time) :to-be nil) (expect (buffer-string) :to-equal "")))) (it "does nothing if player position is unknown." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n")) (let ((subed-mpv-playback-position nil)) (expect (subed-copy-player-pos-to-start-time) :to-be nil) (expect (subed-copy-player-pos-to-stop-time) :to-be nil) (expect (buffer-string) :to-equal (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n"))))) (it "sets start/stop time when possible." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n")) (let ((subed-mpv-playback-position (+ 60000 2000 123)) (subed-enforce-time-boundaries nil)) (expect (subed-copy-player-pos-to-start-time) :to-be subed-mpv-playback-position) (expect (buffer-string) :to-equal (concat "1\n" "00:01:02,123 --> 00:00:02,000\n" "Foo.\n"))) (let ((subed-mpv-playback-position (+ 60000 5000 456)) (subed-enforce-time-boundaries nil)) (expect (subed-copy-player-pos-to-stop-time) :to-be subed-mpv-playback-position) (expect (buffer-string) :to-equal (concat "1\n" "00:01:02,123 --> 00:01:05,456\n" "Foo.\n"))))) (it "runs the appropriate hook." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n")) (let ((foo (setf (symbol-function 'foo) (lambda (msecs) ()))) (subed-enforce-time-boundaries nil)) (spy-on 'foo) (add-hook 'subed-subtitle-time-adjusted-hook 'foo) (let ((subed-mpv-playback-position (+ 60000 2000 123))) (expect (subed-copy-player-pos-to-start-time) :to-be subed-mpv-playback-position) (expect (buffer-string) :to-equal (concat "1\n" "00:01:02,123 --> 00:00:02,000\n" "Foo.\n")) (expect (spy-calls-args-for 'foo 0) :to-equal `(,(+ 60000 2000 123))) (expect (spy-calls-count 'foo) :to-equal 1))) (let ((subed-mpv-playback-position (+ 60000 5000 456))) (expect (subed-copy-player-pos-to-stop-time) :to-be subed-mpv-playback-position) (expect (buffer-string) :to-equal (concat "1\n" "00:01:02,123 --> 00:01:05,456\n" "Foo.\n")) (expect (spy-calls-args-for 'foo 0) :to-equal `(,(+ 60000 2000 123))) (expect (spy-calls-count 'foo) :to-equal 2))) (remove-hook 'subed-subtitle-time-adjusted-hook 'foo)) ) (describe "Jumping" (describe "to subtitle text given msecs" (it "finds the right subtitle" (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text-at-msecs 122234) (expect (looking-at "Bar\\.") :to-equal t))))) (describe "Moving" (it "adjusts start and stop time by the same amount." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n")) (let ((orig-point (subed-jump-to-subtitle-text 1))) (subed-move-subtitle-forward 100) (expect (subed-subtitle-msecs-start) :to-equal 1100) (expect (subed-subtitle-msecs-stop) :to-equal 2100) (subed-move-subtitle-backward 200) (expect (subed-subtitle-msecs-start) :to-equal 900) (expect (subed-subtitle-msecs-stop) :to-equal 1900) (expect (point) :to-equal orig-point)))) (describe "when clipping to time boundaries" (it "adjusts start and stop time by the same amount when bumping into next subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:01,600\n" "Foo.\n\n" "2\n" "00:00:02,000 --> 00:00:03,000\n" "Bar.\n")) (let ((orig-point (subed-jump-to-subtitle-id 1)) (subed-subtitle-spacing 100) (subed-enforce-time-boundaries 'clip)) (subed-move-subtitle-forward 1000) (expect (subed-subtitle-msecs-start) :to-equal 1300) (expect (subed-subtitle-msecs-stop) :to-equal 1900) (expect (point) :to-equal orig-point)))) (it "adjusts start and stop time by the same amount when bumping into previous subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:01,600\n" "Foo.\n\n" "2\n" "00:00:02,000 --> 00:00:03,000\n" "Bar.\n")) (let ((orig-point (subed-jump-to-subtitle-id 2)) (subed-subtitle-spacing 100) (subed-enforce-time-boundaries 'clip)) (subed-move-subtitle-backward 1000) (expect (subed-subtitle-msecs-start) :to-equal 1700) (expect (subed-subtitle-msecs-stop) :to-equal 2700) (expect (point) :to-equal orig-point))))) (describe "when time boundaries are enforced with errors" (it "does not adjust anything if subtitle cannot be moved forward at all." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,000 --> 00:00:03,000\n" "Bar.\n")) (let ((orig-point (subed-jump-to-subtitle-id 1)) (subed-enforce-time-boundaries 'error)) (expect (subed-move-subtitle-forward 1) :to-throw 'error) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 2000) (expect (subed-subtitle-msecs-stop 2) :to-equal 3000) (expect (point) :to-equal orig-point)))) (it "does not adjust anything if subtitle cannot be moved backward at all." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:02,000 --> 00:00:03,000\n" "Bar.\n")) (let ((orig-point (subed-jump-to-subtitle-id 2)) (subed-enforce-time-boundaries 'error)) (expect (subed-move-subtitle-backward 1) :to-throw 'error) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 2000) (expect (subed-subtitle-msecs-stop 2) :to-equal 3000) (expect (point) :to-equal orig-point))))) (describe "adjusts subtitles in the active region," (it "excluding the first subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n" "3\n" "00:00:05,000 --> 00:00:06,000\n" "Baz.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-text 2)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-time-start 3)) (let ((orig-point (subed-jump-to-subtitle-text 2))) (subed-move-subtitle-forward 100) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 3100) (expect (subed-subtitle-msecs-stop 2) :to-equal 4100) (expect (subed-subtitle-msecs-start 3) :to-equal 5100) (expect (subed-subtitle-msecs-stop 3) :to-equal 6100) (expect (point) :to-equal orig-point) (subed-move-subtitle-backward 200) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 2900) (expect (subed-subtitle-msecs-stop 2) :to-equal 3900) (expect (subed-subtitle-msecs-start 3) :to-equal 4900) (expect (subed-subtitle-msecs-stop 3) :to-equal 5900) (expect (point) :to-equal orig-point)))) (it "excluding the last subtitle." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n" "3\n" "00:00:05,000 --> 00:00:06,000\n" "Baz.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-text 1)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-time-stop 2)) (let ((orig-point (subed-jump-to-subtitle-time-stop 3))) (subed-move-subtitle-forward 500) (expect (subed-subtitle-msecs-start 1) :to-equal 1500) (expect (subed-subtitle-msecs-stop 1) :to-equal 2500) (expect (subed-subtitle-msecs-start 2) :to-equal 3500) (expect (subed-subtitle-msecs-stop 2) :to-equal 4500) (expect (subed-subtitle-msecs-start 3) :to-equal 5000) (expect (subed-subtitle-msecs-stop 3) :to-equal 6000) (expect (point) :to-equal orig-point) (subed-move-subtitle-backward 300) (expect (subed-subtitle-msecs-start 1) :to-equal 1200) (expect (subed-subtitle-msecs-stop 1) :to-equal 2200) (expect (subed-subtitle-msecs-start 2) :to-equal 3200) (expect (subed-subtitle-msecs-stop 2) :to-equal 4200) (expect (subed-subtitle-msecs-start 3) :to-equal 5000) (expect (subed-subtitle-msecs-stop 3) :to-equal 6000) (expect (point) :to-equal orig-point)))) (describe "when ignoring time boundaries" (it "does not change spacing between subtitles when moving subtitles forward." (with-temp-srt-buffer (insert "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:10,000 --> 00:00:11,000\n" "Bar.\n\n" "3\n" "00:00:12,000 --> 00:00:13,000\n" "Baz.\n") (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 1)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 2)) (let ((orig-point (subed-jump-to-subtitle-time-start 1)) (subed-enforce-time-boundaries nil)) (subed-move-subtitle-forward 2000) (expect (subed-subtitle-msecs-start 1) :to-equal 3000) (expect (subed-subtitle-msecs-stop 1) :to-equal 4000) (expect (subed-subtitle-msecs-start 2) :to-equal 12000) (expect (subed-subtitle-msecs-stop 2) :to-equal 13000) (expect (subed-subtitle-msecs-start 3) :to-equal 12000) (expect (subed-subtitle-msecs-stop 3) :to-equal 13000) (expect (point) :to-equal orig-point)))) (it "does not change spacing between subtitles when moving subtitles backward." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:03,000 --> 00:00:04,000\n" "Bar.\n\n" "3\n" "00:00:10,000 --> 00:00:11,000\n" "Baz.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 2)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 3)) (let ((orig-point (subed-jump-to-subtitle-time-start 2)) (subed-enforce-time-boundaries nil)) (subed-move-subtitle-backward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 2000) (expect (subed-subtitle-msecs-stop 2) :to-equal 3000) (expect (subed-subtitle-msecs-start 3) :to-equal 9000) (expect (subed-subtitle-msecs-stop 3) :to-equal 10000) (expect (point) :to-equal orig-point)))))) ;; What does it mean by not having space left? ;; (describe "unless there is no space left" ;; (describe "when moving forward" ;; (it "updates the start time." ;; (with-temp-srt-buffer ;; (insert (concat "1\n" ;; "00:00:01,000 --> 00:00:02,000\n" ;; "Foo.\n\n" ;; "2\n" ;; "00:00:10,000 --> 00:00:11,000\n" ;; "Bar.\n\n" ;; "3\n" ;; "00:00:11,000 --> 00:00:12,000\n" ;; "Baz.\n")) ;; (setq mark-active t) ;; (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 1)) ;; (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 2)) ;; (let ((orig-point (subed-jump-to-subtitle-text 1))) ;; (subed-move-subtitle-forward 1) ;; (expect (subed-subtitle-msecs-start 1) :to-equal 1000) ;; (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) ;; (expect (subed-subtitle-msecs-start 2) :to-equal 10000) ;; (expect (subed-subtitle-msecs-stop 2) :to-equal 11000) ;; (expect (subed-subtitle-msecs-start 3) :to-equal 11000) ;; (expect (subed-subtitle-msecs-stop 3) :to-equal 12000) ;; (expect (point) :to-equal orig-point))))) ;; (it "when moving backward." ;; (with-temp-srt-buffer ;; (insert (concat "1\n" ;; "00:00:01,000 --> 00:00:02,000\n" ;; "Foo.\n\n" ;; "2\n" ;; "00:00:02,000 --> 00:00:03,000\n" ;; "Bar.\n\n" ;; "3\n" ;; "00:00:11,000 --> 00:00:12,000\n" ;; "Baz.\n")) ;; (setq mark-active t) ;; (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 2)) ;; (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 3)) ;; (let ((orig-point (subed-jump-to-subtitle-id 3))) ;; (subed-move-subtitle-backward 1) ;; (expect (subed-subtitle-msecs-start 1) :to-equal 1000) ;; (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) ;; (expect (subed-subtitle-msecs-start 2) :to-equal 2000) ;; (expect (subed-subtitle-msecs-stop 2) :to-equal 3000) ;; (expect (subed-subtitle-msecs-start 3) :to-equal 11000) ;; (expect (subed-subtitle-msecs-stop 3) :to-equal 12000) ;; (expect (point) :to-equal orig-point)))) ;; ) (describe "ignoring spacing for non-leading subtitles" (it "when moving forward." (with-temp-srt-buffer (insert (concat "1\n" "00:00:00,000 --> 00:00:01,000\n" "Foo.\n\n" "2\n" "00:00:01,050 --> 00:00:02,000\n" "Bar.\n\n" "3\n" "00:00:05,000 --> 00:00:6,000\n" "Baz.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 1)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 2)) (let ((orig-point (subed-jump-to-subtitle-time-start 3))) (subed-move-subtitle-forward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 2050) (expect (subed-subtitle-msecs-stop 2) :to-equal 3000) (expect (subed-subtitle-msecs-start 3) :to-equal 5000) (expect (subed-subtitle-msecs-stop 3) :to-equal 6000) (expect (point) :to-equal orig-point)))) (it "when moving backward." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:04,000 --> 00:00:05,000\n" "Bar.\n\n" "3\n" "00:00:05,000 --> 00:00:05,000\n" "Baz.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 2)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 3)) (let ((orig-point (subed-jump-to-subtitle-time-stop 1))) (subed-move-subtitle-backward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 3000) (expect (subed-subtitle-msecs-stop 2) :to-equal 4000) (expect (subed-subtitle-msecs-start 3) :to-equal 4000) (expect (subed-subtitle-msecs-stop 3) :to-equal 4000) (expect (point) :to-equal orig-point)))) ) (describe "ignoring overlapping subtitles" (it "when moving forward." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:01,500\n" "Foo.\n\n" "2\n" "00:00:01,300 --> 00:00:02,000\n" "Bar.\n\n" "3\n" "00:00:05,000 --> 00:00:6,000\n" "Baz.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 1)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 2)) (let ((orig-point (subed-jump-to-subtitle-text 2))) (subed-move-subtitle-forward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 2000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2500) (expect (subed-subtitle-msecs-start 2) :to-equal 2300) (expect (subed-subtitle-msecs-stop 2) :to-equal 3000) (expect (subed-subtitle-msecs-start 3) :to-equal 5000) (expect (subed-subtitle-msecs-stop 3) :to-equal 6000) (expect (point) :to-equal orig-point)))) (it "when moving backward." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:04,500 --> 00:00:04,000\n" "Bar.\n\n" "3\n" "00:00:04,500 --> 00:00:04,490\n" "Baz.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-id 2)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-text 3)) (let ((orig-point (subed-jump-to-subtitle-text 1))) (subed-move-subtitle-backward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 3500) (expect (subed-subtitle-msecs-stop 2) :to-equal 3000) (expect (subed-subtitle-msecs-start 3) :to-equal 3500) (expect (subed-subtitle-msecs-stop 3) :to-equal 3490) (expect (point) :to-equal orig-point)))) ) (it "ignoring start time being larger than stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,500 --> 00:00:01,400\n" "Foo.\n\n" "2\n" "00:00:02,500 --> 00:00:02,499\n" "Bar.\n\n" "3\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-text 1)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-time-start 2)) (let ((orig-point (subed-jump-to-subtitle-time-stop 1))) (subed-move-subtitle-forward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 2500) (expect (subed-subtitle-msecs-stop 1) :to-equal 2400) (expect (subed-subtitle-msecs-start 2) :to-equal 3500) (expect (subed-subtitle-msecs-stop 2) :to-equal 3499) (expect (subed-subtitle-msecs-start 3) :to-equal 5000) (expect (subed-subtitle-msecs-stop 3) :to-equal 6000) (expect (point) :to-equal orig-point) (subed-move-subtitle-backward 500) (expect (subed-subtitle-msecs-start 1) :to-equal 2000) (expect (subed-subtitle-msecs-stop 1) :to-equal 1900) (expect (subed-subtitle-msecs-start 2) :to-equal 3000) (expect (subed-subtitle-msecs-stop 2) :to-equal 2999) (expect (subed-subtitle-msecs-start 3) :to-equal 5000) (expect (subed-subtitle-msecs-stop 3) :to-equal 6000) (expect (point) :to-equal orig-point)))) (it "ignoring stop time being smaller than start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:04,100 --> 00:00:04,099\n" "Bar.\n\n" "3\n" "00:00:05,500 --> 00:00:05,000\n" "Bar.\n")) (setq mark-active t) (spy-on 'region-beginning :and-return-value (subed-jump-to-subtitle-text 2)) (spy-on 'region-end :and-return-value (subed-jump-to-subtitle-time-start 3)) (let ((orig-point (subed-jump-to-subtitle-text 1))) (subed-move-subtitle-forward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 5100) (expect (subed-subtitle-msecs-stop 2) :to-equal 5099) (expect (subed-subtitle-msecs-start 3) :to-equal 6500) (expect (subed-subtitle-msecs-stop 3) :to-equal 6000) (expect (point) :to-equal orig-point) (subed-move-subtitle-backward 500) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 4600) (expect (subed-subtitle-msecs-stop 2) :to-equal 4599) (expect (subed-subtitle-msecs-start 3) :to-equal 6000) (expect (subed-subtitle-msecs-stop 3) :to-equal 5500) (expect (point) :to-equal orig-point)))) (it "disables subtitle replay while moving subtitles." (with-temp-srt-buffer (insert mock-srt-data) (subed-enable-replay-adjusted-subtitle :quiet) (spy-on 'subed-enable-replay-adjusted-subtitle :and-call-through) (spy-on 'subed-disable-replay-adjusted-subtitle :and-call-through) (spy-on 'subed-adjust-subtitle-time-start :and-call-fake (lambda (msecs &optional a b) (expect (subed-replay-adjusted-subtitle-p) :to-be nil))) (spy-on 'subed-adjust-subtitle-stop :and-call-fake (lambda (msecs &optional a b) (expect (subed-replay-adjusted-subtitle-p) :to-be nil))) (subed-move-subtitle-forward 100) (expect 'subed-disable-replay-adjusted-subtitle :to-have-been-called-times 1) (expect 'subed-enable-replay-adjusted-subtitle :to-have-been-called-times 1) (subed-move-subtitle-backward 100) (expect 'subed-disable-replay-adjusted-subtitle :to-have-been-called-times 2) (expect 'subed-enable-replay-adjusted-subtitle :to-have-been-called-times 2))) (it "does not enable subtitle replay afterwards if it is disabled." (with-temp-srt-buffer (insert mock-srt-data) (subed-disable-replay-adjusted-subtitle :quiet) (spy-on 'subed-enable-replay-adjusted-subtitle :and-call-through) (spy-on 'subed-disable-replay-adjusted-subtitle :and-call-through) (spy-on 'subed-adjust-subtitle-time-start :and-call-fake (lambda (msecs &optional a b) (expect (subed-replay-adjusted-subtitle-p) :to-be nil))) (spy-on 'subed-adjust-subtitle-stop :and-call-fake (lambda (msecs &optional a b) (expect (subed-replay-adjusted-subtitle-p) :to-be nil))) (subed-move-subtitle-forward 100) (expect 'subed-disable-replay-adjusted-subtitle :to-have-been-called-times 1) (expect 'subed-enable-replay-adjusted-subtitle :to-have-been-called-times 0) (subed-move-subtitle-backward 100) (expect 'subed-disable-replay-adjusted-subtitle :to-have-been-called-times 2) (expect 'subed-enable-replay-adjusted-subtitle :to-have-been-called-times 0))) (it "seeks player to current subtitle if region is not active." (with-temp-srt-buffer (insert mock-srt-data) (spy-on 'subed-replay-adjusted-subtitle-p :and-return-value t) (spy-on 'subed-mpv-jump) (subed-move-subtitle-forward 100) (expect 'subed-mpv-jump :to-have-been-called-times 1) (expect 'subed-mpv-jump :to-have-been-called-with 183550) (subed-move-subtitle-backward 200) (expect 'subed-mpv-jump :to-have-been-called-times 2) (expect 'subed-mpv-jump :to-have-been-called-with 183350))) (it "seeks player to first subtitle in active region." (with-temp-srt-buffer (insert mock-srt-data) (let ((beg 15) (end (point-max))) (setq mark-active t) (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (spy-on 'subed-replay-adjusted-subtitle-p :and-return-value t) (spy-on 'subed-mpv-jump) (subed-move-subtitle-forward 100) (expect 'subed-mpv-jump :to-have-been-called-times 1) (expect 'subed-mpv-jump :to-have-been-called-with '61100) (subed-move-subtitle-backward 300) (expect 'subed-mpv-jump :to-have-been-called-times 2) (expect 'subed-mpv-jump :to-have-been-called-with '60800)))) (describe "to a specified start timestamp" (describe "when focusing on the current subtitle" (it "adjusts start and stop time by the same amount." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (subed-move-subtitles-to-start-at-timestamp "00:02:02,334" nil nil) (expect (subed-subtitle-msecs-start 1) :to-equal (subed-timestamp-to-msecs "00:01:01,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123")) (expect (subed-subtitle-msecs-start 2) :to-equal (subed-timestamp-to-msecs "00:02:02,334")) (expect (subed-subtitle-msecs-stop 2) :to-equal (subed-timestamp-to-msecs "00:02:10,445")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,450")) (expect (subed-subtitle-msecs-stop 3) :to-equal (subed-timestamp-to-msecs "00:03:15,500"))))) (describe "when moving current and following subtitles" (it "adjusts start and stop time by the same amount." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (subed-move-subtitles-to-start-at-timestamp "00:02:02,334" (point) (point-max)) (expect (subed-subtitle-msecs-start 1) :to-equal (subed-timestamp-to-msecs "00:01:01,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123")) (expect (subed-subtitle-msecs-start 2) :to-equal (subed-timestamp-to-msecs "00:02:02,334")) (expect (subed-subtitle-msecs-stop 2) :to-equal (subed-timestamp-to-msecs "00:02:10,445")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,550")) (expect (subed-subtitle-msecs-stop 3) :to-equal (subed-timestamp-to-msecs "00:03:15,600"))))))) (describe "Shifting" (describe "by an msec offset" (it "adjusts start and stop time by the same amount." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (subed-shift-subtitles 100) (expect (subed-subtitle-msecs-start 1) :to-equal (subed-timestamp-to-msecs "00:01:01,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123")) (expect (subed-subtitle-msecs-start 2) :to-equal (subed-timestamp-to-msecs "00:02:02,334")) (expect (subed-subtitle-msecs-stop 2) :to-equal (subed-timestamp-to-msecs "00:02:10,445")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,550")) (expect (subed-subtitle-msecs-stop 3) :to-equal (subed-timestamp-to-msecs "00:03:15,600"))))) (describe "to a specified start timestamp" (it "adjusts start and stop time by the same amount." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (subed-shift-subtitles-to-start-at-timestamp "00:02:02,334") (expect (subed-subtitle-msecs-start 1) :to-equal (subed-timestamp-to-msecs "00:01:01,000")) (expect (subed-subtitle-msecs-stop 1) :to-equal (subed-timestamp-to-msecs "00:01:05,123")) (expect (subed-subtitle-msecs-start 2) :to-equal (subed-timestamp-to-msecs "00:02:02,334")) (expect (subed-subtitle-msecs-stop 2) :to-equal (subed-timestamp-to-msecs "00:02:10,445")) (expect (subed-subtitle-msecs-start 3) :to-equal (subed-timestamp-to-msecs "00:03:03,550")) (expect (subed-subtitle-msecs-stop 3) :to-equal (subed-timestamp-to-msecs "00:03:15,600")))))) (describe "Inserting evenly spaced" (describe "in an empty buffer," (describe "appending" (it "a single subtile." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtiles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "0\n" "00:00:01,100 --> 00:00:02,100\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "prepending" (it "a single subtile." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtiles." (cl-loop for arg in (list -2 (list -16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "0\n" "00:00:01,100 --> 00:00:02,100\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) (describe "in a non-empty buffer" (describe "prepending between subtitles" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:30,000 --> 00:01:31,000\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:20,000 --> 00:01:21,000\n" "\n\n" "0\n" "00:01:40,000 --> 00:01:41,000\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "appending between subtitles" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:30,000 --> 00:01:31,000\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:20,000 --> 00:01:21,000\n" "\n\n" "0\n" "00:01:40,000 --> 00:01:41,000\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "prepending to the first subtitle" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:30,000 --> 00:00:31,000\n" "\n\n" "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:20,000 --> 00:00:21,000\n" "\n\n" "0\n" "00:00:40,000 --> 00:00:41,000\n" "\n\n" "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "appending to the last subtitle" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:01,100\n" "\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 3) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:01,100\n" "\n\n" "0\n" "00:01:01,200 --> 00:01:02,200\n" "\n")) (expect (point) :to-equal 71)))) ) (describe "when there is not enough time for the subtitles" (describe "to append" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,600 --> 00:01:02,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:00,500\n" "\n\n" "2\n" "00:01:00,600 --> 00:01:02,000\n" "Foo.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,600 --> 00:01:02,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:00,250\n" "\n\n" "0\n" "00:01:00,350 --> 00:01:00,500\n" "\n\n" "2\n" "00:01:00,600 --> 00:01:02,000\n" "Foo.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "to prepend" (describe "between subtitles" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:57,000 --> 00:00:59,100\n" "Foo.\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:57,000 --> 00:00:59,100\n" "Foo.\n\n" "0\n" "00:00:59,200 --> 00:00:59,900\n" "\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Foo.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:57,000 --> 00:00:59,100\n" "Foo.\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:57,000 --> 00:00:59,100\n" "Foo.\n\n" "0\n" "00:00:59,200 --> 00:00:59,500\n" "\n\n" "0\n" "00:00:59,600 --> 00:00:59,900\n" "\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Foo.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "before the first subtitle" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:00,500 --> 00:00:02,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,100 --> 00:00:00,400\n" "\n\n" "1\n" "00:00:00,500 --> 00:00:02,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:00,600 --> 00:00:01,500\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,100 --> 00:00:00,250\n" "\n\n" "0\n" "00:00:00,350 --> 00:00:00,500\n" "\n\n" "1\n" "00:00:00,600 --> 00:00:01,500\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) ) (describe "when there is not enough time for spacing" (describe "between subtitles" (describe "when prepending" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:55,000 --> 00:00:59,950\n" "Foo.\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:55,000 --> 00:00:59,950\n" "Foo.\n\n" "0\n" "00:00:59,950 --> 00:00:59,950\n" "\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:57,000 --> 00:00:59,999\n" "Foo.\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:57,000 --> 00:00:59,999\n" "Foo.\n\n" "0\n" "00:00:59,999 --> 00:00:59,999\n" "\n\n" "0\n" "00:00:59,999 --> 00:00:59,999\n" "\n\n" "2\n" "00:01:00,000 --> 00:01:02,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "when appending" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,010 --> 00:01:02,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,000 --> 00:01:00,000\n" "\n\n" "2\n" "00:01:00,010 --> 00:01:02,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,100 --> 00:01:02,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,000 --> 00:01:00,000\n" "\n\n" "0\n" "00:01:00,000 --> 00:01:00,000\n" "\n\n" "2\n" "00:01:00,100 --> 00:01:02,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) (describe "before the first subtitle" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:00,050 --> 00:00:02,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:00,000\n" "\n\n" "1\n" "00:00:00,050 --> 00:00:02,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:00,100 --> 00:00:01,500\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:00,000\n" "\n\n" "0\n" "00:00:00,000 --> 00:00:00,000\n" "\n\n" "1\n" "00:00:00,100 --> 00:00:01,500\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) ) ) (describe "Inserting adjacent" (describe "in an empty buffer," (describe "appending" (it "a single subtile." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtiles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "0\n" "00:00:01,100 --> 00:00:02,100\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "prepending" (it "a single subtile." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtiles." (cl-loop for arg in (list -2 (list -16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "0\n" "00:00:01,100 --> 00:00:02,100\n" "\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) (describe "in a non-empty buffer" (describe "prepending between subtitles" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:58,900 --> 00:01:59,900\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:57,800 --> 00:01:58,800\n" "\n\n" "0\n" "00:01:58,900 --> 00:01:59,900\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "appending between subtitles" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:01,100\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:01,100\n" "\n\n" "0\n" "00:01:01,200 --> 00:01:02,200\n" "\n\n" "2\n" "00:02:00,000 --> 00:02:01,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "prepending to the first subtitle" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:58,900 --> 00:00:59,900\n" "\n\n" "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:57,800 --> 00:00:58,800\n" "\n\n" "0\n" "00:00:58,900 --> 00:00:59,900\n" "\n\n" "1\n" "00:01:00,000 --> 00:01:01,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "appending to the last subtitle" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:01,100\n" "\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 3) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:01,100\n" "\n\n" "0\n" "00:01:01,200 --> 00:01:02,200\n" "\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "when there is not enough time for the subtitles" (describe "to append" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,500 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:00,400\n" "\n\n" "2\n" "00:01:00,500 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,500 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:00,200\n" "\n\n" "0\n" "00:01:00,300 --> 00:01:00,400\n" "\n\n" "2\n" "00:01:00,500 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "to prepend" (describe "between subtitles" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,700 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:00,600\n" "\n\n" "2\n" "00:01:00,700 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,500 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,100 --> 00:01:00,200\n" "\n\n" "0\n" "00:01:00,300 --> 00:01:00,400\n" "\n\n" "2\n" "00:01:00,500 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "before the first subtitle" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,100 --> 00:00:00,900\n" "\n\n" "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:00,800 --> 00:00:03,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,100 --> 00:00:00,350\n" "\n\n" "0\n" "00:00:00,450 --> 00:00:00,700\n" "\n\n" "1\n" "00:00:00,800 --> 00:00:03,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) ) (describe "when there is not enough time for spacing" (describe "between subtitles" (describe "when prepending" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,005 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,005 --> 00:01:00,005\n" "\n\n" "2\n" "00:01:00,005 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,025 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,025 --> 00:01:00,025\n" "\n\n" "0\n" "00:01:00,025 --> 00:01:00,025\n" "\n\n" "2\n" "00:01:00,025 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) (describe "when appending" (it "a single subtitle." (cl-loop for arg in (list nil 1) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,099 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,000 --> 00:01:00,000\n" "\n\n" "2\n" "00:01:00,099 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list 2) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "2\n" "00:01:00,075 --> 00:01:05,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:59,000 --> 00:01:00,000\n" "Foo.\n\n" "0\n" "00:01:00,000 --> 00:01:00,000\n" "\n\n" "0\n" "00:01:00,000 --> 00:01:00,000\n" "\n\n" "2\n" "00:01:00,075 --> 00:01:05,000\n" "Bar.\n")) (expect (point) :to-equal 71) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) (describe "before the first subtitle" (it "a single subtitle." (cl-loop for arg in (list '- -1 (list 4)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:00,040 --> 00:00:05,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,040 --> 00:00:00,040\n" "\n\n" "1\n" "00:00:00,040 --> 00:00:05,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) (it "multiple subtitles." (cl-loop for arg in (list -2 (list 16)) do (with-temp-srt-buffer (spy-on 'subed-regenerate-ids-soon) (insert (concat "1\n" "00:00:00,024 --> 00:00:05,000\n" "Foo.\n")) (subed-jump-to-subtitle-text 1) (expect (subed-insert-subtitle-adjacent arg) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,024 --> 00:00:00,024\n" "\n\n" "0\n" "00:00:00,024 --> 00:00:00,024\n" "\n\n" "1\n" "00:00:00,024 --> 00:00:05,000\n" "Foo.\n")) (expect (point) :to-equal 33) (expect 'subed-regenerate-ids-soon :to-have-been-called-times 1) (spy-calls-reset 'subed-regenerate-ids-soon)))) ) ) ) ) (describe "Syncing player to point" :var (subed-mpv-playback-position) (before-each (setq subed-mpv-playback-position 0) (spy-on 'subed-subtitle-msecs-start :and-return-value 5000) (spy-on 'subed-subtitle-msecs-stop :and-return-value 6500) (spy-on 'subed-mpv-jump)) (it "does not seek player if point is on current subtitle." (setq subed-mpv-playback-position 5000) (subed--sync-player-to-point) (expect 'subed-mpv-jump :not :to-have-been-called) (setq subed-mpv-playback-position 6500) (subed--sync-player-to-point) (expect 'subed-mpv-jump :not :to-have-been-called)) (it "seeks player if point is on future subtitle." (with-temp-buffer (subed-srt-mode) (setq subed-mpv-playback-position 6501) (subed--sync-player-to-point) (expect 'subed-mpv-jump :to-have-been-called-with 5000))) (it "seeks player if point is on past subtitle." (with-temp-buffer (subed-srt-mode) (setq subed-mpv-playback-position 4999) (subed--sync-player-to-point) (expect 'subed-mpv-jump :to-have-been-called-with 5000))) ) (describe "Temporarily disabling point-to-player syncing" (before-each (spy-on 'subed-disable-sync-point-to-player) (spy-on 'timerp :and-return-value t)) (describe "when point-to-player syncing is disabled" (before-each (setq subed--point-sync-delay-after-motion-timer nil) (spy-on 'subed-sync-point-to-player-p :and-return-value nil) (spy-on 'run-at-time)) (it "does not disable point-to-player syncing." (subed-disable-sync-point-to-player-temporarily) (expect 'subed-disable-sync-point-to-player :not :to-have-been-called)) (it "does not schedule re-enabling of point-to-player syncing." (subed-disable-sync-point-to-player-temporarily) (expect 'run-at-time :not :to-have-been-called) (expect subed--point-sync-delay-after-motion-timer :to-be nil)) ) (describe "when point-to-player syncing is enabled" :var (subed--point-sync-delay-after-motion-timer) (before-each (spy-on 'subed-sync-point-to-player-p :and-return-value t) (spy-on 'run-at-time :and-return-value "mock timer") (spy-on 'cancel-timer) (setq subed--point-sync-delay-after-motion-timer nil)) (it "disables point-to-player syncing." (subed-disable-sync-point-to-player-temporarily) (expect 'subed-disable-sync-point-to-player :to-have-been-called)) (it "schedules re-enabling of point-to-player syncing." (subed-disable-sync-point-to-player-temporarily) (expect 'run-at-time :to-have-been-called) ;; Does not play well with undercover and edebug ;; (expect 'run-at-time :to-have-been-called-with ;; subed-point-sync-delay-after-motion nil ;; '(closure (t) nil ;; (setq subed--point-sync-delay-after-motion-timer nil) ;; (subed-enable-sync-point-to-player :quiet))) ) (it "cancels previously scheduled re-enabling of point-to-player syncing." (subed-disable-sync-point-to-player-temporarily) (expect 'cancel-timer :not :to-have-been-called-with "mock timer") (subed-disable-sync-point-to-player-temporarily) (expect 'cancel-timer :to-have-been-called-with "mock timer") (expect 'cancel-timer :to-have-been-called-times 1)) ) ) (describe "Splitting subtitles" (it "handles empty subtitles" (with-temp-srt-buffer (insert "1 00:01:23,000 --> 00:02:34,567 ") (forward-line -1) (let ((subed-subtitle-spacing 100)) (subed-split-subtitle 100)) (expect (buffer-string) :to-equal "1 00:01:23,000 --> 00:01:23,100 0 00:01:23,200 --> 00:02:34,567 "))) (describe "when there are multiple lines" (describe "at the last subtitle" :var ((text "1 00:01:23,000 --> 00:02:34,567 This is a subtitle that has two lines. ") (subed-subtitle-spacing 100)) (it "properly splits text when called at the beginning of the subtitle." (with-temp-srt-buffer (insert text) (re-search-backward "This is a subtitle") (goto-char (match-beginning 0)) (save-excursion (subed-split-subtitle 100)) (expect (buffer-string) :to-equal "1 00:01:23,000 --> 00:01:23,100 0 00:01:23,200 --> 00:02:34,567 This is a subtitle that has two lines. "))) (it "properly splits text when called in the middle of the subtitle." (with-temp-srt-buffer (insert text) (re-search-backward "This is a") (goto-char (match-end 0)) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal "subtitle\nthat has two lines."))) (it "properly splits text when called at the end of a line in the middle of the subtitle" (with-temp-srt-buffer (insert text) (re-search-backward "This is a subtitle") (goto-char (match-end 0)) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a subtitle") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal "that has two lines."))) (it "properly splits text when called at the beginning of a line in the middle of the subtitle." (with-temp-srt-buffer (insert text) (re-search-backward "that has two lines") (goto-char (match-beginning 0)) (subed-split-subtitle 100) (expect (buffer-string) :to-equal "1 00:01:23,000 --> 00:01:23,100 This is a subtitle 0 00:01:23,200 --> 00:02:34,567 that has two lines. ") (expect (subed-subtitle-text 1) :to-equal "This is a subtitle") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal "that has two lines."))) (it "properly splits text when called at the end of the subtitle." (with-temp-srt-buffer (insert text) (subed-jump-to-subtitle-end 1) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a subtitle\nthat has two lines.") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal ""))) (it "properly splits text when called before whitespace at the end of the subtitle." (with-temp-srt-buffer (insert text) (subed-jump-to-subtitle-end 1) (save-excursion (insert " ")) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a subtitle\nthat has two lines.") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal "")))) (describe "with another subtitle after it" :var ((text "1 00:01:23,000 --> 00:02:34,567 This is a subtitle that has two lines. 2 00:05:00,000 --> 00:06:00,000 This is another. ")) (it "properly splits text when called at the beginning of the subtitle." (with-temp-srt-buffer (insert text) (re-search-backward "This is a subtitle") (goto-char (match-beginning 0)) (let ((subed-subtitle-spacing 100)) (save-excursion (subed-split-subtitle 100)) (expect subed-subtitle-spacing :to-equal 100)) (expect (buffer-string) :to-equal "1 00:01:23,000 --> 00:01:23,100 0 00:01:23,200 --> 00:02:34,567 This is a subtitle that has two lines. 2 00:05:00,000 --> 00:06:00,000 This is another. "))) (it "properly splits text when called in the middle of the subtitle." (with-temp-srt-buffer (insert text) (re-search-backward "This is a subtitle") (goto-char (match-end 0)) (backward-word 1) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal "subtitle\nthat has two lines."))) (it "properly splits text when called at the end of a line in the middle of the subtitle" (with-temp-srt-buffer (insert text) (re-search-backward "This is a subtitle") (goto-char (match-end 0)) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a subtitle") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal "that has two lines."))) (it "properly splits text when called at the beginning of a line in the middle of the subtitle." (with-temp-srt-buffer (insert text) (re-search-backward "that has two lines") (goto-char (match-beginning 0)) (let ((subed-enforce-time-boundaries 'adjust) (subed-subtitle-spacing 100)) (subed-split-subtitle 100)) (expect (buffer-string) :to-equal "1 00:01:23,000 --> 00:01:23,100 This is a subtitle 0 00:01:23,200 --> 00:02:34,567 that has two lines. 2 00:05:00,000 --> 00:06:00,000 This is another. ") (expect (subed-subtitle-text 1) :to-equal "This is a subtitle") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal "that has two lines."))) (it "properly splits text when called at the end of the subtitle." (with-temp-srt-buffer (insert text) (subed-jump-to-subtitle-end 1) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a subtitle\nthat has two lines.") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal ""))) (it "properly splits text when called before whitespace at the end of the subtitle." (with-temp-srt-buffer (insert text) (subed-jump-to-subtitle-end 1) (save-excursion (insert " ")) (subed-split-subtitle 100) (expect (subed-subtitle-text 1) :to-equal "This is a subtitle\nthat has two lines.") (subed-regenerate-ids) (expect (subed-subtitle-text 2) :to-equal ""))) (it "accepts a timestamp." (with-temp-srt-buffer (insert text) (re-search-backward "subtitle") (end-of-line) (subed-split-subtitle "00:01:43,100") (expect (subed-subtitle-msecs-start) :to-equal 103100) (subed-backward-subtitle-time-start) (expect (subed-subtitle-msecs-stop) :to-equal (- 103100 subed-subtitle-spacing)))))) (describe "when playing the media in MPV" (it "splits at point in the middle of the subtitle." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo\\.") (end-of-line) (save-excursion (insert " Some text here.")) (setq-local subed-mpv-playback-position 61600) (setq-local subed-subtitle-spacing 100) (subed-split-subtitle) (expect (subed-subtitle-msecs-start) :to-equal 61700) (expect (subed-subtitle-msecs-stop) :to-equal 65123) (expect (subed-subtitle-text) :to-equal "Some text here.") (subed-backward-subtitle-time-start) (expect (subed-subtitle-msecs-stop) :to-equal 61600) (expect (subed-subtitle-text) :to-equal "Foo."))) (it "splits at the end even if there are spaces." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo\\.") (subed-jump-to-subtitle-end) (insert " ") (setq-local subed-mpv-playback-position 61600) (setq-local subed-subtitle-spacing 100) (subed-split-subtitle) (expect (subed-subtitle-text) :to-equal "") (expect (subed-subtitle-msecs-start) :to-equal 61700) (expect (subed-subtitle-msecs-stop) :to-equal 65123) (subed-backward-subtitle-time-start) (expect (subed-subtitle-text) :to-equal "Foo.") (expect (subed-subtitle-msecs-stop) :to-equal 61600))) (it "splits at the beginning." (with-temp-srt-buffer (save-excursion (insert mock-srt-data)) (subed-jump-to-subtitle-text) (setq-local subed-mpv-playback-position 61600) (setq-local subed-subtitle-spacing 100) (subed-split-subtitle) (expect (subed-subtitle-text) :to-equal "Foo.") (expect (subed-subtitle-msecs-start) :to-equal 61700) (expect (subed-subtitle-msecs-stop) :to-equal 65123) (subed-backward-subtitle-time-start) (expect (subed-subtitle-text) :to-equal "") (expect (subed-subtitle-msecs-stop) :to-equal 61600)))) (describe "when a positive offset is specified" (it "splits from the starting time." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo\\.") (end-of-line) (save-excursion (insert " Some text here.")) (setq-local subed-subtitle-spacing 100) (subed-split-subtitle 300) (expect (subed-subtitle-msecs-start) :to-equal 61400) (expect (subed-subtitle-msecs-stop) :to-equal 65123) (expect (subed-subtitle-text) :to-equal "Some text here.") (subed-backward-subtitle-time-start) (expect (subed-subtitle-msecs-stop) :to-equal 61300) (expect (subed-subtitle-text) :to-equal "Foo."))) (it "uses the offset instead of the playing position." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo\\.") (setq-local subed-mpv-playback-position 61600) (setq-local subed-subtitle-spacing 100) (subed-split-subtitle 300) (expect (subed-subtitle-msecs-start) :to-equal 61400) (expect (subed-subtitle-msecs-stop) :to-equal 65123)))) (describe "when a negative offset is specified" (it "splits from the ending time." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo\\.") (end-of-line) (save-excursion (insert " Some text here.")) (setq-local subed-subtitle-spacing 100) (subed-split-subtitle -300) (expect (subed-subtitle-msecs-start) :to-equal 64923) (expect (subed-subtitle-msecs-stop) :to-equal 65123) (expect (subed-subtitle-text) :to-equal "Some text here.") (subed-backward-subtitle-time-start) (expect (subed-subtitle-msecs-stop) :to-equal 64823) (expect (subed-subtitle-text) :to-equal "Foo."))) (it "uses the offset instead of the playing position." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo\\.") (setq-local subed-subtitle-spacing 100) (setq-local subed-mpv-playback-position 61600) (subed-split-subtitle -300) (expect (subed-subtitle-msecs-start) :to-equal 64923) (expect (subed-subtitle-msecs-stop) :to-equal 65123) (subed-backward-subtitle-time-start) (expect (subed-subtitle-msecs-stop) :to-equal 64823)))) (describe "when nothing is specified" (it "splits proportional to the location." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo\\.") (end-of-line) (save-excursion (insert " Bar")) (setq-local subed-subtitle-spacing 100) (subed-split-subtitle) (expect (subed-subtitle-msecs-start) :to-equal 63161) (expect (subed-subtitle-msecs-stop) :to-equal 65123) (expect (subed-subtitle-text) :to-equal "Bar") (subed-backward-subtitle-time-start) (expect (subed-subtitle-msecs-stop) :to-equal 63061) (expect (subed-subtitle-text) :to-equal "Foo."))))) (describe "Scaling subtitles" (it "without providing beginning and end." (with-temp-srt-buffer (insert mock-srt-data) (spy-on 'subed-scale-subtitles :and-call-through) (subed-scale-subtitles-forward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122734) (expect (subed-subtitle-msecs-stop 2) :to-equal 130845) (expect (subed-subtitle-msecs-start 3) :to-equal 184450) (expect (subed-subtitle-msecs-stop 3) :to-equal 196500) (subed-scale-subtitles-backward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122234) (expect (subed-subtitle-msecs-stop 2) :to-equal 130345) (expect (subed-subtitle-msecs-start 3) :to-equal 183450) (expect (subed-subtitle-msecs-stop 3) :to-equal 195500) (expect (spy-calls-all-args 'subed-scale-subtitles) :to-equal '((+1000 nil nil) (-1000 nil nil))))) (it "without providing end." (with-temp-srt-buffer (insert mock-srt-data) (subed-scale-subtitles 1000 (point-min) nil) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122734) (expect (subed-subtitle-msecs-stop 2) :to-equal 130845) (expect (subed-subtitle-msecs-start 3) :to-equal 184450) (expect (subed-subtitle-msecs-stop 3) :to-equal 196500) (subed-scale-subtitles -1000 (point-min) nil) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122234) (expect (subed-subtitle-msecs-stop 2) :to-equal 130345) (expect (subed-subtitle-msecs-start 3) :to-equal 183450) (expect (subed-subtitle-msecs-stop 3) :to-equal 195500))) (it "without providing beginning." (with-temp-srt-buffer (insert mock-srt-data) (subed-scale-subtitles 1000 nil (point-max)) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122734) (expect (subed-subtitle-msecs-stop 2) :to-equal 130845) (expect (subed-subtitle-msecs-start 3) :to-equal 184450) (expect (subed-subtitle-msecs-stop 3) :to-equal 196500) (subed-scale-subtitles -1000 nil (point-max)) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122234) (expect (subed-subtitle-msecs-stop 2) :to-equal 130345) (expect (subed-subtitle-msecs-start 3) :to-equal 183450) (expect (subed-subtitle-msecs-stop 3) :to-equal 195500))) (it "with active region on entire buffer." (with-temp-srt-buffer (insert mock-srt-data) (let ((beg (point-min)) (end (point-max))) (spy-on 'subed-scale-subtitles :and-call-through) (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (setq mark-active t) (subed-scale-subtitles-forward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122734) (expect (subed-subtitle-msecs-stop 2) :to-equal 130845) (expect (subed-subtitle-msecs-start 3) :to-equal 184450) (expect (subed-subtitle-msecs-stop 3) :to-equal 196500) (subed-scale-subtitles-backward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122234) (expect (subed-subtitle-msecs-stop 2) :to-equal 130345) (expect (subed-subtitle-msecs-start 3) :to-equal 183450) (expect (subed-subtitle-msecs-stop 3) :to-equal 195500) (expect (spy-calls-all-args 'subed-scale-subtitles) :to-equal `((+1000 ,beg ,end) (-1000 ,beg ,end)))))) (it "with a zero msec extension/contraction." (with-temp-srt-buffer (insert mock-srt-data) (subed-scale-subtitles-forward 0) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122234) (expect (subed-subtitle-msecs-stop 2) :to-equal 130345) (expect (subed-subtitle-msecs-start 3) :to-equal 183450) (expect (subed-subtitle-msecs-stop 3) :to-equal 195500) (subed-scale-subtitles-backward 0) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 122234) (expect (subed-subtitle-msecs-stop 2) :to-equal 130345) (expect (subed-subtitle-msecs-start 3) :to-equal 183450) (expect (subed-subtitle-msecs-stop 3) :to-equal 195500))) (it "with active region on one subtitle." (with-temp-srt-buffer (insert mock-srt-data) (let ((beg 77) ; point at ID of third subtitle (end (point-max))) (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (spy-on 'user-error :and-call-through) (setq mark-active t) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with fewer than 3 subtitles"))) (expect (buffer-string) :to-equal mock-srt-data)))) (it "with active region on two subtitles." (with-temp-srt-buffer (insert mock-srt-data) (let ((beg 39) ; point at ID of second subtitle (end (point-max))) (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (spy-on 'user-error :and-call-through) (setq mark-active t) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with only 2 subtitles"))) (expect (buffer-string) :to-equal mock-srt-data)))) (it "with active region contraction." (with-temp-srt-buffer (insert (concat "1\n" "00:00:43,233 --> 00:00:45,861\n" "a\n" "\n" "2\n" "00:00:51,675 --> 00:00:54,542\n" "b\n" "\n" "3\n" "00:01:00,717 --> 00:01:02,378\n" "c\n" "\n" "4\n" "00:01:02,452 --> 00:01:05,216\n" "d\n")) (let ((beg (point-min)) (end 103)) ; point at TEXT of third subtitle (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (setq mark-active t) (subed-scale-subtitles-backward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 43233) (expect (subed-subtitle-msecs-stop 1) :to-equal 45861) (expect (subed-subtitle-msecs-start 2) :to-equal 51192) (expect (subed-subtitle-msecs-stop 2) :to-equal 54059) (expect (subed-subtitle-msecs-start 3) :to-equal 59717) (expect (subed-subtitle-msecs-stop 3) :to-equal 61378) (expect (subed-subtitle-msecs-start 4) :to-equal 62452) (expect (subed-subtitle-msecs-stop 4) :to-equal 65216)))) (it "with active region extension." (with-temp-srt-buffer (insert (concat "1\n" "00:00:43,233 --> 00:00:45,861\n" "a\n" "\n" "2\n" "00:00:51,192 --> 00:00:54,059\n" "b\n" "\n" "3\n" "00:00:59,717 --> 00:01:01,378\n" "c\n" "\n" "4\n" "00:01:02,452 --> 00:01:05,216\n" "d\n")) (let ((beg (point-min)) (end 103)) ; point at TEXT of third subtitle (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (setq mark-active t) (setq-local subed-subtitle-spacing 0) (subed-scale-subtitles-forward 1000) (expect (subed-subtitle-msecs-start 1) :to-equal 43233) (expect (subed-subtitle-msecs-stop 1) :to-equal 45861) (expect (subed-subtitle-msecs-start 2) :to-equal 51675) (expect (subed-subtitle-msecs-stop 2) :to-equal 54542) (expect (subed-subtitle-msecs-start 3) :to-equal 60717) (expect (subed-subtitle-msecs-stop 3) :to-equal 62378) (expect (subed-subtitle-msecs-start 4) :to-equal 62452) (expect (subed-subtitle-msecs-stop 4) :to-equal 65216)))) (describe "when active region extension overlaps next subtitle" (it "reports an error" (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "00:00:43,233 --> 00:00:45,861\n" "a\n" "\n" "2\n" "00:00:51,675 --> 00:00:54,542\n" "b\n" "\n" "3\n" "00:01:00,717 --> 00:01:02,378\n" "c\n" "\n" "4\n" "00:01:02,452 --> 00:01:05,216\n" "d\n")) (beg 1) (end 103)) ; point at TEXT of third subtitle (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (spy-on 'user-error :and-call-through) (insert initial-contents) (setq mark-active t) (let ((subed-enforce-time-boundaries 'error)) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error)) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when extension would overlap subsequent subtitles"))) (expect (buffer-string) :to-equal initial-contents)))) (it "when end subtitle start time moved to same time as begin subtitle start time." (with-temp-srt-buffer (insert mock-srt-data) (let ((beg (point-min)) (end (point-max)) (delta (- (subed-subtitle-msecs-start 3) (subed-subtitle-msecs-start 1)))) (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (spy-on 'user-error :and-call-through) (setq mark-active t) (expect (subed-scale-subtitles-backward delta) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when contraction would eliminate region"))) (expect (buffer-string) :to-equal mock-srt-data))))) (it "when end subtitle start time moved to just before begin subtitle start time." (with-temp-srt-buffer (insert mock-srt-data) (let ((beg (point-min)) (end (point-max)) (delta (- (subed-subtitle-msecs-start 3) (subed-subtitle-msecs-start 1)))) (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (spy-on 'user-error :and-call-through) (setq mark-active t) (expect (subed-scale-subtitles-backward (+ delta 1)) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when contraction would eliminate region"))) (expect (buffer-string) :to-equal mock-srt-data)))) (it "when end subtitle start time moved to just after begin subtitle start time." (with-temp-srt-buffer (insert mock-srt-data) (let ((beg (point-min)) (end (point-max)) (delta (- (subed-subtitle-msecs-start 3) (subed-subtitle-msecs-start 1)))) (spy-on 'region-beginning :and-return-value beg) (spy-on 'region-end :and-return-value end) (setq mark-active t) (subed-scale-subtitles-backward (- delta 1)) (expect (subed-subtitle-msecs-start 1) :to-equal 61000) (expect (subed-subtitle-msecs-stop 1) :to-equal 65123) (expect (subed-subtitle-msecs-start 2) :to-equal 61001) (expect (subed-subtitle-msecs-stop 2) :to-equal 69112) (expect (subed-subtitle-msecs-start 3) :to-equal 61001) (expect (subed-subtitle-msecs-stop 3) :to-equal 73051)))) (it "when begin start time same as end start time." (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n" "\n" "2\n" "00:01:01,000 --> 00:01:05,123\n" "Bar.\n" "\n" "3\n" "00:01:01,000 --> 00:01:05,123\n" "Baz.\n"))) (spy-on 'user-error :and-call-through) (insert initial-contents) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale subtitle range with 0 time interval"))) (expect (buffer-string) :to-equal initial-contents) (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale subtitle range with 0 time interval"))) (expect (buffer-string) :to-equal initial-contents)))) (it "when buffer is empty." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with fewer than 3 subtitles"))) (expect (buffer-string) :to-equal "") (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with fewer than 3 subtitles"))) (expect (buffer-string) :to-equal ""))) (it "when buffer contains one subtitle." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n"))) (insert initial-contents) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with fewer than 3 subtitles"))) (expect (buffer-string) :to-equal initial-contents) (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with fewer than 3 subtitles"))) (expect (buffer-string) :to-equal initial-contents)))) (it "when buffer contains two subtitles." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n" "\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n"))) (insert initial-contents) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with only 2 subtitles"))) (expect (buffer-string) :to-equal initial-contents) (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with only 2 subtitles"))) (expect (buffer-string) :to-equal initial-contents)))) (it "reports an error if the subtitle in region has a start time after end start time." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n" "\n" "2\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n" "\n" "3\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n")) (subed-enforce-time-boundaries 'error) (subed-subtitle-spacing 100)) (insert initial-contents) (expect subed-enforce-time-boundaries :to-equal 'error) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when nonchronological subtitles exist"))) (expect (buffer-string) :to-equal initial-contents) (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when nonchronological subtitles exist"))) (expect (buffer-string) :to-equal initial-contents)))) (it "with first subtitle containing no timestamp." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "a\n" "\n" "2\n" "00:00:51,675 --> 00:00:54,542\n" "b\n" "\n" "3\n" "00:01:00,717 --> 00:01:02,378\n" "c\n"))) (insert initial-contents) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when first subtitle timestamp missing"))) (expect (buffer-string) :to-equal initial-contents) (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when first subtitle timestamp missing"))) (expect (buffer-string) :to-equal initial-contents)))) (it "with last subtitle containing no timestamp." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "00:00:43,233 --> 00:00:45,861\n" "a\n" "\n" "2\n" "00:00:51,675 --> 00:00:54,542\n" "b\n" "\n" "3\n" "c\n"))) (insert initial-contents) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when last subtitle timestamp missing"))) (expect (buffer-string) :to-equal initial-contents) (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when last subtitle timestamp missing"))) (expect (buffer-string) :to-equal initial-contents)))) (it "with subtitle in region containing no timestamp." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (let ((initial-contents (concat "1\n" "00:00:43,233 --> 00:00:45,861\n" "a\n" "\n" "2\n" "b\n" "\n" "3\n" "00:01:00,717 --> 00:01:02,378\n" "c\n"))) (insert initial-contents) (expect (subed-scale-subtitles-forward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when subtitle timestamp missing"))) (expect (buffer-string) :to-equal initial-contents) (spy-calls-reset 'user-error) (expect (subed-scale-subtitles-backward 1000) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale when subtitle timestamp missing"))) (expect (buffer-string) :to-equal initial-contents)))) (it "with out-of-order range." (spy-on 'user-error :and-call-through) (with-temp-srt-buffer (expect (subed-scale-subtitles 1000 5 4) :to-throw 'error) (expect (spy-calls-all-args 'user-error) :to-equal '(("Can't scale with improper range")))))) (describe "Trimming subtitles" (describe "when spacing is 0" (it "detects overlaps" (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:05,000\nA\n\n" "2\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 0)) (expect (subed--identify-overlaps) :to-equal '(1))))) (it "ignores non-overlapping subtitles" (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,000\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 0)) (expect (subed--identify-overlaps) :to-equal nil))))) (describe "when spacing is 1" (it "detects overlaps" (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:04,000\nA\n\n" "2\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 1)) (expect (subed--identify-overlaps) :to-equal '(1))))) (it "ignores non-overlapping subtitles" (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,999\nA\n\n" "2\n00:00:03,000 --> 00:00:04,000\nA\n\n") (let ((subed-subtitle-spacing 1)) (expect (subed--identify-overlaps) :to-equal nil))))) (describe "when spacing is greater" (it "detects overlaps because of spacing" (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,999\nA\n\n" "2\n00:00:03,000 --> 00:00:05,000\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (expect (subed--identify-overlaps) :to-equal '(1 2))))) (it "ignores non-overlapping subtitles." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:03,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (expect (subed--identify-overlaps) :to-equal nil))))) (describe "overlap end time" (it "sets it to the next timestamp minus spacing." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-jump-to-subtitle-id 2) (subed-trim-overlap-stop) (expect (subed-subtitle-msecs-stop) :to-equal 3900)))) (it "sets it to the next timestamp minus the argument." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-jump-to-subtitle-id 2) (subed-trim-overlap-stop 500) (expect (subed-subtitle-msecs-stop) :to-equal 3500)))) (it "ignores non-overlapping subtitles." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-jump-to-subtitle-id 1) (subed-trim-overlap-stop) (expect (subed-subtitle-msecs-stop) :to-equal 2000)))) (it "handles the last subtitle gracefully." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-jump-to-subtitle-id 3) (subed-trim-overlap-stop 500) (expect (subed-subtitle-msecs-stop) :to-equal 6000)))) (it "handles empty buffers gracefully." (with-temp-srt-buffer (subed-trim-overlap-stop 500) (expect (subed-subtitle-msecs-stop) :to-equal nil))) (describe "when adjusting to time boundaries" (it "adjusts the start time if the new stop would be before the start time." (with-temp-srt-buffer (insert "1\n00:00:01,500 --> 00:00:02,000\nA\n\n" "2\n00:00:01,000 --> 00:00:02,000\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100) (subed-enforce-time-boundaries 'adjust)) (subed-jump-to-subtitle-id 1) (expect (subed-trim-overlap-stop) :to-equal 900) (expect (subed-subtitle-msecs-stop 1) :to-equal 900) (expect (subed-subtitle-msecs-start 1) :to-equal 900))))) (describe "when clipping to time boundaries" (it "adjusts the start time if the new stop would be before the start time." (with-temp-srt-buffer (insert "1\n00:00:01,500 --> 00:00:02,000\nA\n\n" "2\n00:00:01,000 --> 00:00:02,000\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100) (subed-enforce-time-boundaries 'clip)) (subed-jump-to-subtitle-id 1) (expect (subed-trim-overlap-stop) :to-equal 1500) (expect (subed-subtitle-msecs-stop 1) :to-equal 1500) (expect (subed-subtitle-msecs-start 1) :to-equal 1500)))))) (describe "overlap start time" (it "sets next start to the current timestamp plus spacing." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-jump-to-subtitle-id 2) (subed-trim-overlap-next-start) (subed-forward-subtitle-time-start) (expect (subed-subtitle-msecs-start) :to-equal 4600)))) (it "sets next start to the current timestamp plus the argument." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-jump-to-subtitle-id 2) (subed-trim-overlap-next-start 500) (subed-forward-subtitle-time-start) (expect (subed-subtitle-msecs-start) :to-equal 5000)))) (it "handles the last subtitle gracefully." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-jump-to-subtitle-id 3) (subed-trim-overlap-next-start 500) (expect (subed-subtitle-msecs-start) :to-equal 4000)))) (it "adjusts the timestamp if the new start is past the stop time." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:01,500 --> 00:00:02,000\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100) (subed-enforce-time-boundaries 'adjust)) (subed-jump-to-subtitle-id 1) (expect (subed-trim-overlap-next-start 500) :to-equal 2500) (expect (subed-subtitle-msecs-start 2) :to-equal 2500) (expect (subed-subtitle-msecs-stop 2) :to-equal 2500)))) (it "handles empty buffers gracefully." (with-temp-srt-buffer (subed-trim-overlap-next-start 500) (expect (subed-subtitle-msecs-stop) :to-equal nil)))) (describe "trimming overlaps" (it "adjusts stop times by default." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n" "4\n00:00:05,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-trim-overlaps)) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-stop 2) :to-equal 3900) (expect (subed-subtitle-msecs-stop 3) :to-equal 4900))) (it "adjusts start times if specified." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n" "4\n00:00:05,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100) (subed-enforce-time-boundaries 'adjust) (subed-trim-overlap-use-start t)) (subed-trim-overlaps) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-stop 2) :to-equal 4500) (expect (subed-subtitle-msecs-start 3) :to-equal 4600) (expect (subed-subtitle-msecs-start 4) :to-equal 6100)))) (it "can specify the number of milliseconds." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA\n\n" "4\n00:00:05,000 --> 00:00:06,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (subed-trim-overlaps 200)) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-stop 2) :to-equal 3800) (expect (subed-subtitle-msecs-stop 3) :to-equal 4800))) (it "handles empty buffers gracefully." (with-temp-srt-buffer (expect (subed-trim-overlaps) :not :to-throw))) (it "handles single subtitles gracefully." (with-temp-srt-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n") (let ((subed-subtitle-spacing 100)) (expect (subed-trim-overlaps) :not :to-throw)) (expect (subed-subtitle-msecs-start 1) :to-equal 1000) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000)))) (describe "when configured to trim on save," (it "trims overlaps after sorting." (with-temp-srt-buffer (let ((subed-trim-overlap-on-save t) (subed-subtitle-spacing 200)) (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:04,000 --> 00:00:06,000\nA\n\n" "3\n00:00:03,000 --> 00:00:04,500\nA\n\n" "4\n00:00:05,000 --> 00:00:06,000\nA\n\n") (subed-prepare-to-save) (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-stop 2) :to-equal 3800) (expect (subed-subtitle-msecs-stop 3) :to-equal 4800))))) (describe "when configured to check on save," (it "reports overlaps." (with-temp-srt-buffer ;; Changed the test data to avoid sorting confusion (insert "1\n00:00:01,000 --> 00:00:02,000\nA1\n\n" "2\n00:00:03,000 --> 00:00:04,500\nA2\n\n" "3\n00:00:04,000 --> 00:00:06,000\nA3\n\n" "4\n00:00:05,000 --> 00:00:06,000\nA4\n\n") (let ((subed-trim-overlap-check-on-save t) (subed-trim-overlap-on-save nil) (subed-subtitle-spacing 200) (subed-enforce-time-boundaries 'adjust) (buffer-modified-p nil)) (spy-on 'subed-trim-overlap-check :and-call-through) (spy-on 'subed-trim-overlaps :and-call-through) (spy-on 'yes-or-no-p :and-return-value t) (subed-prepare-to-save) (expect 'subed-trim-overlap-check :to-have-been-called) (expect 'yes-or-no-p :to-have-been-called) ;; Note changed behaviour: adjust the start time if needed, ;; and don't change stop if there's enough space (expect (subed-subtitle-msecs-stop 1) :to-equal 2000) (expect (subed-subtitle-msecs-start 2) :to-equal 3000) (expect (subed-subtitle-msecs-stop 2) :to-equal 3800) (expect (subed-subtitle-msecs-stop 3) :to-equal 4800))))) (describe "when configured to check on load," (it "reports overlaps." (with-temp-buffer (insert "1\n00:00:01,000 --> 00:00:02,000\nA\n\n" "2\n00:00:04,000 --> 00:00:06,000\nA\n\n" "3\n00:00:03,000 --> 00:00:04,500\nA\n\n" "4\n00:00:05,000 --> 00:00:06,000\nA\n\n") (let ((subed-trim-overlap-check-on-load t) (subed-subtitle-spacing 200)) (spy-on 'subed-trim-overlap-check :and-return-value nil) (subed-srt-mode) (expect subed--subtitle-format :to-equal "srt") (expect 'subed-trim-overlap-check :to-have-been-called)))))) (describe "Getting a list of subtitles" (it "returns nil in an empty buffer." (with-temp-srt-buffer (expect (subed-subtitle-list) :to-equal nil))) (it "returns the list." (with-temp-srt-buffer (insert mock-srt-data) (expect (subed-subtitle-list) :to-equal '((1 61000 65123 "Foo." nil) (2 122234 130345 "Bar." nil) (3 183450 195500 "Baz." nil))))) (it "returns a subset when bounds are specified." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (backward-char 1) (expect (subed-subtitle-list (point-min) (point)) :to-equal '((1 61000 65123 "Foo." nil) (2 122234 130345 "Bar." nil)))))) (describe "Appending a list of subtitles" (it "adds them." (with-temp-srt-buffer (let ((list '((1 61000 65123 "Foo." nil) (2 122234 130345 "Bar." nil) (3 183450 195500 "Baz." nil)))) (subed-append-subtitle-list list) (expect (subed-subtitle-list) :to-equal list))))) (describe "Getting the text of a list" (it "returns a blank string when given nothing." (expect (subed-subtitle-list-text nil) :to-equal "")) (it "returns the text of a list of subtitles." (expect (subed-subtitle-list-text '((nil 0 99 "Hello") (nil 100 199 "world" "Comment"))) :to-equal "Hello\nworld\n")) (it "includes comments." (expect (subed-subtitle-list-text '((nil 0 99 "Hello") (nil 100 199 "world" "Comment")) t) :to-equal "Hello\n\nComment\n\nworld\n")) (it "includes comments transformed by a function." (let ((val (subed-subtitle-list-text '((nil 0 99 "Hello") (nil 100 199 "world" "Comment")) #'upcase))) (expect val :to-equal "Hello\n\nCOMMENT\n\nworld\n")))) (describe "Copying region text" (it "works on the whole buffer" (with-temp-srt-buffer (insert mock-srt-data) (subed-copy-region-text) (expect (current-kill 0) :to-equal "Foo.\nBar.\nBaz.\n"))) (it "works on a specified region." (with-temp-srt-buffer (insert mock-srt-data) (subed-copy-region-text (re-search-backward "Foo.") (re-search-forward "Bar.")) (expect (current-kill 0) :to-equal "Foo.\nBar.\n")))) (describe "Sorting" (it "detects sorted lists." (expect (subed--sorted-p '((1 1000 2000 "Test") (2 2000 3000 "Test") (3 3000 4000 "Test"))))) (it "detects unsorted lists." (expect (subed--sorted-p '((1 3000 2000 "Test") (2 4000 3000 "Test") (3 1000 4000 "Test"))) :to-be nil)) (it "doesn't happen in an empty buffer." (with-temp-srt-buffer (spy-on 'sort-subr :and-call-through) (subed-sort) (expect 'sort-subr :not :to-have-been-called))) (describe "already-sorted subtitles" (it "doesn't rearrange subtitles." (with-temp-srt-buffer (insert mock-srt-data) (spy-on 'sort-subr :and-call-through) (subed-sort) (expect 'sort-subr :not :to-have-been-called))) (it "maintains the mark ring." (with-temp-srt-buffer (insert mock-srt-data) (let ((mark-ring)) (push-mark 10 t nil) (push-mark 20 t nil) (push-mark 3 t nil) (expect (marker-position (car mark-ring)) :to-be 20) (expect (marker-position (cadr mark-ring)) :to-be 10) (subed-sort) (expect (marker-position (car mark-ring)) :to-be 20) (expect (marker-position (cadr mark-ring)) :to-be 10))))) (it "sorts subtitles by start time." (with-temp-srt-buffer (insert mock-srt-data "\n4\n00:02:01,000 --> 00:03:01,000\nNot sorted.\n") (expect (subed--sorted-p) :to-be nil) (goto-char (point-min)) (subed-sort) (expect (subed-subtitle-text 2) :to-equal "Not sorted.") (expect (subed-subtitle-text 3) :to-equal "Bar.") (expect (subed-subtitle-text 4) :to-equal "Baz.")))) (describe "An old generic function" :var ((function-list (list "subtitle-id" "subtitle-id-max" "subtitle-id-at-msecs" "subtitle-msecs-start" "subtitle-msecs-stop" "subtitle-text" "subtitle-relative-point" "msecs-to-timestamp" "timestamp-to-msecs" "jump-to-subtitle-id" "jump-to-subtitle-id-at-msecs" "jump-to-subtitle-time-start" "jump-to-subtitle-time-stop" "jump-to-subtitle-text" "jump-to-subtitle-text-at-msecs" "jump-to-subtitle-end" "forward-subtitle-id" "backward-subtitle-id" "forward-subtitle-text" "backward-subtitle-text" "forward-subtitle-end" "backward-subtitle-end" "forward-subtitle-time-start" "backward-subtitle-time-start" "forward-subtitle-time-stop" "backward-subtitle-time-stop" "set-subtitle-time-start" "set-subtitle-time-stop" "prepend-subtitle" "append-subtitle" "kill-subtitle" "merge-with-next" "regenerate-ids" "regenerate-ids-soon" "sanitize" "validate" "sort" "make-subtitle"))) (it "is declared as a common function" (mapc (lambda (f) (let ((function-name (format "subed-%s" f))) (unless (functionp (intern function-name)) (buttercup-fail "%s is not a function" function-name)))) function-list)) (it "has format-specific internal functions" (mapc (lambda (f) (mapc (lambda (sub-format) (let ((function-name (format "subed-%s--%s" sub-format f))) (unless (functionp (intern function-name)) (buttercup-fail "%s is not a function" function-name)))) '("srt" "vtt" "ass"))) function-list))) (describe "Setting subtitle" (describe "text" (it "replaces the text." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Foo") (subed-set-subtitle-text "Hello world") (expect (subed-subtitle-text) :to-equal "Hello world"))) (it "replaces the text of a specified subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-set-subtitle-text "Hello world" 1) (expect (subed-subtitle-text) :to-equal "Hello world") (expect (subed-subtitle-id) :to-equal 1))) (it "blanks out subtitles." (with-temp-srt-buffer (insert mock-srt-data) (subed-set-subtitle-text "" 1) (expect (subed-subtitle-text) :to-equal "") (expect (subed-subtitle-id) :to-equal 1) (expect (buffer-string) :to-equal "1 00:01:01,000 --> 00:01:05,123 2 00:02:02,234 --> 00:02:10,345 Bar. 3 00:03:03,45 --> 00:03:15,5 Baz. "))))) (describe "Merging a region" (it "handles empty buffers." (with-temp-srt-buffer (subed-merge-region (point-min) (point-max)) (expect (buffer-string) :to-equal ""))) (it "merges all the subtitles if requested." (with-temp-srt-buffer (insert mock-srt-data) (expect (boundp 'subed--regenerate-ids-soon-timer) :to-be t) (subed-merge-region (point-min) (point-max)) (expect (buffer-string) :to-equal "1 00:01:01,000 --> 00:03:15,500 Foo. Bar. Baz. "))) (it "merges some subtitles." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Bar") (subed-merge-region (point-min) (point)) (expect (buffer-string) :to-equal "1 00:01:01,000 --> 00:02:10,345 Foo. Bar. 3 00:03:03,45 --> 00:03:15,5 Baz. "))) (it "merges some subtitles, including the last one." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Bar") (subed-merge-region (point) (point-max)) (expect (buffer-string) :to-equal "1 00:01:01,000 --> 00:01:05,123 Foo. 2 00:02:02,234 --> 00:03:15,500 Bar. Baz. ")))) (describe "Merging a region and setting the text" (it "handles empty buffers." (with-temp-srt-buffer (subed-merge-region-and-set-text (point-min) (point-max) "") (expect (buffer-string) :to-equal ""))) (it "merges all the subtitles if requested." (with-temp-srt-buffer (insert mock-srt-data) (expect (boundp 'subed--regenerate-ids-soon-timer) :to-be t) (subed-merge-region-and-set-text (point-min) (point-max) "Hello world") (expect (buffer-string) :to-equal "1 00:01:01,000 --> 00:03:15,500 Hello world "))) (it "merges some subtitles." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Bar") (subed-merge-region-and-set-text (point-min) (point) "Hello world") (expect (buffer-string) :to-equal "1 00:01:01,000 --> 00:02:10,345 Hello world 3 00:03:03,45 --> 00:03:15,5 Baz. "))) (it "merges some subtitles, including the last one." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Bar") (subed-merge-region-and-set-text (point) (point-max) "Hello world") (expect (buffer-string) :to-equal "1 00:01:01,000 --> 00:01:05,123 Foo. 2 00:02:02,234 --> 00:03:15,500 Hello world ")))) (describe "Conversion" (describe "from SRT" (describe "to VTT" (it "creates subtitles in the expected format" (with-temp-buffer (insert mock-srt-data) (subed-srt-mode) (with-current-buffer (subed-convert "VTT") (expect major-mode :to-equal 'subed-vtt-mode) (expect (buffer-string) :to-equal "WEBVTT 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. 00:03:03.450 --> 00:03:15.500 Baz. "))))) )) (describe "Iterating over subtitles" (it "without providing beginning and end." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 1) (subed-for-each-subtitle nil nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert "Hello.")) (expect (subed-subtitle-text 1) :to-equal "Hello.") (expect (subed-subtitle-text 2) :to-equal "Bar.") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 20) (subed-jump-to-subtitle-time-stop 2) (subed-for-each-subtitle nil nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert "HEllo.")) (expect (subed-subtitle-text 1) :to-equal "Hello.") (expect (subed-subtitle-text 2) :to-equal "HEllo.") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 60) (subed-jump-to-subtitle-time-stop 3) (subed-for-each-subtitle nil nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert "HELlo.")) (expect (subed-subtitle-text 1) :to-equal "Hello.") (expect (subed-subtitle-text 2) :to-equal "HEllo.") (expect (subed-subtitle-text 3) :to-equal "HELlo.") (expect (point) :to-equal 99))) (describe "providing only the beginning" (it "forwards." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-start 1) (expect (point) :to-equal 3) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 71 nil nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "A") (expect (subed-subtitle-text 3) :to-equal "B") (expect (point) :to-equal 3))) (it "backwards." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 3) (expect (point) :to-equal 95) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 75 nil :reverse (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "B") (expect (subed-subtitle-text 3) :to-equal "A") (expect (point) :to-equal 92))) ) (describe "providing beginning and end," (describe "excluding subtitles above" (it "forwards." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 1) (expect (point) :to-equal 20) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 71 79 nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "A") (expect (subed-subtitle-text 3) :to-equal "B") (expect (point) :to-equal 20))) (it "backwards." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-start 3) (expect (point) :to-equal 79) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 39 77 :reverse (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "Foo.") (expect (subed-subtitle-text 2) :to-equal "B") (expect (subed-subtitle-text 3) :to-equal "A") (expect (point) :to-equal 76))) ) (describe "excluding subtitles below" (it "forwards." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (expect (point) :to-equal 106) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 5 76 nil (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "A") (expect (subed-subtitle-text 2) :to-equal "B") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 100))) (it "backwards." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 2) (expect (point) :to-equal 58) (let ((new-texts (list "A" "B" "C"))) (subed-for-each-subtitle 20 76 :reverse (expect (looking-at "^[0-9]$") :to-be t) (forward-line 2) (kill-line) (insert (pop new-texts)))) (expect (subed-subtitle-text 1) :to-equal "B") (expect (subed-subtitle-text 2) :to-equal "A") (expect (subed-subtitle-text 3) :to-equal "Baz.") (expect (point) :to-equal 55))) ) ) ) (describe "Parsing files" (it "returns a list of subtitles." (let ((filename (make-temp-file "test" nil ".srt"))) (unwind-protect (progn (with-temp-file filename (insert mock-srt-data)) (let ((data (subed-parse-file filename))) (expect (length data) :to-equal 3) (expect (elt (elt data 0) 3) :to-equal "Foo.") (expect (elt (elt data 1) 3) :to-equal "Bar.") (expect (elt (elt data 2) 3) :to-equal "Baz."))) (delete-file filename)))) (it "uses the specified mode function." (let ((filename (make-temp-file "test"))) (unwind-protect (progn (with-temp-file filename (insert mock-srt-data)) (let ((data (subed-parse-file filename 'subed-srt-mode))) (expect (length data) :to-equal 3) (expect (elt (elt data 0) 3) :to-equal "Foo.") (expect (elt (elt data 1) 3) :to-equal "Bar.") (expect (elt (elt data 2) 3) :to-equal "Baz."))) (delete-file filename)))) (it "defaults to subed-tsv if unknown." (require 'subed-tsv) (let ((filename (make-temp-file "test"))) (unwind-protect (progn (with-temp-file filename (insert "0.100000\t0.200000\tFoo. 0.500000\t0.700000\tBar. 0.800000\t1.000000\tBaz.")) (let ((data (subed-parse-file filename))) (expect (length data) :to-equal 3) (expect (elt (elt data 0) 3) :to-equal "Foo.") (expect (elt (elt data 1) 3) :to-equal "Bar.") (expect (elt (elt data 2) 3) :to-equal "Baz."))) (delete-file filename))))) (describe "Copying region text" (it "copies just the text for the whole buffer." (with-temp-srt-buffer (insert mock-srt-data) (subed-copy-region-text) (expect (current-kill 0) :to-equal "Foo.\nBar.\nBaz.\n"))) (it "copies the specified region." (with-temp-srt-buffer (insert mock-srt-data) (subed-copy-region-text (progn (subed-jump-to-subtitle-id 2) (point)) (progn (subed-jump-to-subtitle-end 3) (point))) (expect (current-kill 0) :to-equal "Bar.\nBaz.\n")))) (describe "Guessing the format" (before-each (spy-on 'delete-process) (spy-on 'make-process) (spy-on 'make-network-process :and-return-value "mock client process")) (it "works when the generic functions is called." (let ((file (make-temp-file "subed-test" nil ".srt")) (auto-mode-alist '(("\\.srt\\'" . subed-mode)))) (find-file file) (expect major-mode :to-equal 'subed-srt-mode) (subed-mpv-kill) (delete-file file))) (it "does not cause a loop when the more-specific function is called." (let ((file (make-temp-file "subed-test" nil ".srt")) (auto-mode-alist '(("\\.srt\\'" . subed-srt-mode)))) (find-file file) (expect major-mode :to-equal 'subed-srt-mode) (subed-mpv-kill) (delete-file file)))) (describe "Creating a subtitle that spans the file" (it "uses the file duration." (let* ((filename (make-temp-file "test-subed-a" nil ".opus")) (file (create-sample-media-file :path filename :duration-audio-stream 2))) (with-temp-srt-buffer (setq subed-mpv-media-file filename) (subed-insert-subtitle-for-whole-file) (let ((list (subed-subtitle-list))) (expect (length list) :to-equal 1) (expect (elt (car list) 1) :to-equal 0) (expect (elt (car list) 2) :to-be-weakly-greater-than 1900) (expect (elt (car list) 2) :to-be-weakly-less-than 2100))) ; some tolerance (delete-file filename)))) (describe "Get duration in milliseconds of a file with a single audio stream" (let (;; `duration-audio-stream' is the duration in seconds for ;; the media file that is used inside the tests. When ;; `duration-audio-stream' is an integer, ffprobe might ;; report a duration that is slightly greater, so we can't ;; expect the duration reported by ffprobe to be equal to ;; the duration that we passed to ffmpeg when creating the ;; sample media file. For this reason, we define the ;; variables `duration-lower-boundary' and ;; `duration-upper-boundary' to set a tolerance to the ;; reported value by ffprobe. ;; ;; When `duration-audio-stream' changes, the variables ;; `duration-lower-boundary' and ;; `duration-upper-boundary' should be set accordingly." (duration-audio-stream "3") (duration-lower-boundary 3000) (duration-upper-boundary 4000)) (describe "audio file" (test-subed-extension ".wav") (test-subed-extension ".ogg") (test-subed-extension ".mp3") (test-subed-extension ".opus") (test-subed-extension ".m4a")) (describe "video format with just audio" (test-subed-extension ".mkv") (test-subed-extension ".mp4") (test-subed-extension ".webm") (test-subed-extension ".avi") (test-subed-extension ".ts") (test-subed-extension ".ogv")))) (describe "Get duration in milliseconds of a file with 1 video and 1 audio stream" ;; In this group of test cases, we want the duration of the audio ;; stream to be shorter than the duration of the video stream, so ;; that we can make sure that subed-waveform-ffprobe-duration-ms ;; specifically gets the duration of the audio stream. (test-subed-extension ".mkv" t) (test-subed-extension ".mp4" t) (test-subed-extension ".webm" t) (test-subed-extension ".avi" t) (test-subed-extension ".ts" t) (test-subed-extension ".ogv" t)) ) subed-1.2.25/tests/test-subed-common.el.license000066400000000000000000000001361474617305700213730ustar00rootroot00000000000000SPDX-FileCopyrightText: 2019-2021 The subed Authors SPDX-License-Identifier: GPL-3.0-or-latersubed-1.2.25/tests/test-subed-mpv.el000066400000000000000000000153401474617305700172670ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (load-file "./tests/undercover-init.el") (require 'subed-mpv) (describe "subed-mpv" (after-each (subed-mpv-kill)) (describe "Starting mpv" (it "passes arguments to make-process." (spy-on 'make-process) (spy-on 'subed-mpv--socket :and-return-value "/mock/path/to/socket") (subed-mpv--server-start "foo" "--bar") (expect 'make-process :to-have-been-called-with :command (list subed-mpv-executable "--input-ipc-server=/mock/path/to/socket" "--idle" "foo" "--bar") :name "subed-mpv-server" :buffer nil :noquery t)) (it "sets subed-mpv--server-proc on success." (spy-on 'make-process :and-return-value "mock process") (subed-mpv--server-start) (expect subed-mpv--server-proc :to-equal "mock process")) (it "signals error on failure." (spy-on 'make-process :and-throw-error 'error) (expect (subed-mpv--server-start) :to-throw 'error)) ) (describe "Stopping mpv" (before-each (setq subed-mpv--server-proc "mock running mpv process") (spy-on 'process-live-p :and-return-value t) (spy-on 'delete-process)) (it "kills the mpv process." (subed-mpv--server-stop) (expect 'delete-process :to-have-been-called-with "mock running mpv process")) (it "resets subed-mpv--server-proc." (expect subed-mpv--server-proc :not :to-be nil) (subed-mpv--server-stop) (expect subed-mpv--server-proc :to-be nil)) ) (describe "Connecting" (before-each (spy-on 'delete-process)) (it "resets global status variables." (spy-on 'subed-mpv--client-connected-p :and-return-value t) (spy-on 'make-network-process :and-return-value "mock client process") (spy-on 'process-send-string) (spy-on 'subed-mpv--client-send) (setq subed-mpv--client-proc "foo" subed-mpv-is-playing "baz" subed-mpv--client-command-queue '(foo bar baz)) (subed-mpv--client-connect '(0 0 0)) (expect subed-mpv--client-proc :to-equal "mock client process") (expect subed-mpv-is-playing :to-be nil) (expect subed-mpv--client-command-queue :to-be nil)) (it "correctly calls make-network-process." (spy-on 'make-network-process) (spy-on 'process-send-string) (spy-on 'subed-mpv--socket :and-return-value "/mock/path/to/socket") (subed-mpv--client-connect '(0 0 0)) (expect 'make-network-process :to-have-been-called-with :name "subed-mpv-client" :family 'local :service (subed-mpv--socket) :coding '(utf-8 . utf-8) :buffer (subed-mpv--client-buffer) :filter #'subed-mpv--client-filter :noquery t :nowait t)) (describe "tests the connection" (it "and sets subed-mpv--client-proc if the test succeeds." (spy-on 'make-network-process :and-return-value "mock client process") (spy-on 'process-send-string) (subed-mpv--client-connect '(0 0 0)) (expect 'process-send-string :to-have-been-called-with "mock client process" (concat subed-mpv--client-test-request "\n")) (expect subed-mpv--client-proc :to-equal "mock client process")) (it "and resets subed-mpv--client-proc if the test fails." (spy-on 'make-network-process :and-return-value "mock client process") (spy-on 'process-send-string :and-throw-error 'error) (setq subed-mpv--client-proc "foo") (subed-mpv--client-connect '(0 0 0)) (expect subed-mpv--client-proc :to-be nil)) (it "and tries again if the test fails." (spy-on 'make-network-process :and-return-value "mock client process") (spy-on 'process-send-string :and-throw-error 'error) (subed-mpv--client-connect '(0 0 0)) ;; FIXME: This seems to be a bug: ;; https://github.com/jorgenschaefer/emacs-buttercup/issues/139 ;; (expect 'process-send-string :to-have-been-called-times 3) (expect subed-mpv--client-proc :to-be nil)) ) (it "sends queued commands and empties the queue." (spy-on 'make-network-process :and-return-value "mock client process") (spy-on 'process-send-string) (spy-on 'subed-mpv--client-send) (spy-on 'subed-mpv--client-connected-p :and-return-value t) (setq subed-mpv--client-command-queue '(foo bar baz)) (subed-mpv--client-connect '(0 0 0)) (expect 'subed-mpv--client-send :to-have-been-called-with 'foo) (expect 'subed-mpv--client-send :to-have-been-called-with 'bar) (expect 'subed-mpv--client-send :to-have-been-called-with 'baz) (expect subed-mpv--client-command-queue :to-be nil)) ) (describe "Sending command" (before-each (spy-on 'delete-process) (setq subed-mpv--client-command-queue nil)) (describe "when mpv process is not running" (before-each (spy-on 'subed-mpv--server-started-p :and-return-value nil)) (it "is not queued if not connected." (spy-on 'subed-mpv--client-connected-p :and-return-value nil) (subed-mpv--client-send '(do this thing)) (expect subed-mpv--client-command-queue :to-be nil)) ) (describe "when mpv process is running" (before-each (spy-on 'subed-mpv--server-started-p :and-return-value t)) (it "is queued if not connected." (spy-on 'subed-mpv--client-connected-p :and-return-value nil) (subed-mpv--client-send '(do this thing)) (expect subed-mpv--client-command-queue :to-equal '((do this thing))) (subed-mpv--client-send '(do something else)) (expect subed-mpv--client-command-queue :to-equal '((do this thing) (do something else)))) (it "sends command if connected." (spy-on 'subed-mpv--client-connected-p :and-return-value t) (spy-on 'process-send-string) (setq subed-mpv--client-proc "mock client process") (subed-mpv--client-send '(do this thing)) (expect 'process-send-string :to-have-been-called-with "mock client process" (concat (json-encode (list :command '(do this thing))) "\n")) (expect subed-mpv--client-command-queue :to-equal nil)) (it "disconnects if sending fails even though we're connected." (spy-on 'subed-mpv--client-connected-p :and-return-value t) (spy-on 'subed-mpv--client-disconnect) (spy-on 'process-send-string :and-throw-error 'error) (expect (subed-mpv--client-send '(do this thing)) :to-throw 'error) (expect 'subed-mpv--client-disconnect :to-have-been-called-times 1) (expect subed-mpv--client-command-queue :to-equal nil)) ) ) ) subed-1.2.25/tests/test-subed-mpv.el.license000066400000000000000000000001311474617305700207000ustar00rootroot00000000000000SPDX-FileCopyrightText: 2019 The subed Authors SPDX-License-Identifier: GPL-3.0-or-latersubed-1.2.25/tests/test-subed-srt.el000066400000000000000000002245751474617305700173110ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (load-file "./tests/undercover-init.el") (require 'subed-srt) (defvar mock-srt-data "1 00:01:01,000 --> 00:01:05,123 Foo. 2 00:02:02,234 --> 00:02:10,345 Bar. 3 00:03:03,45 --> 00:03:15,5 Baz. ") (defmacro with-temp-srt-buffer (&rest body) "Initialize buffer to `subed-srt-mode' and run BODY." `(with-temp-buffer (subed-srt-mode) (progn ,@body))) (describe "subed-srt" (describe "Getting" (describe "the subtitle ID" (it "returns the subtitle ID if it can be found." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (expect (subed-subtitle-id) :to-equal 2))) (it "returns nil if no subtitle ID can be found." (with-temp-srt-buffer (expect (subed-subtitle-id) :to-equal nil))) ) (describe "the subtitle ID at playback time" (it "returns subtitle ID if time is equal to start time." (with-temp-srt-buffer (insert mock-srt-data) (cl-loop for target-id from 1 to 3 do (let ((msecs (subed-subtitle-msecs-start target-id))) (cl-loop for outset-id from 1 to 3 do (progn (subed-jump-to-subtitle-id outset-id) (expect (subed-subtitle-id-at-msecs msecs) :to-equal target-id))))))) (it "returns subtitle ID if time is equal to stop time." (with-temp-srt-buffer (insert mock-srt-data) (cl-loop for target-id from 1 to 3 do (let ((msecs (subed-subtitle-msecs-stop target-id))) (cl-loop for outset-id from 1 to 3 do (progn (subed-jump-to-subtitle-id outset-id) (expect (subed-subtitle-id-at-msecs msecs) :to-equal target-id))))))) (it "returns subtitle ID if time is between start and stop time." (with-temp-srt-buffer (insert mock-srt-data) (cl-loop for target-id from 1 to 3 do (let ((msecs (+ 1 (subed-subtitle-msecs-start target-id)))) (cl-loop for outset-id from 1 to 3 do (progn (subed-jump-to-subtitle-id outset-id) (expect (subed-subtitle-id-at-msecs msecs) :to-equal target-id))))))) (it "returns nil if time is before the first subtitle's start time." (with-temp-srt-buffer (insert mock-srt-data) (let ((msecs (- (save-excursion (goto-char (point-min)) (subed-subtitle-msecs-start)) 1))) (cl-loop for outset-id from 1 to 3 do (progn (subed-jump-to-subtitle-id outset-id) (expect (subed-subtitle-id-at-msecs msecs) :to-equal nil)))))) (it "returns nil if time is after the last subtitle's start time." (with-temp-srt-buffer (insert mock-srt-data) (let ((msecs (+ (save-excursion (goto-char (point-max)) (subed-subtitle-msecs-stop)) 1))) (cl-loop for outset-id from 1 to 3 do (progn (subed-jump-to-subtitle-id outset-id) (expect (subed-subtitle-id-at-msecs msecs) :to-equal nil)))))) (it "returns nil if time is between subtitles." (with-temp-srt-buffer (insert mock-srt-data) (cl-loop for target-id from 1 to 2 do (let ((msecs (+ (subed-subtitle-msecs-stop target-id) 1))) (cl-loop for outset-id from 1 to 3 do (progn (subed-jump-to-subtitle-id outset-id) (expect (subed-subtitle-id-at-msecs msecs) :to-equal nil)))) (let ((msecs (- (subed-subtitle-msecs-start (+ target-id 1)) 1))) (cl-loop for outset-id from 1 to 3 do (progn (subed-jump-to-subtitle-id outset-id) (expect (subed-subtitle-id-at-msecs msecs) :to-equal nil))))))) (it "doesn't fail if start time is invalid." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (let ((msecs (- (subed-subtitle-msecs-start) 1))) (subed-jump-to-subtitle-time-start) (forward-char 8) (delete-char 1) (expect (subed-subtitle-id-at-msecs msecs) :to-equal 2)))) ) (describe "the subtitle start/stop time" (it "returns the time in milliseconds." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (expect (subed-subtitle-msecs-start) :to-equal (+ (* 2 60000) (* 2 1000) 234)) (expect (subed-subtitle-msecs-stop) :to-equal (+ (* 2 60000) (* 10 1000) 345)))) (it "handles lack of digits in milliseconds gracefully." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "00:03:03,45 --> 00:03:15,5\n") (expect (subed-subtitle-msecs-start) :to-equal (+ (* 3 60 1000) (* 3 1000) 450)) (expect (subed-subtitle-msecs-stop) :to-equal (+ (* 3 60 1000) (* 15 1000) 500)))) (it "returns nil if time can't be found." (with-temp-srt-buffer (expect (subed-subtitle-msecs-start) :to-be nil) (expect (subed-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-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (kill-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and at the beginning without a trailing newline." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (kill-whole-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and in the middle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (kill-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and at the end with a trailing newline." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (kill-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and at the end without a trailing newline." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (kill-whole-line) (expect (subed-subtitle-text) :to-equal ""))) ) (describe "when text is not empty" (it "and has no linebreaks." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (expect (subed-subtitle-text) :to-equal "Bar."))) (it "and has linebreaks." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (insert "Bar.\n") (expect (subed-subtitle-text) :to-equal "Bar.\nBar."))) ) ) (describe "the point within the subtitle" (it "returns the relative point if we can find an ID." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (expect (subed-subtitle-relative-point) :to-equal 0) (forward-line) (expect (subed-subtitle-relative-point) :to-equal 2) (forward-line) (expect (subed-subtitle-relative-point) :to-equal 32) (forward-char) (expect (subed-subtitle-relative-point) :to-equal 33) (forward-line) (expect (subed-subtitle-relative-point) :to-equal 37) (forward-line) (expect (subed-subtitle-relative-point) :to-equal 0))) (it "returns nil if we can't find an ID." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (insert "foo") (expect (subed-subtitle-relative-point) :to-equal nil))) ) (describe "the subtitle start position" (it "returns the start from inside a subtitle." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "Bar") (expect (subed-subtitle-start-pos) :to-equal 39))) (it "returns the start from the beginning of the line." (with-temp-srt-buffer (insert mock-srt-data) (re-search-backward "^2\n") (expect (subed-subtitle-start-pos) :to-equal (point))))) ) (describe "Converting to msecs" (it "works with numbers." (expect (with-temp-srt-buffer (subed-to-msecs 5123)) :to-equal 5123)) (it "works with numbers as strings." (expect (with-temp-srt-buffer (subed-to-msecs "5123")) :to-equal 5123)) (it "works with timestamps." (with-temp-srt-buffer (expect (subed-to-msecs "00:00:05,124") :to-equal 5124)))) (describe "Jumping" (describe "to current subtitle ID" (it "returns ID's point when point is already on the ID." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-jump-to-subtitle-id) :to-equal 1) (expect (thing-at-point 'word) :to-equal "1"))) (it "returns ID's point when point is on the duration." (with-temp-srt-buffer (insert mock-srt-data) (search-backward ",234") (expect (thing-at-point 'word) :to-equal "02") (expect (subed-jump-to-subtitle-id) :to-equal 39) (expect (thing-at-point 'word) :to-equal "2"))) (it "returns ID's point when point is on the text." (with-temp-srt-buffer (insert mock-srt-data) (search-backward "Baz.") (expect (thing-at-point 'word) :to-equal "Baz") (expect (subed-jump-to-subtitle-id) :to-equal 77) (expect (thing-at-point 'word) :to-equal "3"))) (it "returns ID's point when point is between subtitles." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (search-forward "Bar.\n") (expect (thing-at-point 'line) :to-equal "\n") (expect (subed-jump-to-subtitle-id) :to-equal 39) (expect (thing-at-point 'word) :to-equal "2"))) (it "returns nil if buffer is empty." (with-temp-srt-buffer (expect (buffer-string) :to-equal "") (expect (subed-jump-to-subtitle-id) :to-equal nil))) (it "returns ID's point when buffer starts with blank lines." (with-temp-srt-buffer (insert (concat " \n \t \n" mock-srt-data)) (search-backward "Foo.") (expect (thing-at-point 'line) :to-equal "Foo.\n") (expect (subed-jump-to-subtitle-id) :to-equal 7) (expect (thing-at-point 'word) :to-equal "1"))) (it "returns ID's point when subtitles are separated with blank lines." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (search-forward "Foo.\n") (insert " \n \t \n") (expect (subed-jump-to-subtitle-id) :to-equal 1) (expect (thing-at-point 'word) :to-equal "1"))) ) (describe "to specific subtitle ID" (it "returns ID's point if wanted ID exists." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-max)) (expect (subed-jump-to-subtitle-id 2) :to-equal 39) (expect (thing-at-point 'word) :to-equal "2") (expect (subed-jump-to-subtitle-id 1) :to-equal 1) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-jump-to-subtitle-id 3) :to-equal 77) (expect (thing-at-point 'word) :to-equal "3"))) (it "returns nil and does not move if wanted ID does not exists." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (search-forward "Foo") (let ((stored-point (point))) (expect (subed-jump-to-subtitle-id 4) :to-equal nil) (expect stored-point :to-equal (point))))) ) (describe "to subtitle ID at specific time" (it "returns ID's point if point changed." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-max)) (spy-on 'subed-subtitle-id-at-msecs :and-return-value (point-min)) (expect (subed-jump-to-subtitle-id-at-msecs 123450) :to-equal (point-min)) (expect (point) :to-equal (point-min)) (expect 'subed-subtitle-id-at-msecs :to-have-been-called-with 123450) (expect 'subed-subtitle-id-at-msecs :to-have-been-called-times 1))) (it "returns nil if point didn't change." (with-temp-srt-buffer (insert mock-srt-data) (goto-char 75) (spy-on 'subed-subtitle-id-at-msecs :and-return-value 75) (expect (subed-jump-to-subtitle-id-at-msecs 123450) :to-equal nil) (expect (point) :to-equal 75) (expect 'subed-subtitle-id-at-msecs :to-have-been-called-with 123450) (expect 'subed-subtitle-id-at-msecs :to-have-been-called-times 1))) ) (describe "to subtitle start time" (it "returns start time's point if movement was successful." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-time-start) :to-equal 3) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:01:01,000") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-start) :to-equal 41) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:02:02,234") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-start) :to-equal 79) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:03:03,45"))) (it "returns nil if movement failed." (with-temp-srt-buffer (expect (subed-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-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-time-stop) :to-equal 20) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:01:05,123") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-stop) :to-equal 58) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:02:10,345") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-stop) :to-equal 95) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:03:15,5"))) (it "returns nil if movement failed." (with-temp-srt-buffer (expect (subed-jump-to-subtitle-time-stop) :to-equal nil))) ) (describe "to subtitle text" (it "returns subtitle text's point if movement was successful." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-text) :to-equal 33) (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Foo."))) (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-text) :to-equal 71) (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Bar."))) (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-text) :to-equal 106) (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Baz."))))) (it "returns nil if movement failed." (with-temp-srt-buffer (expect (subed-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-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-end) :to-be 37) (expect (looking-back "^Foo.$") :to-be t) (forward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 75) (expect (looking-back "^Bar.$") :to-be t) (forward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 110) (expect (looking-back "^Baz.$") :to-be t) (goto-char (point-max)) (backward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 110) (expect (looking-back "^Baz.$") :to-be t))) (it "returns nil if subtitle end cannot be found." (with-temp-srt-buffer (expect (subed-jump-to-subtitle-end) :to-be nil))) (it "returns nil if point did not move." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (kill-line) (expect (subed-jump-to-subtitle-end) :to-be nil))) (it "works if text is empty with trailing newline." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (kill-line) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 33) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text 2) (kill-line) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 67) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text 3) (kill-line) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 98) (expect (looking-at "^$") :to-be t))) (it "works if text is empty without trailing newline." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (kill-whole-line) (expect (subed-jump-to-subtitle-end) :to-be nil) (expect (looking-at "^$") :to-be t) (goto-char (point-min)) (expect (subed-jump-to-subtitle-end) :to-be 33) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text 2) (kill-whole-line) (expect (subed-jump-to-subtitle-end) :to-be nil) (expect (looking-at "^$") :to-be t) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 66) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text 3) (kill-whole-line) (expect (subed-jump-to-subtitle-end) :to-be nil) (expect (looking-at "^$") :to-be t) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 96) (expect (looking-at "^$") :to-be t))) (it "works with multi-line cues where a line is all numbers." (with-temp-srt-buffer (insert "1 00:00:00,000 --> 00:00:01,000 This is first subtitle. 123456789 2 00:00:01,000 --> 00:00:02,000 This is second subtitle. ") (goto-char (point-min)) (expect (subed-jump-to-subtitle-end) :to-be 66)))) (describe "to next subtitle ID" (it "returns point when there is a next subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-forward-subtitle-id) :to-be 39) (expect (thing-at-point 'word) :to-equal "2") (subed-jump-to-subtitle-time-start 2) (expect (thing-at-point 'word) :to-equal "00") (expect (subed-forward-subtitle-id) :to-be 77) (expect (thing-at-point 'word) :to-equal "3"))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-srt-buffer (expect (thing-at-point 'word) :to-equal nil) (expect (subed-forward-subtitle-id) :to-be nil)) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-forward-subtitle-id) :to-be 39) (expect (thing-at-point 'word) :to-equal "2") (subed-jump-to-subtitle-time-stop 2) (expect (thing-at-point 'word) :to-equal "00") (expect (subed-forward-subtitle-id) :to-be 77) (expect (thing-at-point 'word) :to-equal "3")) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (expect (thing-at-point 'word) :to-equal "Baz") (expect (subed-forward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "Baz")) (with-temp-srt-buffer (insert (concat mock-srt-data "\n\n")) (subed-jump-to-subtitle-time-stop 3) (expect (thing-at-point 'word) :to-equal "00") (expect (subed-forward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "00"))) ) (describe "to previous subtitle ID" (it "returns point when there is a previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (expect (thing-at-point 'word) :to-equal "Bar") (expect (subed-backward-subtitle-id) :to-be 1) (expect (thing-at-point 'word) :to-equal "1") (subed-jump-to-subtitle-time-stop 3) (expect (thing-at-point 'word) :to-equal "00") (expect (subed-backward-subtitle-id) :to-be 39) (expect (thing-at-point 'word) :to-equal "2"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-srt-buffer (expect (subed-backward-subtitle-id) :to-be nil)) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-backward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "1")) (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-backward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "Foo")) (with-temp-srt-buffer (insert (concat "\n\n\n" mock-srt-data)) (subed-jump-to-subtitle-time-stop 1) (expect (thing-at-point 'word) :to-equal "00") (expect (subed-backward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "00"))) ) (describe "to next subtitle text" (it "returns point when there is a next subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-forward-subtitle-text) :to-be 71) (expect (thing-at-point 'word) :to-equal "Bar"))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-srt-buffer (goto-char (point-max)) (insert (concat mock-srt-data "\n\n")) (subed-jump-to-subtitle-id 3) (expect (thing-at-point 'word) :to-equal "3") (expect (subed-forward-subtitle-text) :to-be nil) (expect (thing-at-point 'word) :to-equal "3"))) ) (describe "to previous subtitle text" (it "returns point when there is a previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (expect (thing-at-point 'word) :to-equal "3") (expect (subed-backward-subtitle-text) :to-be 71) (expect (thing-at-point 'word) :to-equal "Bar"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-backward-subtitle-text) :to-be nil) (expect (thing-at-point 'word) :to-equal "1"))) ) (describe "to next subtitle end" (it "returns point when there is a next subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (expect (thing-at-point 'word) :to-equal "Bar") (expect (subed-forward-subtitle-end) :to-be 110) (expect (thing-at-point 'word) :to-equal nil))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-srt-buffer (insert (concat mock-srt-data "\n\n")) (subed-jump-to-subtitle-text 3) (end-of-line) (expect (thing-at-point 'word) :to-equal nil) (expect (subed-forward-subtitle-end) :to-be nil) (expect (thing-at-point 'word) :to-equal nil))) ) (describe "to previous subtitle end" (it "returns point when there is a previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (expect (thing-at-point 'word) :to-equal "3") (expect (subed-backward-subtitle-text) :to-be 71) (expect (thing-at-point 'word) :to-equal "Bar"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-backward-subtitle-text) :to-be nil) (expect (thing-at-point 'word) :to-equal "1"))) ) (describe "to next subtitle start time" (it "returns point when there is a next subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-forward-subtitle-time-start) :to-be 41) (expect (thing-at-point 'word) :to-equal "00"))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (expect (thing-at-point 'word) :to-equal "3") (expect (subed-forward-subtitle-time-start) :to-be nil) (expect (thing-at-point 'word) :to-equal "3"))) ) (describe "to previous subtitle start time" (it "returns point when there is a previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (expect (thing-at-point 'word) :to-equal "2") (expect (subed-backward-subtitle-time-start) :to-be 3) (expect (thing-at-point 'word) :to-equal "00"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-backward-subtitle-time-start) :to-be nil) (expect (thing-at-point 'word) :to-equal "1"))) ) (describe "to next subtitle stop time" (it "returns point when there is a next subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-forward-subtitle-time-stop) :to-be 58) (expect (thing-at-point 'word) :to-equal "00"))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (expect (thing-at-point 'word) :to-equal "3") (expect (subed-forward-subtitle-time-stop) :to-be nil) (expect (thing-at-point 'word) :to-equal "3"))) ) (describe "to previous subtitle stop time" (it "returns point when there is a previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (expect (thing-at-point 'word) :to-equal "3") (expect (subed-backward-subtitle-time-stop) :to-be 58) (expect (thing-at-point 'word) :to-equal "00"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (expect (thing-at-point 'word) :to-equal "1") (expect (subed-backward-subtitle-time-stop) :to-be nil) (expect (thing-at-point 'word) :to-equal "1"))) ) ) (describe "Setting start/stop time" (it "of current subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-end 2) (subed-set-subtitle-time-start (subed-timestamp-to-msecs "1:02:03,400") nil t t) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n\n" "2\n" "01:02:03,400 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")) (subed-set-subtitle-time-stop (subed-timestamp-to-msecs "5:06:07,800") nil t t) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n\n" "2\n" "01:02:03,400 --> 05:06:07,800\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")))) (it "of specific subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 3) (subed-set-subtitle-time-start (+ (* 2 60 60 1000) (* 4 60 1000) (* 6 1000) 800) 1 t t) (expect (buffer-string) :to-equal (concat "1\n" "02:04:06,800 --> 00:01:05,123\n" "Foo.\n\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")) (subed-jump-to-subtitle-text 1) (subed-set-subtitle-time-stop (+ (* 3 60 60 1000) (* 5 60 1000) (* 7 1000) 900) 3 t t) (expect (buffer-string) :to-equal (concat "1\n" "02:04:06,800 --> 00:01:05,123\n" "Foo.\n\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 03:05:07,900\n" "Baz.\n")))) (it "when milliseconds lack digits." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (subed-set-subtitle-time-start (+ (* 1 60 60 1000) (* 2 60 1000) (* 3 1000) 4) 3 t) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "01:02:03,004 --> 00:03:15,5\n") (subed-set-subtitle-time-stop (+ (* 2 60 60 1000) (* 3 60 1000) (* 4 1000) 60) 3 t) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "01:02:03,004 --> 02:03:04,060\n"))) ) (describe "Inserting a subtitle" (describe "in an empty buffer" (describe "before" (it "passing nothing." (with-temp-srt-buffer (expect (subed-prepend-subtitle) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID." (with-temp-srt-buffer (expect (subed-prepend-subtitle 2) :to-equal 33) (expect (buffer-string) :to-equal (concat "2\n" "00:00:00,000 --> 00:00:01,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID and start time." (with-temp-srt-buffer (expect (subed-prepend-subtitle 3 60000) :to-equal 33) (expect (buffer-string) :to-equal (concat "3\n" "00:01:00,000 --> 00:01:01,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID, start time and stop time." (with-temp-srt-buffer (expect (subed-prepend-subtitle 4 60000 65000) :to-equal 33) (expect (buffer-string) :to-equal (concat "4\n" "00:01:00,000 --> 00:01:05,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID, start time, stop time and text." (with-temp-srt-buffer (expect (subed-prepend-subtitle 5 60000 65000 "Foo, bar\nbaz.") :to-equal 33) (expect (buffer-string) :to-equal (concat "5\n" "00:01:00,000 --> 00:01:05,000\n" "Foo, bar\nbaz.\n")) (expect (point) :to-equal 33))) ) (describe "after" (it "passing nothing." (with-temp-srt-buffer (expect (subed-append-subtitle) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID." (with-temp-srt-buffer (expect (subed-append-subtitle 2) :to-equal 33) (expect (buffer-string) :to-equal (concat "2\n" "00:00:00,000 --> 00:00:01,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID and start time." (with-temp-srt-buffer (expect (subed-append-subtitle 3 60000) :to-equal 33) (expect (buffer-string) :to-equal (concat "3\n" "00:01:00,000 --> 00:01:01,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID, start time and stop time." (with-temp-srt-buffer (expect (subed-append-subtitle 4 60000 65000) :to-equal 33) (expect (buffer-string) :to-equal (concat "4\n" "00:01:00,000 --> 00:01:05,000\n\n")) (expect (point) :to-equal 33))) (it "passing ID, start time, stop time and text." (with-temp-srt-buffer (expect (subed-append-subtitle 5 60000 65000 "Foo, bar\nbaz.") :to-equal 33) (expect (buffer-string) :to-equal (concat "5\n" "00:01:00,000 --> 00:01:05,000\n" "Foo, bar\nbaz.\n")) (expect (point) :to-equal 33))) ) ) (describe "in a non-empty buffer" (describe "before the current subtitle" (describe "with point on the first subtitle" (it "passing nothing." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle) :to-equal 33) (expect (buffer-string) :to-equal (concat "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (expect (point) :to-equal 33))) (it "passing ID." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle 7) :to-equal 33) (expect (buffer-string) :to-equal (concat "7\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (expect (point) :to-equal 33))) (it "passing ID and start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle 6 1500) :to-equal 33) (expect (buffer-string) :to-equal (concat "6\n" "00:00:01,500 --> 00:00:02,500\n" "\n\n" "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (expect (point) :to-equal 33))) (it "passing ID, start time and stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle 5 1500 2000) :to-equal 33) (expect (buffer-string) :to-equal (concat "5\n" "00:00:01,500 --> 00:00:02,000\n" "\n\n" "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (expect (point) :to-equal 33))) (it "passing ID, start time, stop time and text." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle 4 1500 3000 "Bar.") :to-equal 33) (expect (buffer-string) :to-equal (concat "4\n" "00:00:01,500 --> 00:00:03,000\n" "Bar.\n\n" "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (expect (point) :to-equal 33))) ) (describe "with point on a non-first subtitle" (it "passing nothing." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-prepend-subtitle) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-prepend-subtitle 9) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "9\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID and start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-prepend-subtitle 9 7000) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "9\n" "00:00:07,000 --> 00:00:08,000\n" "\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID, start time and stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-prepend-subtitle 9 7000 7123) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "9\n" "00:00:07,000 --> 00:00:07,123\n" "\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID, start time, stop time and text." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (subed-jump-to-subtitle-text 2) (expect (subed-prepend-subtitle 9 7000 7123 "Baz.") :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "9\n" "00:00:07,000 --> 00:00:07,123\n" "Baz.\n\n" "2\n" "00:00:10,000 --> 00:00:12,000\n" "Bar.\n")) (expect (point) :to-equal 71))) ) ) (describe "after the current subtitle" (describe "with point on the last subtitle" (it "passing nothing." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n")) (expect (point) :to-equal 71))) (it "passing ID." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle 5) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "5\n" "00:00:00,000 --> 00:00:01,000\n" "\n")) (expect (point) :to-equal 71))) (it "passing ID and start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle 5 12345) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "5\n" "00:00:12,345 --> 00:00:13,345\n" "\n")) (expect (point) :to-equal 71))) (it "passing ID, start time and stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle 5 12345 15000) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "5\n" "00:00:12,345 --> 00:00:15,000\n" "\n")) (expect (point) :to-equal 71))) (it "passing ID, start time, stop time and text." (with-temp-srt-buffer (insert (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle 5 12345 15000 "Bar.") :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:05,000 --> 00:00:06,000\n" "Foo.\n\n" "5\n" "00:00:12,345 --> 00:00:15,000\n" "Bar.\n")) (expect (point) :to-equal 71))) ) (describe "with point on a non-last subtitle" (it "passing nothing." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start 1) (expect (subed-append-subtitle) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start 1) (expect (subed-append-subtitle 7) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "7\n" "00:00:00,000 --> 00:00:01,000\n" "\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID and start time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start 1) (expect (subed-append-subtitle 7 2500) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "7\n" "00:00:02,500 --> 00:00:03,500\n" "\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID, start time and stop time." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start 1) (expect (subed-append-subtitle 7 2500 4000) :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "7\n" "00:00:02,500 --> 00:00:04,000\n" "\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (expect (point) :to-equal 71))) (it "passing ID, start time, stop time and text." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start 1) (expect (subed-append-subtitle 7 2500 4000 "Baz.") :to-equal 71) (expect (buffer-string) :to-equal (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "Foo.\n\n" "7\n" "00:00:02,500 --> 00:00:04,000\n" "Baz.\n\n" "2\n" "00:00:05,000 --> 00:00:06,000\n" "Bar.\n")) (expect (point) :to-equal 71))) ) ) (it "when point is on empty text." (with-temp-srt-buffer (insert (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle) :to-equal 67) (expect (buffer-string) :to-equal (concat "1\n" "00:00:01,000 --> 00:00:02,000\n" "\n\n" "0\n" "00:00:00,000 --> 00:00:01,000\n" "\n")) (expect (point) :to-equal 67))) ) ) (describe "Killing a subtitle" (it "removes the first subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")))) (it "removes it in between." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")))) (it "removes the last subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n")))) (describe "removes the previous subtitle when point is right above the ID" (it "of the last subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 3) (backward-char) (expect (looking-at "^\n3\n") :to-be t) (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")))) (it "of a non-last subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 2) (backward-char) (expect (looking-at "^\n2\n") :to-be t) (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")))) ) ) (describe "Validating" (it "works in empty buffer." (with-temp-srt-buffer (subed-validate))) (it "works in buffer that contains only newlines." (with-temp-srt-buffer (cl-loop for _ from 1 to 10 do (insert "\n") (subed-validate)))) (it "works in buffer that contains only spaces." (with-temp-srt-buffer (cl-loop for _ from 1 to 10 do (insert " ") (subed-validate)))) (it "works in buffer that contains only spaces and newlines." (with-temp-srt-buffer (cl-loop for _ from 1 to 10 do (if (eq (random 2) 0) (insert " ") (insert "\n")) (subed-validate)))) (it "reports invalid IDs." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-id 1) (insert "x") (expect (subed-validate) :to-throw 'error '("Found invalid subtitle ID: \"x1\"")) (expect (point) :to-equal 1))) (it "reports invalid start time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-start 1) (forward-char 5) (delete-char 1) (expect (subed-validate) :to-throw 'error '("Found invalid start time: \"00:0101,000 --> 00:01:05,123\"")) (expect (point) :to-equal 3))) (it "reports invalid stop time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 1) (forward-char 10) (insert "3") (expect (subed-validate) :to-throw 'error '("Found invalid stop time: \"00:01:01,000 --> 00:01:05,1323\"")) (expect (point) :to-equal 20))) (it "reports invalid time separator." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-stop 1) (delete-char -1) (expect (subed-validate) :to-throw 'error '("Found invalid separator between start and stop time: \"00:01:01,000 -->00:01:05,123\"")) (expect (point) :to-equal 15))) (it "reports invalid start time in later entries." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-start 3) (forward-char 3) (insert "##") (expect (subed-validate) :to-throw 'error '("Found invalid start time: \"00:##03:03,45 --> 00:03:15,5\"")) (expect (point) :to-equal 79))) (it "does not report error when last subtitle text is empty." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (kill-whole-line) (forward-char -2) (subed-validate) (expect (point) :to-equal 104))) (it "preserves point if there is no error." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (forward-char 2) (subed-validate) (expect (point) :to-equal 73))) (it "runs before saving." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-time-start 3) (forward-char 3) (insert "##") (expect (subed-prepare-to-save) :to-throw 'error '("Found invalid start time: \"00:##03:03,45 --> 00:03:15,5\"")) (expect (point) :to-equal 79)))) (describe "Sanitizing" (it "removes trailing tabs and spaces from all lines." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match " \n")) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data)) (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match "\t\n")) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data))) (it "removes leading tabs and spaces from all lines." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match "\n ")) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data)) (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match "\n\t")) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data))) (it "removes excessive empty lines between subtitles." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (while (re-search-forward "\n\n" nil t) (replace-match "\n \n \t \t\t \n\n \t\n")) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data))) (it "ensures double newline between subtitles if text of previous subtitle is empty." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 1) (kill-whole-line) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")) (subed-sanitize) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "\n\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "Baz.\n")))) (it "removes empty lines from beginning of buffer." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (insert " \n\t\n") (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data))) (it "removes empty lines from end of buffer." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-max)) (insert " \n\t\n\n") (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data))) (it "ensures a single newline after the last subtitle." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-max)) (while (eq (char-before (point-max)) ?\n) (delete-backward-char 1)) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data))) (it "ensures single newline after last subtitle if text is empty." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (kill-whole-line) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "")) (subed-sanitize) (expect (buffer-string) :to-equal (concat "1\n" "00:01:01,000 --> 00:01:05,123\n" "Foo.\n\n" "2\n" "00:02:02,234 --> 00:02:10,345\n" "Bar.\n\n" "3\n" "00:03:03,45 --> 00:03:15,5\n" "\n")))) (it "ensures single space before and after time separators." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match "-->") (expect (buffer-string) :not :to-equal mock-srt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-srt-data))) (it "does not insert newline in empty buffer." (with-temp-srt-buffer (expect (buffer-string) :to-equal "") (subed-sanitize) (expect (buffer-string) :to-equal ""))) (it "runs before saving." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match "-->") (spy-on 'subed-sanitize :and-call-through) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-prepare-to-save) (expect 'subed-sanitize :to-have-been-called) (expect (buffer-string) :to-equal mock-srt-data)))) (describe "Renumbering" (it "ensures consecutive subtitle IDs." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (while (looking-at "^[0-9]$") (replace-match "123")) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-regenerate-ids) (expect (buffer-string) :to-equal mock-srt-data))) (it "runs before saving." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (while (looking-at "^[0-9]$") (replace-match "123")) (expect (buffer-string) :not :to-equal mock-srt-data) (subed-prepare-to-save) (expect (buffer-string) :to-equal mock-srt-data))) (it "does not modify the kill-ring." (with-temp-srt-buffer (insert mock-srt-data) (kill-new "asdf") (goto-char (point-min)) (while (looking-at "^[0-9]$") (insert "555")) (subed-regenerate-ids) (expect (car kill-ring) :to-equal "asdf"))) (it "does not modify empty buffer." (with-temp-srt-buffer (subed-regenerate-ids) (expect (buffer-string) :to-equal ""))) ) (describe "Sorting" (it "orders subtitles by start time." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (re-search-forward "01:01") (replace-match "12:01") (goto-char (point-min)) (re-search-forward "02:02") (replace-match "10:02") (goto-char (point-min)) (re-search-forward "03:03") (replace-match "11:03") (subed-sort) (expect (buffer-string) :to-equal (concat "1\n" "00:10:02,234 --> 00:02:10,345\n" "Bar.\n" "\n" "2\n" "00:11:03,45 --> 00:03:15,5\n" "Baz.\n" "\n" "3\n" "00:12:01,000 --> 00:01:05,123\n" "Foo.\n")))) (describe "preserves point in the current subtitle" (it "when subtitle text is non-empty." (with-temp-srt-buffer (insert mock-srt-data) (goto-char (point-min)) (re-search-forward "01:01") (replace-match "12:01") (search-forward "\n") (expect (current-word) :to-equal "Foo") (subed-sort) (expect (current-word) :to-equal "Foo"))) (it "when subtitle text is empty." (with-temp-srt-buffer (insert "1\n00:12:01,000 --> 00:01:05,123\n") (goto-char (point-max)) (subed-sort) (expect (point) :to-equal 33))) ) ) (describe "Converting msecs to timestamp" (it "uses the right format" (with-temp-srt-buffer (expect (subed-msecs-to-timestamp 1401) :to-equal "00:00:01,401")))) (describe "Merging with next subtitle" (it "throws an error in an empty buffer." (with-temp-srt-buffer (expect (subed-merge-with-next) :to-throw 'error))) (it "throws an error with the last subtitle." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 3) (expect (subed-merge-with-next) :to-throw 'error))) (it "combines the text and the time." (with-temp-srt-buffer (insert mock-srt-data) (subed-jump-to-subtitle-text 2) (subed-merge-with-next) (expect (subed-subtitle-text) :to-equal "Bar. Baz.") (expect (subed-subtitle-msecs-start) :to-equal 122234) (expect (subed-subtitle-msecs-stop) :to-equal 195500))) (it "handles lines that are all numbers." (with-temp-srt-buffer (insert "1 00:00:00,000 --> 00:00:01,000 This is first subtitle. 2 00:00:01,000 --> 00:00:02,000 This is the second subtitle. 3 00:00:02,000 --> 00:00:03,000 This is the third subtitle. 4 00:00:03,000 --> 00:00:05,000 This is the fourth subtitle. 123456789 5 00:00:05,000 --> 00:00:06,000 This is the sixth subtitle.") (re-search-backward "This is the fourth subtitle") (subed-merge-with-next) (expect (buffer-string) :to-equal "1 00:00:00,000 --> 00:00:01,000 This is first subtitle. 2 00:00:01,000 --> 00:00:02,000 This is the second subtitle. 3 00:00:02,000 --> 00:00:03,000 This is the third subtitle. 4 00:00:03,000 --> 00:00:06,000 This is the fourth subtitle. 123456789 This is the sixth subtitle.")))) (describe "A comment" (it "is validated." (with-temp-srt-buffer (insert mock-srt-data "\n\n4\n00:04:00,000 --> 00:05:00,000\n{\\This is a comment} Hello\n") (subed-validate) (expect (point) :to-equal (point-max)))) (it "is highlighted as a comment." (with-temp-srt-buffer (insert mock-srt-data "\n\n4\n00:04:00,000 --> 00:05:00,000\n{\\This is a comment} Hello\n") (re-search-backward "comment") (expect (nth 4 (syntax-ppss)) :to-be t) (re-search-forward "Hello") (expect (nth 4 (syntax-ppss)) :to-be nil)))) (describe "Font-locking" (it "recognizes SRT syntax." (with-temp-srt-buffer (insert mock-srt-data) (font-lock-fontify-buffer) (goto-char (point-min)) (re-search-forward "00:01:01") (expect (face-at-point) :to-equal 'subed-time-face) (re-search-forward "-->") (backward-char 1) (expect (face-at-point) :to-equal 'subed-time-separator-face) (re-search-forward "^2$") (goto-char (match-beginning 0)) (expect (face-at-point) :to-equal 'subed-id-face))))) subed-1.2.25/tests/test-subed-srt.el.license000066400000000000000000000001361474617305700207130ustar00rootroot00000000000000SPDX-FileCopyrightText: 2019-2020 The subed Authors SPDX-License-Identifier: GPL-3.0-or-latersubed-1.2.25/tests/test-subed-tsv.el000066400000000000000000000441711474617305700173050ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (add-to-list 'load-path "./subed") (require 'subed) (require 'subed-tsv) (defvar mock-tsv-data "11.120000\t14.000000\tHello, world! 14.000000\t16.800000\tThis is a test. 17.000000\t19.800000\tI hope it works. ") (defmacro with-temp-tsv-buffer (&rest body) "Call `subed-tsv--init' in temporary buffer before running BODY." `(with-temp-buffer (subed-tsv-mode) (progn ,@body))) (describe "subed-tsv" (describe "Getting" (describe "the subtitle start/stop time" (it "returns the time in milliseconds." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.000000") (expect (floor (subed-subtitle-msecs-start)) :to-equal 14000) (expect (floor (subed-subtitle-msecs-stop)) :to-equal 16800))) (it "returns nil if time can't be found." (with-temp-tsv-buffer (expect (subed-subtitle-msecs-start) :to-be nil) (expect (subed-subtitle-msecs-stop) :to-be nil)))) (describe "the subtitle start position" (it "returns the start from inside a subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (re-search-backward "This is") (expect (subed-subtitle-start-pos) :to-equal (line-beginning-position)))) (it "returns the start from the beginning of the line." (with-temp-tsv-buffer (insert mock-tsv-data) (re-search-backward "14\\.0000") (expect (subed-subtitle-start-pos) :to-equal (line-beginning-position))))) (describe "the subtitle text" (describe "when text is empty" (it "and at the beginning with a trailing newline." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "14.000000") (kill-line) (expect (subed-subtitle-text) :to-equal ""))))) (describe "when text is not empty" (it "and has no linebreaks." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "14.000000") (expect (subed-subtitle-text) :to-equal "This is a test."))))) (describe "Converting to msecs" (it "works with numbers, although these use seconds because that's what TSV uses." (expect (with-temp-tsv-buffer (floor (subed-to-msecs "5.123"))) :to-equal 5123)) (it "works with numbers." (expect (subed-to-msecs 5123) :to-equal 5123))) (describe "Jumping" (describe "to current subtitle timestamp" (it "can handle different formats of timestamps." (with-temp-tsv-buffer (insert mock-tsv-data) (expect (subed-jump-to-subtitle-id "11.120") :to-equal 1) (expect (floor (subed-subtitle-msecs-start)) :to-equal 11120))) (it "returns timestamp's point when point is already on the timestamp." (with-temp-tsv-buffer (insert mock-tsv-data) (goto-char (point-min)) (subed-jump-to-subtitle-id "11.120000") (expect (subed-jump-to-subtitle-time-start) :to-equal (point)) (expect (looking-at subed-tsv--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "11.120000"))) (it "returns timestamp's point when point is on the text." (with-temp-tsv-buffer (insert mock-tsv-data) (search-backward "test") (expect (thing-at-point 'word) :to-equal "test") (expect (subed-jump-to-subtitle-time-start) :to-equal 35) (expect (looking-at subed-tsv--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "14.000000"))) (it "returns nil if buffer is empty." (with-temp-tsv-buffer (expect (buffer-string) :to-equal "") (expect (subed-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-tsv-buffer (insert mock-tsv-data) (goto-char (point-max)) (expect (subed-jump-to-subtitle-id "11.12") :to-equal 1) (expect (looking-at (regexp-quote "11.120000\t14.000000\tHello, world!")) :to-be t) (expect (subed-jump-to-subtitle-id "17.00") :to-equal 71) (expect (looking-at (regexp-quote "17.000000\t19.800000\tI hope it works.")) :to-be t))) (it "returns nil and does not move if wanted ID does not exists." (with-temp-tsv-buffer (insert mock-tsv-data) (goto-char (point-min)) (search-forward "test") (let ((stored-point (point))) (expect (subed-jump-to-subtitle-id "8.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-tsv-buffer (insert mock-tsv-data) (re-search-backward "world") (expect (subed-jump-to-subtitle-time-start) :to-equal 1) (expect (looking-at subed-tsv--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "11.120000"))) (it "returns nil if movement failed." (with-temp-tsv-buffer (expect (subed-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-tsv-buffer (insert mock-tsv-data) (re-search-backward "test") (expect (subed-jump-to-subtitle-time-stop) :to-equal 45) (expect (looking-at subed-tsv--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "16.800000"))) (it "returns nil if movement failed." (with-temp-tsv-buffer (expect (subed-jump-to-subtitle-time-stop) :to-equal nil)))) (describe "to subtitle text" (it "returns subtitle text's point if movement was successful." (with-temp-tsv-buffer (insert mock-tsv-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-text) :to-equal 21) (expect (looking-at "Hello, world!") :to-equal t) (forward-line 1) (expect (subed-jump-to-subtitle-text) :to-equal 55) (expect (looking-at "This is a test.") :to-equal t))) (it "returns nil if movement failed." (with-temp-tsv-buffer (expect (subed-jump-to-subtitle-text) :to-equal nil)))) (describe "to end of subtitle text" (it "returns point if subtitle end can be found." (with-temp-tsv-buffer (insert mock-tsv-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-end) :to-be 34) (expect (looking-back "Hello, world!") :to-be t) (forward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 70) (expect (looking-back "This is a test.") :to-be t) (forward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 107) (expect (looking-back "I hope it works.") :to-be t))) (it "returns nil if subtitle end cannot be found." (with-temp-tsv-buffer (expect (subed-jump-to-subtitle-end) :to-be nil))) (it "returns nil if point did not move." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "11.12") (subed-jump-to-subtitle-end) (expect (subed-jump-to-subtitle-end) :to-be nil))) (it "works if text is empty." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "11.12") (kill-line) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 21)))) (describe "to next subtitle ID" (it "returns point when there is a next subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "11.12") (expect (subed-forward-subtitle-id) :to-be 35) (expect (looking-at (regexp-quote "14.00")) :to-be t))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-tsv-buffer (expect (thing-at-point 'word) :to-equal nil) (expect (subed-forward-subtitle-id) :to-be nil)) (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "17.00") (expect (subed-forward-subtitle-id) :to-be nil)))) (describe "to previous subtitle ID" (it "returns point when there is a previous subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "14.00") (expect (subed-backward-subtitle-id) :to-be 1))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-tsv-buffer (expect (subed-backward-subtitle-id) :to-be nil)) (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "11.12") (expect (subed-backward-subtitle-id) :to-be nil)))) (describe "to next subtitle text" (it "returns point when there is a next subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.00") (expect (subed-forward-subtitle-text) :to-be 91) (expect (thing-at-point 'word) :to-equal "I"))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-tsv-buffer (goto-char (point-max)) (insert (concat mock-tsv-data "\n\n")) (subed-jump-to-subtitle-id "17.00") (expect (subed-forward-subtitle-text) :to-be nil)))) (describe "to previous subtitle text" (it "returns point when there is a previous subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.00") (expect (subed-backward-subtitle-text) :to-be 21) (expect (thing-at-point 'word) :to-equal "Hello"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (goto-char (point-min)) (expect (looking-at (regexp-quote "11.12")) :to-be t) (expect (subed-backward-subtitle-text) :to-be nil) (expect (looking-at (regexp-quote "11.12")) :to-be t)))) (describe "to next subtitle end" (it "returns point when there is a next subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "14.00") (expect (thing-at-point 'word) :to-equal "This") (expect (subed-forward-subtitle-end) :to-be 107))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-tsv-buffer (insert (concat mock-tsv-data "\n\n")) (subed-jump-to-subtitle-text "17.00") (expect (subed-forward-subtitle-end) :to-be nil)))) (describe "to previous subtitle end" (it "returns point when there is a previous subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.00") (expect (subed-backward-subtitle-end) :to-be 34))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (goto-char (point-min)) (expect (looking-at (regexp-quote "11.12")) :to-be t) (expect (subed-backward-subtitle-text) :to-be nil) (expect (looking-at (regexp-quote "11.12")) :to-be t)))) (describe "to next subtitle start time" (it "returns point when there is a next subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.00") (expect (subed-forward-subtitle-time-start) :to-be 71))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "17.00") (let ((pos (point))) (expect (subed-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-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.00") (expect (subed-backward-subtitle-time-stop) :to-be 11))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (goto-char (point-min)) (expect (subed-backward-subtitle-time-stop) :to-be nil) (expect (looking-at (regexp-quote "11.12")) :to-be t)))) (describe "to next subtitle stop time" (it "returns point when there is a next subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.00") (expect (subed-forward-subtitle-time-stop) :to-be 81))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "17.00") (let ((pos (point))) (expect (subed-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-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-id "14.00") (subed-set-subtitle-time-start (+ (* 15 1000) 400)) (expect (floor (subed-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-tsv-buffer (subed-prepend-subtitle) (expect (buffer-string) :to-equal "0.000000\t1.000000\t\n"))) (it "creates a subtitle with a start time." (with-temp-tsv-buffer (subed-prepend-subtitle nil 12340) (expect (buffer-string) :to-equal "12.340000\t13.340000\t\n"))) (it "creates a subtitle with a start time and stop time." (with-temp-tsv-buffer (subed-prepend-subtitle nil 60000 65000) (expect (buffer-string) :to-equal "60.000000\t65.000000\t\n"))) (it "creates a subtitle with start time, stop time and text." (with-temp-tsv-buffer (subed-prepend-subtitle nil 60000 65000 "Hello world") (expect (buffer-string) :to-equal "60.000000\t65.000000\tHello world\n")))) (describe "after the current subtitle" (it "creates an empty subtitle when passed nothing." (with-temp-tsv-buffer (subed-append-subtitle) (expect (buffer-string) :to-equal "0.000000\t1.000000\t\n"))) (it "creates a subtitle with a start time." (with-temp-tsv-buffer (subed-append-subtitle nil 12340) (expect (buffer-string) :to-equal "12.340000\t13.340000\t\n"))) (it "creates a subtitle with a start time and stop time." (with-temp-tsv-buffer (subed-append-subtitle nil 60000 65000) (expect (buffer-string) :to-equal "60.000000\t65.000000\t\n"))) (it "creates a subtitle with start time, stop time and text." (with-temp-tsv-buffer (subed-append-subtitle nil 60000 65000 "Hello world") (expect (buffer-string) :to-equal "60.000000\t65.000000\tHello 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-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-time-stop) (subed-prepend-subtitle) (expect (buffer-substring (line-beginning-position) (line-end-position)) :to-equal "0.000000\t1.000000\t")))) (describe "with point on a middle subtitle" (it "creates the subtitle before the current one." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-time-stop "14.00") (subed-prepend-subtitle) (expect (buffer-substring (line-beginning-position) (line-end-position)) :to-equal "0.000000\t1.000000\t") (forward-line 1) (beginning-of-line) (expect (looking-at "14.00")))))) (describe "after the current subtitle" (describe "with point on a subtitle" (it "creates the subtitle after the current one." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-time-stop "14.00") (subed-append-subtitle) (expect (buffer-substring (line-beginning-position) (line-end-position)) :to-equal "0.000000\t1.000000\t") (forward-line -1) (expect (floor (subed-subtitle-msecs-start)) :to-be 14000)))))) (describe "Killing a subtitle" (it "removes the first subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "11.12") (subed-kill-subtitle) (expect (floor (subed-subtitle-msecs-start)) :to-be 14000) (forward-line -1) (beginning-of-line) (expect (looking-at "14\\.00000"))))) (it "removes it in between." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "14.00") (subed-kill-subtitle) (expect (floor (subed-subtitle-msecs-start)) :to-be 17000))) (it "removes the last subtitle." (with-temp-tsv-buffer (insert mock-tsv-data) (subed-jump-to-subtitle-text "17.00") (subed-kill-subtitle) (expect (buffer-string) :to-equal "11.120000\t14.000000\tHello, world! 14.000000\t16.800000\tThis is a test. "))) (describe "Merging" (it "is limited to the region when at the start of the line." (with-temp-tsv-buffer (insert "5.673000 5.913000 phone 5.953000 6.013000 to 6.053000 6.213000 write 6.253000 6.333000 the 6.373000 6.713000 text, ") (goto-char (point-min)) (forward-line 3) (subed-merge-region (point-min) (point)) (expect (buffer-string) :to-equal "5.673000 6.213000 phone to write 6.253000 6.333000 the 6.373000 6.713000 text, " ) ))) (describe "Converting msecs to timestamp" (it "uses the right format" (with-temp-tsv-buffer (expect (subed-msecs-to-timestamp 1410) :to-equal "1.410000"))))) subed-1.2.25/tests/test-subed-vtt.el000066400000000000000000003100611474617305700173000ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (load-file "./tests/undercover-init.el") (require 'subed) (require 'subed-vtt) (require 'subed-common) (defvar mock-vtt-data "WEBVTT 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. 00:03:03.45 --> 00:03:15.5 Baz. ") (defmacro with-temp-vtt-buffer (&rest body) "Call `subed-vtt--init' in temporary buffer before running BODY." `(with-temp-buffer (subed-vtt-mode) (progn ,@body))) ;; (defmacro with-temp-vtt-buffer (&rest body) ;; "Call `subed-vtt--init' in temporary buffer before running BODY." ;; `(with-current-buffer (get-buffer-create "*test*") ;; (erase-buffer) ;; (unless (derived-mode-p 'subed-vtt-mode) (subed-vtt-mode)) ;; (display-buffer (current-buffer)) ;; (progn ,@body))) (describe "subed-vtt" (describe "Detecting" (describe "whether you're in the file header" (it "returns t in an empty buffer." (with-temp-vtt-buffer (expect (subed-in-header-p) :to-be t))) (it "works at the beginning of the header." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (expect (subed-in-header-p) :to-be t))) (it "works in the middle of the header." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (+ (point-min) 2)) (expect (subed-in-header-p) :to-be t))) (it "returns t on the line before a comment." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment ") (re-search-backward "\nNOTE") (expect (subed-in-header-p) :to-be t))) (describe "when the buffer starts with a cue timestamp" (it "returns nil from the timing line." (with-temp-vtt-buffer (insert "00:04:02.234 --> 00:04:10.345 Baz.") (goto-char (point-min)) (expect (subed-in-header-p) :to-be nil))) (it "returns nil from the cue text." (with-temp-vtt-buffer (insert "00:04:02.234 --> 00:04:10.345 Baz.") (expect (subed-in-header-p) :to-be nil)))) (it "returns nil at the beginning of a comment." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment ") (re-search-backward "NOTE") (expect (subed-in-header-p) :to-be nil))) (it "returns nil in the middle of a comment." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment ") (re-search-backward "comment") (expect (subed-in-header-p) :to-be nil))) (it "returns nil at the start of an ID." (with-temp-vtt-buffer (insert "WEBVTT 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "^1") (expect (subed-in-header-p) :to-be nil))) (it "returns nil at the start of a timestamp." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "^0") (expect (subed-in-header-p) :to-be nil))) (it "returns nil in the middle of timing information." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "--") (expect (subed-in-header-p) :to-be nil))) (it "returns nil in the middle of a cue." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "This") (expect (subed-in-header-p) :to-be nil)) ) (it "returns nil in the middle of a cue with the text WEBVTT." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 WEBVTT ") (expect (subed-in-header-p) :to-be nil)))) (describe "whether you're in a comment" (it "returns nil in the header." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a test 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (goto-char (point-min)) (expect (subed-in-comment-p) :to-be nil))) (it "returns t at the beginning of a NOTE." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a test 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "NOTE") (expect (subed-in-comment-p) :to-be t))) (it "returns t in the middle of NOTE." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a test 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "OTE") (expect (subed-in-comment-p) :to-be t))) (it "returns t in the middle of NOTE text." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a test 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "test") (expect (subed-in-comment-p) :to-be t))) (it "returns t in the middle of a multi-line NOTE." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment with multiple lines. 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "multiple") (expect (subed-in-comment-p) :to-be t))) (it "returns t in an empty line before an ID." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment with multiple lines. 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "\n1") (expect (subed-in-comment-p) :to-be t))) (it "returns t in an empty line before a timestamp." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment with multiple lines. 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "\n0") (expect (subed-in-comment-p) :to-be t))) (it "returns nil at the beginning of an ID." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment with multiple lines. 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "^1") (expect (subed-in-comment-p) :to-be nil))) (it "returns nil at the beginning of a timestamp." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment with multiple lines. 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "^0") (expect (subed-in-comment-p) :to-be nil))) (it "returns nil in the middle of timing information." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment with multiple lines. 1 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "--") (expect (subed-in-comment-p) :to-be nil))) (it "returns t if there's a comment between the cursor and the previous cue." (with-temp-vtt-buffer (insert "WEBVTT 1 00:00:00.000 --> 00:00:01.000 This is a subtitle NOTE This is a comment with multiple lines. 2 00:00:00.000 --> 00:00:01.000 This is another subtitle") (re-search-backward "multiple") (expect (subed-in-comment-p) :to-be t))) (it "handles multiple blocks in a cue." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. NOTE This is a comment 2 00:02:02.234 --> 00:02:10.345 Apparently a subtitle can have multiple comements. Bar. 00:04:02.234 --> 00:04:10.345 Baz. ") (re-search-backward "Bar") (expect (subed-in-comment-p) :to-be nil))) (it "returns nil if there's a cue between the cursor and the previous comment." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment with multiple lines. 1 00:00:00.000 --> 00:00:01.000 This is the first subtitle 2 00:00:00.000 --> 00:00:01.000 This is a subtitle ") (re-search-backward "first") (expect (subed-in-comment-p) :to-be nil))) (it "returns nil if there's no comment." (with-temp-vtt-buffer (insert "WEBVTT 1 00:00:00.000 --> 00:00:01.000 This is the first subtitle 2 00:00:00.000 --> 00:00:01.000 This is the second subtitle ") (re-search-backward "second") (expect (subed-in-comment-p) :to-be nil))))) (describe "Jumping" (describe "to subtitle ID" (describe "in the current subtitle" (describe "from the header" (it "returns nil when the next subtitle starts with a timestamp." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-id) :to-be nil))) (it "returns nil when the next subtitle starts with a comment." (with-temp-vtt-buffer (insert "WEBVTT NOTE 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.000 --> 00:03:05.123 Bar. ") (goto-char (point-min)) (expect (subed-jump-to-subtitle-id) :to-be nil)) ) (it "returns nil when the next subtitle starts with an ID." (with-temp-vtt-buffer (insert "WEBVTT NOTE 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.000 --> 00:03:05.123 Bar. ") (goto-char (point-min)) (expect (subed-jump-to-subtitle-id) :to-be nil)))) (describe "when there is no comment" (it "goes to the ID if specified." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "Foo") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "1") :to-be t))) (it "goes to the timestamp if there is no ID." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. 00:04:02.234 --> 00:04:10.345 Baz. ") (re-search-backward "Bar") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "00:02:02.234") :to-be t)))) (describe "when there is no header" (it "goes to the timestamp if there is no ID." (with-temp-vtt-buffer (insert "00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "Foo") (expect (subed-jump-to-subtitle-id) :to-equal 1))) ) (describe "when there is a comment" (it "goes to the ID if specified." (with-temp-vtt-buffer (insert "WEBVTT NOTE Hello world 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "Foo") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "1") :to-be t))) (it "goes to the timestamp if there is no ID." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. NOTE This is a comment 00:02:02.234 --> 00:02:10.345 Bar. 00:04:02.234 --> 00:04:10.345 Baz. ") (re-search-backward "Bar") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "00:02:02.234") :to-be t)))) (describe "when there are multiple blocks" (it "goes to the ID if specified." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. NOTE This is a comment 2 00:02:02.234 --> 00:02:10.345 Apparently a subtitle can have multiple comements. Bar. 00:04:02.234 --> 00:04:10.345 Baz. ") (re-search-backward "Bar") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "2") :to-be t))) (it "goes to the timestamp if there is no ID." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. NOTE This is a comment 00:02:02.234 --> 00:02:10.345 Apparently a subtitle can have multiple comements. Bar. 00:04:02.234 --> 00:04:10.345 Baz. ") (re-search-backward "Bar") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "00:02:02.234") :to-be t)))) (describe "when called from a comment" (it "goes to the ID of the subtitle after the comment." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 Something goes here NOTE This is a comment 2 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "This is a comment") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "2\n") :to-be t))) (it "goes to the ID of the subtitle after the comment even at the NOTE line." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 Something goes here NOTE This is a comment 2 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "NOTE") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at "2\n") :to-be t))) (it "goes to the timestamp of the subtitle after the comment if no ID is specified." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. NOTE This is a comment 00:02:02.234 --> 00:02:10.345 Bar. 00:03:02.234 --> 00:03:10.345 Baz. ") (re-search-backward "This is a comment") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t))) (it "goes to the timestamp of the subtitle after the comment even with a short timestamp." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. NOTE This is a comment 02:02.234 --> 00:02:10.345 Bar. 00:03:02.234 --> 00:03:10.345 Baz. ") (re-search-backward "This is a comment") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at (regexp-quote "02:02.234")) :to-be t))) (it "goes to the timestamp of the last subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (re-search-backward "00:03:03") (expect (subed-jump-to-subtitle-id) :not :to-be nil) (expect (looking-at (regexp-quote "00:03:03")) :to-be t))))) (describe "when given an ID" (it "returns ID's point if wanted time exists." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. NOTE This is a comment 00:02:02.234 --> 00:02:10.345 Bar. ") (goto-char (point-max)) (expect (subed-jump-to-subtitle-id "1") :not :to-be nil) (expect (looking-at "1\n") :to-be t))) (it "returns nil and does not move if wanted ID does not exists." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (search-forward "Foo") (let ((stored-point (point))) (expect (subed-jump-to-subtitle-id "3") :to-equal nil) (expect stored-point :to-equal (point)))))) (describe "when given a timestamp" (it "returns timestamp's point if wanted time exists." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-max)) (expect (subed-jump-to-subtitle-id "00:02:02.234") :to-equal 45) (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t) (expect (subed-jump-to-subtitle-id "00:01:01.000") :to-equal 9) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t))) (it "returns nil and does not move if wanted ID does not exists." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (search-forward "Foo") (let ((stored-point (point))) (expect (subed-jump-to-subtitle-id "0:08:00") :to-equal nil) (expect stored-point :to-equal (point))))))) (describe "to subtitle start pos" (describe "in the current subtitle" (it "returns nil in the header." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-start-pos) :to-be nil))) (it "goes to the ID if specified." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "Foo") (expect (subed-jump-to-subtitle-start-pos) :not :to-be nil) (expect (looking-at "1") :to-be t))) (it "goes to the timestamp if there is no ID." (with-temp-vtt-buffer (insert "WEBVTT 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "Bar") (expect (subed-jump-to-subtitle-start-pos) :not :to-be nil) (expect (looking-at "00:02:02.234") :to-be t))) (it "goes to the comment if there is one." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "Foo") (expect (subed-jump-to-subtitle-start-pos) :not :to-be nil) (expect (looking-at "NOTE This is a comment") :to-be t))) (describe "when called from a comment" (it "goes to the start of the comment." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "This is a comment") (expect (subed-jump-to-subtitle-start-pos) :not :to-be nil) (expect (looking-at "NOTE\nThis is a comment") :to-be t))) (it "goes to the start of the comment." (with-temp-vtt-buffer (insert "WEBVTT NOTE This is a comment 1 00:01:01.000 --> 00:01:05.123 Foo. 00:02:02.234 --> 00:02:10.345 Bar. ") (re-search-backward "OTE") (expect (subed-jump-to-subtitle-start-pos) :not :to-be nil) (expect (looking-at "NOTE\nThis is a comment") :to-be t)))))) (describe "to subtitle start time" (it "returns start time's point if movement was successful." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-forward-subtitle-id) (expect (subed-jump-to-subtitle-time-start) :to-equal 9) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:01:01.000") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-start) :to-equal 45) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:02:02.234") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-start) :to-equal 81) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:03:03.45"))) (it "returns nil if movement failed." (with-temp-vtt-buffer (expect (subed-jump-to-subtitle-time-start) :to-equal nil))) (describe "when timing info doesn't have a blank line before it" :var ((test-data "WEBVTT 00:00:01.000 --> 00:00:01.999 This is a test 00:00:02.000 --> 00:00:02.999 This is another test NOTE This is a comment with a second line. 00:00:03.000 --> 00:00:03.999 This is a third test. ")) (it "returns nil from the header." (with-temp-vtt-buffer (insert test-data) (goto-char (point-min)) (expect (subed-jump-to-subtitle-time-start) :to-be nil) (expect (point) :to-equal 1))) (it "jumps to the first timing from the start of the timestamp." (with-temp-vtt-buffer (insert test-data) (re-search-backward "00:00:01\\.000") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:01") :to-be t))) (it "jumps to the first timing line from the middle of the timestamp." (with-temp-vtt-buffer (insert test-data) (goto-char (point-min)) (re-search-forward "--") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:01") :to-be t))) (it "jumps to the first timing line from the text." (with-temp-vtt-buffer (insert test-data) (re-search-backward "This is a test") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:01") :to-be t))) (it "jumps to the middle timing line from the start of the timestamp." (with-temp-vtt-buffer (insert test-data) (re-search-backward "00:00:02\\.000") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:02") :to-be t))) (it "jumps to the middle timing line from the middle of the timestamp." (with-temp-vtt-buffer (insert test-data) (re-search-backward "00:00:02\\.000 --") (goto-char (match-end 0)) (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:02") :to-be t))) (it "jumps to the middle timing line from the text." (with-temp-vtt-buffer (insert test-data) (re-search-backward "another") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:02") :to-be t))) (it "jumps to the last timing line from the start of the comment." (with-temp-vtt-buffer (insert test-data) (re-search-backward "NOTE") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:03") :to-be t)) ) (it "jumps to the last timing line from the middle of the comment." (with-temp-vtt-buffer (insert test-data) (re-search-backward "comment") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:03") :to-be t))) (it "jumps to the last timing line from the end of the comment." (with-temp-vtt-buffer (insert test-data) (re-search-backward "second line.") (goto-char (match-end 0)) (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:03") :to-be t))) (it "jumps to the last timing line from the start of the timestamp." (with-temp-vtt-buffer (insert test-data) (re-search-backward "00:00:03\\.000") (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:03") :to-be t))) (it "jumps to the closest timing line from the end of the file." (with-temp-vtt-buffer (insert test-data) (subed-jump-to-subtitle-time-start) (expect (looking-at "00:00:03") :to-be t))))) (describe "to subtitle stop time" (it "returns stop time's point if movement was successful." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-forward-subtitle-id) (expect (subed-jump-to-subtitle-time-stop) :to-equal 26) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:01:05.123") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-stop) :to-equal 62) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:02:10.345") (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-time-stop) :to-equal 97) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:03:15.5"))) (it "returns nil if movement failed." (with-temp-vtt-buffer (expect (subed-jump-to-subtitle-time-stop) :to-equal nil))) ) (describe "to current subtitle timestamp" (it "returns timestamp's point when point is already on the timestamp." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-jump-to-subtitle-id "00:01:01.000") (expect (subed-jump-to-subtitle-time-start) :to-equal (point)) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:01:01.000"))) (it "returns timestamp's point when point is on the text." (with-temp-vtt-buffer (insert mock-vtt-data) (search-backward "Baz.") (expect (thing-at-point 'word) :to-equal "Baz") (expect (subed-jump-to-subtitle-time-start) :to-equal 81) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:03:03.45"))) (it "returns timestamp's point when point is between subtitles." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (search-forward "Bar.\n") (expect (thing-at-point 'line) :to-equal "\n") (expect (subed-jump-to-subtitle-time-start) :to-equal 45) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:02:02.234"))) (it "returns nil if buffer is empty." (with-temp-vtt-buffer (expect (buffer-string) :to-equal "") (expect (subed-jump-to-subtitle-time-start) :to-equal nil))) (it "returns timestamp's point when buffer starts with blank lines." (with-temp-vtt-buffer (insert (concat "WEBVTT \n \t \n" (replace-regexp-in-string "WEBVTT" "" mock-vtt-data))) (search-backward "Foo.") (expect (thing-at-point 'line) :to-equal "Foo.\n") (expect (subed-jump-to-subtitle-time-start) :to-equal 15) (expect (looking-at subed--regexp-timestamp) :to-be t) (expect (match-string 0) :to-equal "00:01:01.000"))) ;; I'm not sure this is actually supported by the spec. ;; (it "returns timestamp's point when subtitles are separated with blank lines." ;; (with-temp-vtt-buffer ;; (insert mock-vtt-data) ;; (goto-char (point-min)) ;; (search-forward "Foo.\n") ;; (insert " \n \t \n") ;; (expect (subed-jump-to-subtitle-time-start) :to-equal 9) ;; (expect (looking-at subed--regexp-timestamp) :to-be t) ;; (expect (match-string 0) :to-equal "00:01:01.000"))) (it "works with short timestamps from a comment." (with-temp-vtt-buffer (insert "WEBVTT\n\nNOTE A comment goes here 09:34.900 --> 00:09:37.659 Subtitle 1 00:10:34.900 --> 00:11:37.659 Subtitle 2") (re-search-backward "NOTE") (goto-char (line-beginning-position)) (expect (subed-jump-to-subtitle-time-start) :to-equal 35))) ) (describe "to subtitle text" (it "returns subtitle text's point if movement was successful." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-forward-subtitle-id) (expect (subed-jump-to-subtitle-text) :to-equal 39) (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Foo."))) (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-text) :to-equal 75) (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Bar."))) (re-search-forward "\n\n") (expect (subed-jump-to-subtitle-text) :to-equal 108) (expect (point) :to-equal (save-excursion (goto-char (point-max)) (search-backward "Baz."))))) (it "returns nil if movement failed." (with-temp-vtt-buffer (expect (subed-jump-to-subtitle-time-stop) :to-equal nil))) (it "works with short timestamps from a comment." (with-temp-vtt-buffer (insert "WEBVTT\n\nNOTE A comment goes here 09:34.900 --> 00:09:37.659 Subtitle 1 00:10:34.900 --> 00:11:37.659 Subtitle 2") (re-search-backward "NOTE") (goto-char (line-beginning-position)) (expect (subed-jump-to-subtitle-text) :to-equal 62))) (it "works even when the subtitle has no text and is the only subtitle." (with-temp-vtt-buffer (insert "00:00:00.000 --> 00:00:01.000 ") (goto-char (point-min)) (subed-jump-to-subtitle-text) (expect (looking-back "\\.000\n") :to-be t)) ) ) (describe "to end of subtitle text" (it "returns point if subtitle end can be found." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-forward-subtitle-id) (expect (subed-jump-to-subtitle-end) :to-be 43) (expect (looking-back "^Foo.$") :to-be t) (forward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 79) (expect (looking-back "^Bar.$") :to-be t) (forward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 112) (expect (looking-back "^Baz.$") :to-be t) (goto-char (point-max)) (backward-char 2) (expect (subed-jump-to-subtitle-end) :to-be 112) (expect (looking-back "^Baz.$") :to-be t))) (it "returns nil if subtitle end cannot be found." (with-temp-vtt-buffer (expect (subed-jump-to-subtitle-end) :to-be nil))) (it "returns nil if point did not move." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (kill-line) (expect (subed-jump-to-subtitle-end) :to-be nil))) (it "works if text is empty with trailing newline." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (kill-line) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 39) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text "00:02:02.234") (kill-line) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 71) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text "00:03:03.45") (kill-line) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 100) (expect (looking-at "^$") :to-be t))) (it "works if text is empty without trailing newline." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (kill-whole-line) (expect (subed-jump-to-subtitle-end) :to-be nil) (expect (looking-at "^$") :to-be t) (goto-char (point-min)) (subed-forward-subtitle-id) (expect (subed-jump-to-subtitle-end) :to-be 39) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text "00:02:02.234") (kill-whole-line) (expect (subed-jump-to-subtitle-end) :to-be nil) (expect (looking-at "^$") :to-be t) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 70) (expect (looking-at "^$") :to-be t) (subed-jump-to-subtitle-text "00:03:03.45") (kill-whole-line) (expect (subed-jump-to-subtitle-end) :to-be nil) (expect (looking-at "^$") :to-be t) (backward-char) (expect (subed-jump-to-subtitle-end) :to-be 98) (expect (looking-at "^$") :to-be t))) (it "handles linebreaks at the beginning." (with-temp-vtt-buffer (insert "WEBVTT Kind: captions Language: en 00:00:02.459 --> 00:00:05.610 align:start position:0% Hello world ") (goto-char (point-min)) (subed-forward-subtitle-id) (subed-jump-to-subtitle-end) (expect (point) :to-be-greater-than (- (point-max) 2)))) (it "works with short timestamps from a comment." (with-temp-vtt-buffer (insert "WEBVTT\n\nNOTE A comment goes here 09:34.900 --> 00:09:37.659 Subtitle 1 00:10:34.900 --> 00:11:37.659 Subtitle 2") (re-search-backward "NOTE") (goto-char (line-beginning-position)) (expect (subed-jump-to-subtitle-end) :to-equal 72))) (it "works with optional IDs and multi-line cues where a line is all numbers." (with-temp-vtt-buffer (insert "WEBVTT 1 00:00:00.000 --> 00:00:01.000 This is first subtitle. 123456789 2 00:00:01.000 --> 00:02:00.000 This is second subtitle. ") (re-search-backward "This is first") (expect (subed-jump-to-subtitle-end) :to-be 74))) (it "works with multiple blocks in a subtitle." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 A subtitle can consist of multiple blocks 00:00:01.000 --> 00:02:00.000 This is the second subtitle. ") (re-search-backward "A subtitle can") (expect (subed-jump-to-subtitle-end) :to-be 82))) (it "ignores ending blank lines and spaces." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 A subtitle can consist of multiple blocks 00:00:01.000 --> 00:02:00.000 This is the second subtitle. ") (re-search-backward "A subtitle can") (expect (subed-jump-to-subtitle-end) :to-be 82))) (it "ignores ending blank lines at the end of the buffer." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 A subtitle can consist of multiple blocks ") (re-search-backward "A subtitle can") (expect (subed-jump-to-subtitle-end) :to-be 82))) (it "stops before lines that have -->." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:01.000 This is a test a--> ") (re-search-backward "This is a test") (expect (subed-jump-to-subtitle-end) :to-be 53)))) (describe "to next subtitle ID" (it "returns point when there is a next subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:01:01.000") (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (expect (subed-forward-subtitle-id) :to-be 45) (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t) (subed-jump-to-subtitle-time-start "00:02:02.234") (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t) (expect (subed-forward-subtitle-id) :to-be 81) (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t))) (it "returns nil in an empty buffer." (with-temp-vtt-buffer (expect (thing-at-point 'word) :to-equal nil) (expect (subed-forward-subtitle-id) :to-be nil))) (it "moves forward in a buffer." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-forward-subtitle-id) :to-be 45) (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t) (subed-jump-to-subtitle-time-stop "00:02:02.234") (expect (looking-at (regexp-quote "00:02:10.345")) :to-be t) (expect (subed-forward-subtitle-id) :to-be 81) (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t))) (it "doesn't move when at the last subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:03:03.45") (expect (thing-at-point 'word) :to-equal "Baz") (expect (subed-forward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "Baz"))) (it "doesn't move when at the last subtitle's time stop." (with-temp-vtt-buffer (insert (concat mock-vtt-data "\n\n")) (subed-jump-to-subtitle-time-stop "00:03:03.45") (expect (thing-at-point 'word) :to-equal "00") (expect (subed-forward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "00"))) (it "finds the next subtitle timing even when there's no blank line before it." (with-temp-vtt-buffer (insert "WEBVTT 00:00:01.000 --> 00:00:01.999 This is a test 00:00:02.000 --> 00:00:02.999 This is another test NOTE This is a comment with a second line. 00:00:03.000 --> 00:00:03.999 This is a third test. ") (re-search-backward "This is a test") (subed-forward-subtitle-id) (expect (looking-at "00:00:02") :to-be t) ))) (describe "to previous subtitle ID" (it "returns point when there is a previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (expect (thing-at-point 'word) :to-equal "Bar") (expect (subed-backward-subtitle-id) :to-be 9) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (subed-jump-to-subtitle-time-stop "00:03:03.45") (expect (looking-at (regexp-quote "00:03:15.5")) :to-be t) (expect (subed-backward-subtitle-id) :to-be 45) (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t))) (it "does not get confused by empty lines at the end of the buffer." (with-temp-vtt-buffer (insert mock-vtt-data "\n\n") (expect (subed-backward-subtitle-id) :not :to-be nil))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-vtt-buffer (expect (subed-backward-subtitle-id) :to-be nil)) (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:01:01.000") (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (expect (subed-backward-subtitle-id) :to-be nil) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t)) (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-backward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "Foo")) (with-temp-vtt-buffer (insert (concat "\n\n\n" mock-vtt-data)) (subed-jump-to-subtitle-time-stop "00:01:01.000") (expect (thing-at-point 'word) :to-equal "00") (expect (subed-backward-subtitle-id) :to-be nil) (expect (thing-at-point 'word) :to-equal "00"))) ) (describe "to next subtitle text" (it "returns point when there is a next subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:01:01.000") (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (expect (subed-forward-subtitle-text) :to-be 75) (expect (thing-at-point 'word) :to-equal "Bar"))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-vtt-buffer (goto-char (point-max)) (insert (concat mock-vtt-data "\n\n")) (subed-jump-to-subtitle-id "00:03:03.45") (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t) (expect (subed-forward-subtitle-text) :to-be nil) (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t))) (it "handles blank lines at the start of a caption." (with-temp-vtt-buffer (insert "WEBVTT Kind: captions Language: en 00:00:02.459 --> 00:00:05.610 align:start position:0% hi<00:00:03.459> welcome<00:00:03.850> to<00:00:03.999> another<00:00:04.149> episode<00:00:04.509> of<00:00:05.020> Emacs ") (goto-char (point-min)) (subed-forward-subtitle-text) (expect (looking-at "\nhi") :to-be t))) ) (describe "to previous subtitle text" (it "returns point when there is a previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t) (expect (subed-backward-subtitle-text) :to-be 75) (expect (thing-at-point 'word) :to-equal "Bar"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-forward-subtitle-time-start) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (expect (subed-backward-subtitle-text) :to-be nil) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t))) ) (describe "to next subtitle end" (it "returns point when there is a next subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (expect (thing-at-point 'word) :to-equal "Bar") (expect (subed-forward-subtitle-end) :to-be 112) (expect (thing-at-point 'word) :to-equal nil))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-vtt-buffer (insert (concat mock-vtt-data "\n\n")) (subed-jump-to-subtitle-text "00:03:03.45") (end-of-line) (expect (thing-at-point 'word) :to-equal nil) (expect (subed-forward-subtitle-end) :to-be nil) (expect (thing-at-point 'word) :to-equal nil))) ) (describe "to previous subtitle end" (it "returns point when there is a previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t) (expect (subed-backward-subtitle-text) :to-be 75) (expect (thing-at-point 'word) :to-equal "Bar"))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-forward-subtitle-id) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (expect (subed-backward-subtitle-text) :to-be nil) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t))) ) (describe "to next subtitle start time" (it "returns point when there is a next subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-forward-subtitle-time-start) :to-be 45) (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t) (expect (subed-forward-subtitle-time-start) :to-be nil) (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t))) ) (describe "to previous subtitle start time" (it "returns point when there is a previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:02:02.234") (expect (looking-at (regexp-quote "00:02:02.234")) :to-be t) (expect (subed-backward-subtitle-time-start) :to-be 9) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:01:01.000") (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (expect (subed-backward-subtitle-time-start) :to-be nil) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t))) ) (describe "to next subtitle stop time" (it "returns point when there is a next subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (expect (thing-at-point 'word) :to-equal "Foo") (expect (subed-forward-subtitle-time-stop) :to-be 62) (expect (looking-at (regexp-quote "00:02:10.345")) :to-be t))) (it "returns nil and doesn't move when there is no next subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t) (expect (subed-forward-subtitle-time-stop) :to-be nil) (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t))) ) (describe "to previous subtitle stop time" (it "returns point when there is a previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (expect (looking-at (regexp-quote "00:03:03.45")) :to-be t) (expect (subed-backward-subtitle-time-stop) :to-be 62) (expect (looking-at (regexp-quote "00:02:10.345")) :to-be t))) (it "returns nil and doesn't move when there is no previous subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:01:01.000") (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t) (expect (subed-backward-subtitle-time-stop) :to-be nil) (expect (looking-at (regexp-quote "00:01:01.000")) :to-be t))) )) (describe "Getting" (describe "the subtitle ID" (it "returns the subtitle ID if it can be found." (with-temp-vtt-buffer (insert mock-vtt-data) (re-search-backward "00:01:01.000") (expect (subed-subtitle-id) :to-equal "00:01:01.000"))) (it "returns nil if no subtitle ID can be found." (with-temp-vtt-buffer (expect (subed-subtitle-id) :to-equal nil))) (it "handles extra attributes" (with-temp-vtt-buffer (insert "WEBVTT 00:00:01.000 --> 00:00:02.000 align:start position:0% Hello world") (expect (subed-subtitle-id) :to-equal "00:00:01.000")))) (describe "the subtitle ID at playback time" (it "returns subtitle ID if time is equal to start time." (with-temp-vtt-buffer (insert mock-vtt-data) (expect (subed-subtitle-id-at-msecs (subed-timestamp-to-msecs "00:01:01.000")) :to-equal "00:01:01.000"))) (it "returns subtitle ID if time is equal to stop time." (with-temp-vtt-buffer (insert mock-vtt-data) (expect (subed-subtitle-id-at-msecs (subed-timestamp-to-msecs "00:02:10.345")) :to-equal "00:02:02.234"))) (it "returns subtitle ID if time is between start and stop time." (with-temp-vtt-buffer (insert mock-vtt-data) (expect (subed-subtitle-id-at-msecs (subed-timestamp-to-msecs "00:02:05.345")) :to-equal "00:02:02.234"))) (it "returns nil if time is before the first subtitle's start time." (with-temp-vtt-buffer (insert mock-vtt-data) (let ((msecs (- (save-excursion (goto-char (point-min)) (subed-forward-subtitle-id) (subed-subtitle-msecs-start)) 1))) (expect (subed-subtitle-id-at-msecs msecs) :to-equal nil)))) (it "returns nil if time is after the last subtitle's start time." (with-temp-vtt-buffer (insert mock-vtt-data) (let ((msecs (+ (save-excursion (goto-char (point-max)) (subed-subtitle-msecs-stop)) 1))) (expect (subed-subtitle-id-at-msecs msecs) :to-equal nil)))) (it "returns nil if time is between subtitles." (with-temp-vtt-buffer (insert mock-vtt-data) (expect (subed-subtitle-id-at-msecs (subed-timestamp-to-msecs "00:01:06.123")) :to-equal nil)))) (describe "the subtitle start/stop time" (it "returns the time in milliseconds." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:02:02.234") (expect (subed-subtitle-msecs-start) :to-equal (+ (* 2 60000) (* 2 1000) 234)) (expect (subed-subtitle-msecs-stop) :to-equal (+ (* 2 60000) (* 10 1000) 345)))) (it "handles lack of digits in milliseconds gracefully." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "00:03:03.45 --> 00:03:15.5\n") (expect (subed-subtitle-msecs-start) :to-equal (+ (* 3 60 1000) (* 3 1000) 450)) (expect (subed-subtitle-msecs-stop) :to-equal (+ (* 3 60 1000) (* 15 1000) 500)))) (it "handles lack of hours in milliseconds gracefully." (with-temp-vtt-buffer (insert "WEBVTT\n\n01:02.000 --> 03:04.000\nHello\n") (expect (subed-subtitle-msecs-start) :to-equal (+ (* 1 60 1000) (* 2 1000))) (expect (subed-subtitle-msecs-stop) :to-equal (+ (* 3 60 1000) (* 4 1000))))) (it "returns nil if time can't be found." (with-temp-vtt-buffer (expect (subed-subtitle-msecs-start) :to-be nil) (expect (subed-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-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (kill-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and at the beginning without a trailing newline." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (kill-whole-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and in the middle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (kill-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and at the end with a trailing newline." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:03:03.45") (kill-line) (expect (subed-subtitle-text) :to-equal ""))) (it "and at the end without a trailing newline." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:03:03.45") (kill-whole-line) (expect (subed-subtitle-text) :to-equal ""))) ) (describe "when text is not empty" (it "handles no linebreaks." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (expect (subed-subtitle-text) :to-equal "Bar."))) (it "handles linebreaks." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (insert "Bar.\n") (expect (subed-subtitle-text) :to-equal "Bar.\nBar."))) (it "handles linebreaks at the beginning." (with-temp-vtt-buffer (insert "WEBVTT Kind: captions Language: en 00:00:02.459 --> 00:00:05.610 align:start position:0% Hello world ") (subed-jump-to-subtitle-text "00:00:02.459") (expect (subed-subtitle-text) :to-equal "\nHello world"))) ) ) (describe "the point within the subtitle" (it "returns the relative point if we can find an ID." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:02:02.234") (expect (subed-subtitle-relative-point) :to-equal 0) (forward-line) (expect (subed-subtitle-relative-point) :to-equal 30) (forward-char) (expect (subed-subtitle-relative-point) :to-equal 31) (forward-line) (expect (subed-subtitle-relative-point) :to-equal 35) (forward-line) (expect (subed-subtitle-relative-point) :to-equal 0))) (it "returns nil if we can't find an ID." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:01:01.000") (insert "foo") (expect (subed-subtitle-relative-point) :to-equal nil))) ) (describe "the subtitle start position" (it "returns the start from inside a subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (re-search-backward "Bar") (expect (subed-subtitle-start-pos) :to-equal 45))) (it "returns the start from the beginning of the line." (with-temp-vtt-buffer (insert mock-vtt-data) (re-search-backward "00:02:02\\.234") (expect (subed-subtitle-start-pos) :to-equal 45))) (it "returns the start of a comment" (with-temp-vtt-buffer (insert mock-vtt-data) (re-search-backward "00:02:02\\.234") (insert "NOTE\n\nThis is a comment\n\n") (expect (subed-subtitle-start-pos) :to-equal 45))))) (describe "Converting to msecs" (it "works with numbers." (expect (with-temp-vtt-buffer (subed-to-msecs 5123)) :to-equal 5123)) (it "works with numbers as strings." (expect (with-temp-vtt-buffer (subed-to-msecs "5123")) :to-equal 5123)) (it "works with timestamps." (expect (with-temp-vtt-buffer (subed-to-msecs "00:00:05.124")) :to-equal 5124))) (describe "Setting start/stop time" (it "of current subtitle updates it." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-end "00:02:02.234") (subed-set-subtitle-time-start (+ (* 1 60 60 1000) (* 2 60 1000) (* 3 1000) 400) nil t t) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\n" "01:02:03.400 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")) (subed-set-subtitle-time-stop (+ (* 5 60 60 1000) (* 6 60 1000) (* 7 1000) 800) nil t t) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\n" "01:02:03.400 --> 05:06:07.800\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")))) (it "of specific subtitle updates it." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-time-stop "00:01:01.000") (subed-set-subtitle-time-start (+ (* 2 60 60 1000) (* 4 60 1000) (* 6 1000) 800) 1 t t) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "02:04:06.800 --> 00:01:05.123\n" "Foo.\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")) (subed-jump-to-subtitle-text "00:03:03.45") (subed-set-subtitle-time-stop (+ (* 3 60 60 1000) (* 5 60 1000) (* 7 1000) 900) 3 t t) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "02:04:06.800 --> 00:01:05.123\n" "Foo.\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 03:05:07.900\n" "Baz.\n")))) (it "when milliseconds lack digits, fills the rest in." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (subed-set-subtitle-time-start (+ (* 1 60 60 1000) (* 2 60 1000) (* 3 1000) 4) 3 t t) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "01:02:03.004 --> 00:03:15.5\n") (subed-set-subtitle-time-stop (+ (* 2 60 60 1000) (* 3 60 1000) (* 4 1000) 60) 3 t t) (expect (save-excursion (subed-jump-to-subtitle-time-start) (thing-at-point 'line)) :to-equal "01:02:03.004 --> 02:03:04.060\n")))) (describe "Shifting subtitles" (describe "starting at a specific timestamp" (it "works when called from the start of the buffer." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-shift-subtitles-to-start-at-timestamp 500) (let ((data (subed-subtitle-list))) (expect (elt (elt (subed-subtitle-list) 0) 1) :to-equal 500) (expect (elt (elt (subed-subtitle-list) 0) 2) :to-equal 4623) (expect (elt (elt (subed-subtitle-list) 2) 1) :to-equal 122950)))) (it "only affects the current and following subtitles." (with-temp-vtt-buffer (insert mock-vtt-data) (re-search-backward "^Bar") (subed-shift-subtitles-to-start-at-timestamp 120000) (let ((data (subed-subtitle-list))) (expect (elt (elt (subed-subtitle-list) 0) 1) :to-equal 61000) (expect (elt (elt (subed-subtitle-list) 1) 1) :to-equal 120000)))))) (describe "Inserting a subtitle" (describe "in an empty buffer" (describe "before" (it "creates a cue with default values." (with-temp-vtt-buffer (expect (subed-prepend-subtitle) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:00:00.000 --> 00:00:01.000\n\n")) (expect (point) :to-equal 31))) (it "creates a cue with a start time." (with-temp-vtt-buffer (expect (subed-prepend-subtitle nil 60000) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:01:00.000 --> 00:01:01.000\n\n")) (expect (point) :to-equal 31))) (it "creates a cue with a start time and stop time." (with-temp-vtt-buffer (expect (subed-prepend-subtitle nil 60000 65000) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:01:00.000 --> 00:01:05.000\n\n")) (expect (point) :to-equal 31))) (it "creates a cue with a start time, stop time and text." (with-temp-vtt-buffer (expect (subed-prepend-subtitle nil 60000 65000 "Foo. bar\nbaz.") :to-equal 31) (expect (buffer-string) :to-equal (concat "00:01:00.000 --> 00:01:05.000\n" "Foo. bar\nbaz.\n")) (expect (point) :to-equal 31))) ) (describe "when appending" (it "creates a subtitle with default arguments." (with-temp-vtt-buffer (expect (subed-append-subtitle) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:00:00.000 --> 00:00:01.000\n\n")) (expect (point) :to-equal 31))) (it "creates a subtitle with a start time." (with-temp-vtt-buffer (expect (subed-append-subtitle nil 60000) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:01:00.000 --> 00:01:01.000\n\n")) (expect (point) :to-equal 31))) (it "creates a subtitle with a start time and stop time." (with-temp-vtt-buffer (expect (subed-append-subtitle nil 60000 65000) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:01:00.000 --> 00:01:05.000\n\n")) (expect (point) :to-equal 31))) (it "creates a subtitle with a start time, stop time and text." (with-temp-vtt-buffer (expect (subed-append-subtitle nil 60000 65000 "Foo, bar\nbaz.") :to-equal 31) (expect (buffer-string) :to-equal (concat "00:01:00.000 --> 00:01:05.000\n" "Foo, bar\nbaz.\n")) (expect (point) :to-equal 31))) (it "creates a subtitle with a start time, stop time, text, and a single-line comment." (with-temp-vtt-buffer (subed-append-subtitle nil 60000 65000 "Foo, bar\nbaz." "Hello") (expect (buffer-string) :to-equal (concat "NOTE Hello\n\n00:01:00.000 --> 00:01:05.000\n" "Foo, bar\nbaz.\n")) (expect (looking-at "Foo") :to-be t))) (it "creates a subtitle with a start time, stop time, text, and a multi-line comment." (with-temp-vtt-buffer (subed-append-subtitle nil 60000 65000 "Foo, bar\nbaz." "Hello\nworld") (expect (buffer-string) :to-equal (concat "NOTE\nHello\nworld\n\n00:01:00.000 --> 00:01:05.000\n" "Foo, bar\nbaz.\n")) (expect (looking-at "Foo") :to-be t))))) (describe "in a non-empty buffer" (describe "before the current subtitle" (describe "with point on the first subtitle" (it "passing nothing." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:00:00.000 --> 00:00:01.000\n" "\n\n" "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (expect (point) :to-equal 31))) (it "passing start time." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle nil 1500) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:00:01.500 --> 00:00:02.500\n" "\n\n" "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (expect (point) :to-equal 31))) (it "passing start time and stop time." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle nil 1500 2000) :to-equal 31) (expect (buffer-string) :to-equal (concat "00:00:01.500 --> 00:00:02.000\n" "\n\n" "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (expect (point) :to-equal 31))) (it "passing start time, stop time and text." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-time-stop) (expect (subed-prepend-subtitle nil 1500 3000 "Bar.") :to-equal 31) (expect (buffer-string) :to-equal (concat "00:00:01.500 --> 00:00:03.000\n" "Bar.\n\n" "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (expect (point) :to-equal 31))) ) (describe "with point on a non-first subtitle" (it "passing nothing." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (subed-jump-to-subtitle-text "00:00:10.000") (expect (subed-prepend-subtitle) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:00.000 --> 00:00:01.000\n" "\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (expect (point) :to-equal 67))) (it "passing start time." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (subed-jump-to-subtitle-text "00:00:10.000") (expect (subed-prepend-subtitle nil 7000) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:07.000 --> 00:00:08.000\n" "\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (expect (point) :to-equal 67))) (it "passing start time and stop time." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (subed-jump-to-subtitle-text "00:00:10.000") (expect (subed-prepend-subtitle nil 7000 7123) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:07.000 --> 00:00:07.123\n" "\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (expect (point) :to-equal 67))) (it "passing start time, stop time and text." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (subed-jump-to-subtitle-text "00:00:10.000") (expect (subed-prepend-subtitle nil 7000 7123 "Baz.") :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:07.000 --> 00:00:07.123\n" "Baz.\n\n" "00:00:10.000 --> 00:00:12.000\n" "Bar.\n")) (expect (point) :to-equal 67))) ) ) (describe "after the current subtitle" (describe "with point on the last subtitle" (it "passing nothing." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:00.000 --> 00:00:01.000\n" "\n")) (expect (point) :to-equal 67))) (it "passing start time." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle nil 12345) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:12.345 --> 00:00:13.345\n" "\n")) (expect (point) :to-equal 67))) (it "passing start time and stop time." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle nil 12345 15000) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:12.345 --> 00:00:15.000\n" "\n")) (expect (point) :to-equal 67))) (it "passing start time, stop time and text." (with-temp-vtt-buffer (insert (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n")) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle nil 12345 15000 "Bar.") :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:05.000 --> 00:00:06.000\n" "Foo.\n\n" "00:00:12.345 --> 00:00:15.000\n" "Bar.\n")) (expect (point) :to-equal 67))) ) (describe "with point on a non-last subtitle" (it "inserts an empty subtitle." (with-temp-vtt-buffer (insert (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start "00:00:01.000") (subed-append-subtitle) (expect (point) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:00.000 --> 00:00:01.000\n" "\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (expect (point) :to-equal 67))) (it "passing start time." (with-temp-vtt-buffer (insert (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start "00:00:01.000") (expect (subed-append-subtitle nil 2500) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:02.500 --> 00:00:03.500\n" "\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (expect (point) :to-equal 67))) (it "passing start time and stop time." (with-temp-vtt-buffer (insert (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start "00:00:01.000") (expect (subed-append-subtitle nil 2500 4000) :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:02.500 --> 00:00:04.000\n" "\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (expect (point) :to-equal 67))) (it "passing start time, stop time and text." (with-temp-vtt-buffer (insert (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start "00:00:01.000") (expect (subed-append-subtitle nil 2500 4000 "Baz.") :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:02.500 --> 00:00:04.000\n" "Baz.\n\n" "00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (expect (point) :to-equal 67))) ) ) (describe "before a comment" (it "inserts before the comment." (with-temp-vtt-buffer (insert (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "NOTE comment\n\n00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (subed-jump-to-subtitle-time-start "00:00:01.000") (expect (subed-append-subtitle nil 2500 4000 "Baz.") :to-equal 67) (expect (buffer-string) :to-equal (concat "00:00:01.000 --> 00:00:02.000\n" "Foo.\n\n" "00:00:02.500 --> 00:00:04.000\n" "Baz.\n\n" "NOTE comment\n\n00:00:05.000 --> 00:00:06.000\n" "Bar.\n")) (expect (point) :to-equal 67)) ) ) (it "when point is on empty text." (with-temp-vtt-buffer (insert (concat "00:00:01.000 --> 00:00:02.000\n" "\n")) (forward-char -1) (subed-jump-to-subtitle-text) (expect (subed-append-subtitle) :to-equal 63) (expect (buffer-string) :to-equal (concat "00:00:01.000 --> 00:00:02.000\n" "\n\n" "00:00:00.000 --> 00:00:01.000\n" "\n")) (expect (point) :to-equal 63))) ) ) (describe "Killing a subtitle" (it "removes the first subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")))) (it "removes it in between." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")))) (it "removes the last subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:03:03.45") (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n")))) (describe "removes the previous subtitle when point is right above the timestamp" (it "of the last subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:03:03.45") (backward-char) (expect (looking-at "^\n00:03:03.45") :to-be t) (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")))) (it "of a non-last subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-id "00:02:02.234") (backward-char) (expect (looking-at "^\n00:02:02.234") :to-be t) (subed-kill-subtitle) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")))) ) ) (describe "Validating" (it "works in empty buffer." (with-temp-vtt-buffer (subed-validate))) (it "works in buffer that contains only newlines." (with-temp-vtt-buffer (cl-loop for _ from 1 to 10 do (insert "\n") (subed-validate)))) (it "works in buffer that contains only spaces." (with-temp-vtt-buffer (cl-loop for _ from 1 to 10 do (insert " ") (subed-validate)))) (it "works in buffer that contains only spaces and newlines." (with-temp-vtt-buffer (cl-loop for _ from 1 to 10 do (if (eq (random 2) 0) (insert " ") (insert "\n")) (subed-validate)))) (it "reports invalid stop time." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-time-stop "00:01:01.000") (forward-char 10) (insert "3") (expect (subed-validate) :to-throw 'error '("Found invalid stop time: \"00:01:01.000 --> 00:01:05.1323\"")) (expect (point) :to-equal 26))) (it "runs before saving." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-time-stop "00:01:01.000") (forward-char 10) (insert "3") (expect (subed-prepare-to-save) :to-throw 'error '("Found invalid stop time: \"00:01:01.000 --> 00:01:05.1323\"")) (expect (point) :to-equal 26))) (it "reports invalid time separator." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-time-stop "00:01:01.000") (delete-char -1) (expect (subed-validate) :to-throw 'error '("Found invalid separator between start and stop time: \"00:01:01.000 -->00:01:05.123\"")) (expect (point) :to-equal 21))) (it "does not report error when last subtitle text is empty." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:03:03.45") (kill-whole-line) (forward-char -2) (subed-validate) (expect (point) :to-equal 106))) (it "accepts mm:ss timestamps." (with-temp-vtt-buffer (insert "WebVTT\n\n00:00.003 --> 00:05.123\nThis is a test") (subed-validate) (expect (point) :to-equal (point-max)))) (it "preserves point if there is no error." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (forward-char 2) (subed-validate) (expect (point) :to-equal 77))) (it "accepts cue text that starts with something that looks like a timestamp." (with-temp-vtt-buffer (insert "WebVTT\n\n00:00:00.003 --> 00:00:05.123\n12:00 is noon.\n\n00:10:00.003 --> 00:11:05.123\nThis should be fine.") (subed-validate) (expect (point) :to-equal (point-max)))) ) (describe "Sanitizing" (it "removes trailing tabs and spaces from all lines." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match " \n")) (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data)) (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match "\t\n")) (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data))) (it "removes leading tabs and spaces from all lines." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match "\n ")) (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data)) (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (while (re-search-forward "\n" nil t) (replace-match "\n\t")) (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data))) (it "removes excessive empty lines between subtitles." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (forward-line 2) (while (re-search-forward "\n\n" nil t) (replace-match "\n\n\n\n\n\n")) (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data))) (it "retains comments" (with-temp-vtt-buffer (insert (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\nNOTE This is a test\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "NOTE\nAnother comment\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")) (subed-sanitize) (expect (buffer-string) :to-match "NOTE This is a test") (expect (buffer-string) :to-match "Another comment"))) (it "ensures double newline between subtitles if text of previous subtitle is empty." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:01:01.000") (kill-whole-line) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")) (subed-sanitize) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "Baz.\n")))) (it "removes empty lines from end of buffer." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-max)) (insert " \n\n\n") (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data))) (it "ensures a single newline after the last subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-max)) (while (eq (char-before (point-max)) ?\n) (delete-backward-char 1)) (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data))) (it "ensures single newline after last subtitle if text is empty." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:03:03.45") (kill-whole-line) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "")) (subed-sanitize) (expect (buffer-string) :to-equal (concat "WEBVTT\n\n" "00:01:01.000 --> 00:01:05.123\n" "Foo.\n\n" "00:02:02.234 --> 00:02:10.345\n" "Bar.\n\n" "00:03:03.45 --> 00:03:15.5\n" "\n")))) (it "ensures single space before and after time separators." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match "-->") (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-sanitize) (expect (buffer-string) :to-equal mock-vtt-data))) (it "runs before saving." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match " --> ") (re-search-forward " --> ") (replace-match "-->") (expect (buffer-string) :not :to-equal mock-vtt-data) (subed-prepare-to-save) (expect (buffer-string) :to-equal mock-vtt-data))) (it "does not insert newline in empty buffer." (with-temp-vtt-buffer (expect (buffer-string) :to-equal "") (subed-sanitize) (expect (buffer-string) :to-equal ""))) ) (describe "Sorting" (it "orders subtitles by start time." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (re-search-forward "01:01") (replace-match "12:01") (goto-char (point-min)) (re-search-forward "02:02") (replace-match "10:02") (goto-char (point-min)) (re-search-forward "03:03") (replace-match "11:03") (subed-sort) (expect (buffer-string) :to-equal (concat "WEBVTT\n" "\n" "00:10:02.234 --> 00:02:10.345\n" "Bar.\n" "\n" "00:11:03.45 --> 00:03:15.5\n" "Baz.\n" "\n" "00:12:01.000 --> 00:01:05.123\n" "Foo.\n")))) (it "runs before saving." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (re-search-forward "01:01") (replace-match "12:01") (goto-char (point-min)) (re-search-forward "02:02") (replace-match "10:02") (goto-char (point-min)) (re-search-forward "03:03") (replace-match "11:03") (subed-prepare-to-save) (expect (buffer-string) :to-equal (concat "WEBVTT\n" "\n" "00:10:02.234 --> 00:02:10.345\n" "Bar.\n" "\n" "00:11:03.45 --> 00:03:15.5\n" "Baz.\n" "\n" "00:12:01.000 --> 00:01:05.123\n" "Foo.\n")))) (describe "point preservation" (it "works when subtitle text is non-empty." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (re-search-forward "01:01") (replace-match "12:01") (search-forward "\n") (expect (current-word) :to-equal "Foo") (subed-sort) (expect (current-word) :to-equal "Foo"))) (it "works when subtitle text is empty." (with-temp-vtt-buffer (insert "WEBVTT\n\n00:12:01.000 --> 00:01:05.123\n") (let ((pos (point))) (subed-sort) (expect (buffer-string) :to-equal "WEBVTT\n\n00:12:01.000 --> 00:01:05.123\n\n") (expect (point) :to-equal pos)))) (it "works in the header." (with-temp-vtt-buffer (insert mock-vtt-data) (goto-char (point-min)) (subed-sort) (expect (point) :to-equal (point-min)))))) (describe "Converting msecs to timestamp" (it "uses the right format" (with-temp-vtt-buffer (expect (subed-msecs-to-timestamp 1401) :to-equal "00:00:01.401")))) (describe "Getting the list of subtitles" (it "handles arrows and the lack of blank lines between cues." (with-temp-vtt-buffer ;; https://github.com/web-platform-tests/wpt/blob/master/webvtt/parsing/file-parsing/tests/support/arrows.vtt (insert "WEBVTT --> 00:00:00.000 --> 00:00:01.000 text0 foo--> 00:00:00.000 --> 00:00:01.000 text1 -->foo 00:00:00.000 --> 00:00:01.000 text2 ---> 00:00:00.000 --> 00:00:01.000 text3 -->--> 00:00:00.000 --> 00:00:01.000 text4 00:00:00.000 --> 00:00:01.000 text5 00:00:00.000 -a --> 00:00:00.000 --a --> 00:00:00.000 - --> 00:00:00.000 -- -->") (let ((list (subed-subtitle-list))) (expect (length list) :to-equal 6) (seq-map-indexed (lambda (cue i) (expect (elt cue 0) :to-equal "00:00:00.000") (expect (elt cue 3) :to-equal (format "text%d" i))) list)))) (it "ignores things that look like comments in cue text." (with-temp-vtt-buffer (insert "WEBVTT NOTE this is real comment that should be ignored 00:00:00.000 --> 00:00:01.000 NOTE text NOTE this is also a real comment that should be ignored this is also a real comment that should be ignored 00:00:01.000 --> 00:00:02.000 NOTE text NOTE text2") (let ((list (subed-subtitle-list))) (expect (elt (elt list 0) 3) :to-equal "NOTE text") (expect (elt (elt list 0) 4) :to-equal "this is real comment that should be ignored") (expect (elt (elt list 1) 3) :to-equal "NOTE text\nNOTE text2") (expect (elt (elt list 1) 4) :to-equal "this is also a real comment that should be ignored\nthis is also a real comment that should be ignored"))) ) ) (describe "Working with comments" (before-each (setq mock-vtt-comments-data "WEBVTT 00:00:00.000 --> 00:00:01.000 This is a test. NOTE A comment can go here and have more text as needed. 00:01:00.000 --> 00:00:02.000 This is another test here. ")) (it "ignores the comment when jumping to the end of the subtitle" (with-temp-vtt-buffer (insert mock-vtt-comments-data) (goto-char (point-min)) (subed-forward-subtitle-end) (expect (current-word) :to-equal "test") (subed-forward-subtitle-end) (expect (current-word) :to-equal "here"))) (describe "jumping to the comment" (it "returns nil when there is no comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (re-search-backward "This is a test") (expect (subed-jump-to-subtitle-comment) :to-be nil))) (it "jumps to the comment for the current subtitle." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (goto-char (point-max)) (subed-jump-to-subtitle-comment) (expect (looking-at "NOTE A comment") :to-be t)))) (describe "getting the comment" (it "returns nil when there is no comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (re-search-backward "This is a test") (expect (subed-subtitle-comment) :to-be nil))) (it "returns the comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (goto-char (point-max)) (expect (subed-subtitle-comment) :to-equal "A comment can go here\nand have more text as needed.")))) (describe "setting the comment" (it "sets the comment when there isn't one yet." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (re-search-backward "This is a test") (subed-set-subtitle-comment "Skip") (expect (buffer-string) :to-equal "WEBVTT NOTE Skip 00:00:00.000 --> 00:00:01.000 This is a test. NOTE A comment can go here and have more text as needed. 00:01:00.000 --> 00:00:02.000 This is another test here. "))) (it "replaces the comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (goto-char (point-max)) (subed-set-subtitle-comment "Replaced.") (expect (buffer-string) :to-equal "WEBVTT 00:00:00.000 --> 00:00:01.000 This is a test. NOTE Replaced. 00:01:00.000 --> 00:00:02.000 This is another test here. "))) (it "clears the comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (goto-char (point-max)) (subed-set-subtitle-comment nil) (expect (buffer-string) :to-equal "WEBVTT 00:00:00.000 --> 00:00:01.000 This is a test. 00:01:00.000 --> 00:00:02.000 This is another test here. ")))) (describe "going to the next subtitle's comment" (it "returns nil in an empty buffer." (with-temp-vtt-buffer (expect (subed-forward-subtitle-comment) :to-be nil))) (it "returns nil at the end of the file." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (expect (subed-forward-subtitle-comment) :to-be nil))) (it "returns nil if the next subtitle does not have a comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (save-excursion (subed-append-subtitle)) (expect (subed-forward-subtitle-comment) :to-be nil))) (it "jumps to the next subtitle's comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (re-search-backward "This is a test") (expect (subed-forward-subtitle-comment) :not :to-be nil) (expect (looking-at "NOTE ") :to-be t)))) (describe "going to the previous comment" (it "returns nil in an empty buffer." (with-temp-vtt-buffer (expect (subed-backward-subtitle-comment) :to-be nil))) (it "returns nil at the start of the file." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (goto-char (point-min)) (expect (subed-backward-subtitle-comment) :to-be nil))) (it "returns nil if the previous subtitle does not have a comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (re-search-backward "This is another test here") (expect (subed-backward-subtitle-comment) :to-be nil))) (it "jumps to the previous subtitle's comment." (with-temp-vtt-buffer (insert mock-vtt-comments-data) (subed-append-subtitle) (expect (subed-backward-subtitle-comment) :not :to-be nil) (expect (looking-at "NOTE ") :to-be t)))) (describe "when the cue text starts with Note" (it "is not confused." (with-temp-vtt-buffer (insert "WEBVTT 00:00:00.000 --> 00:00:00.999 Note this is a test 00:00:01.000 --> 00:00:01.000 another test ") (let ((case-fold-search t)) (expect (elt (car (subed-subtitle-list)) 3) :to-equal "Note this is a test")))))) (describe "Merging with next subtitle" (it "throws an error in an empty buffer." (with-temp-vtt-buffer (expect (subed-merge-with-next) :to-throw 'error))) (it "throws an error with the last subtitle." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text 3) (expect (subed-merge-with-next) :to-throw 'error))) (it "combines the text and the time." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (subed-merge-with-next) (expect (subed-subtitle-text) :to-equal "Bar. Baz.") (expect (subed-subtitle-msecs-start) :to-equal 122234) (expect (subed-subtitle-msecs-stop) :to-equal 195500))) (it "updates looping." (with-temp-vtt-buffer (insert mock-vtt-data) (subed-jump-to-subtitle-text "00:02:02.234") (let ((subed-loop-seconds-before 1) (subed-loop-seconds-after 1)) (subed--set-subtitle-loop) (expect subed--subtitle-loop-start :to-equal (subed-timestamp-to-msecs "00:02:01.234")) (expect subed--subtitle-loop-stop :to-equal (subed-timestamp-to-msecs "00:02:11.345")) (subed-merge-with-next) (expect subed--subtitle-loop-start :to-equal (subed-timestamp-to-msecs "00:02:01.234")) (expect subed--subtitle-loop-stop :to-equal (subed-timestamp-to-msecs "00:03:16.500")))))) (describe "Font-locking" (it "recognizes VTT syntax." (with-temp-vtt-buffer (insert mock-vtt-data) (font-lock-fontify-buffer) (goto-char (point-min)) (re-search-forward "00:01:01") (expect (face-at-point) :to-equal 'subed-time-face) (re-search-forward "-->") (backward-char 1) (expect (face-at-point) :to-equal 'subed-time-separator-face)))) (describe "with cues" (it "parses properly." (with-temp-vtt-buffer (insert "WEBVTT - This file has cues. 14 00:01:14.815 --> 00:01:18.114 - What? - Where are we now? 15 00:01:18.171 --> 00:01:20.991 - This is big bat country. 16 00:01:21.058 --> 00:01:23.868 - [ Bats Screeching ] - They won't get in your hair. They're after the bugs.") (expect (elt (car (subed-subtitle-list)) 3) :to-equal "- What?\n- Where are we now?")))) (describe "conversion" (it "creates TXT." (with-temp-vtt-buffer (insert mock-vtt-data) (with-current-buffer (subed-convert "TXT") (expect (buffer-string) :to-equal "Foo.\nBar.\nBaz.\n")))) (it "includes comments in TXT if requested." (with-temp-vtt-buffer (insert "WEBVTT 00:01:14.815 --> 00:01:18.114 Hello NOTE Comment 00:01:18.171 --> 00:01:20.991 World 00:01:21.058 --> 00:01:23.868 Again") (with-current-buffer (subed-convert "TXT" t) (expect (buffer-string) :to-equal "Hello\n\nComment\n\nWorld\nAgain\n"))))) (describe "iterating over subtitles" (describe "forwards" (it "handles headers." (with-temp-vtt-buffer (insert mock-vtt-data) (let (result) (subed-for-each-subtitle (point-min) (point-max) nil (add-to-list 'result (point))) (expect (length result) :to-equal 3)))) (it "handles blank lines at the start of a caption." (with-temp-vtt-buffer (insert "WEBVTT Kind: captions Language: en 00:00:02.459 --> 00:00:05.610 align:start position:0% hi<00:00:03.459> welcome<00:00:03.850> to<00:00:03.999> another<00:00:04.149> episode<00:00:04.509> of<00:00:05.020> Emacs ") (let (result) (subed-for-each-subtitle (point-min) (point-max) nil (push (point) result)) (expect (length result) :to-equal 1))))) (describe "backwards" (it "handles headers." (with-temp-vtt-buffer (insert mock-vtt-data) (let (result) (subed-for-each-subtitle (point-min) (point-max) t (add-to-list 'result (point))) (expect (length result) :to-equal 3)))) (it "handles empty lines." (with-temp-vtt-buffer (insert mock-vtt-data "\n\n") (let (result) (subed-for-each-subtitle (point-min) (point-max) t (add-to-list 'result (point))) (expect (length result) :to-equal 3))))))) subed-1.2.25/tests/test-subed-vtt.el.license000066400000000000000000000001321474617305700207140ustar00rootroot00000000000000SPDX-FileCopyrightText: 2020 The subed Authors SPDX-License-Identifier: GPL-3.0-or-later subed-1.2.25/tests/test-subed-waveform.el000066400000000000000000000002421474617305700203060ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (require 'subed-waveform) (describe "waveform" ;; moved the duration tests to subed-common. ) subed-1.2.25/tests/test-subed-waveform.el.license000066400000000000000000000001271474617305700217310ustar00rootroot00000000000000SPDX-FileCopyrightText: 2024 Rodrigo Morales SPDX-License-Identifier: GPL-3.0-or-latersubed-1.2.25/tests/test-subed-word-data.el000066400000000000000000000063471474617305700203560ustar00rootroot00000000000000;; -*- lexical-binding: t; eval: (buttercup-minor-mode) -*- (load-file "./tests/undercover-init.el") (require 'subed-word-data) (describe "subed-word-data" (it "gets word data from YouTube VTTs." (let ((words (subed-word-data--extract-words-from-youtube-vtt "WEBVTT Kind: captions Language: en 00:00:02.459 --> 00:00:05.610 align:start position:0% hi<00:00:03.459> welcome<00:00:03.850> to<00:00:03.999> another<00:00:04.149> episode<00:00:04.509> of<00:00:05.020> Emacs 00:00:05.610 --> 00:00:05.620 align:start position:0% hi welcome to another episode of Emacs 00:00:05.620 --> 00:00:07.860 align:start position:0% hi welcome to another episode of Emacs chat<00:00:05.950> i'm<00:00:06.160> sasha<00:00:06.520> schewe<00:00:06.939> and<00:00:07.149> today<00:00:07.450> we<00:00:07.660> have 00:00:07.860 --> 00:00:07.870 align:start position:0% chat i'm sasha schewe and today we have " t))) words)) (describe "Finding approximate matches" (it "handles early oopses." (let ((result (subed-word-data-find-approximate-match "This is a test" (split-string "This is oops This is a test." " ") "\\"))) (expect (car result) :to-be-greater-than 0.01) (expect (string-join (cdr result) " ") :to-equal "This is oops"))) (it "handles early oopses in a longer phrase." (let ((result (subed-word-data-find-approximate-match "The quick brown fox jumps over the lazy dog" (split-string "The quick, oops, the quick brown fox jumps over the lazy dog and goes all sorts of places" " ") "\\"))) (expect (car result) :to-be-greater-than 0.01) (expect (string-join (cdr result) " ") :to-equal "The quick, oops,"))) (it "handles misrecognized words." (let ((result (subed-word-data-find-approximate-match "Emacs is a text editor." (split-string "Emax is a text editor. More stuff goes here." " ")))) (expect (car result) :to-be-greater-than 0) (expect (string-join (cdr result) " ") :to-equal "Emax is a text editor."))) (it "handles split up words." (let ((result (subed-word-data-find-approximate-match "Go in to the room." (split-string "Go into the room, more stuff goes here." " ")))) (expect (car result) :to-be-greater-than 0) (expect (string-join (cdr result) " ") :to-equal "Go into the room,"))) (it "handles exact matches." (let ((result (subed-word-data-find-approximate-match "We're lucky if we can get an exact match." (split-string "We're lucky if we can get an exact match. More stuff goes here." " ")))) (expect (car result) :to-be-less-than 0.001) (expect (string-join (cdr result) " ") :to-equal "We're lucky if we can get an exact match."))) ) ) subed-1.2.25/tests/test-subed-word-data.el.license000066400000000000000000000001221474617305700217600ustar00rootroot00000000000000SPDX-FileCopyrightText: 2024 Sacha Chua SPDX-License-Identifier: GPL-3.0-or-latersubed-1.2.25/tests/undercover-init.el000066400000000000000000000004451474617305700175250ustar00rootroot00000000000000(add-to-list 'load-path (expand-file-name "./subed")) ;FIXME: ?? (when (require 'undercover nil t) (setq coverage-dir (expand-file-name "./coverage/")) (setq undercover-force-coverage t) (undercover "./subed/*.el" (:report-format 'simplecov) (:send-report nil) (:merge-report t))) subed-1.2.25/word-data-and-waveform.png000066400000000000000000002132621474617305700177030ustar00rootroot00000000000000PNG  IHDR@V pHYs+ IDATx^w|SUM]J˦l% Ȇ e(nq!q p*bȔ=D~ E6J@ɽ?Җ4MZ}^<=s=w|sG\%&%w.SR4 !B!V( ~4kނ 7]Rr"7')9E[!B!6]rJU !B!JJZ& 7oK6)=q3y0Y ٘"u6jp?gJ/NTt En"vq?z4NK2{>#/u, t.l0l0w lnVA9?l4|9cF>Lj4iܐe:zIIɌ=eqwwSx`h6n민^XTZmZc49~ /S&BʕPsPG=Ĉ!r%P^qڵw&O *Ply^y?Z+Q_~IN9ːGG2zPThUUħsa<GD0_oxX>ggK~><}Ǟ}Aǰl&<0IɌ~j<{!j)Nc#*HT<cQx^:SzDsۛߏuB?Fծe\Dڵ ףQD1 н^;1aӅn]{\U4FVZ[֜f+Kh|o9<сI FŤ9B!n YY|巜<}UU9vc/S9 Ц6oh_r\4_Ki __B wwwg˹VL@/;'?w余>LiFt^x R '#3Wx'O[g@|B"&߈/ae^-E.!'''O_;B~Z'Oa2HIM𑣄 =M8"ډhddffҬI#Z:q W%%|Ev{$0///Ξ;dh4u6wwe> .谇x5 >s|Bx} eO`>g`O|4*V(ϥqŬlr|w # ϺJ!`Q^Oڵh۲3foXQg :wJ*kB1Au%ܓFvyVS"##EQhߦt<^q ||ƍ[dN.tԁ;IS pksd2Y|5]:u`[{,_MrJGgyV0x=WA- 8[r_{9`5k3 drNǞmTw=iٜ;Įg9w,44 *y4+UIA@nRBۇ^gX|c.QJL _A G霯 hքjU lؼ}/Ç W_ds/; EAfVV2X-_O&QU_,GuYxddf[(Z58y 3>O'Oy_XlegEpyۂuP^z={DouoOz?ޜ5rrsYz6m ?b#;uBi,~Zka{˽^ hVesrF>i,eAV}Ԛ4tF77SnpvyHLLǟiQ|ʺ@*~[2f|lLpi hFJj*_\[NNIӳxy[=o7ϿBY^dgJvI6Ce+ ~edeg1gZz̪ұ]k:mͦۯy5Cn̛ȋόNEE0aCw8}E[!Rڵr< \6L~qA_\jW5dfp+ Fבcbh%cly$hnFəWڣo!7`#==gU ^/cb(Zwz%+VS 惩o7g̚.;G:E)r֚~_LpW_TrEE/8q1nqD\jJU\N֙͟;vr%׫Ӕ,d2—}O6< uԪAXPZۜ [ouf)##e+ l1d}0 碇oOzI/=Dž: ]iZ[U\b HIcSt_ FD)ۄfr!7Drt&Q!77$bQU&e!yxx0^̷IUaÖ?lںWx?/I)/ΔIoK+e?ֱq˟u|g4b/].HZӄ1p1?__>pA) Q<0Bʕh׺%oN|}]:'33 zwwcFҦe vm gV5]ը~=x2мT\Oɕx:m͊5PZs9TULH0# Ep%.tvGq!6 X%33UO@QRRR  $(0yk{ٹ{/U*Wh4;/_U3GB! ;a&5࿯={=ڻJl=}4 }yRЖ0P!(ējuCL*xyQ1xp3HO!'rːB:m͋>aٍ̱Ӄ#N쯈OHGӭsGO¯KW0oOz=ީͷl.7&ivS.lս $( tv?}eCcS)iw֬h]@?/<3Z5n0}EonXF <,1c<7GHIHp0/\ſcnGTeT(r\K >e29 mڸ!S&¬zޅCQos1=1&>!^x#E!w"p7j"'7ש`mdWl I^^=|ͿojiMxʄXƛ+R9 Ig2Hʺ^meSP$B!B]F((:%:BNQ.C@':Uǥ#)\ܗLv59bEnB!]:O?>:b+QZUd^4L7;CS9s3w.tRNv4(Bܮޢ\ [wj(}Τ3 QNա7)F#W2/pTEVH2!B!ԵJem_-a"-)SP?};:ɔKjn$Ģʕ[!B!7ͷ([Sfd _7\XB!B!nUUS!B!6iB!B! !B!lgB!B[ܬf\B!BqS5yɔB!B;dJ!B!A\!B!w pB!BTUB!Bq4+B! !B!#Lk9cʟ`:¬A5Ǻ-CaYV;<¢WkdG"{mi*I{ҡS7l? ,e#ԕ7ߤ/ ~Vi(=7c.܌w(A~-+n(MpN-U/?-'~zbCOV=/v/"tU =zV۪`I0?&![O@:5LJ\ HpkXW͋w“cPú-WfV|)sQg2Ϻ+Bn %azQ&Ѕ0LlFvSό9e2Umfx=%~:d]z/>Q|HvXvY%zXk9?/q֛E]i,|ŭ_2G&TN}7a*tW,+ 6(g ><@/r.rRw5;zn6_ؤ˸qM!> ϮbJ;wPY-~5S'2=~U+=KsRد\ KtYyqvH"򛦞a'm9ܟRlrz+ЅɇX~{+W ~}rl:t%: /um7x\ XU 526f[`qu5N+gzo9㺒BYg<50SH緘_BqeEѣӡ)f&eލ.9۹T*; ;mrήqىnKW?-Wgxwc猠o$jc:ϳsTҒj!xxPq_&;gs{ cLއ:%%nW滉#ݩ:etYCx~tОub6yJdd:7i/8XQ 7G&\2rbJ~|XPNK;eaL;g m~"?;)Y6a }ζ,stYCFݞ~z'Fqȷ=b1aБdXց1vV.*ٽ3ÍĘ?V /tWl:?fNi)AMHmE'e*qWܕ@qxz1oONo,qμ8@Gѷs;Zuƀ˹]iRb{F_SK5B~x}SmjFGN=i|~-k_[ƣ61:ܟo^P)v[.ckq5#;0gu~bW}Rn5td~ LQ #J@#uR6wő-9*X,m~Ce˧㘽N(pHې 8i{n2:X[X>FhAOL X rŝc 0/#;r>8!'z=1: >Z3{=k~$&p|Nl BʅEj4MzUfZ|ɿF߸ k<܉SyȚ_M9Wh IqYb>PG}*>fX+ݻϚi-MGٴi%ߧ䵩︍j _{>Ùr3[Wa[\OO<6l^˼QAly[ w1MX1elVbP3YO mu|;Hq1O9"_g>!&|<}+0%mܝ{(~~73X{迣u(Bn*^>frp),Ka'Kٴ'LJ̖/ͬj3_9Oekֲv#6В>y| =GUӵ 'bBKMt36DZe9lIŹ~XelW(M|utGO#*62etVz|2Ùn*/vJ}(*!<Dl E/:ظv\kaXQr|p?Y+|飏qo` XDzߧX9o jM>=so:gfp4?_hT;Zɍ% cE-mh `YO`sӝϺgG(mt\=2\{dv_| Wn,4Wh &OeWgYn3O?.NRqۂ.\\#Gtޝ&/ Pm` Ն5ၞ9鯼C%Тys5oNmx~U*ƨmlZUu . Zj2)hF@P$` io(ΗZg~6I˷OĚcuxpl$0Z=1+Xu80F#hSErHs.NEZriX8 jSͅ[kct}t6+^`L 5ǩ{VN ?7ܼB@4<>_|r Bb%6=[q ̽ޔMm/bel[͟w,}hrn-kOʕ-k8P'jo|P;1nX0|ώw<7[Ͳпst ӗٲ IDATKw?Nlm~kkQ%FжM[:˴M^ p*+'OۦZ2G(}:!-4Ѩσ4 vś#hw;s g1hT;AW#zPŕAsu<` &O-(\-]k.?[y|>4Yw?.sF4͘{x&J?Xs&Fx-\ )K RO $5TILZ(7}{2-q 1AhJ#Jr's%!FpH՚zthh&UCY\ԓh\/Ipƈd^:'WYz{hK;8极>wr`ef.e0n_2Aס_Ezyi\;y+p.UC7pH՞gE3E9.<7wFIG38}>}W33Av_S3ά*Kž.3[<2,|s'w>cڣͯK?h46AKK&枂 NuЕ&fm)FժU(P/ɖ!4{ zNFQ 58+)Rl!R yj.DX7 աW ctaTO{? eNY-:ݜ2 :T.j)$6su/>?޺car~w K}J~ۖo?8>fJ=j ΂겷=f 4 Λ0n:(^s4y. hh=p놎G-/>(MOv ]s %ǽ:1~f6k#~vszq-_ m3exoLSEn|p$ύ@`4}'K׻1x";ђ8>GU 6XTqb_T[F*<:>;ZF>ƪ' o"ts5SrB`?m\H@B绤xx r8EbۂO [ULϚMDJv);{SI4&S?UWmCo-+M?L P T8hZ R_w%}$s)9rӉwL60 ''> N.˚fD⠍*| ^ ]=9.C|Cѩ}@eEPCQJ}̷Q\2?C6e8akqsV迣u(̍`vx7_a֤o7ЂbLJ շ*"ѣzt-Ny"˿kh[*>iI.$fk&:~ߑL:~J? _GD5?~jљ*Ҹp:4F]=fP{L~a"u8i+Qهe^v[zm!`y۲c3F֋lٲa5u߽\\so,㹃|}qlσȕO&|-9DXͧ/"c_5_ùrE-~䏤&t6ڞorbΝJsd[W\ UeȻ~%J|6xԠSS䞷43qa&﫿贗H~]蒆!Fӎ]6Sh?Q6L}]jYC#J?3;l7*ԥe)|5'LD־,yn OA_M{:FX~O>jݽyGkȫZ^y9bI`H&a,﹞ h:0j* ?#(GiǺ"qw[cŗ&Oň1yF}{%"1V#xjL6_?ﺏnen]=,߼Yk…1-9g̟xer E>Sy*pp~mj0)zEѓ 㓷z}ڨӦ;fvϞq=oۚ@B_xkQf (҇;uGyV{5k1ƳV-v\l:$ѫCNdW!hV-=D^ߔ>td K]ecV;ٜ%;O]77"}<h寋>Loto+ԢIgfXWj-ə3rh išq;ṑ[-F2ˠxWMs;̙Z*v[5$&ݬW"LGS>۽gM!2GsGwԨCV}@%%hay_ !x(Nijy''#C{9hl~%gk7Rqce!B!%Ǥ!L+O2vbMhvoOc q3pQEY!B!m7 Q B!Bۛ$%3B!B!no8 pB!Btz=5WW!B!mN3Zʍ8|CB!n}۶Ф]d!xB>G&Má\!B!7O4'B!B;B!B!n{ !B!Ei*A13(\!B!MDPÓ$s  !B!1ִy !B!#H+B! B!B;B!B! !B!#H+B! B!B۞i !B!3H+B!ⶦW!B!ĝA\!B& B!D! !6NB! W! :A!@4$B!BqgW!B!A\!B!w pB!Bܬ N+$!-yWj$JA($!y#2~)B !B!#H+B! B!B;B!B! !B!#H+B!g g?!q>LE,cCp>V ZCuԮJYWS`F !`.dt^qRi] &)W(4@`.^Р£]Gއ\q+\וrj03 σ\XpvZp@Xp9 @V #[] !BǹN}IHlVJ\ܚuYgUyNCP; 80;8/u̕z])-6ט.‰VjCmhiC//ЪBƥ3qI _< .{KPU4W+e]JuKs]tU YoqzZ{Mqݠ{[>sLc2> 2LYoK7s0%LDUĭOgLUj84tr|UIAԎ5,۸c1dPF}ܪ<TbVǤ%0CKh:Տ -7-^IR Z} ھ2B!9u0{ s\9 P֧۳x /OzI ?OA S[0]34lRgeq^WʺBPvuօlH=1kX@fu:~ҠGM@C/Co~`j? ɮ{iduh`*[fWʺ•z]) ƒZszZ{lcSRJk,o`X8d?}(%̴tҕ22WGJ\X)%]Z2)עw ۘBl MW]DN#'Go`gi^1ZA4ݞ /ʾF/p\&B!(yNxvh>qɿ[t}QS.c nO@R$aH'hvqoJ͙-/+p-4صb}ె6izgz}Fϻgzo:?|3M}# qo:,ĕJY;\וV) 67u H`n V-FN4qmAv,DP͢O2dAdihjg-N+8NE^6]nc<4*s*4wed; V׊W;S$_g fgk$3!}kc[RZU+A)ӆ3g OB{;ҫ{;{ܚ_ !yLZ((u`9rDj :t y 'rb`_PB@3

ܣƒe ZoqB(gxr~|k.zrPm;gqS򱝬\Ƌ)Ҵ-mwӻRp ayX԰?NóbrfLFdjj!Bb8p8Lq*Jx3_\THW}z)tI 20mBT~YWuli1PEW8% q\W;a^Sku}ЭphW zBO#1Y? [.%=?}րz?_gW+e]JtЦ|\GkuD sBeHʇ_< P+ W^~iݞؘ(t~AjR &UFMO aAxYQ{=YO!W>2xZ )c%;/P\C](U*yswSAG5騜%2ݷ,$l2Z2%B!g?do؆QSPw|GҮtsAx!cG\n(g-k%jwQe1;o9fJYK,F=Ψh :=<0L 6F6ndC֙fI @M; Q'~n2w\)k:v^WZrWdg:1V иu6\F'5f ~zfTOGV織mC+:6& a˷]vMRHtz !BXqw$mBORvje2qkVeɝ&i1WOxZ:Omlvif=m7~#0>z%mכ=0dkZ,1Kgn7~\??ԮF@/Ԥ(v& hms !B\B5ԗC{"# Rڂ݇)qxB^ cڃeY *1UɶKRւ6xu\X:0I~ƵoCbڀ;w@4+BvGaWf0!)| N{GseRւyJ 8?+5Thq0lK0B%_a@];Gsl{Ԙ.M;+<LgY7"q`c9z|C+Ӡ8t=$94wk%f WcٞtLTGOWʌժx HNA RN<1;M}]OBQxT-gê['quBܖUN tu:z>:Z' !ũe {MX]-̎+5|x7/ !B!H+B! B!B;B!B! !B!#we&Y' qj;+; kiª]ܱZ4guV%3۪GɼBPZJuR+B!B!nk:ES\!B!w pB!B$B!BqGp)!B!VK( pB?{%Eɑa!  b IDATJQ̊*aE]ş"*IDqY89tg'9[umN=T-:*J{-@. ,pa  \@X ~ -vZ,k@sдBWØC@EXuwEQ{<nnsМp&Jc}k|OC<ޡ9 լ5p4|7է:amhI<y5Ų^se;cO[}9>%[}_W oȾkokSw]y9 ").Ff]ֵWдtȡfIG*n5[}xRq4PY/n(ۯ>eyQz)깷ֿm_?e^oC9G059X kʳ.~:PYϯ6viyX:~™u91=kgNIWcTGISrܩ%ImEu ?XxrxjG(7D ߘk ԏJ1ʺ6XvXvUtڡ?sJ_xopwp=P.jx?tX@}y_{z>ja?>KhȀRPkLֹsnm?ѿlӆ29b1Əڨ8*FnL ظ57H7:ͬW1mNC;=[U ^IsוAj+KK+q n?Ԏo}:VrJjb'H rR׆â TlJс' ~0P5G6R=BwM:C'%(ibG3uIE췞\r$7ܭkv?B/zA/-西^{<BwdQz':ӕpX\Ce-˥}&B֤!9u4W*ᆬTi[>QacpMǾrY%:OC:>h*[w kgZƺW|58\}Tk=ĥ²JJ4cz55Ci rTdiOުmG~Ft3ziD!XF#Iz}~q7Q~Y{i{~,[%Z~>+OVfJz)ҺO)דO>G~G{Goэ#$)ZC{vROKtQ֊(ܫ/-3Y~A9շq+=c-H?^8S>}vR(׮+&SFӂ'<;~RC=3,͚t=zتV\-_)7')X ^p-NTf-EyfZ=G>6j*~)+M%v҄kAjNL\342j=>k4 ;Z'E-WUzkk¯ Pjj{vZwߟ M7NU/?'~|wUG!M.a4jg\G%)!)LK1ʺj߯oc/v/1[)M\5g-@EFv0;W߭E%ĸXK-U hvo~PrJz޽(Uk}ݏ:o-D4C߭Ym*33ST۶mٶ_\ (-oz-V\@6MEFFZ)iZ%pa  \@X @. ,pa  \@X @.h!* im\@X @. ,pa  \@X " 51ћٚmvIQ:pzR,1NvSkWEN>?-Q }<%N"W^[n7[jl{>0mf}Ve*tDMRtE :*ƧX ּ}Ko gƪӻa%3vjӯZ=+_ݗVuKv^Cvj%.oӟʵPRh J׎p]zwZ^kkɫ{^8ZrZ;k\ѿkT.Mr c4V.dˠ~{@=Yc4|\ҢM]WOm gjRDBI:=Qc^+(qYqd:3vjӯZ^H2J *+ׂE*hP(;!sф # =Z?SVv&vN@NvjluZDeG%;k]Zέcu'JP5Y/W -GԖn9DgTUSlG]Sӥ-[+[kkKr+a r;کu JwMUYRiu+JZ*ʭT mXyqin$T7aΚDž:}r]Uy^{>JL̃ZZי~Z:ju*6hfC%Se Ti)gCF|'Iw34ߢl'Ŵw*ѐ̢KϮ :ζnPD]ZrbS?pC&HVuWj 総O/^ޭ?vNA3;ک9H1ԕ֒J-CCPеoclGSD?f*/rL KФ+Rd!Ԛ0F]4KT)ة\Ff3|ۼݯZc#g.R#&ѐ+B+7juV^)zN,]MTjBZ}nyIjU/ekIYp˨,(@>7Xwjh SYݦNWߤcWxb ?.P?R`: _;6P̭v4$κ5xv^CA}J^{6Ox-BN1v\W*$-hyj ^ DIʷqQʔd*/" vNmqbuҰ8uYΉ^m18yv?ulF[7w@o n99ӥeӓ+״y4윹E\0Sޡ5jƭ@;?S{HVmyj=spkoa_;Ԧ +J׍]P;!i#zndJH״R¿jYS@ 8:JK6Y/LҾ)걝Z;k9"4=TwRJz UƼ|薻ϤH2]Z^;R=&fE3ǀu;g~HUFꑁN 6jOgh_DsH%#̃Z;kjꝦ끝PMC}\ڹRr}H%eC;UaZ׾SGwvIrDKֱշ7Kkj;vNmp$Pn,u̔ti螏*cR Z LsfQ3X ⛇5'/jziλjZl=̃Z;k6__85ÿ\켆j5t'ʚZu6+J%Qqg!qjcH}uջnW_vd** кEׯZ_b*RUEQFx 9{+U]IrUj'qDwOIq{tG.nrn%eΚ(R~^op+<ةUsl_;jC$#Ag WK1y ٩'M\IufVA܉9>YW-Ҵ9 mgh/Edu:C=Ԓ7񥦎sGųfW;ک=,$@vHBR+BRBzKd#Է{ڦrWeZStrcIc]e^>:SUӶF1ݩxKV-nu<칐7=r]zk;>9Zܱ1je:gM'z5ñ3;ک=,`a 4oD÷z!;UB\.Z_KT>I^ֵga4cA\05So%z+Sm5T|v|(NNGG)yK:[>?a_;^A~GS?uϭoxjmhV}eh Z|\Ci{{L 8ЧcxҤC maEP7Z#$ia\@x 7='$iC6)Rhk/IR|FݶR4@. ,pa  \@X @. ,pa  \ =em pqAH9an揀 <4&Pv\soShsnc"ԦiO޻XTǬx%o0g`owp[ }ﻮz 6>f}5Pی<4uš?G} 4XDzƞ;c֘fxڎM:uJE]%#!vk>M_o5,s\sM꺿sMs .X oMsZ>֚jIBy55fpb.Y/_S]3P@m1kS>5<'߱b9s߹Wc4uub+P-@V-ڎOc۪1śc|TXTұB*{ k>y-Y_ꃑwimjm:zenb=~Ky5fSBIҎgZ*|P<ֵX%<J_chc{hk/I;.kt.a}>;Xc>k=P*oo(@3Wc߯O3gj>m˗/œ&z9Jubg+kzɷpokׄxWkUMk&%wmL-йԕcu\ޏz kz<uXu9B hXNk>p%Fެ=SӒX/J}7=O9:?xox؁?3˻۽ySmckmJ?L ::Q` IDATu8߅u0za^7xw}+k%}А-Hsд;!nv;fs=N5.qмp F쪯cW?xݜPP@rXkJ_X{hRC1Y7PRunt˟MFQ=6fp؎}5czޡz jQ#4>պaŶѭy~ hjy ?ð Ulߦ].koOEr[[ԇR1Q{vUrRR:ާ\f _};Eeޠ,+|:,ө3]6JlGï|JIC%[jqݔ]hӵ,۴Yz꒙ԣw?#[֥ΊsWZU{GN׈gVTR%z~׽ZwR~g'j[^> [?G꨸6j=3}n6uײ ս]['eM:oN3*辳TƸ3Tq^~o:맃x5<|^8Aw|QWޢdȵKܟ4n~}WUZs:Ⱦ֍cЌ54SX,yREHR\G{Ӧԅ+6͏㢴w[CUϭ~B}[Z|UWn-u::$9; S)nװ)^oQvviT:zv*|ac^pfOiFiMuvh϶Uz듍ơ<`k Fn+ƟN ߱Z/}=SZ~`u>Պ݋tSۈN<\LqkÏ_wʎ\gMeӊi= xg0K+yZIʲW7hu[;fܣE՛SUD}#5`zhU;9] 'CMZ=Ic~0M:覘ڼ%SosK5t2-6o(M!TKG3v~Ua:r32$eҀ^Ry޻}w:ӣ!MݯnhnqzK/ϙgnh4|. #M}uzz~?2~ނ;k)>hP<8o ؁}OEВOӌ M=Եz9/hȻUր1i׈ ظ57H72$ M֭SS45@(K 8.zQ^ˆ^VjwDI(ӲE߫P{`w>Gל<)7/_FQP_PV銟l9@Wx$Etċ(O*0kۖ-ڼ-Yz;rtY;i{T>\f0]ڱqcGGTVKZ[q:%>W~T^[9;\'YíBB%Iź+k vn_#g?8c:%XTک!n%INVf oͺ~՗_WxB$9R5qQ~`Gpwpuo);إ'cuM7KGs]oFtV.avTLd[Jrm׆rm{IuxɷZlRkqB7uIR:wVԻQT/tLxE2j^nP"e>&uzZRx"7lNQ*~Վn%#n8wW߹]{Q"~[@fJz}]-\AҔ)FOCȡU_;k9,WvJ얮vĤg+Csהn/ WjᶽLnː۳Ua7SuYRRSލm\I{wS;K_|UwN vɊ Y)ԐÐ"OIot9J0$ Cr8GɈTQR;g3RwJ㺩Giն%xƝŏ[c{]eï]OE'tIUBC_Mcջ؆i Nk^՘痪k:!-A NC>ҘZݯQf8~/`GJ9Ov:vWu)Q}0$W|lԁT\vmV)JNmVq搤N9Bmnanz82>ӡ];喺z帢_g >fk/gWоkj5~%Uúîfg4ŨXG;q|ۿ[;ȥ.ge:wwk[cpRFdrf,9i rm_~7~jAuxERN+Y]KtXwH=k:/=yu[t~UIm^V1;5>:ĶؼPzcsT;C>>KwL_#{)#@7UGKFI)ߡQϽ@{o@JWƆT;w h(mDI /ԯmIG]=6J>>LO_0AnTDi9za9yM~&ĶwUS4w _Xzn[-_egHe9;u><..Ti-ڥQz. )qB?xU7щ +)!ɩL/<}ktS _oV׃xo0ʊ5@ t gv}5']bSR-9Z[cОz;" e˵L?7/Kw>Cj97kG[uFj~.CoEsi#hXWat/4 דz—_.ii78GV>_}V>4U/:?yz|^aUJ/La2D[@;Q`g>}_=A_Ď?{o?S=<{ZZ>`K+>~Vc>=u%}vvPQV\ݿsMYi*9&\ ׄV{ %I4kJzwl{޾5^mO-PX9{7Tk@0/o(@3Wc߯O3gj>m˗/œ&z9Juo@. ,pa  \@X @. ,pa  \@X @. ,pa  \@X .h!|p@ aXA. ,pa  \@X @. ,pa!P\ߖiWNK (a_;vU9z S]PY^Gs}EKӼʰn UW֫My7EReN>z?_ Vj>MC駤}krZ;`;ک+Z.wr5ev晊KRt c͵^HO*׾bCIm5|\+];yS #лӲZCǎH _[O^ݣm:t k;ک,)gsm8ժ agF\Z%G^\ ub[I2]-IҤN[bDU*I%[tsԩ'&J0*HW 6g t*)ySk~Z={nnN ҥE3on%eDX뮬sф # =Z?SY풲 3Rg )xVNԶo.;/էr22NXvzi)gC{F|'IwxN-Z1t);OIq*ѐ̢]#/ڦ1ԛ>)RwrR}6h.yos51G'ޯW} OՖѷkBggkG1d${Zt]ٺm=~{چ؃^oclG[iŎmȭ2hq nyaH2fA-(m'(Rd0UaJ1vj-: 6oAk}Psn\Y/g.R#&ѐ+B+7juV^)ꛙn3v34CeZ07WBe쌪~CW4bߩ+NGlgu:]]W~]⥋582Nutvjm( ekSH=.Fc}J^{6Ox-BN1v\W*$CUd-NM(T<=R=.JYLS#PDգZ;kY~tE麱~f%hQ:λX4,NݧGs4$tIfi&=}m2tm9z{a&\-gϙk_3UZi4C;mOꛧf)1@ݣ>G֎Z;kḳ}EtzNMwiɼl=,]>Mޖ5ƐSF&GzNȐU2E+1FΙk4RzdS9_|G\9Rɱ ٙ;vNm1݃Z)O IrhE]}zaPvR/ ߵ.pyz{K;wU@\]Ubs/[ %ѝr}:ul R|@ڥNvSH~/ru]R*Hn$|s3B]IJqK>W*L9 3#W<?}QOtU*`aga_; ti)oz9aS\'E8X^(:Y]͊5}#Z A\:fHRtd]y|.[+G$;ʲ5B+nkWcQOd$쾁;JG~iPy>Tq88V J/]*Pv笩"Y:z̓Z_?vS1`8&Ő\}7D\ڤ;Eh urѴʕ48YgvneWP7UbOU}4muyBCK>Y]gj3z|z#Nvj <I*>PPn.) IDAT劐 4oD÷h|u ֿPQjbȝ_k^CNɇSֵwgsFvsro,,ɺs;UYӉGgm;;`Nvj <)ص3t\=q>m?%VG%Ky;J"Oֽ#ߵ.MV:rib-\ZIzxvjВpeDhLE赯LŶֈRu1T;^:.n~z 8iMuʃz?{U}g&;(M ETP)UײZ~*uEE]*bEA{(!'3# \f2sHks=O=wf$}CGfWEDptg_w u{fesaWn16:V0s{[%[KݲŇqɺx zYȱ2Vb=<ά!` މzv8P-Qv'趩qcOF=%@Qræp ='M.>[В\^ܽlD15t]1.t)u:V|ևz쫹+bWWlvd6zzCAk#|V T|TїW1IҸq7V)|nV( 52su$iȍkI|w@ _UǓu<[?~Ju6>{+j:+jյr[uZK 3< h ^] ,~mrG}]~״ '_<.iEQ}9m(sc%5m%s̵x6#GbxM\C`ļ~)z\>Fz#mhs+5`3w_F;D?{Qy\öeV/5~yq-x:7W.ZV|g+3u]y 6|k=;gCАjH|gܣUYٖ﹮u||{pGw@<ӆh}9\OcXxv*P_Zrc7c{1TS:kyKCj #ymgnKX5{oki|X݆-`_JM)k~9k}4g>󶤦ތޏg7T}ϕ֮[ђjin}G< L;o~زkk5`k[˜7kUou=_X[]zsoGz9#ʡH\?s . $B . $B . $B . $B . $B . $B . $B . ${7̓bCGĚ_jИa@ WTp<%:J  4@  4@  \vf9EEApic'+&KRT9EhiW}tW4/n\A&EQI+V9@ 0ؔyL}/_.I*Y{La-F3[]I)Îׄ@pk_n*+{w\Jjg:5~@ o_ 4/y+Eu:Լ߫lj܆5uUх+4M6 x왧Ԯ]f^ܪUj͗+qC(0ha~ .Udk'pÓtIVyYx[5vRԀ{ʼF\1o~lVR՞zj YIqϴs ފ8^tMxq-CzwPtlt>SZNrW8N32C>EFv饛+t|nJOWRp݋Z>Νs3U豪$[7Y1cF7873g_nVҳ׳nTؕW%-?^pUͲ+Wؕ7he>D\_itO5[ M[UK}nxZmH,IZ5;pMJMxsV>mל4ەp͍J+u՞'ٵ]]6VQ:~\9 l p|^R7Ҥ+>sJ;O}23zhU7֫L+UQVS1%UmݨU1ңl>U'] 邫nYr!Iq{Z>mf}GzR?n]]ѭ]<&ޭauu|{/x^X(OӨUOOK?˹m^zf}vDKFxM;%A}NqRT@ igkVdl:tlRM_Q5w.mI:㰮[hOQe=phs~?F{wCOs??Q!j~ ݸ>\cGvzxN^E7!5yzt/15y=IiF,&]_8?x ӐOi..HxzRTꌃnVMg}n%o15z0ThI7-+T>C^?2ʈHP. +əq@OJA8DM?VhMv$]?F}~fd}ŻWpzcXyz3"zYZDQQFN|]u1L=/Xbh*Xp9T|#{)9$GM6\>:?w~mݼY6yۜ|sr󗫤ә|\ 󕗟jDl^ࣱchrZ>X9*+j9:x-m} uHCba$O2)-]WoTI@]~t KJWR2ziDd^J[x mZJέϚJ"-EGjzu{Z$\q=UiMռMqQI"Y#6!r%h}K%:/K<QT|h$]qys~A~.:x];::*$ɦ9d+1&-ݐ\9%`n^Zu~)X=N)gemW%S쒪l2$}^}_Fx&0dn?1]#,LaAIaCҫsOi$k@vǐ͐ŽZo<8Q>iK؃yk|=%FHSl:g=1i^`ۯ]A/Zg~Z~[w_q5c?~aa_d)pk^mڷ}mڌGtN֨'_-m'tS$۫K֯)ȸ6].E4 NljaSrI]#y^)ْ-&ҺuרGf R; ԝ+tuu-qT%%J?iCQ#a՗ڒP^;eњtjg[U&+8^'N%A 룱R\;_s~'ڭ.Sis4ctM8sv^x{b*7igwW p-ؖӦ/U_&Hh"MŦek YV1N{Rf]ZYi5q֕Жt@wNTqXjv-#F6♚qxyz|o; {:-[BFϞy ֡~Sя>Q%Y|ޥNNkJ6 r 2`|0VYQ6fh_Qem>Tg>OqqJOJSS@ uyfn|kFrPLֲ79w>|?<ұmo-F:=Z};Ar^4DkMJ+\zҨOI<8)XڏUﮇ)'E+iJuq7;o8M=kZXf6Imu\W߿+}ICZos@G+'1O7y#Y&?~9C9,wOIOnG/$E򤶾{R Iu,=zUP}5+u^z>SS=VWq t^փ7@Ow~^+1=&©?] ÍGmf<ÑI|ǾAg \%'~cuLJ]4ze";NTڽq6.ߠ @U #w:o~7'֚K4hX@ WTp<%:J$+@3I \@H\@H\@H\@H\@H\@H\@H\@H\@H\@H\@H\@HDy/4B Z <\@H\@H\@H\@H\@H\@HpqT7r4gEvKqiyjDSl% +yZU[E*G+$əSyspuvAsb%mMUn>|@.Ӗ}. K?GybeĆ2+`% +yZ^XE?Uh_>а?Ho_Tjo[1qr]yZRힱՂ75J33K3gL.nkhb}m(kM+VXk%wi>>.nî6u]ꉿ;ya:qBI5fmJ7+tsnGV5X]8!LKv[VJlKtmS蟕ykVb-r; u.;uݧEZQ*\jR;y{U6jyruia7+`% +yZַs+ZܟSkOWtG9zw\[]kX9KbhuώS{vjB};-Rckƃ76AܻpUʏԱrWiy5֡2-)XOnmz?KߩW|.>XOjj/U8*ݒ0hh(%.A)Xl:OɚXGskm͆S4gH͛r1aez tYk%SЏvtNmn%ɰY fВUry, s(2B&y4d_/*+vnp]Uڼ%{ Y~+uryKVcMvnP^IoqAn%X:kaxU.QR1:>ѥ%kwT.PtXH[?)UqLNR}Gt +`%Wo}VFniZה9*lZGu 4$X=x68Kf, yZ+VXk%rwJnI}y>w@+yڒzCnVOVKC/O՟5JIʳꋅڔ'Wrj_VJVZm2n}}Stj$4zZjOL#Yɇw'VbUAT_=>_/su%+Z+VXk%)6 8+]oMvj*ʦvKK uXXr;捽sazN?N m~G_vd|i әKP1e9[ҹJVZm*n6mwȈ1)ދRFX7V92Zv:\YU+ݲ̈́y[Fu?myا`c+ݯ8K‡-oKǪM}ZVJVZmF]nU6)\tXo}!S+_٣;>Rs3ДZ Z ; =R݇uQd#ee8J!k=%ɭ JmTG(Vvf)A=}!бnrײt FiT;~[W?nC]:~VڶU:-\EyyK|tx6sU?([eO/M)CrBԭ0Yל)4cp%|TNlZk%COoM>VK7t1kFNylnK0 8!EMSjk8#LVIs|=_law\n:/^)Q @y\@H\@HkFV^U%Ipg@Sx"!MΊ Iҵy{e0Ewp!hp!hp!hp!hp!hp!hp!hp!hp!I3ghR@C6&[{nif̜5ΉQ>{S{.79 Qjj͘9GRs幍؞?,Wc-S HU TX<[x6j ֧j y֥y=vj_'tx<k:#g}̘9[1NyUlk_՘#sZ zicOCӐu]8I5;zlI o>~[:| y_nӜ\9μ. 9GnjmoU}k.}0#w&ySl|/C}.&Okh f%X v]܄ԗy;$\7K#SD5/fx3_??_?w5l-UQW@Zne۶y TO0j)[˚_jИa@ WTp<%:J7=yPjb%VRg ݰzpYS1σ\ ʊ0)yjnpG} _IލRsU:Vw#}LF᪴f2\h}u)/(缘Q.W+@_n_9͗|6n([9Gjk}֑Z빎+kiv#:5\K \Hyj΋lN+1 v`.͹/WWkU"f}fМQ[g{m^#"<,Twl|Us0{ k.FK \hZ|[5]P5Wn'P^y|+W\03?6͘9ܖ6u`yt=qr­0l恆tau47Ϛ6snk_WP5CwkF9jl6<95q w<<jG8Rhp!hp!hp!hp!hp!hp!hp!hp!hp!aC#BO䙇ĮgBwp!hp!hp!Jzv~w% z6ʚHA4FkkCyI59r" \*ٹNE%}Oopj홚u^Ƚy}~^xfiG?Eϝw;;k4zrZf^a>|e (\?K^^W>QAG{~7]<+I=zoZ^|u\ 61ə2*w.دR+ݼҧsP\Z:{o J<=mvOԨs)\ zʍ;^}pMUڷjJ}?ESV2}?*2¶xQsKr“2VEy.)Y #U1 6INEtP1̣ {|7ttMU=e_wbSw>_RЧu*sntS9ܥĪiǷYt~d͉j܀胫ۏΛſϔއR%S_.INg^Vm& Pim:d7֚߫"F ЋV.qs;}Eo::m.]ܢqLjrsTŷ.ܪQ{rv4X~:Wg_6lإLX#/NL]Y{@-Y}dwQ+4KWۺUhs5G#ʵ:9I}ڕ]&{HwKF:7u_y~+u~~EtVN, +J\vwh mX\WkVbvFmj8rQcj/jiCc$DrK{&[_:@e9R@E{>ޭ_D% jEm"}E}v_"f?5M׭TPhx$1q:GnnTrSU*9!v~I guSϳgkɪ\_{Pz>u~'uQ8Z.%[4o~MmʈpG%_jSO*o_`F%LzH;dKm3=&",^-k_g߮UZYoJtjwJ4G(AY; ?nRO' Svvm߲VyRW)e%]y:NZ [V+*Sql{s: RJsL;U딛.~F4u٣W^^_,VqJ险Iv[#<9?:SafUJ>E0}NGYzڟ^\*X|P?mk^^O雯/ՙ'I{+#TU ?kQK5mƮ6ghqZ>G15yDe=N_>))]*r?P\MTRj`wE\O*oj&42$e˹;ec)n7*PYa6dkLhzm5L Sݔ1ZU?>W@Il}y@W$uPOߪR{L;SĨ8_*/.mFk׺~Yl!c55=Թ'ٌ8| nlJ.|I hC^W|@9e&]Y* #R0C2Uyι)QfDCTirӆ(`ekl)"ڦ׼3L"drk0:7U܍Zqxtx_%*ʫdKULM=*qKm[%gzְ-y~p-[J0=O*">JSP; 7 a[?>5/oYU a-gE)Ir-#,Js8R|HILؤ8ri~Q.9Q,$y 6e6f߲@?־x% o^J.gm՚|D; +FXϦ C'u[o}w]t>h@w%IEu>SoGM/ZY}Г(ʐaiՋW)2:NyM#kh)UT􇋮W{^-޾XI%5ϫj?Jչf{'iś_ҧ$ɡiKt>Ir}]_"G<)f4ӱZ,sA2]ڰmm.MutFqj^M.PMslW:rNaS.TgiQ#4wʗmKkNVlFsھyU~/ܽ|X66>du SKԍ;@  4@  !!Sߓ|=]nyq\@3ø h$>d "hpZe[ h towp[bZrL\@h\@H\@H\@HDy/4B Z <\@H\@H\@H\@H\@H\@HpqT7r4gEvKqiyjDSl% +yZU[E*G+$|w^}3_~оC}J]1a58s4on.׎|S5x]vNDzF[|PKbbR#46(ڬ;VbJlVb ryEX+7V@#iu^X V~c-9+Y7.#Z\>)JwN8)[J^+K+[ٺ\}խ0-gX? 4yDR˴}US8nV FIc^`h5&J}-LF+&ol]<#O?iĂ2-P_WDj1fS}NV+VXk%+ǃX+Cy+f)/5R#hԀWV/ yVNX+y#]ٻ(.ÿM% $jK"v!bAEAQ,(H{UzB!Hٝ$Y6ly|dgܹ;sg枹3!\@\"8qQ@MBC;!!@(4;C<JYS5PklTH;YrƹSIO1{&s)z բbxv5-ls7]52cKk"|nnx{wz-?~хXK̚rͥ:`ME9T@/0+|G۾n= +W!B<44 6 rpn,+x}3:`PPXDۑ]sO3\kbf 92mb"_N:/JP, vԯlî̝r`K~2C&DZ5͞gwl~& HDo@_[59+z./Rmy[ifێ5V3Zke`M{&6/9=-ͤLMfW˛qpU YB!xh2PptӉ|"Ǽ*sTQ5Z{x䋸k|1/)mO!n8OeJbKYg̈oqL2C{0ug|- ɩvh0ēj`!I]3:+kM-)u<[&Ხ>ƾO[ڎ5ְ\kb5X )W+]i=/-FX).֗+B!':=s?~M"ʷ4e-h4uY5Z{/):j?ͬn_$ lq A&Mj^,+xct4̛~<],btS?;*#eχO;g[HW%z~${JYޙZKINN'ڦޞMgZR0ĂumǚXkXS5eM{&V>4t{u4bzB! =G=avbBN5ְ\kbb'bNwYcH]>ޢ:g@qGYuKQE‰ Mo?߯LB>NUJ6l،-3Dէ;sxtp+ 氦Xk kʵ&0iZn!4t:xnB!& 8jKᩀ}|_aMb9 R-`]۱&6|UZ[u`M`A^PLn>`dTuԪlTB!M;5K3n c IaLzxūplyo0S;՚r:Ƥs) TC `L$t:6N9*[`TVmK#ߕϞs:Tʹgj/:hՕvT4g9R4$)fd'3t+EhZ*N]ZGzwM&6ƾp:v4zi;Vp;\kbo+ִkbMֳbC*{_di;U'{S|HN2]rB!FSbCa>̊g$mRqœey~ךX!voJ)kʵ&Du@М+ [mgh"by6@mq{k3u}&iv4œݝhcNoݘϘB!"_ !B!($B!BQ"H+B!DW!B!D B!BA\!B!%$B!B!JIpB!B !B!($B!BQ"H+B!DW!B!D B!BA\!B!%$BKcN` 擅B!CL\!IlB!J&IpW̓[B!% B!BAs󾚌v$mJ!BaהΥiZH#GXsn()2B!M nAn-ntE(7u/YTe[}|&BDSk k:J Y2Ynw( G0t_;ZZ^o3k=he5_.5eX+6TB!l'+OκFxlN3)K"xK,5빛u36,T%q91}t9ZxL`=뺈J]e^A0?&̏¬S!$*0͋y9׺, iڃV4^˺ͷ{QZNbjik'DK+>9,2pN2̗B!(.J(|~a>IPQԉ*uYL;}R=)Ӈj܊Z-̷}~FLϯlrLQk%y X`B{in-kt^9m6ZKRݬ:(ݴg!x%_5Ks =1-&gs9i\mR\KFxu7?2go%=i٦1֙mQ*3:t[j/'Y6y[*L/>kI^ӭ"DP'!:QdAȻ Y5˽iw!7Uavr˙^PXybMLNQ(hZZ鴼-3=&_X[2 B!DQ*0W׺E~񖘖aZsߙk>YݔciYKi)JŽ>a=}d~ 0l~ܛ{]B!DIV` wv̷cfZ/EQϢ( ժrx_֏L{8Sa7w֕zg󵹇<,BM v9QH;EakSEY_k˲6)B!xPYZq"?ҞDau)F BQhNp#B!AV,W!rN!WM^!BC\!V[!BE\!%InB!JIpB!B !B!($B!BQ"H+B!DW!B!D B!BA\!B!%$B!B!JIpB!B !B!($B!BQ"H+B!DW!B!D B!B|(YZ>Rϣ擅B!DFpB!B !B!($B!BQ"H+B!DW!B!DpKt1"%8]O^yo2[=fFjk>_)Džz)ʂMʩ9 鷦)x3B!A&*Wvͦ846&KQKnՋ{(W;coٌM 3~&1S 9&_yXԌY%72t.8TǷvKֆ7y_MPpK`hԵ=mTRןSyA:9 ̜g-4hdz|A#$y֡B4%ι3BP|7P/%b{_6S9>a^:p@ʶ߿hCs|M2\5~gNp/=Bz^{͊OI8ʙ VvehT-'B!dМd&F2e~=xd7rt o(kڿFeG݀첞g`[|vΕ֋c[qL7>D\J*720;fLQ{hx5,[t9c\r{=;"+(~OѨ$D `<;{)PcVϡ{O2b] ^qُq ;=M&dp ~7Gң\ÅylG&gh:h(f駈\{6#::ŵ i#1*F=kQʼ-ɷ$%K/>1r%pCuo:ԥZ9r"I׍`1V~t6oJH=m8;i?%#V-كFI4uGԔd%SDb:GTsN|GB!_^?K1f[u2:OʓR8:=]_akD&<0g nݛlzE%ʎgbŧšy}_vcaG U7jF-}QXG(Nz4Q.qu84BS n'ӭU6TJr ؉Jr2ї1jсEݍ+Li"oU6|SC ~[7YV'du1W8ѭU{x9]3fi;! !Ba&j4?֕ Ͱa2BvO5=&x>[;JPdX8k-ƓÙ=k4IZ)Ԩـdrx=h۸-|uݲ(iuIL(ӵ(x9_J!\P|QI,\9-_?Wt pL >[BRY[_/6;HӲ wj5l-٣sO??&/ҧ? F{-my_5Nr-3ӳi8w.\NnPM;ʔ;H ۾l=j݀~z1MY? z*ǚh[B!wÃO/pɳV7`lPpuq^)Mu?'\ -[Z˙7뾚E4j{pS{/ @2&!;a2灠BL=ܨAi\>3UNoJWOjv"=%DR1=͎2/vyO{_h?!vSSu6+KǣL{3^oU Au՜I*] j7b8S>"*&zːj$ ! 7Q*ƶn!'yl]庥/B!:\=58셟y'n 㸪BE_,Wu⻕@}]^瀻Cւ:zvIz;GgĮJ%o%ʔq*,)*a']n^fMͮu/sC;Yf^̼'ͦG B}7=iA?Yʁ߲w fFɣ?$ ˹BP=;F~#[zGƪǽAg<…}HrlE۬r04d ~faDCF:|.)d , =3/]AmZ2q(A]!B+* ZTԬI]ܭ]EcYҩr#&y\Y3IsK?Ob}_c@ܼlP8ViF@-.Z!ͤŠe9rIS8דasW!~0֭RL<б5ќ/K9򍗇 eucU+L~Y.#.[l(jx^ȟzGll؉[9z=Wt cGW8K/YvU[ŕaqѷߵ͡H3fL NUhᥠH%![nDf=DX"B!e(El IDAT$Ǟ0Fz1|7[7kg2isU{Y=!kD'r%`b>Gʻ&ˇ NLk=j;z}N=OE7R ێ_ci#;F)b {?;nϵ]q<1'ӂ`}S91/yWxy-'#p([Z׶ci;맶\ N$A־i aYO欷ruܽ)eNrԿݶ555*cPniط=&ĖuyC?ݤ؅?rhU!QS0KVxbϜU"t->C!.z\"^XXi1?yz&MM !aqsBQBH+B!DW!B!D B!BA\!B!%$B!B!@y !B!xlͧܦ3H+B!AdZlJO$B!B R`:{]%vnY,1 B!B<غO͓ !B!($B!BQ"H+B!DP飚OB!B!u[t퍬zB!Bޗ<,B!DW!B!D B!BA\!B!%$B!B!JZn,I/iB!J *Eb0w29UsE_=ݧB!>'SYܜp-*FTּ#l"3c9y+:,Sw1gi:u*Eun)hkh-W2k!r+8&i=A=-0x'lu ]u5`}4x0bgғ!64QɇG; ^j[]ߋ (֌ywljM1f&_2 o2\pm_ 6y0޺ N(þEC9Bk&+mvS!4s{+yX(e3gȮI3w3h2TйP|Ņ;ijZ^_18xR3WNcU`; S/ьsLζߟ%2ӟ޼ܻdkp6s -ylHjx_3~L<˜.eQ0p>Uϗ+],I~dGi?b-8oVȘWv+þ|p?}/dM.\E~1q_ c| /8}sĤ\[,o{y^~Ȩqt@Ɏ ~K)֓=羀#y͙x˘ѵq3q5j/~^ frÿc_}; 7 opkNz?Hǀ/;+?D Qx40z@sO.g3+;{Z6ݢEu\XT?KѴAc5R/:Nԫlӓk:i]Ws嚢%JR+%|3xs3X8]F0mGT7?ί`뙏 cZKxfOi>Zh^ ~l}ߧvE/zjjBܢ(ʽ;'2^*$9T~a?}:;=ޫa<6j^qS1xh Dzt9邳b$iR6n9wjBQm2ej,'<+>~MJZO&t~TꉮLC%F MIsoe~_;Ҳ -} ϲz\vicr4P2r:2F?b#qQIt#$֤Q=r6Vthb<}:x霞>1d8ݵ+4p}\hXQG ?vwUY0 0ih z7p%<āVxYOYp1:toA^xs-]5嶼F⣯GXZJu;,A(W5Z̈tr;gHM'ٲ,F \XZ3ehzɉiY?6-P\~A!xVU;lw6LAErC&}|N8Vӭ;aZbB(z\]n`#1ִj̹/2uO*( K9xѨ,F. Z:U %a*XhBK9Fbc1`5 dY Z}R:'+{q΃&cO ^ٜzukPM[<46HU[ÑEl̾a_֣kZItEũ5aӶ4hP:@K}av$$Bi[jtl5\^ɶ<bnL^I; _3@^tOZq%$M+kbL=det]:u2uB {gZqW6-*G0nm5~2;8nv\|zЦN[j=z<*U9q;[v_3I` #șc8Pc,םɯYb Yf2I^hރ0s9fO} OW+U6>_Ӹ,޸w﹦ɵrژ_Ύ̌`ӆkT_Ph@(U0Za¹S L)]Թ}Z0@_&hBnO;}1XwoWh0/h2`֥Sq cpq޳ی˯vT.:],^eX4GƜ;+ΒƮߑ6h vŰzFcE~/b"ϾWɈ|h߰?7.{"D~Dxs>`9PeL}Fneffwc5Y}S70yȣu܈_yVeݺpywF=ѬNycb#؞ޜ>|_E#aGRhg94|k_L\乛ahZyj&_]`5@>}?[&ԓ& k~續DoOazPOY/A{GUW[ˆhB4CHw6j'?~owE2緉+PIj`q~i(O ^}0,rᢂo`Ʉml3佾άyc::R *Fc,[ǾƦxo::EΕ gX/^^̼Mx%<͟6G,8j>U=>5LheԤ(]6&4NNZnFZt7S.bto ]W/cpMyw}6C&DYJt>XRy$R}uZ<ډmkəT6O +#0k~f0pY3c3e9}L \GRwKBOm{\ }gEx[7Zb2Kߪ:Ү@֔zϾ{[UR+-+dɗ9R* EIBM'9SGscmL$-]w<.D{%_MDZK}QJY{],>O*1GyQVK疰ROOkMM4I2+_9I>-(I{2sCrfo z}^ ߲i4)u5SW?qf0}fN\\/ ä7>֚ 0Pw'h涌U;{v[;χm)+^筷k2<rdgb;/f鄫]z>ŪaMNfM{[OƌI_S$Mwj*7nSޯj(NT jR9%ޱUx׿8][qGq"Uxi`s8'Sw,vuhz:}g~C0?2-^\۳eyܿ Gw{ )xtqz|w4|I-kּ.q4j4O_i⁝+V|̌ܛȐIoX)sK>הҗR+gdRΈ "${&H`zx*oXs})ŵ];dasؗãӄc縒WSRq{?WO'NdP1!.9NOj[F.}3(*}Sˎ25sXNPRppPIKMcZb0:]5%ir@ڀPDAFJ 8X>vQ/-6p#%ƫDj6n"!b*GqEh@)P?}(oW<~xNG^6k{~Ůd8ӐLHq Um؂v{Xi-N>DumNb&P;̅[` 6׮ޓ#'.{>ٺ׽4Hޠhꁝ1?g [laǹkJa/p&*R͊bLP[vśocH?F5rpEDnSI9I\rxqT)r7U&#x쇊zm.~ggTh8|/f,toW:yi,jY3 Fgت<{rf-ĥ#H49ˡ:#~:m1ZXzeo3;ޞ8I6{hچ&ޯ++ y~cgHVm: ֥NE;" Y_݃G6q$vF4eCt(FB'0Wo]QW0y$,Mp:dW >pP[ɴnEW1%IDATZb =jB Dړ=!M|G<H\¯l¹T>+I++ti\;4gpkJW__8|Fa|775%8Џ6IZB/MGba? Sg'9~,=0-&=ndž \>wտc߾ }~IƄJ‘ Y?|{}M%5,UI rY@=4|jR-}⊷#ǖA0lBOwJPtQxr Lnȋue3܈'Ғ'tlw&x=ϴ](c¬Zڱs~Vګ%J:v{]և<^-*9߽w{rzQGi܏a$ ͿxYE|y%FB QRPOpQ4h3̥ ui~QS9E'L/RsyMӏ=jW-1ZXޟ:k.]$ދMyGo#~j_hD~}8v;Qwa[46/ЙŸ.0u!Ʋb>"6#FO ٷ^!@:?7kyʅ7BrcWI:OkcL$"lEHI[PREѫRBjWKQ\-ZjKKةĖZB ,C0H2gyޯW<9g39|UߣojTOƅ_`Ws((oXa s11%&+'%3m_fM[ՅvaI:=1Vfըz!SN$g(#fd7h9nK2^ޖ|w(u5?pK,?O"%^bmźcfu׻ʽuL>Q:3טTL&bvM/8ACF|p,5we[/׶k+6dh76ݟJb-  h1{u?[OGN_69W8 {]_) :\{Dd[O$%^&5v ?l[1M AŔ?YdE ǼlJ/ҟ-B'&SB!ċ!wcSS̲dW!BU= ۅH452!B!⥡( ʍ+ ^onac$B!B-uæ<^įV-f͏ĩ. B!BB!B!^ R !B!x%]B!B'_aR(kfJ-Zh] "dJFpB!Bp''םXZ㋏/eʱ+zד QU/nuh*u$8/r PRXEcjcG/O]옗Pv 33>$f|]ʖΝkOy(g֨<nfFxyx`o^dY~҉.Zm=2 sC)t͢DW>t1 5^4h:ˆWlKO1#ЙfhLm,i]rtԪAKs|?:3zP '?_͉_dg3MdZ} ŕJqk5XN #cH#ngt]9+6çIno z`mi5|Bw\zeCru¼;>};D%;N8$=c,B!D 3U>6~FUVָŸw(u޲~҉n~G\йPwR o%qRBl zyl88p I'"h}i6=L zFky4gⶲe<&n۶qگuwѼEwAw8G"bǴ402?ux6ލȕ=L)ڂޓt)l}T,tV27"^K4*~"6QsӺ~/2"B!B0# \(z2Ɩg㻵I8Te=>Б^n8j|?[rhq2TRֲ^N"C@H.D] k ߗς0U@]Οn*sa;$#XG{W~0σ=*AII‘yMu‰$Á ^ ?F-tBE >.W/g[ad89֪4bw'kL:aqho[$irY("?.+,՝J.NTxӏ]fGxFר=2-žf*Ali`7YTV! >t7c9z>?lV늭[ȥ?.?A,ĕkhoVICͰVMG}rafuSwwM;M_I,B!f|ƛN#fh4ݣ6`]2?&4ƝQk~^(6xY?G-m݆mWZY/0b)Xrs6Q6rJZI\L[8$%'b^3Ý~Vy1*ʽ{ (f1я~t{L!(vYClP&+6jD;[EQ6x hL.WBW = B!B${MgԢm Wrk&cey`W:G%lH C@Iҳudܾ)y us"ff8CҵKU'F(~d^6dgdY@dLLʼޜÀw [Eϙ @GYwnr'Tnf-ʞU~reFb$\ʹ݋>cN!=hjTfu3D.`D]=Ǯ0`aOȤ}G5Β.s|{6~y;џ9[h~fGsI\'~!f^TsgEUL=dޮs\IȞc,}p` $ג%Žٻ7M< -T1,B!DIQT&fF: MlkP{o$.ͤOul2R3PnJNvټ.]X,ržrzz{&e]B6cJ S?S&z4RtYCpR!ٯ,=v?scSpĮD$錋1XO<9! [mz,Ƭ&rhU45tuq֓kSȈGu̝qvQ~0ݔ?/"~e$hV8Ȩ@_|1@fM 1_m<%ݖѕR?]ibF16? ;fUסg'5[&7y̫吝gq=b TfSu[{>6/L}J7f59cT[UIpߨ(EY!B( ʍ+ M[69 px@*Qڿ^$?B!B#uæ<^įV?"nVƌ*҈^&B!B3_ d~E U&q1n6K0H~B!JV7X07O_&%4 l$#B!BbEyE \P67lI~B!EQ? HokIENDB`