diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ebc0be1e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.pytest_cache/ +docs/build/ +examples/__pycache__/ +examples/Output.ass +pyonfx/__pycache__/ +tests/Ass - Crazy/ +tests/__pycache__/ +tests/Output.ass \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..1ecc6c77 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +

+ PyonFX Logo +

+ +**PyonFX** is an *easy way* to do **KFX** and **complex typesetting** based on subtitle format **ASS** (Advanced Substation Alpha). + +Powered by **Python3**, **PyonFX** aims to offer stability, efficiency and ease of use +for everyone who wants to create something more visually complex with the ASS format. + +## Getting Started + +TO DO. + +## Installing + +TO DO. + +``` +pip install ... +``` + +## Examples + +*Examples* directory contains templates which are ready to go for testing, for absolute beginners until advanced users. You can load and execute them, look into the code, reuse parts of them, learn by doing. + +## Contributing + +If you want to contribute to PyonFX, be sure to review the [contribution +guidelines](CONTRIBUTING.md) (TO DO). + +This project will use [GitHub issues](link_to_project_issues) for +tracking **requests and bugs**, so please *don't use* issues for general questions and discussion. + +## License + +This project is licensed under the LGPL v3.0 License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +* **Youka** for the original main functions ideas of **NyuFX**; +* **McWhite** for the original functions ideas of his library for **NyuFX**; +* **Siplas** for helping me out in the realization of the logo of **PyonFX**. \ No newline at end of file diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..69fe55ec --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,19 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line. +SPHINXOPTS = +SPHINXBUILD = sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) \ No newline at end of file diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 00000000..4d9eb83d --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% + +:end +popd diff --git a/docs/source/_static/PyonFX Logo.png b/docs/source/_static/PyonFX Logo.png new file mode 100644 index 00000000..d63a279c Binary files /dev/null and b/docs/source/_static/PyonFX Logo.png differ diff --git a/docs/source/ass utility.rst b/docs/source/ass utility.rst new file mode 100644 index 00000000..91aebc83 --- /dev/null +++ b/docs/source/ass utility.rst @@ -0,0 +1,6 @@ +This file contains all the functions and class definitions to work with the ASS format. + +PyonFX Ass Utility +================== +.. automodule:: pyonfx.ass_utility + :members: \ No newline at end of file diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 00000000..86010ff3 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,167 @@ +# -*- coding: utf-8 -*- +# +# Configuration file for the Sphinx documentation builder. + +import os +import sys +import sphinx_rtd_theme + +# Updating path +sys.path.insert(0, os.path.abspath('..//..')) +sys.setrecursionlimit(1500) + +from pyonfx import __version__ + +# -- Project information ----------------------------------------------------- + +project = 'PyonFX' +copyright = '2019, Antonio Strippoli' +author = 'Antonio Strippoli (CoffeeStraw/YellowFlash)' + +# The short X.Y version +version = '' +# The full version, including alpha/beta/rc tags +release = __version__ + + +# -- General configuration --------------------------------------------------- +extensions = [ + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.ifconfig', + 'sphinx.ext.viewcode', + 'sphinx.ext.githubpages', +] + +# Add any paths that contain templates here, relative to this directory. +templates_path = ['_templates'] + +# The suffix(es) of source filenames. +source_suffix = '.rst' + +# The master toctree document. +master_doc = 'index' + +# The language for content autogenerated by Sphinx. +language = None + +# List of patterns, relative to source directory, that match files and +# directories to ignore when looking for source files. +# This pattern also affects html_static_path and html_extra_path. +exclude_patterns = [] + +# The name of the Pygments (syntax highlighting) style to use. +pygments_style = None + + +# -- Options for HTML output ------------------------------------------------- + +# The theme to use for HTML and HTML Help pages. See the documentation for +# a list of builtin themes. +html_theme = 'sphinx_rtd_theme' +htm_theme_path = [sphinx_rtd_theme.get_html_theme_path()] + +# Theme options are theme-specific and customize the look and feel of a theme +# further. +html_theme_options = { + 'canonical_url': '', + 'logo_only': True, + 'display_version': True, + 'prev_next_buttons_location': 'bottom', + 'style_external_links': True, + 'collapse_navigation': True, + 'sticky_navigation': True, + 'navigation_depth': 4, + 'includehidden': True, + 'titles_only': False +} + +# Add any paths that contain custom static files (such as style sheets) here, +# relative to this directory. They are copied after the builtin static files, +# so a file named "default.css" will overwrite the builtin "default.css". +html_static_path = ['_static'] + +# Custom sidebar templates, must be a dictionary that maps document names +# to template names. +# +# The default sidebars (for documents that don't match any pattern) are +# defined by theme itself. Builtin themes are using these templates by +# default: ``['localtoc.html', 'relations.html', 'sourcelink.html', +# 'searchbox.html']``. +# +# html_sidebars = {} + +html_logo = "_static/PyonFX Logo.png" + + +# -- Options for HTMLHelp output --------------------------------------------- + +# Output file base name for HTML help builder. +htmlhelp_basename = 'PyonFXdoc' + + +# -- Options for LaTeX output ------------------------------------------------ + +latex_elements = { + 'papersize': 'letterpaper', + 'pointsize': '10pt', + 'preamble': '', + 'figure_align': 'htbp', +} + +# Grouping the document tree into LaTeX files. List of tuples +# (source start file, target name, title, +# author, documentclass [howto, manual, or own class]). +latex_documents = [ + (master_doc, 'PyonFX.tex', 'PyonFX Documentation', + 'Antonio Strippoli (CoffeeStraw/YellowFlash)', 'manual'), +] + + +# -- Options for manual page output ------------------------------------------ + +# One entry per manual page. List of tuples +# (source start file, name, description, authors, manual section). +man_pages = [ + (master_doc, 'pyonfx', 'PyonFX Documentation', + [author], 1) +] + + +# -- Options for Texinfo output ---------------------------------------------- + +# Grouping the document tree into Texinfo files. List of tuples +# (source start file, target name, title, author, +# dir menu entry, description, category) +texinfo_documents = [ + (master_doc, 'PyonFX', 'PyonFX Documentation', + author, 'PyonFX', 'One line description of project.', + 'Miscellaneous'), +] + + +# -- Options for Epub output ------------------------------------------------- + +# Bibliographic Dublin Core info. +epub_title = project + +# The unique identifier of the text. This can be a ISBN number +# or the project homepage. +# +# epub_identifier = '' + +# A unique identification for the text. +# +# epub_uid = '' + +# A list of files that should not be packed into the epub file. +epub_exclude_files = ['search.html'] + + +# -- Extension configuration ------------------------------------------------- +extensions = ['sphinxcontrib.napoleon'] + +# -- Options for intersphinx extension --------------------------------------- + +# Example configuration for intersphinx: refer to the Python standard library. +intersphinx_mapping = {'https://docs.python.org/': None} diff --git a/docs/source/convert.rst b/docs/source/convert.rst new file mode 100644 index 00000000..80d995ec --- /dev/null +++ b/docs/source/convert.rst @@ -0,0 +1,6 @@ +This file contains the Convert class definition. + +PyonFX Convert Functions +======================== +.. automodule:: pyonfx.convert + :members: \ No newline at end of file diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 00000000..1f4aee09 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,13 @@ +.. toctree:: + :hidden: + :maxdepth: 2 + + ass utility + convert + utils + settings + +The PyonFX API Reference +************************ + +.. automodule:: pyonfx \ No newline at end of file diff --git a/docs/source/settings.rst b/docs/source/settings.rst new file mode 100644 index 00000000..6c34f7e8 --- /dev/null +++ b/docs/source/settings.rst @@ -0,0 +1,6 @@ +This file contains some settings that can be customized by the user in the top of their .py file. + +PyonFX Settings +=============== +.. automodule:: pyonfx.settings + :members: \ No newline at end of file diff --git a/docs/source/utils.rst b/docs/source/utils.rst new file mode 100644 index 00000000..2b03b1cf --- /dev/null +++ b/docs/source/utils.rst @@ -0,0 +1,6 @@ +This file contains the Utils class definition. + +PyonFX Utils +============ +.. automodule:: pyonfx.utils + :members: \ No newline at end of file diff --git a/examples/First Effect.py b/examples/First Effect.py new file mode 100644 index 00000000..c1031dfd --- /dev/null +++ b/examples/First Effect.py @@ -0,0 +1,72 @@ +# If you're trying this example having downloaded the repository +# and not only installed PyonFX, uncomment the following lines: +# import sys +# sys.path.insert(0,'../') + +from pyonfx import * + +io = Ass("..\\tests\\Ass\\in.ass") +meta, styles, lines = io.get_data() + +def romaji_kanji(line, l): + # Leadin Effect + for syli, syl in Utils.all_non_empty(line.syls): + l.layer = 0 + + l.start_time = line.start_time - line.leadin/2 + l.end_time = line.start_time + syl.start_time + l.dur = l.end_time - l.start_time + + l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(%d,0)}%s" % ( + syl.center, syl.middle, line.leadin/2, syl.text) + + io.write_line(l) + + # Main Effect + for syli, syl in Utils.all_non_empty(line.syls): + l.layer = 1 + + l.start_time = line.start_time + syl.start_time + l.end_time = line.start_time + syl.end_time + l.dur = l.end_time - l.start_time + + l.text = "{\\an5\\pos(%.3f,%.3f)"\ + "\\t(0,%d,0.5,\\1c&HFFFFFF&\\3c&HABABAB&\\fscx125\\fscy125)"\ + "\\t(%d,%d,1.5,\\fscx100\\fscy100\\1c%s\\3c%s)}%s" % ( + syl.center, syl.middle, + l.dur/3, l.dur/3, l.dur, line.styleref.color1, line.styleref.color3, syl.text) + + io.write_line(l) + + # Leadout Effect + for syli, syl in Utils.all_non_empty(line.syls): + l.layer = 0 + + l.start_time = line.start_time + syl.end_time + l.end_time = line.end_time + line.leadout/2 + l.dur = l.end_time - l.start_time + + l.text = "{\\an5\\pos(%.3f,%.3f)\\fad(0,%d)}%s" % ( + syl.center, syl.middle, line.leadout/2, syl.text) + + io.write_line(l) + +def lyrics(line, l): + # Translation Effect + l.start_time = line.start_time - line.leadin/2 + l.end_time = line.end_time + line.leadout/2 + l.dur = l.end_time - l.start_time + + l.text = "{\\fad(%d,%d)}%s" % ( + line.leadin/2, line.leadout/2, line.text_stripped) + + io.write_line(l) + +for li, line in enumerate(lines): + # Generating lines + if line.styleref.alignment >= 4: + romaji_kanji(line, line.copy()) + elif line.styleref.alignment <= 3: + lyrics(line, line.copy()) + +io.save() \ No newline at end of file diff --git a/pyonfx/__init__.py b/pyonfx/__init__.py new file mode 100644 index 00000000..d2d83f0f --- /dev/null +++ b/pyonfx/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +"""An easy way to do KFX and complex typesetting based on subtitle format ASS (Advanced Substation Alpha).""" + +from .ass_utility import Meta, Style, Line, Word, Syllable, Char, Ass +from .font_utility import Font +from .convert import Convert +from .utils import Utils +from .settings import Settings + +__version__ = '0.1.0' diff --git a/pyonfx/ass_utility.py b/pyonfx/ass_utility.py new file mode 100644 index 00000000..ddf9afc3 --- /dev/null +++ b/pyonfx/ass_utility.py @@ -0,0 +1,1006 @@ +# -*- coding: utf-8 -*- +# PyonFX: An easy way to do KFX and complex typesetting based on subtitle format ASS (Advanced Substation Alpha). +# Copyright (C) 2019 Antonio Strippoli (CoffeeStraw/YellowFlash) +# +# 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyonFX 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +import os +import sys +import time +import re +import copy +from .font_utility import Font +from .convert import Convert +from .settings import Settings + + +class Meta: + """Meta object contains informations about the Ass. + + More info about each of them can be found on http://docs.aegisub.org/manual/Styles + + Attributes: + wrap_style (int): Determines how line breaking is applied to the subtitle line + scaled_border_and_shadow (bool): Determines if it has to be used script resolution (*True*) or video resolution (*False*) to scale border and shadow + play_res_x (int): Video Width + play_res_y (int): Video Height + audio (str): Loaded audio path (absolute) + video (str): Loaded video path (absolute) + """ + wrap_style = 0 + scaled_border_and_shadow = True + play_res_x = 0 + play_res_y = 0 + audio = "" + video = "" + + def __repr__(self): + return f"Meta Object {id(self)}:\n\tWrap Style: {self.wrap_style}\n\tScaled Border And Shadow: {self.scaled_border_and_shadow}\n\t"\ + f"Play Resolution X: {self.play_res_x}\n\tPlay Resolution Y: {self.play_res_y}\n\t"\ + f"Audio: {self.audio}\n\tVideo: {self.video}" + + +class Style: + """Style object contains a set of typographic formatting rules that is applied to dialogue lines. + + More info about styles can be found on http://docs.aegisub.org/3.2/ASS_Tags/. + + Attributes: + fontname (str): Font name + fontsize (int): Font size in points + color1 (str): Primary color (fill) + alpha1 (str): Trasparency of color1 + color2 (str): Secondary color (secondary fill, for karaoke effect) + alpha2 (str): Trasparency of color2 + color3 (str): Outline (border) color + alpha3 (str): Trasparency of color3 + color4 (str): Shadow color + alpha4 (str): Trasparency of color4 + bold (bool): Font with bold + italic (bool): Font with italic + underline (bool): Font with underline + strikeout (bool): Font with strikeout + scale_x (float): Text stretching in the horizontal direction + scale_y (float): Text stretching in the vertical direction + spacing (float): Horizontal spacing between letters + angle (float): Rotation of the text + border_style (bool): *True* for opaque box, *False* for standard outline + outline (float): Border thickness value + shadow (float): How far downwards and to the right a shadow is drawn + alignment (int): Alignment of the text + margin_l (int): Distance from the left of the video frame + margin_r (int): Distance from the right of the video frame + margin_v (int): Distance from the bottom (or top if alignment >= 7) of the video frame + encoding (int): Codepage used to map codepoints to glyphs + """ + fontname = "" + fontsize = 0 + color1 = "" + alpha1 = "" + color2 = "" + alpha2 = "" + color3 = "" + alpha3 = "" + color4 = "" + alpha4 = "" + bold = False + italic = False + underline = False + strikeout = False + scale_x = 100.0 + scale_y = 100.0 + spacing = 0.0 + angle = 0.0 + border_style = False + outline = 2.0 + shadow = 0.0 + alignment = 8 + margin_l = 30 + margin_r = 30 + margin_v = 30 + encoding = 1 + + def __repr__(self): + return str(self.__dict__) + + +class Line: + """Line object contains informations about a single line in the Ass. + + Note: + (*) = This field is available only if :class:`extended` = True + + Attributes: + comment (bool): If *True*, this line will not be displayed on the screen. + layer (int): Layer for the line. Higher layer numbers are drawn on top of lower ones. + start_time (int): Line start time (in milliseconds). + end_time (int): Line end time (in milliseconds). + duration (int): Line duration (in milliseconds) (*). + leadin (float): Time between this line and the previous one (in milliseconds; first line = 1000.1) (*). + leadout (float): Time between this line and the next one (in milliseconds; first line = 1000.1) (*). + style (str): Style name used for this line. + styleref (obj): Reference to the Style object of this line (*). + actor (str): Actor field. + margin_l (int): Left margin for this line. + margin_r (int): Right margin for this line. + margin_v (int): Vertical margin for this line. + effect (str): Effect field. + text (str): Line raw text. + text_stripped (str): Line stripped text. + width (float): Line text width (*). + height (float): Line text height (*). + ascent (float): Line font ascent (*). + descent (float): Line font descent (*). + internal_leading (float): Line font internal lead (*). + external_leading (float): Line font external lead (*). + x (float): Line text position horizontal (depends on alignment) (*). + y (float): Line text position vertical (depends on alignment) (*). + left (float): Line text position left (*). + center (float): Line text position center (*). + right (float): Line text position right (*). + top (float): Line text position top (*). + middle (float): Line text position middle (*). + bottom (float): Line text position bottom (*). + words (list): List containing objects :class:`Word` in this line (*). + syls (list): List containing objects :class:`Syllable` in this line (if available) (*). + chars (list): List containing objects :class:`Char` in this line (*). + """ + comment = False + layer = 0 + start_time = 0 + end_time = 0 + style = "" + actor = "" + margin_l = 0 + margin_r = 0 + margin_v = 0 + effect = "" + text = "" + text_stripped = "" + + def __repr__(self): + return str(self.__dict__) + + def copy(self): + """ + Returns: + A deep copy of this object (line) + """ + return copy.deepcopy(self) + + +class Word: + """Word object contains informations about a single word of a line in the Ass. + + A word can be defined as some text with some optional space before or after + (e.g.: In the string "What a beautiful world!", "beautiful" and "world" are both distinct words). + + Attributes: + start_time (int): Word start time (same as line start time) (in milliseconds). + end_time (int): Word end time (same as line end time) (in milliseconds). + duration (int): Word duration (same as line duration) (in milliseconds). + text (str): Word text. + prespace (int): Word free space before text. + postspace (int): Word free space after text. + width (float): Word text width. + height (float): Word text height. + ascent (float): Word font ascent. + descent (float): Word font descent. + internal_leading (float): Word font internal lead. + external_leading (float): Word font external lead. + x (float): Word text position horizontal (depends on alignment). + y (float): Word text position vertical (depends on alignment). + left (float): Word text position left. + center (float): Word text position center. + right (float): Word text position right. + top (float): Word text position top. + middle (float): Word text position middle. + bottom (float): Word text position bottom. + """ + start_time = 0 + end_time = 0 + duration = 0 + + text = "" + + prespace = 0 + postspace = 0 + + width = 0 + height = 0 + + ascent = 0 + descent = 0 + + internal_leading = 0 + external_leading = 0 + + x = 0 + y = 0 + + left = 0 + center = 0 + right = 0 + + top = 0 + middle = 0 + bottom = 0 + + def __repr__(self): + return str(self.__dict__) + + +class Syllable: + """Syllable object contains informations about a single syl of a line in the Ass. + + A syl can be defined as some text after a karaoke tag (k, ko, kf) + (e.g.: In "{\\k0}Hel{\\k0}lo {\\k0}Pyon{\\k0}FX {\\k0}users!", "Pyon" and "FX" are distinct syllables), + + Attributes: + word_i (int): Syllable word index (e.g.: In line text "{\\k0}Hel{\\k0}lo {\\k0}Pyon{\\k0}FX {\\k0}users!", syl "Pyon" will have word_i=1). + start_time (int): Syllable start time (in milliseconds). + end_time (int): Syllable end time (in milliseconds). + duration (int): Syllable duration (in milliseconds). + text (str): Syllable text. + inline_fx (str): Syllable inline effect (marked as \\-EFFECT in karaoke-time). + prespace (int): Syllable free space before text. + postspace (int): Syllable free space after text. + width (float): Syllable text width. + height (float): Syllable text height. + ascent (float): Syllable font ascent. + descent (float): Syllable font descent. + internal_leading (float): Syllable font internal lead. + external_leading (float): Syllable font external lead. + x (float): Syllable text position horizontal (depends on alignment). + y (float): Syllable text position vertical (depends on alignment). + left (float): Syllable text position left. + center (float): Syllable text position center. + right (float): Syllable text position right. + top (float): Syllable text position top. + middle (float): Syllable text position middle. + bottom (float): Syllable text position bottom. + """ + word_i = 0 + + start_time = 0 + end_time = 0 + duration = 0 + + text = "" + inline_fx = "" + + prespace = 0 + postspace = 0 + + width = 0 + height = 0 + + ascent = 0 + descent = 0 + + internal_leading = 0 + external_leading = 0 + + x = 0 + y = 0 + + left = 0 + center = 0 + right = 0 + + top = 0 + middle = 0 + bottom = 0 + + def __repr__(self): + return str(self.__dict__) + + +class Char: + """Char object contains informations about a single char of a line in the Ass. + + A char is defined by some text between two karaoke tags (k, ko, kf). + + Attributes: + word_i (int): Char word index (e.g.: In line text "Hello PyonFX users!", letter "u" will have word_i=2). + syl_i (int): Char syl index (e.g.: In line text "{\\k0}Hel{\\k0}lo {\\k0}Pyon{\\k0}FX {\\k0}users!", letter "F" will have syl_i=3). + syl_char_i (int): Char invidual syl index (e.g.: In line text "{\\k0}Hel{\\k0}lo {\\k0}Pyon{\\k0}FX {\\k0}users!", letter "e" of "users" will have syl_char_i=2). + start_time (int): Char start time (in milliseconds). + end_time (int): Char end time (in milliseconds). + duration (int): Char duration (in milliseconds). + text (str): Char text. + inline_fx (str): Char inline effect (marked as \\-EFFECT in karaoke-time). + prespace (int): Char free space before text. + postspace (int): Char free space after text. + width (float): Char text width. + height (float): Char text height. + ascent (float): Char font ascent. + descent (float): Char font descent. + internal_leading (float): Char font internal lead. + external_leading (float): Char font external lead. + x (float): Char text position horizontal (depends on alignment). + y (float): Char text position vertical (depends on alignment). + left (float): Char text position left. + center (float): Char text position center. + right (float): Char text position right. + top (float): Char text position top. + middle (float): Char text position middle. + bottom (float): Char text position bottom. + """ + word_i = -1 + syl_i = -1 + syl_char_i = -1 + + start_time = 0 + end_time = 0 + duration = 0 + + text = "" + + width = 0 + height = 0 + + ascent = 0 + descent = 0 + + internal_leading = 0 + external_leading = 0 + + x = 0 + y = 0 + + left = 0 + center = 0 + right = 0 + + top = 0 + middle = 0 + bottom = 0 + + def __repr__(self): + return str(self.__dict__) + + +class Ass: + """Contains all the informations about a file in the ASS format and the methods to work with it. + + Usually you will create an Ass object and use it for input and output (see example_ section). + PyonFX set automatically an absolute path for all the info in the output, so that wherever you will + put your generated file, it will always take the right path for the video and the audio. + + Args: + path_input (str): Path for the input file (either relative or absolute). + path_output (str): Path for the output file (either relative or absolute) (DEFAULT: "Output.ass"). + 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. + + Attributes: + path_input (str): Path for input file (absolute). + 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: + >>> io = Ass("in.ass") + >>> meta, styles, lines = io.get_data() + + """ + def __init__(self, path_input="", path_output="Output.ass", extended=True, vertical_kanji=True): + # Starting to take process time + 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) + + # 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_input = path_input + self.path_output = path_output + + # 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.meta.sub = path_input + section = "" + self.__output = [] + for line in open(self.meta.sub, "r", encoding="utf-8-sig"): + # Getting section + section_pattern = re.compile(r"^\[([^\]]*)") + if section_pattern.match(line): + # Updating section + section = section_pattern.match(line).group(1) + # Appending line to output + self.__output.append(line) + + # Parsing Meta data + elif section == "Script Info" or section == "Aegisub Project Garbage": + # Internal function that tries to get the absolute path for media files in meta + def get_media_abs_path(subfile, mediafile): + if not os.path.isfile(mediafile): + tmp = mediafile + media_dir = os.path.dirname(subfile) + while mediafile.startswith("../"): + media_dir = os.path.dirname(media_dir) + mediafile = mediafile[3:] + + mediafile = os.path.normpath("%s%s%s" % (media_dir, os.sep, mediafile)) + if not os.path.isfile(mediafile): + mediafile = tmp + return mediafile + + # Switch + if re.match(r"^WrapStyle: *?(\d+)$", line): + self.meta.wrap_style = int(line[11:].strip()) + elif re.match(r"^ScaledBorderAndShadow: *?(.+)$", line): + self.meta.scaled_border_and_shadow = line[23:].strip() == "yes" + elif re.match(r"^PlayResX: *?(\d+)$", line): + self.meta.play_res_x = int(line[10:].strip()) + elif re.match(r"^PlayResY: *?(\d+)$", line): + self.meta.play_res_y = int(line[10:].strip()) + elif re.match(r"^Audio File: *?(.*)$", line): + self.meta.audio = get_media_abs_path(self.meta.sub, line[11:].strip()) + line = "Audio File: %s\n" % self.meta.audio + elif re.match(r"^Video File: *?(.*)$", line): + self.meta.video = get_media_abs_path(self.meta.sub, line[11:].strip()) + line = "Video File: %s\n" % self.meta.video + + # Appending line to output + self.__output.append(line) + # Parsing Styles + elif section == "V4+ Styles": + # Appending line to output + self.__output.append(line) + style = re.match(r"^Style: (.+?)$", line) + + if style: + # Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, + # Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, + # BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding + style = [el for el in style.group(1).split(',')] + tmp = Style() + + tmp.fontname = style[1] + tmp.fontsize = float(style[2]) + + r, g, b, a = Convert.coloralpha(style[3]) + tmp.color1 = Convert.coloralpha(r, g, b) + tmp.alpha1 = Convert.coloralpha(a) + + r, g, b, a = Convert.coloralpha(style[4]) + tmp.color2 = Convert.coloralpha(r, g, b) + tmp.alpha2 = Convert.coloralpha(a) + + r, g, b, a = Convert.coloralpha(style[5]) + tmp.color3 = Convert.coloralpha(r, g, b) + tmp.alpha3 = Convert.coloralpha(a) + + r, g, b, a = Convert.coloralpha(style[6]) + tmp.color4 = Convert.coloralpha(r, g, b) + tmp.alpha4 = Convert.coloralpha(a) + + tmp.bold = style[7] == "-1" + tmp.italic = style[8] == "-1" + tmp.underline = style[9] == "-1" + tmp.strikeout = style[10] == "-1" + + tmp.scale_x = float(style[11]) + tmp.scale_y = float(style[12]) + + tmp.spacing = float(style[13]) + tmp.angle = float(style[14]) + + tmp.border_style = style[15] == "3" + tmp.outline = float(style[16]) + tmp.shadow = float(style[17]) + + tmp.alignment = int(style[18]) + tmp.margin_l = float(style[19]) + tmp.margin_r = float(style[20]) + tmp.margin_v = float(style[21]) + + tmp.encoding = int(style[22]) + + self.styles[style[0]] = tmp + # Parsing Dialogues + elif section == "Events": + # Appending line to output (commented) + self.__output.append(re.sub(r"^(Dialogue|Comment):", "Comment:", line)) + + # Analyzing line + line = re.match(r"^(Dialogue|Comment): (.+?)$", line) + + if line: + # Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text + tmp = Line() + tmp.comment = line.group(1) == "Comment" + line = [el for el in line.group(2).split(',')] + + tmp.layer = int(line[0]) + + tmp.start_time = Convert.time(line[1]) + tmp.end_time = Convert.time(line[2]) + + tmp.style = line[3] + tmp.actor = line[4] + + tmp.margin_l = int(line[5]) + tmp.margin_r = int(line[6]) + tmp.margin_v = int(line[7]) + + tmp.effect = line[8] + + tmp.text = ','.join(line[9:]) + + self.lines.append(tmp) + + + # Adding informations to lines and meta? + if extended: + lines_by_styles = {} + # Let the fun begin (Pyon!) + for li, line in enumerate(self.lines): + try: + line.styleref = self.styles[line.style] + except KeyError: + line.styleref = None + + # Append dialog to styles (for leadin and leadout later) + if line.style not in lines_by_styles: + lines_by_styles[line.style] = [] + lines_by_styles[line.style].append(line) + + line.duration = line.end_time - line.start_time + line.text_stripped = re.sub(r"\{.*?\}", "", line.text) + + # Add dialog text sizes and positions (if possible) + if line.styleref: + font = Font(line.styleref) + line.width, line.height = font.get_text_extents(line.text_stripped) + line.ascent, line.descent, line.internal_leading, line.external_leading = font.get_metrics() + if self.meta.play_res_x > 0 and self.meta.play_res_y > 0: + # Horizontal position + if (line.styleref.alignment-1) % 3 == 0: + line.left = line.margin_l if line.margin_l != 0 else line.styleref.margin_l + line.center = line.left + line.width / 2 + line.right = line.left + line.width + line.x = line.left + elif (line.styleref.alignment-2) % 3 == 0: + line.left = self.meta.play_res_x / 2 - line.width / 2 + line.center = line.left + line.width / 2 + line.right = line.left + line.width + line.x = line.center + else: + line.left = self.meta.play_res_x - (line.margin_r if line.margin_r != 0 else line.styleref.margin_r) - line.width + line.center = line.left + line.width / 2 + line.right = line.left + line.width + line.x = line.right + + # Vertical position + if line.styleref.alignment > 6: + line.top = line.margin_v if line.margin_v != 0 else line.styleref.margin_v + line.middle = line.top + line.height / 2 + line.bottom = line.top + line.height + line.y = line.top + elif line.styleref.alignment > 3: + line.top = self.meta.play_res_y / 2 - line.height / 2 + line.middle = line.top + line.height / 2 + line.bottom = line.top + line.height + line.y = line.middle + else: + line.top = self.meta.play_res_y - (line.margin_v if line.margin_v != 0 else line.styleref.margin_v) - line.height + line.middle = line.top + line.height / 2 + line.bottom = line.top + line.height + line.y = line.bottom + + # Calculating space width + space_width = font.get_text_extents(" ")[0] + + # Adding words + line.words = [] + + for prespace, word_text, postspace in re.findall(r"(\s*)(\w+)(\s*)", line.text_stripped): + word = Word() + + word.start_time = line.start_time + word.end_time = line.end_time + word.duration = line.duration + + word.text = word_text + + word.prespace = len(prespace) + word.postspace = len(postspace) + + word.width, word.height = font.get_text_extents(word.text) + word.ascent, word.descent, word.internal_leading, word.external_leading = font.get_metrics() + + line.words.append(word) + + # Calculate word positions with all words data already available + if len(line.words) > 0 and self.meta.play_res_x > 0 and self.meta.play_res_y > 0: + if line.styleref.alignment > 6 or line.styleref.alignment < 4: + cur_x = line.left + for word in line.words: + # Horizontal position + cur_x = cur_x + word.prespace * space_width + + word.left = cur_x + word.center = word.left + word.width / 2 + word.right = word.left + word.width + + if (line.styleref.alignment-1) % 3 == 0: + word.x = word.left + elif (line.styleref.alignment-2) % 3 == 0: + word.x = word.center + else: + word.x = word.right + + # Vertical position + word.top = line.top + word.middle = line.middle + word.bottom = line.bottom + word.y = line.y + + # Updating cur_x + cur_x = cur_x + word.width + word.postspace * space_width + else: + max_width, sum_height = 0, 0 + for word in line.words: + max_width = max(max_width, word.width) + sum_height = sum_height + word.height + + cur_y = x_fix = self.meta.play_res_y / 2 - sum_height / 2 + for word in line.words: + # Horizontal position + x_fix = (max_width - word.width) / 2 + + if line.styleref.alignment == 4: + word.left = line.left + x_fix + word.center = word.left + word.width / 2 + word.right = word.left + word.width + word.x = word.left + elif line.styleref.alignment == 5: + word.left = self.meta.play_res_x / 2 - word.width / 2 + word.center = word.left + word.width / 2 + word.right = word.left + word.width + word.x = word.center + else: + word.left = line.right - word.width - x_fix + word.center = word.left + word.width / 2 + word.right = word.left + word.width + word.x = word.right + + # Vertical position + word.top = cur_y + word.middle = word.top + word.height / 2 + word.bottom = word.top + word.height + word.y = word.middle + cur_y = cur_y + word.height + + + # Add dialog text chunks, to create syllables + text_chunks = [] + tag_pattern = re.compile(r"\{.*?\}") + tag = tag_pattern.search(line.text) + word_i = 0 + + if not tag: + # No tags found + text_chunks.append({'tags': "", 'text': line.text}) + else: + # First chunk without tags + if tag.start() != 0: + text_chunks.append({'tags': "", 'text': line.text[0:tag.start()]}) + + # Searching for other tags + while True: + next_tag = tag_pattern.search(line.text, tag.end()) + tmp = {'tags': line.text[tag.start()+1:tag.end()-1], 'text': line.text[tag.end():(next_tag.start() if next_tag else None)], 'word_i': word_i} + text_chunks.append(tmp) + + if len(re.findall(r"(.*?)(\s*)$", tmp['text'])[0][1]) > 0: + word_i = word_i + 1 + + if not next_tag: + break + tag = next_tag + + # Adding syls + last_time = 0 + line.syls = [] + for text_chunk in text_chunks: + try: + pretags, kdur, posttags = re.findall(r"(.*?)\\[kK][of]?(\d+)(.*?)", text_chunk['tags'])[0][:] + syl = Syllable() + + syl.word_i = text_chunk['word_i'] + + syl.start_time = last_time + syl.end_time = last_time + int(kdur) * 10 + syl.duration = int(kdur) * 10 + + syl.inline_fx = "" + syl.tags = pretags + posttags + syl.prespace, syl.text, syl.postspace = re.findall(r"^(\s*)(.*?)(\s*)$", text_chunk['text'])[0][:] + + syl.prespace, syl.postspace = len(syl.prespace), len(syl.postspace) + syl.width, syl.height = font.get_text_extents(syl.text) + syl.ascent, syl.descent, syl.internal_leading, syl.external_leading = font.get_metrics() + + line.syls.append(syl) + last_time = syl.end_time + except IndexError: + line.syls.clear() + break + + # Calculate syllables positions with all syllables data already available + if len(line.syls) > 0 and self.meta.play_res_x > 0 and self.meta.play_res_y > 0: + if line.styleref.alignment > 6 or line.styleref.alignment < 4 or not vertical_kanji: + cur_x = line.left + for syl in line.syls: + cur_x = cur_x + syl.prespace * space_width + # Horizontal position + syl.left = cur_x + syl.center = syl.left + syl.width / 2 + syl.right = syl.left + syl.width + + if (line.styleref.alignment-1) % 3 == 0: + syl.x = syl.left + elif (line.styleref.alignment-2) % 3 == 0: + syl.x = syl.center + else: + syl.x = syl.right + + cur_x = cur_x + syl.width + syl.postspace * space_width + + # Vertical position + syl.top = line.top + syl.middle = line.middle + syl.bottom = line.bottom + syl.y = line.y + + else: # Kanji vertical position + max_width, sum_height = 0, 0 + for syl in line.syls: + max_width = max(max_width, syl.width) + sum_height = sum_height + syl.height + + cur_y = self.meta.play_res_y / 2 - sum_height / 2 + + # Fixing line positions + line.top = cur_y + line.middle = self.meta.play_res_y / 2 + line.bottom = line.top + sum_height + line.width = max_width + line.height = sum_height + if line.styleref.alignment == 4: + line.center = line.left + max_width / 2 + line.right = line.left + max_width + elif line.styleref.alignment == 5: + line.left = line.center - max_width / 2 + line.right = line.left + max_width + else: + line.left = line.right - max_width + line.center = line.left + max_width / 2 + + for syl in line.syls: + # Horizontal position + x_fix = (max_width - syl.width) / 2 + if line.styleref.alignment == 4: + syl.left = line.left + x_fix + syl.center = syl.left + syl.width / 2 + syl.right = syl.left + syl.width + syl.x = syl.left + elif line.styleref.alignment == 5: + syl.left = line.center - syl.width / 2 + syl.center = syl.left + syl.width / 2 + syl.right = syl.left + syl.width + syl.x = syl.center + else: + syl.left = line.right - syl.width - x_fix + syl.center = syl.left + syl.width / 2 + syl.right = syl.left + syl.width + syl.x = syl.right + + # Vertical position + syl.top = cur_y + syl.middle = syl.top + syl.height / 2 + syl.bottom = syl.top + syl.height + syl.y = syl.middle + cur_y = cur_y + syl.height + + # Adding chars + line.chars = [] + + # Creating some local variables to avoid some useless iterations during the additions of some fields in char obj + word_index = 0 + syl_index = 0 + char_index = 0 + + tmp = "" + if line.words: + tmp = line.words[0] + if line.syls: + tmp = line.syls[0] + + # Getting chars + for char_i, char_text in enumerate(list(line.text_stripped)): + char = Char() + + char.start_time = line.start_time + char.end_time = line.end_time + char.duration = line.duration + + char.text = char_text + + # Adding indexes + if line.syls: + if char_index >= len("{}{}{}".format(" "*tmp.prespace, tmp.text, " "*tmp.postspace)): + char_index = 0 + syl_index += 1 + tmp = line.syls[syl_index] + + char.word_i = tmp.word_i + char.syl_i = syl_index + char.syl_char_i = char_index + else: # We have no syls, let's only work with words + if char_index >= len("{}{}{}".format(" "*tmp.prespace, tmp.text, " "*tmp.postspace)): + char_index = 0 + word_index += 1 + tmp = line.words[syl_index] + + char.word_i = word_index + + # Adding last fields based on the existance of syls or not + char.start_time = tmp.start_time + char.end_time = tmp.end_time + char.duration = tmp.duration + + char.width, char.height = font.get_text_extents(char.text) + char.ascent, char.descent, char.internal_leading, char.external_leading = font.get_metrics() + + line.chars.append(char) + char_index += 1 + + # Calculate character positions with all characters data already available + if len(line.chars) > 0 and self.meta.play_res_x > 0 and self.meta.play_res_y > 0: + if line.styleref.alignment > 6 or line.styleref.alignment < 4: + cur_x = line.left + for char in line.chars: + # Horizontal position + char.left = cur_x + char.center = char.left + char.width / 2 + char.right = char.left + char.width + + if (line.styleref.alignment-1) % 3 == 0: + char.x = char.left + elif (line.styleref.alignment-2) % 3 == 0: + char.x = char.center + else: + char.x = char.right + + cur_x = cur_x + char.width + + # Vertical position + char.top = line.top + char.middle = line.middle + char.bottom = line.bottom + char.y = line.y + else: + max_width, sum_height = 0, 0 + for char in line.chars: + max_width = max(max_width, char.width) + sum_height = sum_height + char.height + + cur_y = x_fix = self.meta.play_res_y / 2 - sum_height / 2 + for char in line.chars: + # Horizontal position + x_fix = (max_width - char.width) / 2 + if line.styleref.alignment == 4: + char.left = line.left + x_fix + char.center = char.left + char.width / 2 + char.right = char.left + char.width + char.x = char.left + elif line.styleref.alignment == 5: + char.left = self.meta.play_res_x / 2 - char.width / 2 + char.center = char.left + char.width / 2 + char.right = char.left + char.width + char.x = char.center + else: + char.left = line.right - char.width - x_fix + char.center = char.left + char.width / 2 + char.right = char.left + char.width + char.x = char.right + + # Vertical position + char.top = cur_y + char.middle = char.top + char.height / 2 + char.bottom = char.top + char.height + char.y = char.middle + cur_y = cur_y + char.height + + # Add durations between dialogs + for style in lines_by_styles: + lines_by_styles[style].sort(key=lambda x: x.start_time) + for li, line in enumerate(lines_by_styles[style]): + line.leadin = 1000.1 if li == 0 else line.start_time - lines_by_styles[style][li-1].end_time + line.leadout = 1000.1 if li == len(lines_by_styles[style])-1 else lines_by_styles[style][li+1].start_time - line.end_time + + # Done + + def get_data(self): + """Utility function to retrieve easily meta styles and lines. + + Returns: + :attr:`meta`, :attr:`styles` and :attr:`lines` + """ + return self.meta, self.styles, self.lines + + def write_line(self, line): + """Appends a line to the output list (which is private). + + Use it whenever you've prepared a line, it will not impact performance since you + will not actually write anything until :func:`save` will be called. + + Parameters: + line (:class:`Line`): A line object. If not valid, TypeError is raised. + """ + if isinstance(line, Line): + self.__output.append("\n%s: %d,%s,%s,%s,%s,%04d,%04d,%04d,%s,%s" % ( + "Comment" if line.comment else "Dialogue", + line.layer, + Convert.time(int(line.start_time)), + Convert.time(int(line.end_time)), + line.style, + line.actor, + line.margin_l, + line.margin_r, + line.margin_v, + line.effect, + line.text + )) + self.__plines += 1 + else: + raise TypeError("Expected Line object, got %s." % type(line)) + + def save(self, quiet=False): + """Write everything inside the output list to a file. + + This should be the last function called inside your fx.py file. + Additionally, if pyonfx.Settings.aegisub is True, then the file will automatically + be opened with Aegisub at the end of the generation. + + Parameters: + quiet (bool): If True, you will not get printed any message. + """ + + # Writing to file + with open(self.path_output, 'w', encoding="utf-8-sig") as f: + f.writelines(self.__output) + if not quiet: + print("Produced lines: %d\nProcess duration (in seconds): %.3f" % (self.__plines, time.time() - self.__ptime)) + + # Open with Aegisub? + if Settings.aegisub: + os.startfile(self.path_output) diff --git a/pyonfx/convert.py b/pyonfx/convert.py new file mode 100644 index 00000000..aae06441 --- /dev/null +++ b/pyonfx/convert.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# PyonFX: An easy way to do KFX and complex typesetting based on subtitle format ASS (Advanced Substation Alpha). +# Copyright (C) 2019 Antonio Strippoli (CoffeeStraw/YellowFlash) +# +# 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyonFX 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +import re +import math + +class Convert: + """ + This class is a collection of static methods that will help + the user toconvert everything needed to the ASS format. + """ + + @staticmethod + def time(ass_ms): + """Converts between milliseconds and ASS timestamp. + + Parameters: + ass_ms (either int or str): If int, than milliseconds are expected, else ASS timestamp as str is expected. + + Returns: + If milliseconds -> ASS timestamp, else if ASS timestamp -> milliseconds, else ValueError will be raised. + """ + # Milliseconds? + if type(ass_ms) is int and ass_ms >= 0: + return "{:d}:{:02d}:{:02d}.{:02d}".format( + math.floor(ass_ms / 3600000) % 10, + math.floor(ass_ms % 3600000 / 60000), + math.floor(ass_ms % 60000 / 1000), + math.floor(ass_ms % 1000 / 10)) + # ASS timestamp? + elif type(ass_ms) is str and re.match(r"^\d:\d+:\d+\.\d+$", ass_ms): + return int(ass_ms[0]) * 3600000 + int(ass_ms[2:4]) * 60000 + int(ass_ms[5:7]) * 1000 + int(ass_ms[8:10]) * 10 + else: + raise ValueError("Milliseconds or ASS timestamp expected") + + @staticmethod + def coloralpha(ass_r_a, g="", b="", a=""): + """Converts between rgb color &/+ alpha numeric and ASS color &/+ alpha. + + Parameters: + ass_r_a (int or str): If str, an ASS color + optionally alpha or only alpha is expected, else if it is a number this will be the red value or the alpha value in rgb. + g (int, optional): If given, an rgb + optional alpha is expected, so you will have to fill also the other parameters. + b (int, optional): If given, an rgb + optional alpha is expected, so you will have to fill also the other parameters. + a (int, optional): If given, an rgb + alpha is expected, so you will have to fill also the other parameters. + + Returns: + According to the parameters, either an rgb + optionally alpha as multiple returns value or a str containing either an ASS color+alpha, an ASS color or an ASS alpha. + + """ + # Alpha / red numeric? + if type(ass_r_a) == int and ass_r_a >= 0 and ass_r_a <= 255: + # Green + blue numeric? + if type(g) == int and g >= 0 and g <= 255 and type(b) == int and b >= 0 and b <= 255: + # Alpha numeric? + if type(a) == int and a >= 0 and a <= 255: + return "&H{:02X}{:02X}{:02X}{:02X}".format(255 - a, b, g, ass_r_a) + else: + return "&H{:02X}{:02X}{:02X}&".format(b, g, ass_r_a) + else: + return "&H{:02X}&".format(255 - ass_r_a) + # ASS value? + elif type(ass_r_a) == str: + # ASS alpha? + if re.match(r"^&H[0-9a-fA-F]{2}&$", ass_r_a): + return 255 - int(ass_r_a[2:4], 16) + # ASS color? + elif re.match(r"^&H[0-9a-fA-F]{6}&$", ass_r_a): + return int(ass_r_a[6:8], 16), int(ass_r_a[4:6], 16), int(ass_r_a[2:4], 16) + # ASS color+alpha (from style definition)? + elif re.match(r"^&H[0-9a-fA-F]{8}$", ass_r_a): + return int(ass_r_a[8:10], 16), int(ass_r_a[6:8], 16), int(ass_r_a[4:6], 16), 255 - int(ass_r_a[2:4], 16) + else: + raise ValueError("Invalid ASS string") + else: + raise ValueError("Color, Alpha, Color+Alpha as numeric or ASS expected") + + @staticmethod + def shape_to_pixels(shape): + pass + + @staticmethod + def text_to_shape(text, style): + pass + + @staticmethod + def text_to_pixels(text, style, off_x=0, off_y=0): + pass + + @staticmethod + def image_to_ass(image): + pass + + @staticmethod + def image_to_pixels(image): + pass diff --git a/pyonfx/font_utility.py b/pyonfx/font_utility.py new file mode 100644 index 00000000..34851fb2 --- /dev/null +++ b/pyonfx/font_utility.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# PyonFX: An easy way to do KFX and complex typesetting based on subtitle format ASS (Advanced Substation Alpha). +# Copyright (C) 2019 Antonio Strippoli (CoffeeStraw/YellowFlash) +# +# 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyonFX 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +""" +This file contains the Font class definition, which has some functions +to help getting informations from a specific font +""" +import sys +import win32gui +import win32ui +import win32con + +# CONFIGURATION +FONT_PRECISION = 64 # Font scale for better precision output from native font system + +class Font: + """ + Font class definition + """ + def __init__(self, style): + self.family = style.fontname + self.bold = style.bold + self.italic = style.italic + self.underline = style.underline + self.strikeout = style.strikeout + self.size = style.fontsize + self.xscale = style.scale_x / 100 + self.yscale = style.scale_y / 100 + self.hspace = style.spacing + self.upscale = FONT_PRECISION + self.downscale = 1 / FONT_PRECISION + + if sys.platform == "win32": + # Create device context + self.dc = win32gui.CreateCompatibleDC(None) + # Set context coordinates mapping mode + win32gui.SetMapMode(self.dc, win32con.MM_TEXT) + # Set context backgrounds to transparent + win32gui.SetBkMode(self.dc, win32con.TRANSPARENT) + # Create font handle + font_spec = { + 'height': int(self.size * self.upscale), + 'width': 0, + 'escapement': 0, + 'orientation': 0, + 'weight': win32con.FW_BOLD if self.bold else win32con.FW_NORMAL, + 'italic': int(self.italic), + 'underline': int(self.underline), + 'strike out': int(self.strikeout), + 'charset': win32con.DEFAULT_CHARSET, + 'out precision': win32con.OUT_TT_PRECIS, + 'clip precision': win32con.CLIP_DEFAULT_PRECIS, + 'quality': win32con.ANTIALIASED_QUALITY, + 'pitch and family': win32con.DEFAULT_PITCH + win32con.FF_DONTCARE, + 'name': self.family + } + font = win32ui.CreateFont(font_spec) + win32gui.SelectObject(self.dc, font.GetSafeHandle()) + else: + raise NotImplementedError + + def get_metrics(self): + metrics = win32gui.GetTextMetrics(self.dc) + + return ( + #'height': metrics['Height'] * self.downscale * self.yscale, + metrics['Ascent'] * self.downscale * self.yscale, + metrics['Descent'] * self.downscale * self.yscale, + metrics['InternalLeading'] * self.downscale * self.yscale, + metrics['ExternalLeading'] * self.downscale * self.yscale + ) + + def get_text_extents(self, text): + cx, cy = win32gui.GetTextExtentPoint32(self.dc, text) + + return ( + (cx * self.downscale + self.hspace) * self.xscale, + cy * self.downscale * self.yscale + ) diff --git a/pyonfx/settings.py b/pyonfx/settings.py new file mode 100644 index 00000000..add083b8 --- /dev/null +++ b/pyonfx/settings.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- + +class Settings: + """ + Class Settings with the following fields: + """ + + aegisub = True + """ + If **True**, PyonFX will automatically open the output with Aegisub at the end of the generation. + """ + + mpv = False + """ + | **Currently Not Implemented**. + | If **True**, PyonFX will automatically open the output with mpv, playing in softsub. + """ \ No newline at end of file diff --git a/pyonfx/utils.py b/pyonfx/utils.py new file mode 100644 index 00000000..a8d97335 --- /dev/null +++ b/pyonfx/utils.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# PyonFX: An easy way to do KFX and complex typesetting based on subtitle format ASS (Advanced Substation Alpha). +# Copyright (C) 2019 Antonio Strippoli (CoffeeStraw/YellowFlash) +# +# 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 +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# PyonFX 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 Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. + +class Utils: + """ + This class is a collection of static methods that will help the user in some tasks. + """ + + @staticmethod + def all_non_empty(lines_chars_syls_or_words): + """ + Helps to not check everytime for text containing only spaces or object's duration equals to zero. + + Parameters: + lines_chars_syls_or_words (list of :class:`Line`, :class:`Char`, :class:`Syllable` or :class:`Word`) + + Returns: + An enumerate object containing lines_chars_syls_or_words without objects with duration equals to zero or without some text except spaces. + """ + out = [] + for obj in lines_chars_syls_or_words: + if obj.text.strip() and obj.duration > 0: + out.append(obj) + return enumerate(out) + + def clean_tags(text): + # TODO: Cleans up ASS subtitle lines of badly-formed override. Returns a cleaned up text. + pass \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..97b2a943 --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +import setuptools +from pyonfx import __version__ + +setuptools.setup( + name="PyonFX", + url="https://github.com/CoffeeStraw/PyonFX", + author="Antonio Strippoli", + author_email="clarantonio98@gmail.com", + version=__version__, + license='GNU LGPL 3.0 or later', + description="An easy way to do KFX and complex typesetting based on subtitle format ASS (Advanced Substation Alpha).", + long_description=open('README.rst', 'rt').read(), + packages=['pyonfx'], + install_requires=[], + classifiers=[ + 'Development Status :: 2 - Pre-Alpha', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + ], +) \ No newline at end of file diff --git a/tests/Ass/in.ass b/tests/Ass/in.ass new file mode 100644 index 00000000..b67990c3 --- /dev/null +++ b/tests/Ass/in.ass @@ -0,0 +1,58 @@ +[Script Info] +; Script generated by Aegisub 8962-master-5c15667 +; http://www.aegisub.org/ +PlayResX: 1280 +PlayResY: 720 + +[Aegisub Project Garbage] +Last Style Storage: Default +Video File: ?dummy:23.976000:2250:1920:1080:11:135:226:c +Video AR Value: 1.777778 +Video Zoom Percent: 0.625000 +Active Line: 1 +Video Position: 342 + +[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,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1 +Style: Romaji,Migu 1P,48,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,8,25,25,25,1 +Style: Translation,Migu 1P,46,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,2,25,25,25,1 +Style: Kanji,Migu 1P,44,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,4,25,25,25,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Comment: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,Font used (Version 1P, Bold): https://www.freejapanesefont.com/migu-font-%E3%83%9F%E3%82%B0%E3%83%95%E3%82%A9%E3%83%B3%E3%83%88/ +Dialogue: 0,0:00:14.24,0:00:24.23,Romaji,,0,0,0,,{\k56}su{\k13}re{\k22}chi{\k36}ga{\k48}u{\k25} {\k34}ko{\k33}to{\k50}ba {\k15}no {\k17}u{\k34}ra {\k46}ni{\k33} {\k28}to{\k36}za{\k65}sa{\k33}{\k30}re{\k51}ta{\k16} {\k33}ko{\k33}ko{\k78}ro {\k15}no {\k24}ka{\k95}gi +Dialogue: 0,0:00:24.68,0:00:34.50,Romaji,,0,0,0,,{\k51}ki{\k13}mi {\k18}to {\k32}i{\k49}u{\k28} {\k37}fu{\k32}ra{\k46}gu {\k19}ka{\k13}i{\k35}jo {\k51}ga{\k30} {\k33}e{\k34}ga{\k32}o{\k30} {\k35}su{\k38}ku{\k59}e{\k64}ru {\k67}no{\k29} {\k19}na{\k88}ra +Dialogue: 0,0:00:35.06,0:00:40.09,Romaji,,0,0,0,,{\k16}na{\k18}tsu {\k15}no {\k30}ka{\k35}ze {\k12}i{\k40}za{\k14}na{\k50}u{\k32} {\k17}ha{\k12}ku{\k19}chu{\k31}u {\k25}no {\k23}su{\k33}ko{\k15}o{\k66}ru +Dialogue: 0,0:00:40.84,0:00:44.80,Romaji,,0,0,0,,{\k34}ha{\k37}cho{\k39}u{\k16} {\k76}shi{\k96}n{\k12}ku{\k86}ro +Dialogue: 0,0:00:45.41,0:00:50.30,Romaji,,0,0,0,,{\k21}ro{\k27}man{\k36}chi{\k33}kku {\k16}mi{\k36}ta{\k19}i {\k40}ni{\k35} {\k17}ki {\k16}no {\k16}ki{\k30}i{\k33}ta {\k18}ko{\k31}to{\k19}ba {\k46}mo +Dialogue: 0,0:00:50.58,0:00:59.34,Romaji,,0,0,0,,{\k37}mi{\k17}tsu{\k47}ka{\k19}ra{\k46}nai {\k19}ke{\k36}do{\k45} {\k32}a{\k28}o{\k33}zo{\k36}ra {\k29}o{\k31} {\k32}me{\k31}za{\k178}su{\k56} {\k14}ka{\k110}ra +Dialogue: 0,0:00:59.70,0:01:04.24,Romaji,,0,0,0,,{\k33}bo{\k19}ku{\k30}ta{\k33}chi {\k85}wa{\k27} {\k15}hi{\k16}to{\k36}tsu {\k17}ni {\k31}na{\k32}re{\k80}ru +Dialogue: 0,0:01:04.56,0:01:09.78,Romaji,,0,0,0,,{\k22}to{\k16}ma{\k30}do{\k16}i {\k35}no {\k30}na{\k111}mi{\k20}da {\k43}mo{\k34} {\k34}yu{\k31}me {\k100}mo +Dialogue: 0,0:01:10.00,0:01:15.96,Romaji,,0,0,0,,{\k46}shi{\k11}n{\k29}ji{\k33}tsu {\k84}ni{\k26} {\k24}hi{\k10}ki{\k29}yo{\k27}se{\k31}ra{\k30}re{\k115}ru {\k17}mo{\k84}no +Dialogue: 0,0:01:16.20,0:01:24.48,Romaji,,0,0,0,,{\k33}so{\k55}re {\k26}wa{\k24} {\k13}i{\k9}to{\k48}shi{\k62}su{\k19}gi{\k85}ru{\k108} {\k40}ma{\k36}ho{\k63}u {\k32}no {\k18}ki{\k32}i{\k101}waa{\k24}do +Comment: 0,0:01:24.39,0:01:26.39,Default,,0,0,0,, +Dialogue: 0,0:00:14.24,0:00:18.56,Kanji,,0,0,0,,{\k56}す{\k13}れ{\k58}違{\k48}う{\k25}{\k67}言{\k50}葉{\k15}の{\k51}裏{\k49}に +Dialogue: 0,0:00:18.86,0:00:24.23,Kanji,,0,0,0,,{\k28}閉{\k36}ざ{\k65}さ{\k33}{\k30}れ{\k51}た{\k16}{\k144}心{\k15}の{\k24}カ{\k95}ギ +Dialogue: 0,0:00:24.68,0:00:28.91,Kanji,,0,0,0,,{\k51}キ{\k13}ミ{\k18}と{\k32}い{\k49}う{\k28}{\k37}フ{\k32}ラ{\k46}グ{\k32}解{\k35}除{\k50}が +Dialogue: 0,0:00:29.22,0:00:34.50,Kanji,,0,0,0,,{\k33}笑{\k66}顔{\k30}{\k73}救{\k59}え{\k64}る{\k67}の{\k29}{\k19}な{\k88}ら +Dialogue: 0,0:00:35.06,0:00:40.09,Kanji,,0,0,0,,{\k34}夏{\k15}の{\k65}風{\k12}い{\k40}ざ{\k14}な{\k50}う{\k32}{\k29}白{\k50}昼{\k25}の{\k23}ス{\k33}コ{\k15}ー{\k66}ル +Dialogue: 0,0:00:40.84,0:00:44.80,Kanji,,0,0,0,,{\k34}波{\k76}長{\k16}{\k76}シ{\k96}ン{\k12}ク{\k86}ロ +Dialogue: 0,0:00:45.41,0:00:50.30,Kanji,,0,0,0,,{\k21}ロ{\k27}マン{\k36}チ{\k33}ック{\k16}み{\k36}た{\k19}い{\k40}に{\k35}{\k17}気{\k16}の{\k16}利{\k30}い{\k33}た{\k49}言{\k19}葉{\k46}も +Dialogue: 0,0:00:50.58,0:00:59.34,Kanji,,0,0,0,,{\k37}見{\k17}つ{\k47}か{\k19}ら{\k46}ない{\k19}け{\k36}ど{\k45}{\k60}青{\k69}空{\k29}を{\k31}{\k32}目{\k31}指{\k178}す{\k56}{\k14}か{\k110}ら +Dialogue: 0,0:00:59.70,0:01:04.24,Kanji,,0,0,0,,{\k52}僕{\k63}達{\k85}は{\k27}{\k31}一{\k36}つ{\k17}に{\k31}な{\k32}れ{\k80}る +Dialogue: 0,0:01:04.56,0:01:09.78,Kanji,,0,0,0,,{\k22}戸{\k46}惑{\k16}い{\k35}の{\k161}涙{\k43}も{\k34}{\k65}夢{\k100}も +Dialogue: 0,0:01:10.00,0:01:15.96,Kanji,,0,0,0,,{\k57}真{\k62}実{\k84}に{\k26}{\k24}引{\k10}き{\k29}寄{\k27}せ{\k31}ら{\k30}れ{\k115}る{\k17}も{\k84}の +Dialogue: 0,0:01:16.20,0:01:24.48,Kanji,,0,0,0,,{\k33}そ{\k55}れ{\k26}は{\k24}{\k22}愛{\k48}し{\k62}す{\k19}ぎ{\k85}る{\k108}{\k40}魔{\k99}法{\k32}の{\k18}キ{\k32}ー{\k101}ワー{\k24}ド +Comment: 0,0:00:13.82,0:00:14.22,Default,,0,0,0,, +Dialogue: 0,0:00:14.24,0:00:24.23,Translation,,0,0,0,,Guarda oltre le parole e cerca la chiave per il mio cuore, +Dialogue: 0,0:00:24.68,0:00:34.50,Translation,,0,0,0,,se credi che il mio affetto possa renderti felice. +Dialogue: 0,0:00:35.06,0:00:40.09,Translation,,0,0,0,,La brezza di tarda estate si trasforma in una tempesta tumultuosa, +Dialogue: 0,0:00:40.84,0:00:44.80,Translation,,0,0,0,,mentre entriamo in sintonia. +Dialogue: 0,0:00:45.41,0:00:50.30,Translation,,0,0,0,,Sono un pessimo romantico, mi mancano le parole giuste, +Dialogue: 0,0:00:50.58,0:00:59.34,Translation,,0,0,0,,eppure sto ancora puntando al cielo, così sconfinato... +Dialogue: 0,0:00:59.70,0:01:04.24,Translation,,0,0,0,,Possiamo diventare una cosa sola +Dialogue: 0,0:01:04.56,0:01:09.78,Translation,,0,0,0,,mentre i nostri sogni e le nostre lacrime confuse +Dialogue: 0,0:01:10.00,0:01:15.96,Translation,,0,0,0,,ci portano sempre più vicini alla verità, +Dialogue: 0,0:01:16.20,0:01:24.48,Translation,,0,0,0,,che ormai è divenuta la nostra amata parola chiave magica. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_ass.py b/tests/test_ass.py new file mode 100644 index 00000000..dc9e3572 --- /dev/null +++ b/tests/test_ass.py @@ -0,0 +1,54 @@ +import os +import pytest +from pyonfx import * + +# Get ass path +dir_path = os.path.dirname(os.path.realpath(__file__)) +path_ass = os.path.join(dir_path, "Ass", "in.ass") + +# Extract infos from ass file +io = Ass(path_ass) +meta, styles, lines = io.get_data() + +def test_meta_values(): + # Tests if all the meta values are taken correctly + assert meta.wrap_style == 0 + assert meta.scaled_border_and_shadow == True + assert meta.play_res_x == 1280 + assert meta.play_res_y == 720 + assert meta.audio == "" + assert meta.video == "?dummy:23.976000:2250:1920:1080:11:135:226:c" + +def test_line_values(): + # Tests if all the line values are taken correctly + line = lines[1] + assert line.comment == False + assert line.layer == 0 + assert line.start_time == Convert.time("0:00:14.24") + assert line.end_time == Convert.time("0:00:24.23") + assert line.duration == Convert.time("0:00:24.23") - Convert.time("0:00:14.24") + assert line.leadin == 1000.1 + assert line.leadout == lines[2].start_time - lines[1].end_time + assert line.style == "Romaji" + assert line.actor == "" + assert line.margin_l == 0 + assert line.margin_r == 0 + assert line.margin_v == 0 + assert line.effect == "" + assert line.text == "{\\k56}su{\\k13}re{\\k22}chi{\\k36}ga{\\k48}u{\\k25} {\\k34}ko{\\k33}to{\\k50}ba {\\k15}no {\\k17}u{\\k34}ra {\\k46}ni{\\k33} {\\k28}to{\\k36}za{\\k65}sa{\\k33}{\\k30}re{\\k51}ta{\\k16} {\\k33}ko{\\k33}ko{\\k78}ro {\\k15}no {\\k24}ka{\\k95}gi" + assert line.text_stripped == "surechigau kotoba no ura ni tozasareta kokoro no kagi" + # Values taken from YutilsCore to test + assert line.width == 941.703125 + assert line.height == 48.0 + assert line.ascent == 36.984375 + assert line.descent == 11.015625 + assert line.internal_leading == 13.59375 + assert line.external_leading == 3.09375 + assert line.x == line.center + assert line.y == line.top + assert line.left == 169.1484375 + assert line.center == 640.0 + assert line.right == 1110.8515625 + assert line.top == 25.0 + assert line.middle == 49.0 + assert line.bottom == 73.0