From c8054f0a6aaada595887608be19ad4438af37e19 Mon Sep 17 00:00:00 2001 From: SoSIE Date: Wed, 23 Jun 2021 22:19:04 +0200 Subject: [PATCH 1/4] with load support and reloaded with 0 Dump Ass values example compatible with aegisub behavior --- .../0 - Dump default ASS values.py | 31 +++++ pyonfx/Untitled.ass | 18 +++ pyonfx/__init__.py | 2 +- pyonfx/ass_core.py | 125 +++++++++++++++--- 4 files changed, 154 insertions(+), 22 deletions(-) create mode 100644 examples/1 - Basics/0 - Dump default ASS values.py create mode 100644 pyonfx/Untitled.ass diff --git a/examples/1 - Basics/0 - Dump default ASS values.py b/examples/1 - Basics/0 - Dump default ASS values.py new file mode 100644 index 00000000..be256f8f --- /dev/null +++ b/examples/1 - Basics/0 - Dump default ASS values.py @@ -0,0 +1,31 @@ +""" +This script visualizes which ASS values you got from input ASS file. + +First of all you need to create an Ass object, which will help you to manage +input/output. Once created, it will automatically extract all the informations +from the input .ass file. + +For more info about the use of Ass class: +https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html#pyonfx.ass_core.Ass + +By executing this script, you'll discover how ASS contents, +like video resolution, styles, lines etc. are stored into objects and lists. +It's important to understand it, because these Python lists and objects +are exactly the values you'll be working with the whole time to create KFX. + +Don't worry about the huge output, there are a lot of information +even in a small input file like the one in this folder. + +You can find more info about each object used to represent the input .ass file here: +https://pyonfx.readthedocs.io/en/latest/reference/ass%20core.html +""" +from pyonfx import * + +io = Ass() +meta, styles, lines = io.get_data() + +print(meta) +print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") +print(styles) +print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") +print(lines) diff --git a/pyonfx/Untitled.ass b/pyonfx/Untitled.ass new file mode 100644 index 00000000..20fd274e --- /dev/null +++ b/pyonfx/Untitled.ass @@ -0,0 +1,18 @@ +[Script Info] +; Script generated by Aegisub 3.2.2 +; http://www.aegisub.org/ +Title: Default Aegisub file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: None + +[Aegisub Project Garbage] + +[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,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,, diff --git a/pyonfx/__init__.py b/pyonfx/__init__.py index 47b9412c..32c8e2f7 100644 --- a/pyonfx/__init__.py +++ b/pyonfx/__init__.py @@ -6,4 +6,4 @@ from .shape import Shape from .utils import Utils, FrameUtility, ColorUtility -__version__ = "0.9.10" +__version__ = "0.9.10-reloaded" diff --git a/pyonfx/ass_core.py b/pyonfx/ass_core.py index d0018f1f..3afe9eb4 100644 --- a/pyonfx/ass_core.py +++ b/pyonfx/ass_core.py @@ -386,7 +386,7 @@ class Ass: Additionally, ``line`` fields will be re-calculated based on the re-positioned ``line.chars``. Attributes: - path_input (str): Path for input file (absolute). + path_input (str): Path for input file (absolute). If none create default from scratch like in aegisub Untitled.ass path_output (str): Path for output file (absolute). meta (:class:`Meta`): Contains informations about the ASS given. styles (list of :class:`Style`): Contains all the styles in the ASS given. @@ -396,11 +396,72 @@ class Ass: Example: .. code-block:: python3 - io = Ass("in.ass") + io = Ass ("in.ass") meta, styles, lines = io.get_data() """ - def __init__( + def __init__( self, + path_input: str = "", + path_output: str = "Output.ass", + keep_original: bool = True, + extended: bool = True, + vertical_kanji: bool = False,): + # Starting to take process time + self.__saved = False + self.__plines = 0 + self.__ptime = time.time() + self.meta, self.styles, self.lines = Meta(), {}, [] + + #if(path_input != ""): + # print("Warning path input is ignored, please use input() or load()") + #self.input(path_input) + + self.__output = [] + self.__output_extradata = [] + + self.load(path_input, path_output, True, True, False) + + + def input(self, path_input) : + """ + Allow to set the input file + Args: + path_input (str): Path for the input file (either relative to your .py file or absolute). + """ + if(path_input == ""): + #Use aesisub default template + path_input=os.path.join(os.path.dirname(os.path.abspath(__file__)),"Untitled.ass") + else: + # Getting absolute sub file path + dirname = os.path.dirname(os.path.abspath(sys.argv[0])) + if not os.path.isabs(path_input): + path_input = os.path.join(dirname, path_input) + + # Checking sub file validity (does it exists?) + if not os.path.isfile(path_input): + raise FileNotFoundError( + "Invalid path for the Subtitle file: %s" % path_input + ) + + self.path_input = path_input + + def output(self, path_output) : + """ + Allow to set the output file + Args: + path_output (str): Path for the output file (either relative to your .py file or absolute) + """ + # Getting absolute sub file path + dirname = os.path.dirname(os.path.abspath(sys.argv[0])) + # Getting absolute output file path + if path_output == "Output.ass": + path_output = os.path.join(dirname, path_output) + elif not os.path.isabs(path_output): + path_output = os.path.join(dirname, path_output) + + self.path_output = path_output + + def load( self, path_input: str = "", path_output: str = "Output.ass", @@ -408,31 +469,42 @@ def __init__( extended: bool = True, vertical_kanji: bool = False, ): + """ + Load an input file using its path + Args: + path_input (str): Path for the input file (either relative to your .py file or absolute). + path_output (str): Path for the output file (either relative to your .py file or absolute) (DEFAULT: "Output.ass"). + keep_original (bool): If True, you will find all the lines of the input file commented before the new lines generated. + extended (bool): Calculate more informations from lines (usually you will not have to touch this). + vertical_kanji (bool): If True, line text with alignment 4, 5 or 6 will be positioned vertically. + Additionally, ``line`` fields will be re-calculated based on the re-positioned ``line.chars``. + + Attributes: + path_input (str): Path for input file (absolute). If none create default from scratch like in aegisub Untitled.ass + path_output (str): Path for output file (absolute). + meta (:class:`Meta`): Contains informations about the ASS given. + styles (list of :class:`Style`): Contains all the styles in the ASS given. + lines (list of :class:`Line`): Contains all the lines (events) in the ASS given. + + .. _example: + Example: + .. code-block:: python3 + + io = Ass ("in.ass") + meta, styles, lines = io.get_data() + """ + # Starting to take process time self.__saved = False self.__plines = 0 self.__ptime = time.time() self.meta, self.styles, self.lines = Meta(), {}, [] - # Getting absolute sub file path - dirname = os.path.dirname(os.path.abspath(sys.argv[0])) - if not os.path.isabs(path_input): - path_input = os.path.join(dirname, path_input) - # Checking sub file validity (does it exists?) - if not os.path.isfile(path_input): - raise FileNotFoundError( - "Invalid path for the Subtitle file: %s" % path_input - ) + self.input(path_input) - # Getting absolute output file path - if path_output == "Output.ass": - path_output = os.path.join(dirname, path_output) - elif not os.path.isabs(path_output): - path_output = os.path.join(dirname, path_output) + self.output(path_input) - self.path_input = path_input - self.path_output = path_output self.__output = [] self.__output_extradata = [] @@ -584,7 +656,18 @@ def get_media_abs_path(mediafile): # Adding informations to lines and meta? if not extended: return None + else: + return self.add_pyonfx_extension() + def add_pyonfx_extension(self): + """ + Calculate more informations from lines ( this affects the lines only if play_res_x and play_res_y are provided in the Script info section). + Args: None + return None + """ + #security check if no video provided, abort calculations + if (not hasattr(self.meta,"play_res_x") or not hasattr(self.meta,"play_res_y")): + return None lines_by_styles = {} # Let the fun begin (Pyon!) for li, line in enumerate(self.lines): @@ -1123,8 +1206,8 @@ def get_media_abs_path(mediafile): def get_data(self) -> Tuple[Meta, Style, List[Line]]: """Utility function to retrieve easily meta styles and lines. - Returns: - :attr:`meta`, :attr:`styles` and :attr:`lines` + Returns: + :attr:`meta`, :attr:`styles` and :attr:`lines` """ return self.meta, self.styles, self.lines From 4840c80ba07f0586d1323b369a237976fc886188 Mon Sep 17 00:00:00 2001 From: SoSIE Date: Thu, 24 Jun 2021 19:14:01 +0200 Subject: [PATCH 2/4] Refactoring 0 - Dump default Ass values.py with bonus --- .../0 - Dump default ASS values.py | 19 +++++- .../1 - Basics/1 - Look into ASS values.py | 2 +- pyonfx/Untitled.ass | 2 +- pyonfx/ass_core.py | 66 +++++++++++++++---- 4 files changed, 71 insertions(+), 18 deletions(-) diff --git a/examples/1 - Basics/0 - Dump default ASS values.py b/examples/1 - Basics/0 - Dump default ASS values.py index be256f8f..5dbce09a 100644 --- a/examples/1 - Basics/0 - Dump default ASS values.py +++ b/examples/1 - Basics/0 - Dump default ASS values.py @@ -21,9 +21,24 @@ """ from pyonfx import * -io = Ass() -meta, styles, lines = io.get_data() +io = Ass() #With no args and no load after... +#...io.path_input will be set so... +stream = open(io.path_input, "r", encoding="utf-8-sig") +content=stream.read() +stream.close() +#....we will have default Aegisub Untitled.ass content file +print(content) + +print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") +#Let's have some fun, content end matches with first line sub as no CR so.. +content=content+"PyonFX reloaded rocks!" +print(content) +io.load(content) + +print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") + +meta, styles, lines = io.get_data( print(meta) print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") print(styles) diff --git a/examples/1 - Basics/1 - Look into ASS values.py b/examples/1 - Basics/1 - Look into ASS values.py index 007d75b1..7617feac 100644 --- a/examples/1 - Basics/1 - Look into ASS values.py +++ b/examples/1 - Basics/1 - Look into ASS values.py @@ -21,7 +21,7 @@ """ from pyonfx import * -io = Ass("in.ass") +io = Ass("in.ass") #equivalent to io = Ass(); io.load("in.ass") meta, styles, lines = io.get_data() print(meta) diff --git a/pyonfx/Untitled.ass b/pyonfx/Untitled.ass index 20fd274e..29d526bf 100644 --- a/pyonfx/Untitled.ass +++ b/pyonfx/Untitled.ass @@ -15,4 +15,4 @@ Style: Default,Arial,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100, [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text -Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,, +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,, \ No newline at end of file diff --git a/pyonfx/ass_core.py b/pyonfx/ass_core.py index 3afe9eb4..301cc4fb 100644 --- a/pyonfx/ass_core.py +++ b/pyonfx/ass_core.py @@ -419,8 +419,7 @@ def __init__( self, self.__output = [] self.__output_extradata = [] - self.load(path_input, path_output, True, True, False) - + self.load(path_input, path_output, keep_original, extended, vertical_kanji) def input(self, path_input) : """ @@ -431,20 +430,39 @@ def input(self, path_input) : if(path_input == ""): #Use aesisub default template path_input=os.path.join(os.path.dirname(os.path.abspath(__file__)),"Untitled.ass") + elif self.validate(path_input): + #path input is an ass valid content + pass else: # Getting absolute sub file path dirname = os.path.dirname(os.path.abspath(sys.argv[0])) - if not os.path.isabs(path_input): - path_input = os.path.join(dirname, path_input) - # Checking sub file validity (does it exists?) - if not os.path.isfile(path_input): + if(re.search("\.(ass|ssa|jass|jsos)$",path_input)): + if not os.path.isabs(path_input): + path_input = os.path.join(dirname, path_input) + + # Checking sub file validity (does it exists?) + if not os.path.isfile(path_input): + raise FileNotFoundError( + "Invalid path for the Subtitle file: %s" % path_input + ) + else: raise FileNotFoundError( - "Invalid path for the Subtitle file: %s" % path_input + "Invalid input for the Subtitle file" ) - self.path_input = path_input - + + def validate(self,content): + """ + Minimal validation check if the content is an ass file + Args: + content (str): the content to check + :Return: + True if valid, else False + """ + section_pattern = re.compile(r"^\[Script Info\]") + return section_pattern.match(content) + def output(self, path_output) : """ Allow to set the output file @@ -454,7 +472,7 @@ def output(self, path_output) : # Getting absolute sub file path dirname = os.path.dirname(os.path.abspath(sys.argv[0])) # Getting absolute output file path - if path_output == "Output.ass": + if path_output == "Output.ass" or path_output == "Untitled.ass": path_output = os.path.join(dirname, path_output) elif not os.path.isabs(path_output): path_output = os.path.join(dirname, path_output) @@ -492,8 +510,7 @@ def load( io = Ass ("in.ass") meta, styles, lines = io.get_data() - """ - + """ # Starting to take process time self.__saved = False self.__plines = 0 @@ -501,7 +518,15 @@ def load( self.meta, self.styles, self.lines = Meta(), {}, [] - self.input(path_input) + content="" + if(self.validate(path_input)): + # input is a content + content = path_input + self.path_input = "Untitled.ass" + else: + # input is a path file + self.input(path_input) + path_input =self.path_input self.output(path_input) @@ -510,7 +535,20 @@ def load( section = "" li = 0 - for line in open(self.path_input, "r", encoding="utf-8-sig"): + + #Get the stream of content or file content + if(self.validate(content)): + from io import StringIO + stream = StringIO(content) + else: + stream = open(path_input, "r", encoding="utf-8-sig") + if (not self.validate(stream.read())): + raise FileNotFoundError( + "Unsupported or broken subtitle file: %s" % path_input + ) + #previous read set the cursor at the end, put it back at the start + stream.seek(0,0) + for line in stream: # Getting section section_pattern = re.compile(r"^\[([^\]]*)") if section_pattern.match(line): From 3560e7ea29b3ad1123224d390e3b9570b2b9aa69 Mon Sep 17 00:00:00 2001 From: SoSIE Date: Thu, 24 Jun 2021 20:45:54 +0200 Subject: [PATCH 3/4] Braket typo --- examples/1 - Basics/0 - Dump default ASS values.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/1 - Basics/0 - Dump default ASS values.py b/examples/1 - Basics/0 - Dump default ASS values.py index 5dbce09a..811ac683 100644 --- a/examples/1 - Basics/0 - Dump default ASS values.py +++ b/examples/1 - Basics/0 - Dump default ASS values.py @@ -29,7 +29,6 @@ stream.close() #....we will have default Aegisub Untitled.ass content file print(content) - print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") #Let's have some fun, content end matches with first line sub as no CR so.. content=content+"PyonFX reloaded rocks!" @@ -38,7 +37,7 @@ print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") -meta, styles, lines = io.get_data( +meta, styles, lines = io.get_data() print(meta) print("▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀") print(styles) From 6be8d8ecb3fa7228dd14cafa60d3d53b571147d6 Mon Sep 17 00:00:00 2001 From: SoSIE Date: Thu, 12 Aug 2021 16:47:45 +0200 Subject: [PATCH 4/4] Bunnified with del_line and Untitled fix --- pyonfx/Untitled_dummy.ass | 22 ++++++++++++++ pyonfx/ass_core.py | 63 +++++++++++++++++++++++---------------- 2 files changed, 60 insertions(+), 25 deletions(-) create mode 100644 pyonfx/Untitled_dummy.ass diff --git a/pyonfx/Untitled_dummy.ass b/pyonfx/Untitled_dummy.ass new file mode 100644 index 00000000..2994300e --- /dev/null +++ b/pyonfx/Untitled_dummy.ass @@ -0,0 +1,22 @@ +[Script Info] +; Script generated by Aegisub 3.2.2 +; http://www.aegisub.org/ +Title: Default Aegisub file +ScriptType: v4.00+ +WrapStyle: 0 +ScaledBorderAndShadow: yes +YCbCr Matrix: None +PlayResX: 640 +PlayResY: 480 + +[Aegisub Project Garbage] +Video File: ?dummy:23.976000:40000:640:480:47:163:254: +Video AR Value: 1.333333 + +[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,20,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0,0,0,, diff --git a/pyonfx/ass_core.py b/pyonfx/ass_core.py index 301cc4fb..b174cd19 100644 --- a/pyonfx/ass_core.py +++ b/pyonfx/ass_core.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # PyonFX: An easy way to create KFX (Karaoke Effects) and complex typesetting using the ASS format (Advanced Substation Alpha). # Copyright (C) 2019 Antonio Strippoli (CoffeeStraw/YellowFlash) +# Copyright (C) 2021 SoSie-js (sos-productions.com) # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Lesser General Public License as published by @@ -419,18 +420,19 @@ def __init__( self, self.__output = [] self.__output_extradata = [] - self.load(path_input, path_output, keep_original, extended, vertical_kanji) + self.parse_ass(path_input, path_output, keep_original, extended, vertical_kanji) - def input(self, path_input) : + def set_input(self, path_input) : """ Allow to set the input file Args: path_input (str): Path for the input file (either relative to your .py file or absolute). """ + section_pattern = re.compile(r"^\[Script Info\]") if(path_input == ""): #Use aesisub default template path_input=os.path.join(os.path.dirname(os.path.abspath(__file__)),"Untitled.ass") - elif self.validate(path_input): + elif section_pattern.match(path_input): #path input is an ass valid content pass else: @@ -452,18 +454,7 @@ def input(self, path_input) : ) self.path_input = path_input - def validate(self,content): - """ - Minimal validation check if the content is an ass file - Args: - content (str): the content to check - :Return: - True if valid, else False - """ - section_pattern = re.compile(r"^\[Script Info\]") - return section_pattern.match(content) - - def output(self, path_output) : + def set_output(self, path_output) : """ Allow to set the output file Args: @@ -479,7 +470,7 @@ def output(self, path_output) : self.path_output = path_output - def load( + def parse_ass( self, path_input: str = "", path_output: str = "Output.ass", @@ -488,7 +479,7 @@ def load( vertical_kanji: bool = False, ): """ - Load an input file using its path + Parse an input ASS file using its path Args: path_input (str): Path for the input file (either relative to your .py file or absolute). path_output (str): Path for the output file (either relative to your .py file or absolute) (DEFAULT: "Output.ass"). @@ -519,16 +510,19 @@ def load( self.meta, self.styles, self.lines = Meta(), {}, [] content="" - if(self.validate(path_input)): + section_pattern = re.compile(r"^\[Script Info\]") + if(section_pattern.match(path_input)): # input is a content content = path_input - self.path_input = "Untitled.ass" + elif(path_input ==""): + dirname = os.path.dirname(__file__) + path_input = os.path.join(dirname, "Untitled.ass") else: # input is a path file - self.input(path_input) + self.set_input(path_input) path_input =self.path_input - self.output(path_input) + self.set_output(path_output) self.__output = [] self.__output_extradata = [] @@ -537,14 +531,15 @@ def load( li = 0 #Get the stream of content or file content - if(self.validate(content)): + if(content): from io import StringIO stream = StringIO(content) else: - stream = open(path_input, "r", encoding="utf-8-sig") - if (not self.validate(stream.read())): + try: + stream = open(path_input, "r", encoding="utf-8-sig") + except FileNotFoundError: raise FileNotFoundError( - "Unsupported or broken subtitle file: %s" % path_input + "Unsupported or broken subtitle file: '%s'" % path_input ) #previous read set the cursor at the end, put it back at the start stream.seek(0,0) @@ -1248,6 +1243,24 @@ def get_data(self) -> Tuple[Meta, Style, List[Line]]: :attr:`meta`, :attr:`styles` and :attr:`lines` """ return self.meta, self.styles, self.lines + + def del_line(self,no): + """Delete a line of the output list (which is private) """ + nb=-1 + + # Retrieve the index of the first line, this is ugly having to do so + #as is if we could'nt rectify self.lines instead and generate self.__output on save() + # in lua this is what has been done when you get the aegisub object + for li, line in enumerate(self.__output): + if re.match(r"\n?(Dialogue|Comment): (.+?)$", line): + nb=li-1 + break; + + if (nb >=0) and isinstance(self.__output[no+nb], str): + del self.__output[no+nb] + self.__plines -= 1 + else: + raise TypeError("No Line %d exists" % no) def write_line(self, line: Line) -> Optional[TypeError]: """Appends a line to the output list (which is private) that later on will be written to the output file when calling save().