mirror of https://github.com/odrling/Aegisub
607 lines
17 KiB
C++
607 lines
17 KiB
C++
// Copyright (c) 2006, Rodrigo Braz Monteiro
|
|
// All rights reserved.
|
|
//
|
|
// Redistribution and use in source and binary forms, with or without
|
|
// modification, are permitted provided that the following conditions are met:
|
|
//
|
|
// * Redistributions of source code must retain the above copyright notice,
|
|
// this list of conditions and the following disclaimer.
|
|
// * Redistributions in binary form must reproduce the above copyright notice,
|
|
// this list of conditions and the following disclaimer in the documentation
|
|
// and/or other materials provided with the distribution.
|
|
// * Neither the name of the Aegisub Group nor the names of its contributors
|
|
// may be used to endorse or promote products derived from this software
|
|
// without specific prior written permission.
|
|
//
|
|
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
// ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
|
|
// LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
// CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
// SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
// INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
// CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
// ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
// POSSIBILITY OF SUCH DAMAGE.
|
|
//
|
|
// Aegisub Project http://www.aegisub.org/
|
|
//
|
|
// $Id$
|
|
|
|
/// @file subtitle_format_srt.cpp
|
|
/// @brief Reading/writing SubRip format subtitles (.SRT)
|
|
/// @ingroup subtitle_io
|
|
///
|
|
|
|
#include "config.h"
|
|
|
|
#include "subtitle_format_srt.h"
|
|
|
|
#ifndef AGI_PRE
|
|
#include <wx/regex.h>
|
|
#endif
|
|
|
|
#include "ass_attachment.h"
|
|
#include "ass_dialogue.h"
|
|
#include "ass_file.h"
|
|
#include "ass_override.h"
|
|
#include "ass_style.h"
|
|
#include "colorspace.h"
|
|
#include "compat.h"
|
|
#include "utils.h"
|
|
#include "text_file_reader.h"
|
|
#include "text_file_writer.h"
|
|
|
|
DEFINE_SIMPLE_EXCEPTION(SRTParseError, SubtitleFormatParseError, "subtitle_io/parse/srt")
|
|
|
|
namespace {
|
|
class SrtTagParser {
|
|
struct FontAttribs {
|
|
wxString face;
|
|
wxString size;
|
|
wxString color;
|
|
};
|
|
|
|
enum TagType {
|
|
// leave 0 unused so indexing an unknown tag in the map won't clash
|
|
TAG_BOLD_OPEN = 1,
|
|
TAG_BOLD_CLOSE,
|
|
TAG_ITALICS_OPEN,
|
|
TAG_ITALICS_CLOSE,
|
|
TAG_UNDERLINE_OPEN,
|
|
TAG_UNDERLINE_CLOSE,
|
|
TAG_STRIKEOUT_OPEN,
|
|
TAG_STRIKEOUT_CLOSE,
|
|
TAG_FONT_OPEN,
|
|
TAG_FONT_CLOSE
|
|
};
|
|
|
|
wxRegEx tag_matcher;
|
|
wxRegEx attrib_matcher;
|
|
std::map<wxString,TagType> tag_name_cases;
|
|
|
|
public:
|
|
SrtTagParser()
|
|
: tag_matcher("^(.*?)<(/?b|/?i|/?u|/?s|/?font)([^>]*)>(.*)$", wxRE_ICASE|wxRE_ADVANCED)
|
|
, attrib_matcher("^[[:space:]]+(face|size|color)=('[^']*'|\"[^\"]*\"|[^[:space:]]+)", wxRE_ICASE|wxRE_ADVANCED)
|
|
{
|
|
if (!tag_matcher.IsValid())
|
|
throw agi::InternalError("Parsing SRT: Failed compiling tag matching regex", 0);
|
|
if (!attrib_matcher.IsValid())
|
|
throw agi::InternalError("Parsing SRT: Failed compiling tag attribute matching regex", 0);
|
|
|
|
tag_name_cases["b"] = TAG_BOLD_OPEN;
|
|
tag_name_cases["/b"] = TAG_BOLD_CLOSE;
|
|
tag_name_cases["i"] = TAG_ITALICS_OPEN;
|
|
tag_name_cases["/i"] = TAG_ITALICS_CLOSE;
|
|
tag_name_cases["u"] = TAG_UNDERLINE_OPEN;
|
|
tag_name_cases["/u"] = TAG_UNDERLINE_CLOSE;
|
|
tag_name_cases["s"] = TAG_STRIKEOUT_OPEN;
|
|
tag_name_cases["/s"] = TAG_STRIKEOUT_CLOSE;
|
|
tag_name_cases["font"] = TAG_FONT_OPEN;
|
|
tag_name_cases["/font"] = TAG_FONT_CLOSE;
|
|
}
|
|
|
|
wxString ToAss(wxString srt)
|
|
{
|
|
int bold_level = 0;
|
|
int italics_level = 0;
|
|
int underline_level = 0;
|
|
int strikeout_level = 0;
|
|
std::vector<FontAttribs> font_stack;
|
|
|
|
wxString ass; // result to be built
|
|
|
|
while (!srt.empty())
|
|
{
|
|
if (!tag_matcher.Matches(srt))
|
|
{
|
|
// no more tags could be matched, end of string
|
|
ass.append(srt);
|
|
break;
|
|
}
|
|
|
|
// we found a tag, translate it
|
|
wxString pre_text = tag_matcher.GetMatch(srt, 1);
|
|
wxString tag_name = tag_matcher.GetMatch(srt, 2);
|
|
wxString tag_attrs = tag_matcher.GetMatch(srt, 3);
|
|
wxString post_text = tag_matcher.GetMatch(srt, 4);
|
|
|
|
// the text before the tag goes through unchanged
|
|
ass.append(pre_text);
|
|
// the text after the tag is the input for next iteration
|
|
srt = post_text;
|
|
|
|
switch (tag_name_cases[tag_name.Lower()])
|
|
{
|
|
case TAG_BOLD_OPEN:
|
|
if (bold_level == 0)
|
|
ass.append("{\\b1}");
|
|
bold_level++;
|
|
break;
|
|
case TAG_BOLD_CLOSE:
|
|
if (bold_level == 1)
|
|
ass.append("{\\b}");
|
|
if (bold_level > 0)
|
|
bold_level--;
|
|
break;
|
|
case TAG_ITALICS_OPEN:
|
|
if (italics_level == 0)
|
|
ass.append("{\\i1}");
|
|
italics_level++;
|
|
break;
|
|
case TAG_ITALICS_CLOSE:
|
|
if (italics_level == 1)
|
|
ass.append("{\\i}");
|
|
if (italics_level > 0)
|
|
italics_level--;
|
|
break;
|
|
case TAG_UNDERLINE_OPEN:
|
|
if (underline_level == 0)
|
|
ass.append("{\\u1}");
|
|
underline_level++;
|
|
break;
|
|
case TAG_UNDERLINE_CLOSE:
|
|
if (underline_level == 1)
|
|
ass.append("{\\u}");
|
|
if (underline_level > 0)
|
|
underline_level--;
|
|
break;
|
|
case TAG_STRIKEOUT_OPEN:
|
|
if (strikeout_level == 0)
|
|
ass.append("{\\s1}");
|
|
strikeout_level++;
|
|
break;
|
|
case TAG_STRIKEOUT_CLOSE:
|
|
if (strikeout_level == 1)
|
|
ass.append("{\\s}");
|
|
if (strikeout_level > 0)
|
|
strikeout_level--;
|
|
break;
|
|
case TAG_FONT_OPEN:
|
|
{
|
|
// new attributes to fill in
|
|
FontAttribs new_attribs;
|
|
FontAttribs old_attribs;
|
|
// start out with any previous ones on stack
|
|
if (font_stack.size() > 0)
|
|
{
|
|
old_attribs = font_stack.back();
|
|
}
|
|
new_attribs = old_attribs;
|
|
// now find all attributes on this font tag
|
|
while (attrib_matcher.Matches(tag_attrs))
|
|
{
|
|
// get attribute name and values
|
|
wxString attr_name = attrib_matcher.GetMatch(tag_attrs, 1);
|
|
wxString attr_value = attrib_matcher.GetMatch(tag_attrs, 2);
|
|
// clean them
|
|
attr_name.MakeLower();
|
|
if ((attr_value.StartsWith("'") && attr_value.EndsWith("'")) ||
|
|
(attr_value.StartsWith("\"") && attr_value.EndsWith("\"")))
|
|
{
|
|
attr_value = attr_value.Mid(1, attr_value.Len()-2);
|
|
}
|
|
// handle the attributes
|
|
if (attr_name == "face")
|
|
{
|
|
new_attribs.face = wxString::Format("{\\fn%s}", attr_value);
|
|
}
|
|
else if (attr_name == "size")
|
|
{
|
|
new_attribs.size = wxString::Format("{\\fs%s}", attr_value);
|
|
}
|
|
else if (attr_name == "color")
|
|
{
|
|
wxColour wxcl = html_to_color(attr_value);
|
|
wxString colorstr = AssColor(wxcl).GetASSFormatted(false, false, false);
|
|
new_attribs.color = wxString::Format("{\\c%s}", colorstr);
|
|
}
|
|
// remove this attribute to prepare for the next
|
|
size_t attr_pos, attr_len;
|
|
attrib_matcher.GetMatch(&attr_pos, &attr_len, 0);
|
|
tag_attrs.erase(attr_pos, attr_len);
|
|
}
|
|
// the attributes changed from old are then written out
|
|
if (new_attribs.face != old_attribs.face)
|
|
ass.append(new_attribs.face);
|
|
if (new_attribs.size != old_attribs.size)
|
|
ass.append(new_attribs.size);
|
|
if (new_attribs.color != old_attribs.color)
|
|
ass.append(new_attribs.color);
|
|
// lastly dump the new attributes state onto the stack
|
|
font_stack.push_back(new_attribs);
|
|
}
|
|
break;
|
|
case TAG_FONT_CLOSE:
|
|
{
|
|
// this requires a font stack entry
|
|
if (font_stack.empty())
|
|
break;
|
|
// get the current attribs
|
|
FontAttribs cur_attribs = font_stack.back();
|
|
// remove them from the stack
|
|
font_stack.pop_back();
|
|
// grab the old attributes if there are any
|
|
FontAttribs old_attribs;
|
|
if (font_stack.size() > 0)
|
|
old_attribs = font_stack.back();
|
|
// then restore the attributes to previous settings
|
|
if (cur_attribs.face != old_attribs.face)
|
|
{
|
|
if (old_attribs.face.empty())
|
|
ass.append("{\\fn}");
|
|
else
|
|
ass.append(old_attribs.face);
|
|
}
|
|
if (cur_attribs.size != old_attribs.size)
|
|
{
|
|
if (old_attribs.size.empty())
|
|
ass.append("{\\fs}");
|
|
else
|
|
ass.append(old_attribs.size);
|
|
}
|
|
if (cur_attribs.color != old_attribs.color)
|
|
{
|
|
if (old_attribs.color.empty())
|
|
ass.append("{\\c}");
|
|
else
|
|
ass.append(old_attribs.color);
|
|
}
|
|
}
|
|
break;
|
|
default:
|
|
// unknown tag, replicate it in the output
|
|
ass.append("<").append(tag_name).append(tag_attrs).append(">");
|
|
break;
|
|
}
|
|
}
|
|
|
|
// make it a little prettier, join tag groups
|
|
ass.Replace("}{", "", true);
|
|
|
|
return ass;
|
|
}
|
|
};
|
|
|
|
AssTime ReadSRTTime(wxString const& ts)
|
|
{
|
|
// For the sake of your sanity, please do not read this function.
|
|
|
|
int d, h, m, s, ms;
|
|
d = h = m = s = ms = 0;
|
|
|
|
size_t ci = 0;
|
|
int ms_chars = 0;
|
|
|
|
for (; ci < ts.length(); ++ci)
|
|
{
|
|
char ch = ts[ci];
|
|
switch (ch)
|
|
{
|
|
case '0':
|
|
case '1':
|
|
case '2':
|
|
case '3':
|
|
case '4':
|
|
case '5':
|
|
case '6':
|
|
case '7':
|
|
case '8':
|
|
case '9':
|
|
s = s * 10 + (ch - '0');
|
|
break;
|
|
case ':':
|
|
d = h;
|
|
h = m;
|
|
m = s;
|
|
s = 0;
|
|
break;
|
|
case ',':
|
|
ci++;
|
|
goto milliseconds;
|
|
default:
|
|
goto allparsed;
|
|
}
|
|
}
|
|
goto allparsed;
|
|
milliseconds:
|
|
for (; ci < ts.length(); ++ci)
|
|
{
|
|
char ch = ts[ci];
|
|
switch (ch)
|
|
{
|
|
case '0':
|
|
case '1':
|
|
case '2':
|
|
case '3':
|
|
case '4':
|
|
case '5':
|
|
case '6':
|
|
case '7':
|
|
case '8':
|
|
case '9':
|
|
ms = ms * 10 + (ch - '0');
|
|
ms_chars++;
|
|
break;
|
|
default:
|
|
goto allparsed;
|
|
}
|
|
}
|
|
allparsed:
|
|
while (ms_chars < 3) ms *= 10, ms_chars++;
|
|
while (ms_chars > 3) ms /= 10, ms_chars--;
|
|
|
|
return ms + 1000*(s + 60*(m + 60*(h + d*24)));
|
|
}
|
|
|
|
wxString WriteSRTTime(AssTime const& ts)
|
|
{
|
|
return wxString::Format("%02d:%02d:%02d,%03d", ts.GetTimeHours(), ts.GetTimeMinutes(), ts.GetTimeSeconds(), ts.GetTimeMiliseconds());
|
|
}
|
|
|
|
}
|
|
|
|
SRTSubtitleFormat::SRTSubtitleFormat()
|
|
: SubtitleFormat("SubRip")
|
|
{
|
|
}
|
|
|
|
wxArrayString SRTSubtitleFormat::GetReadWildcards() const {
|
|
wxArrayString formats;
|
|
formats.Add("srt");
|
|
return formats;
|
|
}
|
|
|
|
wxArrayString SRTSubtitleFormat::GetWriteWildcards() const {
|
|
return GetReadWildcards();
|
|
}
|
|
|
|
void SRTSubtitleFormat::ReadFile(AssFile *target, wxString const& filename, wxString const& encoding) const {
|
|
using namespace std;
|
|
|
|
TextFileReader file(filename, encoding);
|
|
target->LoadDefault(false);
|
|
|
|
// See parsing algorithm at <http://devel.aegisub.org/wiki/SubtitleFormats/SRT>
|
|
|
|
// "hh:mm:ss,fff --> hh:mm:ss,fff" (e.g. "00:00:04,070 --> 00:00:10,04")
|
|
wxRegEx timestamp_regex("^([0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{1,}) --> ([0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{1,})");
|
|
if (!timestamp_regex.IsValid())
|
|
throw agi::InternalError("Parsing SRT: Failed compiling regex", 0);
|
|
|
|
SrtTagParser tag_parser;
|
|
|
|
int state = 1;
|
|
int line_num = 0;
|
|
int linebreak_debt = 0;
|
|
AssDialogue *line = 0;
|
|
while (file.HasMoreLines()) {
|
|
wxString text_line = file.ReadLineFromFile();
|
|
line_num++;
|
|
text_line.Trim(true).Trim(false);
|
|
|
|
switch (state) {
|
|
case 1:
|
|
// start of file, no subtitles found yet
|
|
if (text_line.empty())
|
|
// ignore blank lines
|
|
break;
|
|
if (text_line.IsNumber()) {
|
|
// found the line number, throw it away and hope for timestamps
|
|
state = 2;
|
|
break;
|
|
}
|
|
if (timestamp_regex.Matches(text_line))
|
|
goto found_timestamps;
|
|
|
|
throw SRTParseError(STD_STR(wxString::Format("Parsing SRT: Expected subtitle index at line %d", line_num)), 0);
|
|
case 2:
|
|
// want timestamps
|
|
if (timestamp_regex.Matches(text_line) == false)
|
|
// bad format
|
|
throw SRTParseError(STD_STR(wxString::Format("Parsing SRT: Expected timestamp pair at line %d", line_num)), 0);
|
|
found_timestamps:
|
|
if (line) {
|
|
// finalize active line
|
|
line->Text = tag_parser.ToAss(line->Text);
|
|
line = 0;
|
|
}
|
|
// create new subtitle
|
|
line = new AssDialogue;
|
|
line->Start = ReadSRTTime(timestamp_regex.GetMatch(text_line, 1));
|
|
line->End = ReadSRTTime(timestamp_regex.GetMatch(text_line, 2));
|
|
// store pointer to subtitle, we'll continue working on it
|
|
target->Line.push_back(line);
|
|
// next we're reading the text
|
|
state = 3;
|
|
break;
|
|
case 3:
|
|
// reading first line of subtitle text
|
|
if (text_line.empty()) {
|
|
// that's not very interesting... blank subtitle?
|
|
state = 5;
|
|
// no previous line that needs a line break after
|
|
linebreak_debt = 0;
|
|
break;
|
|
}
|
|
line->Text.Append(text_line);
|
|
state = 4;
|
|
break;
|
|
case 4:
|
|
// reading following line of subtitle text
|
|
if (text_line.empty()) {
|
|
// blank line, next may begin a new subtitle
|
|
state = 5;
|
|
// previous line needs a line break after
|
|
linebreak_debt = 1;
|
|
break;
|
|
}
|
|
line->Text.Append("\\N").Append(text_line);
|
|
break;
|
|
case 5:
|
|
// blank line in subtitle text
|
|
linebreak_debt++;
|
|
if (text_line.empty())
|
|
// multiple blank lines in a row, just add a line break...
|
|
break;
|
|
if (text_line.IsNumber()) {
|
|
// must be a subtitle index!
|
|
// go for timestamps next
|
|
state = 2;
|
|
break;
|
|
}
|
|
if (timestamp_regex.Matches(text_line))
|
|
goto found_timestamps;
|
|
|
|
// assume it's a continuation of the subtitle text
|
|
// resolve our line break debt and append the line text
|
|
while (linebreak_debt-- > 0)
|
|
line->Text.Append("\\N");
|
|
line->Text.Append(text_line);
|
|
state = 4;
|
|
break;
|
|
default:
|
|
throw agi::InternalError(STD_STR(wxString::Format("Parsing SRT: Reached unexpected state %d", state)), 0);
|
|
}
|
|
}
|
|
|
|
if (state == 1 || state == 2) {
|
|
throw SRTParseError("Parsing SRT: Incomplete file", 0);
|
|
}
|
|
|
|
if (line)
|
|
// an unfinalized line
|
|
line->Text = tag_parser.ToAss(line->Text);
|
|
}
|
|
|
|
void SRTSubtitleFormat::WriteFile(const AssFile *src, wxString const& filename, wxString const& encoding) const {
|
|
TextFileWriter file(filename, encoding);
|
|
|
|
// Convert to SRT
|
|
AssFile copy(*src);
|
|
copy.Sort();
|
|
StripComments(copy.Line);
|
|
RecombineOverlaps(copy.Line);
|
|
MergeIdentical(copy.Line);
|
|
#ifdef _WIN32
|
|
ConvertNewlines(copy.Line, "\r\n", false);
|
|
#else
|
|
ConvertNewlines(copy.Line, "\n", false);
|
|
#endif
|
|
|
|
// Write lines
|
|
int i=1;
|
|
for (LineList::const_iterator cur = copy.Line.begin(); cur != copy.Line.end(); ++cur) {
|
|
if (AssDialogue *current = dynamic_cast<AssDialogue*>(*cur)) {
|
|
file.WriteLineToFile(wxString::Format("%d", i++));
|
|
file.WriteLineToFile(WriteSRTTime(current->Start) + " --> " + WriteSRTTime(current->End));
|
|
file.WriteLineToFile(ConvertTags(current));
|
|
file.WriteLineToFile("");
|
|
}
|
|
}
|
|
}
|
|
|
|
bool SRTSubtitleFormat::CanSave(const AssFile *file) const {
|
|
wxString supported_tags[] = { "\\b", "\\i", "\\s", "\\u" };
|
|
|
|
AssStyle defstyle;
|
|
for (std::list<AssEntry*>::const_iterator cur = file->Line.begin(); cur != file->Line.end(); ++cur) {
|
|
// Check style, if anything non-default is found, return false
|
|
if (const AssStyle *curstyle = dynamic_cast<const AssStyle*>(*cur)) {
|
|
if (curstyle->GetEntryData() != defstyle.GetEntryData())
|
|
return false;
|
|
}
|
|
|
|
// Check for attachments, if any is found, return false
|
|
if (dynamic_cast<const AssAttachment*>(*cur)) return false;
|
|
|
|
// Check dialogue
|
|
if (const AssDialogue *curdiag = dynamic_cast<const AssDialogue*>(*cur)) {
|
|
std::vector<AssDialogueBlock*> blocks = curdiag->ParseTags();
|
|
for (size_t i = 0; i < blocks.size(); ++i) {
|
|
AssDialogueBlockOverride *ovr = dynamic_cast<AssDialogueBlockOverride*>(blocks[i]);
|
|
if (!ovr) continue;
|
|
|
|
// Verify that all overrides used are supported
|
|
for (size_t j = 0; j < ovr->Tags.size(); ++j) {
|
|
if (!std::binary_search(supported_tags, supported_tags + 4, ovr->Tags[j]->Name)) {
|
|
delete_clear(blocks);
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
delete_clear(blocks);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
wxString SRTSubtitleFormat::ConvertTags(AssDialogue *diag) const {
|
|
wxString final;
|
|
std::map<char, bool> tag_states;
|
|
tag_states['i'] = false;
|
|
tag_states['b'] = false;
|
|
tag_states['u'] = false;
|
|
tag_states['s'] = false;
|
|
|
|
diag->ParseASSTags();
|
|
|
|
for (size_t i = 0; i < diag->Blocks.size(); ++i) {
|
|
if (AssDialogueBlockOverride* block = dynamic_cast<AssDialogueBlockOverride*>(diag->Blocks[i])) {
|
|
// Iterate through overrides
|
|
for (size_t j = 0; j < block->Tags.size(); j++) {
|
|
AssOverrideTag *tag = block->Tags[j];
|
|
if (tag->IsValid() && tag->Name.size() == 2) {
|
|
std::map<char, bool>::iterator it = tag_states.find(tag->Name[1]);
|
|
if (it != tag_states.end()) {
|
|
bool temp = tag->Params[0]->Get(false);
|
|
if (temp && !it->second)
|
|
final += wxString::Format("<%c>", it->first);
|
|
if (!temp && it->second)
|
|
final += wxString::Format("</%c>", it->first);
|
|
it->second = temp;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Plain text
|
|
else if (AssDialogueBlockPlain *plain = dynamic_cast<AssDialogueBlockPlain*>(diag->Blocks[i])) {
|
|
final += plain->GetText();
|
|
}
|
|
}
|
|
|
|
// Ensure all tags are closed
|
|
// Otherwise unclosed overrides might affect lines they shouldn't, see bug #809 for example
|
|
for (std::map<char, bool>::iterator it = tag_states.begin(); it != tag_states.end(); ++it) {
|
|
if (it->second)
|
|
final += wxString::Format("</%c>", it->first);
|
|
}
|
|
|
|
diag->ClearBlocks();
|
|
|
|
return final;
|
|
}
|