com.github.dnbn.submerge.api.parser.ASSParser Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of submerge-api Show documentation
Show all versions of submerge-api Show documentation
Library to manage SRT and ASS subtitles
The newest version!
package com.github.dnbn.submerge.api.parser;
import java.beans.PropertyDescriptor;
import java.io.BufferedReader;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.time.DateTimeException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import java.util.StringJoiner;
import java.util.TreeSet;
import org.apache.commons.beanutils.PropertyUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.lang.math.NumberUtils;
import com.github.dnbn.submerge.api.parser.exception.InvalidAssSubException;
import com.github.dnbn.submerge.api.subtitle.ass.ASSSub;
import com.github.dnbn.submerge.api.subtitle.ass.ASSTime;
import com.github.dnbn.submerge.api.subtitle.ass.Events;
import com.github.dnbn.submerge.api.subtitle.ass.ScriptInfo;
import com.github.dnbn.submerge.api.subtitle.ass.V4Style;
import com.github.dnbn.submerge.api.utils.ColorUtils;
/**
* Parse SSA/ASS subtitles
*/
public class ASSParser extends BaseParser {
/**
* Comments: lines that start with this character are ignored
*/
private static final String COMMENTS_MARK = ";";
@Override
protected void parse(BufferedReader br, ASSSub sub) throws IOException, InvalidAssSubException {
String line = readFirstTextLine(br);
if (line != null && !("[script info]").equalsIgnoreCase(line.trim())) {
throw new InvalidAssSubException("The line that says “[Script Info]” must be the first line in the script.");
}
// [Script Info]
sub.setScriptInfo(parseScriptInfo(br));
while ((line = readFirstTextLine(br)) != null) {
if (line.matches("(?i:^\\[v.*styles\\+?]$)")) {
// [V4+ Styles]
sub.setStyle(parseStyle(br));
} else if (line.equalsIgnoreCase("[events]")) {
// [Events]
sub.setEvents(parseEvents(br));
}
}
if (sub.getStyle().isEmpty()) {
throw new InvalidAssSubException("Missing style definition");
}
if (sub.getEvents().isEmpty()) {
throw new InvalidAssSubException("No text line found");
}
}
/**
* Parse the events section from the reader.
*
* Example of events section:
*
*
* [Events]
* Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
* Dialogue: 0,0:02:30.84,0:02:34.70,StlyeOne,,0000,0000,0000,,A text line
* Dialogue: 0,0:02:34.92,0:02:37.54,StyleTwo,,0000,0000,0000,,Another text line
*
*
* @param br: the buffered reader
* @throws IOException
* @throws InvalidAssSubException
* @throws IOException
*/
private static Set parseEvents(BufferedReader br) throws IOException, InvalidAssSubException {
String[] eventsFormat = findFormat(br, "events");
Set events = new TreeSet<>();
String line = readFirstTextLine(br);
while (line != null && !line.startsWith("[")) {
if (line.startsWith(Events.DIALOGUE) && !line.startsWith(COMMENTS_MARK)) {
String info = findInfo(line, Events.DIALOGUE);
String[] dialogLine = StringUtils.splitByWholeSeparatorPreserveAllTokens(info, Events.SEP);
// The last field will always be the Text field, so that it can contain
// commas.
int lengthDialog = dialogLine.length;
int lengthFormat = eventsFormat.length;
if (lengthDialog < lengthFormat) {
throw new InvalidAssSubException("Incorrect dialog line : " + info);
}
if (lengthDialog > lengthFormat) {
// The text field contains commas
StringJoiner joiner = new StringJoiner(Events.SEP);
for (int i = lengthFormat - 1; i < lengthDialog; i++) {
joiner.add(dialogLine[i]);
}
dialogLine[lengthFormat - 1] = joiner.toString();
dialogLine = Arrays.copyOfRange(dialogLine, 0, lengthFormat);
}
events.add(parseDialog(eventsFormat, dialogLine));
}
line = markAndRead(br);
}
reset(br, line);
return events;
}
/**
* Parse the style section from the reader.
*
* Example of style section:
*
*
* [V4+ Styles]
* Format: Name,Fontname,Fontsize,PrimaryColour,SecondaryColour,OutlineColour
* Style: StyleOne,Arial,16,64250,16777215,0
* Style: StyleTwo,Arial,16,16383999,16777215,0
*
*
* @param br: the buffered reader
* @throws IOException
* @throws InvalidAssSubException
*/
private static List parseStyle(BufferedReader br) throws IOException, InvalidAssSubException {
String[] styleFormat = findFormat(br, "styles");
List styles = new ArrayList<>();
String line = readFirstTextLine(br);
int index = 1;
while (line != null && !line.startsWith("[")) {
if (line.startsWith(V4Style.STYLE) && !line.startsWith(COMMENTS_MARK)) {
String[] textLine = line.split(":");
if (textLine.length > 1) {
String[] styleLine = textLine[1].split(V4Style.SEP);
styles.add(parseV4Style(styleFormat, styleLine, index));
index++;
}
}
line = markAndRead(br);
}
reset(br, line);
return styles;
}
/**
* Return the Events object from text dialog line
*
* @param eventsFormat: the format definition
* @param dialogLine: the dialog line
* @return the Events object
* @throws InvalidAssSubException
*/
private static Events parseDialog(String[] eventsFormat, String[] dialogLine) throws InvalidAssSubException {
Events events = new Events();
for (int i = 0; i < eventsFormat.length; i++) {
String property = StringUtils.uncapitalize(eventsFormat[i].trim());
String value = dialogLine[i].trim();
try {
switch (property) {
case "start":
events.getTime().setStart(ASSTime.fromString(value));
break;
case "end":
events.getTime().setEnd(ASSTime.fromString(value));
break;
case "text":
List textLines = Arrays.asList(value.split("\\\\N"));
events.setTextLines(new ArrayList<>(textLines));
break;
default:
String error = callProperty(events, property, value);
if (error != null) {
throw new InvalidAssSubException("Invalid property (" + property + ") " + value);
}
break;
}
} catch (DateTimeException e) {
throw new InvalidAssSubException("Invalid time for property " + property + " : " + value);
}
}
return events;
}
/**
* Return the V4Style object from text style line
*
* @param styleFormat: format line
* @param styleLine: the style line
* @param lineIndex: the line index
* @return the style object
* @throws InvalidAssSubException
*/
private static V4Style parseV4Style(String[] styleFormat, String[] styleLine, int lineIndex)
throws InvalidAssSubException {
String message = "Style at index " + lineIndex + ": ";
if (styleFormat.length != styleLine.length) {
throw new InvalidAssSubException(message + "does not match style definition");
}
V4Style style = new V4Style();
for (int i = 0; i < styleFormat.length; i++) {
String property = StringUtils.uncapitalize(styleFormat[i].trim());
String value = styleLine[i].trim();
if (property.toLowerCase().indexOf("colour") > -1) {
// Colors can be number (bgr) or string (&HBBGGRR or &HAABBGGRR)
try {
Integer.parseInt(value);
} catch (NumberFormatException e) {
int bgr = getBGR(value);
if (bgr != -1) {
value = Integer.toString(bgr);
}
}
}
String error = callProperty(style, property, value);
if (error != null) {
throw new InvalidAssSubException(message + error);
}
}
if (StringUtils.isEmpty(style.getName())) {
throw new InvalidAssSubException(message + " missing name");
}
return style;
}
/**
* Get the BGR code from the &HBBGGRR or &HAABBGGRR pattern
*
* @param value: the value to convert
* @return the bgr code
*/
private static int getBGR(String value) {
int length = value.length();
int bgr = -1;
if (length == 10) {
// From ASS
bgr = ColorUtils.HAABBGGRRToBGR(value);
} else if (length == 8) {
// From SSA
bgr = ColorUtils.HBBGGRRToBGR(value);
}
return bgr;
}
/**
* Parse the script info section from the reader.
*
* Example of script info section:
*
*
* [Script Info]
* ScriptType: v4.00+
* Collisions: Normal
* Timer: 100,0000
* Title: My movie title
*
*
* @param br: the buffered reader
* @throws IOException
* @throws InvalidAssSubException
*/
private static ScriptInfo parseScriptInfo(BufferedReader br) throws IOException, InvalidAssSubException {
ScriptInfo scriptInfo = new ScriptInfo();
String line = readFirstTextLine(br);
while (line != null && !line.startsWith("[")) {
if (!line.startsWith(COMMENTS_MARK)) {
String[] split = line.split(ScriptInfo.SEP);
if (split.length > 1) {
String property = StringUtils.deleteWhitespace(split[0]);
property = StringUtils.uncapitalize(property);
StringJoiner joiner = new StringJoiner(ScriptInfo.SEP);
for (int i = 1; i < split.length; i++) {
joiner.add(split[i]);
}
String value = joiner.toString().trim();
String error = callProperty(scriptInfo, property, value);
if (error != null) {
throw new InvalidAssSubException("Script info : " + error);
}
}
}
line = markAndRead(br);
}
reset(br, line);
return scriptInfo;
}
/**
* Call a specific property of an object with reflection
*
* @param object: the object to set a property
* @param property: the property to define
* @param value: the value to set
* @return the error message if an error has occured, null otherwise
*/
private static String callProperty(Object object, String property, String value) {
String error = null;
try {
PropertyDescriptor descriptor = PropertyUtils.getPropertyDescriptor(object, property);
if (descriptor != null) {
String type = descriptor.getPropertyType().getSimpleName();
switch (type) {
case "String":
PropertyUtils.setProperty(object, property, value);
break;
case "int":
PropertyUtils.setProperty(object, property, NumberUtils.toInt(value));
break;
case "boolean":
boolean boolValue = NumberUtils.toInt(value) == -1;
PropertyUtils.setProperty(object, property, boolValue);
break;
case "double":
double doubleValue = NumberUtils.toDouble(value.replace(",", ".").trim());
PropertyUtils.setProperty(object, property, doubleValue);
break;
default:
break;
}
}
} catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
// Property not supported, do nothing
}
return error;
}
/**
* Get the format string definition
*
* @param br: the buffered reader
* @param sectionName: the name of the section to parse
* @return the format string definition
* @throws IOException
* @throws InvalidAssSubException
*/
private static String[] findFormat(BufferedReader br, String sectionName) throws IOException,
InvalidAssSubException {
String line = readFirstTextLine(br);
if (StringUtils.isEmpty(line)) {
throw new InvalidAssSubException("Missing format definition in " + sectionName + " section");
}
if (!line.trim().startsWith(ASSSub.FORMAT)) {
String capitalized = StringUtils.capitalize(sectionName);
throw new InvalidAssSubException(capitalized + " definition must start with 'Format' line");
}
return findInfo(line, ASSSub.FORMAT).split(V4Style.SEP);
}
/**
* Find the information after ":" in a text line
*
* @param line: the line
* @param search: the information to search
* @return info or null if the info is empty / not found
*/
private static String findInfo(String line, String search) {
String info = null;
String sep = ":";
if (line.trim().toLowerCase().startsWith(search.toLowerCase()) && line.indexOf(sep) > 0) {
info = line.substring(line.indexOf(sep) + 1, line.length()).trim();
}
return StringUtils.isEmpty(info) ? null : info;
}
}