Init Git-hub/ea

This commit is contained in:
bskjon committed 2026-10-10 13:18:18 +02:00
commit b2e0341f60
43 files changed
+2062

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Run tests and build artifacts
run: ./gradlew --no-daemon clean test build
+37
View File
@@ -0,0 +1,37 @@
name: Publish to Reposilite
on:
push:
tags: ['v*', '[0-9]*']
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Require Reposilite credentials
env:
REPOSILITE_USERNAME: ${{ secrets.REPOSILITE_USERNAME }}
REPOSILITE_PASSWORD: ${{ secrets.REPOSILITE_PASSWORD }}
run: |
test -n "$REPOSILITE_USERNAME" || { echo 'Missing REPOSILITE_USERNAME secret'; exit 1; }
test -n "$REPOSILITE_PASSWORD" || { echo 'Missing REPOSILITE_PASSWORD secret'; exit 1; }
- name: Resolve release version from tag
shell: bash
run: |
case "$GITHUB_REF" in
refs/tags/*) echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV" ;;
*) echo 'Publishing must be run from a release tag (for example v1.2.3)' >&2; exit 1 ;;
esac
- name: Publish release
env:
REPOSILITE_USERNAME: ${{ secrets.REPOSILITE_USERNAME }}
REPOSILITE_PASSWORD: ${{ secrets.REPOSILITE_PASSWORD }}
run: ./gradlew --no-daemon clean test publish
+23
View File
@@ -0,0 +1,23 @@
name: CI
on:
push:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Run tests and build artifacts
run: ./gradlew --no-daemon clean test build
+40
View File
@@ -0,0 +1,40 @@
name: Publish to Reposilite
on:
push:
tags: ['v*', '[0-9]*']
workflow_dispatch:
permissions:
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- name: Require Reposilite credentials
env:
REPOSILITE_USERNAME: ${{ secrets.REPOSILITE_USERNAME }}
REPOSILITE_PASSWORD: ${{ secrets.REPOSILITE_PASSWORD }}
run: |
test -n "$REPOSILITE_USERNAME" || { echo 'Missing REPOSILITE_USERNAME secret'; exit 1; }
test -n "$REPOSILITE_PASSWORD" || { echo 'Missing REPOSILITE_PASSWORD secret'; exit 1; }
- name: Resolve release version from tag
shell: bash
run: |
case "$GITHUB_REF" in
refs/tags/*) echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_ENV" ;;
*) echo 'Publishing must be run from a release tag (for example v1.2.3)' >&2; exit 1 ;;
esac
- name: Publish release
env:
REPOSILITE_USERNAME: ${{ secrets.REPOSILITE_USERNAME }}
REPOSILITE_PASSWORD: ${{ secrets.REPOSILITE_PASSWORD }}
run: ./gradlew --no-daemon clean test publish
Executable
+39
View File
@@ -0,0 +1,39 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
/src/test/resources/subtitle/
+147
View File
@@ -0,0 +1,147 @@
import java.io.ByteArrayOutputStream
import org.gradle.api.tasks.bundling.Jar
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("jvm") version "2.2.0"
`java-library`
`maven-publish`
}
val groupId = "no.iktdev.library"
val artifactName = "subtitle"
val fallbackVersion = "1.8.1-SNAPSHOT"
group = groupId
version = resolveProjectVersion()
repositories {
mavenCentral()
}
dependencies {
implementation("com.google.code.gson:gson:2.9.0")
implementation("org.json:json:20220320")
testImplementation("junit:junit:4.13.2")
testImplementation(platform("org.junit:junit-bom:5.11.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine")
testImplementation("org.assertj:assertj-core:3.27.7")
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
java {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
withSourcesJar()
withJavadocJar()
}
tasks.test {
useJUnitPlatform()
}
tasks.withType<Jar>().configureEach {
manifest {
attributes(
mapOf(
"Implementation-Title" to artifactName,
"Implementation-Version" to project.version.toString(),
"Implementation-Vendor" to "no.iktdev"
)
)
}
}
val snapshotRepositoryUrl = providers.environmentVariable("REPOSILITE_SNAPSHOT_URL")
.orElse("https://reposilite.iktdev.no/snapshots").get()
val releaseRepositoryUrl = providers.environmentVariable("REPOSILITE_RELEASE_URL")
.orElse("https://reposilite.iktdev.no/releases").get()
val publicationRepositoryUrl = if (version.toString().endsWith("-SNAPSHOT")) {
snapshotRepositoryUrl
} else {
releaseRepositoryUrl
}
publishing {
publications {
create<MavenPublication>("reposilite") {
groupId = project.group.toString()
artifactId = artifactName
version = project.version.toString()
from(components["java"])
pom {
name.set(artifactName)
description.set("Streamit Subtitle Convert Library")
url.set("https://reposilite.iktdev.no")
}
}
}
repositories {
mavenLocal()
maven {
name = "reposilite"
url = uri(publicationRepositoryUrl)
credentials {
username = providers.environmentVariable("REPOSILITE_USERNAME")
.orElse(providers.environmentVariable("reposiliteUsername")).orNull
password = providers.environmentVariable("REPOSILITE_PASSWORD")
.orElse(providers.environmentVariable("reposilitePassword")).orNull
}
}
}
}
fun resolveProjectVersion(): String {
providers.gradleProperty("releaseVersion").orNull?.let { return normalizeTagVersion(it) }
providers.environmentVariable("RELEASE_VERSION").orNull?.let { return normalizeTagVersion(it) }
val ref = sequenceOf("GITHUB_REF", "GITEA_REF", "CI_COMMIT_TAG")
.mapNotNull { providers.environmentVariable(it).orNull }
.firstOrNull { it.isNotBlank() }
if (ref?.startsWith("refs/tags/") == true) {
return normalizeTagVersion(ref.removePrefix("refs/tags/"))
}
val latestTag = gitOutput("describe", "--tags", "--abbrev=0")
return latestTag?.let(::nextSnapshotVersion) ?: fallbackVersion
}
fun normalizeTagVersion(tag: String): String {
val normalized = tag.trim().removePrefix("v")
require(Regex("\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?").matches(normalized)) {
"Release tag '$tag' must be a semantic version such as v1.2.3"
}
return normalized
}
fun nextSnapshotVersion(tag: String): String {
val match = Regex("^(?:v)?(\\d+)\\.(\\d+)\\.(\\d+)(?:[-+].*)?$").matchEntire(tag.trim())
?: return fallbackVersion
val major = match.groupValues[1]
val minor = match.groupValues[2]
val patch = match.groupValues[3].toInt() + 1
return "$major.$minor.$patch-SNAPSHOT"
}
fun gitOutput(vararg arguments: String): String? = try {
val output = ByteArrayOutputStream()
val result = exec {
commandLine = listOf("git", *arguments)
standardOutput = output
isIgnoreExitValue = true
}
output.toString().trim().takeIf { result.exitValue == 0 && it.isNotBlank() }
} catch (_: Exception) {
null
}
+1
View File
@@ -0,0 +1 @@
kotlin.code.style=official
BIN
View File
Binary file not shown.
+5
View File
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+234
View File
@@ -0,0 +1,234 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored Executable
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+2
View File
@@ -0,0 +1,2 @@
rootProject.name = "subtitle"
@@ -0,0 +1,9 @@
package no.iktdev.library.subtitle
class Configuration {
companion object {
var allowOverwrite: Boolean = false
var exportJson: Boolean = false
val carriageReturn = "\r\n"
}
}
+171
View File
@@ -0,0 +1,171 @@
package no.iktdev.library.subtitle
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.classes.DialogType
import no.iktdev.library.subtitle.classes.Time
/**
* Used to merge and handle overlapping dialogs
* It will aslo exclude SIGN_SONG from dialog if overlapping (if enabled)
*/
open class Syncro() {
fun isInConflictOfRef(ref: Dialog, inq: Dialog): Boolean {
return !isEndChange(ref, inq) && isAppendedInTimelineOfRef(ref, inq) || isTrackInRefAndEndsAfter(ref, inq)
}
/**
* Checks if the change is normal
*
* [--------------------------------------]
* [------------]
*/
fun isEndChange(ref: Dialog, check: Dialog): Boolean {
return ref.end.inMilli() <= check.start.inMilli()
}
/**
*
* Track 1: [-------------------------]
* Tracl 2: [---------------]
*
* Track 2.A: [-------------------------]
*
*/
fun isAppendedInTimelineOfRef(ref: Dialog, inq: Dialog): Boolean {
return inq.start.inMilli() >= ref.start.inMilli() && ref.end.inMilli() >= inq.end.inMilli()
}
/**
*
* [------------------] (Ref)
*
* [------------------------------------] (A)
* [--------------------------------] (B)
*
*/
fun isTrackInRefAndEndsAfter(ref: Dialog, inq: Dialog): Boolean {
return (inq.start.inMilli() >= ref.start.inMilli()) && inq.end.inMilli() > ref.end.inMilli()
}
/**
* This function will return dialogs that are directly overlapping the previous one
* In case a dialog is overlapping more than one dialog, this will need to be ran again
* @return List<String> of overlapping dialogs
*/
protected fun getOverlapping(items: List<Dialog>): List<Pair<Dialog, List<Dialog>>> {
/* In order to ensure that we don't get any edge cases where the dialog
that starts first comes later than an overlapping dialog
*/
val _items = items.sortedBy { it.start.inMilli() }
val overlapping: MutableList<Pair<Dialog, List<Dialog>>> = mutableListOf()
_items.forEach { ref ->
if (!overlapping.any { it.first == ref || it.second.contains(ref) }) {
if (ref.type == DialogType.SIGN_SONG) {
ref.ignore = true
} else {
val overlappingRef = items.filter { ref != it }.filter { isInConflictOfRef(ref, it ) }
overlapping.add(Pair(ref, overlappingRef))
}
}
}
return overlapping
}
/**
* @param List<Dialog> list of overlapping
* @return List<Dialog> of newly created dialogs based on overlapping
*/
fun sync(items: List<Dialog>): List<Dialog> {
// val returnable = items.filter { c -> overlapping.find { q -> q.first == c || q.second.contains(c) } != null }.toMutableList() // This list should now not contain any item that is overlapping
val overlapping = getOverlapping(items)
val returnable = tracks(items )
return (returnable).sortedBy { it.start.inMilli() }
}
@Deprecated("Not in use")
protected fun hasOverlapping(items: List<Dialog>): Boolean {
items.forEach { c ->
val isConflict = items.any { isInConflictOfRef(c, it) }
if (isConflict)
return true
}
return false
}
protected fun trackSegments(items: List<Dialog>): List<Dialog> {
val segments = mutableListOf<Dialog>()
for (c: Dialog in items) {
val followingDialog = items.after(c).minByOrNull { it.start.inMilli() } ?: continue
if (followingDialog.start.inMilli() < c.end.inMilli() && c.end.inMilli() > followingDialog.end.inMilli()) {
segments.add(Dialog(c.id, followingDialog.start, followingDialog.end, c.text, c.type))
segments.add(Dialog(c.id, followingDialog.end, c.end, c.text, c.type))
c.end = followingDialog.start // Sets the current end to the start of the overlapping
}
else if (followingDialog.start.inMilli() < c.end.inMilli()) {
segments.add(Dialog(c.id, followingDialog.start, c.end, c.text, c.type))
c.end = followingDialog.start
} else {
// SKIP
c.end
}
}
return segments
}
protected fun tracks(items: List<Dialog>): List<Dialog> {
var dialogs = items.sortedBy { it.start.inMilli() }.toMutableList()
val segments = trackSegments(dialogs)
dialogs.addAll(segments)
dialogs = dialogs.sortedBy { it.start.inMilli() }.toMutableList()
val copy = dialogs.toList()
for (c in copy) {
if (!dialogs.contains(c))
continue
val conflict = dialogs.filter { isAppendedInTimelineOfRef(c, it) && it != c }
conflict.forEach { dialogs.remove(it) }
if (conflict.isNotEmpty()) {
dialogs.remove(c)
val lines = conflict.map { it.text }.distinctBy { it }
dialogs.add(newTrackInTimeline(listOf(c.text) + lines, c.start, c.end))
}
}
return dialogs.sortedBy { it.start.inMilli() }
}
protected fun newTrackInTimeline(texts: List<String>, start: Time, end: Time): Dialog {
return Dialog(
start = start,
end = end,
text = texts.joinToString(Configuration.carriageReturn),
type = DialogType.DIALOG_NORMAL
)
}
}
fun <T> List<T>.after(item: T): List<T> {
if (!this.contains(item))
throw RuntimeException("Item does not exist in list!")
return if (this.indexOf(item) == this.lastIndex)
emptyList<T>()
else
this.subList(this.indexOf(item)+1, this.size)
}
@@ -0,0 +1,13 @@
package no.iktdev.library.subtitle.classes
import org.jetbrains.annotations.NotNull
import java.util.*
data class Dialog(
val id: String = UUID.randomUUID().toString(),
@NotNull var start: Time,
@NotNull var end: Time,
var text: String,
val type: DialogType,
var ignore: Boolean = false
)
@@ -0,0 +1,9 @@
package no.iktdev.library.subtitle.classes
enum class DialogType {
DIALOG_NORMAL,
DIALOG_ITALIC,
DIALOG_BOLD,
SIGN_SONG,
CAPTION
}
@@ -0,0 +1,7 @@
package no.iktdev.library.subtitle.classes
data class SubripData(
val number: String,
val time: String,
val text: MutableList<String>
)
@@ -0,0 +1,14 @@
package no.iktdev.library.subtitle.classes
data class SubstationAlphaDialogData(
val layer: String,
val start: String,
val end: String,
val style: String,
val name: String,
val marginL: String,
val marginR: String,
val marginV: String,
val effect: String,
val text: String
)
+102
View File
@@ -0,0 +1,102 @@
package no.iktdev.library.subtitle.classes
import java.util.*
import java.util.concurrent.TimeUnit
data class Time(val hour: Int, val minute: Int, val seconds: Int, val milliSeconds: Int) {
companion object {
private val cDotFormat = arrayOf(":",".")
private val cCFormat = arrayOf(":", ",")
private fun fromSplatter(value: List<String>): Array<String> {
return arrayOf<String>(
value[0],
value[1],
value[2],
value[3]
)
/*val h = value[0].toInt()
val m = value[1].toInt()
val s = if (value.size > 2) value[2].toInt() else 0
val f = if (value.size > 3) value[3].toInt() else 0*/
//return Time(h, m, s, f)
}
private fun toTimeFromSplatter(timeBreak: Array<String>): Time {
val h = timeBreak[0].toInt()
val m = timeBreak[1].toInt()
val s = when (timeBreak[2].length) {
2 -> timeBreak[2].toInt()
1 -> "0${timeBreak[2]}".toInt()
else -> 0
}
val f = when(timeBreak[3].length) {
3 -> timeBreak[3].toInt()
2 -> "0${timeBreak[3]}".toInt()
else -> 0
}
return Time(h, m, s, f)
}
fun fromAss(value: String): Time {
// ASS Timespec is : h:mm:ss:xx
val timed = fromSplatter(value.split(delimiters = cDotFormat))
timed[3] = timed[3] + "0"
return toTimeFromSplatter(timed)
}
fun fromSrt(value: String): Time {
return toTimeFromSplatter(fromSplatter(value.split(delimiters = cCFormat)))
}
fun fromSmi(value: Long): Time {
val timed = Calendar.getInstance().apply {
timeInMillis = value
}
return Time(timed.get(Calendar.HOUR), timed.get(Calendar.MINUTE), timed.get(Calendar.SECOND), timed.get(Calendar.MILLISECOND))
}
fun fromMilliseconds(value: Long): Time {
val timed = Calendar.getInstance().apply {
timeInMillis = value
}
return Time(timed.get(Calendar.HOUR), timed.get(Calendar.MINUTE), timed.get(Calendar.SECOND), timed.get(Calendar.MILLISECOND))
}
}
private fun hourString(): String {
return if (hour < 10) "0$hour" else hour.toString()
}
private fun minuteString(): String {
return if (minute < 10) "0$minute" else minute.toString()
}
private fun secondString(): String {
return if (seconds < 10) "0$seconds" else seconds.toString()
}
private fun millisecondString(): String {
return when (milliSeconds.toString().length) {
1 -> "00$milliSeconds"
2 -> "0$milliSeconds"
3 -> milliSeconds.toString()
else -> "000"
}
//return if (milliSeconds < 10) (milliSeconds.toString() + "00") else if (milliSeconds < 100) (milliSeconds.toString() + "0") else milliSeconds.toString()
}
fun toAss(): String {
return "${hourString().toInt()}:${minuteString()}:${secondString()}.${millisecondString().dropLast(1)}"
}
fun toSrt(): String {
return "${hourString()}:${minuteString()}:${secondString()},${millisecondString()}"
}
fun toVtt(): String {
return "${hourString()}:${minuteString()}:${secondString()}.${millisecondString()}"
}
fun inMilli(): Long = toMilli()
fun toMilli(): Long {
return TimeUnit.HOURS.toMillis(hour.toLong()) +
TimeUnit.MINUTES.toMillis(minute.toLong()) +
TimeUnit.SECONDS.toMillis(seconds.toLong()) +
milliSeconds
}
}
@@ -0,0 +1,33 @@
package no.iktdev.library.subtitle.cleaner
/**
* Removes noisy characters from the given text. Noisy characters are sequences of the same non-whitespace character
* that occur more than four times in a row.
*
* @param text the text to scrub
* @return the scrubbed text
*
* @example
* // Returns "Hello world! This is a test."
* scrubNoisyCharacters("Hheeeeelllooooo wworllld! This isssss aaaaa teessst.")
*/
fun scrubNoisyCharacters(text: String): String {
return text.replace(Regex("(?<!\\S)(\\S)(?:\\s*\\1){4,}(?!\\S)"), "")
}
/**
* Removes any sequence in the given text where a character with a backslash precedes a letter
* and this sequence is repeated more than twice consecutively.
*
* @param text the text to scrub
* @return the scrubbed text
*
* Example usage:
* scrubSlashers("\\\\\\\\t\\t\\t\\n\\n\\n") -> ""
* scrubSlashers("some\t\t\ttext with spaces\t\t\tand\t\t\ttabs") -> "some text with spaces and tabs"
*/
fun scrubSlashers(text: String): String {
val pattern = "(\\\\[A-Za-z])(?:\\1*)\\1+".toRegex()
val result = text.replace(pattern, " ")
return result.trim().replace(Regex("\\h|\\s+"), " ")
}
@@ -0,0 +1,14 @@
package no.iktdev.library.subtitle.cleaner
class SubStationAlphaScrubber {
companion object {
// ASS override blocks contain styling/drawing commands. Removing the entire
// block leaves the visible subtitle text intact, including inline overrides.
private val overrideBlock = Regex("\\{[^}]*}")
fun stripTagging(text: String): String {
return overrideBlock.replace(text, "")
}
}
}
@@ -0,0 +1,44 @@
package no.iktdev.library.subtitle.export
import no.iktdev.library.subtitle.Configuration
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.export.formats.JsonDocument
import no.iktdev.library.subtitle.export.formats.Subrip
import no.iktdev.library.subtitle.export.formats.SynchronizedAccessibleMediaInterchange
import no.iktdev.library.subtitle.export.formats.VideoTextTracks
import java.io.File
/**
* @param sourceFile File the dialogs are created from
*/
class Export(val sourceFile: File, val folder: File, val baseName: String) {
fun write(dialogs: List<Dialog>): MutableList<File> {
val writtenFiles: MutableList<File> = mutableListOf()
val ext = sourceFile.extension
if (!ext.contains("srt", true))
writtenFiles.add(writeSrt(dialogs))
if (!ext.contains("vtt", true))
writtenFiles.add(writeVtt(dialogs))
if (!ext.contains("smi", true) && !ext.contains("sami", true))
writtenFiles.add(writeSmi(dialogs))
if (Configuration.exportJson) {
JsonDocument(folder = folder, baseName = baseName).write(dialogs)
}
return writtenFiles
}
fun writeSrt(dialogs: List<Dialog>): File {
return Subrip(folder = folder, baseName = baseName).write(dialogs)
}
fun writeVtt(dialogs: List<Dialog>,): File {
return VideoTextTracks(folder = folder, baseName = baseName).write(dialogs)
}
fun writeSmi(dialogs: List<Dialog>,): File {
val smi = SynchronizedAccessibleMediaInterchange(folder = folder, baseName = baseName)
return smi.write(dialogs)
}
}
@@ -0,0 +1,22 @@
package no.iktdev.library.subtitle.export
import no.iktdev.library.subtitle.Configuration
import no.iktdev.library.subtitle.classes.Dialog
import java.io.File
abstract class Exporter(val folder: File, val baseName: String) {
fun outFile(extension: String): File {
return File(folder, "$baseName.$extension")
}
abstract fun format(dialogs: List<Dialog>): List<String>
abstract fun write(dialogs: List<Dialog>): File
fun deleteOnExistAndOverwriteAllowed(file: File) {
if (file.exists() && Configuration.allowOverwrite) {
file.delete()
}
}
}
@@ -0,0 +1,24 @@
package no.iktdev.library.subtitle.export.formats
import com.google.gson.Gson
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.export.Exporter
import java.io.File
class JsonDocument(folder: File, baseName: String): Exporter(folder, baseName) {
override fun format(dialogs: List<Dialog>): List<String> {
// DO nothing
return listOf()
}
override fun write(dialogs: List<Dialog>): File {
val file = outFile("json")
deleteOnExistAndOverwriteAllowed(file)
val data = Gson().toJson(dialogs)
file.printWriter().use { out ->
out.print(data)
}
return file
}
}
@@ -0,0 +1,35 @@
package no.iktdev.library.subtitle.export.formats
import no.iktdev.library.subtitle.Configuration
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.export.Exporter
import java.io.File
class Subrip(folder: File, baseName: String): Exporter(folder, baseName) {
override fun format(dialogs: List<Dialog>): List<String> {
val formatted: MutableList<String> = mutableListOf()
dialogs.withIndex().forEach {
formatted.add((it.index+1).toString()) //Adds counter to SRT
val time = "${it.value.start.toSrt()} --> ${it.value.end.toSrt()}"
formatted.add(time)
formatted.add(it.value.text)
formatted.add(Configuration.carriageReturn)
}
return formatted
}
override fun write(dialogs: List<Dialog>): File {
val file = outFile("srt")
deleteOnExistAndOverwriteAllowed(file)
val formatted = format(dialogs)
file.printWriter().use { out ->
formatted.forEach {
out.println(it)
}
}
return file
}
}
@@ -0,0 +1,62 @@
package no.iktdev.library.subtitle.export.formats
import no.iktdev.library.subtitle.Configuration
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.classes.Time
import no.iktdev.library.subtitle.export.Exporter
import java.io.File
class SynchronizedAccessibleMediaInterchange(folder: File, baseName: String): Exporter(folder, baseName) {
override fun format(dialogs: List<Dialog>): List<String> {
val formatted: MutableList<String> = mutableListOf()
formatted.add("<sami>")
formatted.add("\t<head>")
formatted.add("\t\t<title>${baseName}</title>")
formatted.add("\t</head>")
formatted.add("\t<body>")
dialogs.forEach { dialog ->
getStartItem(dialog).forEach { line ->
formatted.add(line)
}
getEndItem(dialog.end).forEach { line ->
formatted.add(line)
}
}
formatted.add("\t</body>")
formatted.add("</sami>")
return formatted
}
private fun getStartItem(dialog: Dialog): List<String> {
return listOf(
"\t\t<sync Start=\"${dialog.start.inMilli()}\">",
"\t\t\t<p>${dialog.text}</p>",
"\t\t</sync>",
)
}
private fun getEndItem(time: Time): List<String> {
return listOf(
"\t\t<sync Start=\"${time.inMilli()}\">",
"\t\t\t<p>&amp;nbsp;</p>",
"\t\t</sync>",
)
}
override fun write(dialogs: List<Dialog>): File {
val file = outFile("smi")
deleteOnExistAndOverwriteAllowed(file)
val formatted = format(dialogs)
file.printWriter().use { out ->
formatted.forEach {
out.println(it)
}
}
return file
}
}
@@ -0,0 +1,49 @@
package no.iktdev.library.subtitle.export.formats
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.classes.Time
import no.iktdev.library.subtitle.export.Exporter
import java.io.File
class VideoTextTracks(folder: File, baseName: String): Exporter(folder, baseName) {
override fun format(dialogs: List<Dialog>): List<String> {
val formatted: MutableList<String> = mutableListOf()
formatted.add("WEBVTT")
formatted.add(" ")
formatted.add(" ")
dialogs.forEach {
val time = "${it.start.toVtt()} --> ${it.end.toVtt()}"
formatted.add(time)
formatted.add(it.text)
formatted.add("")
formatted.add("")
}
// Since some devices makes the subtitle stuck on the screen after the trakc has ended
val overflowStart = dialogs.last().end
val overflowEnd = Time.fromMilliseconds(overflowStart.inMilli() + 2000)
val time = "${overflowStart.toVtt()} --> ${overflowEnd.toVtt()}"
formatted.add(time)
formatted.add("")
formatted.add("")
formatted.add("")
return formatted
}
override fun write(dialogs: List<Dialog>): File {
val file = outFile("vtt")
deleteOnExistAndOverwriteAllowed(file)
val formatted = format(dialogs)
file.printWriter().use { out ->
formatted.forEach {
out.println(it)
}
}
return file
}
}
@@ -0,0 +1,102 @@
package no.iktdev.library.subtitle.reader
import no.iktdev.library.subtitle.Configuration
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.classes.DialogType
import no.iktdev.library.subtitle.classes.SubstationAlphaDialogData
import no.iktdev.library.subtitle.classes.Time
import no.iktdev.library.subtitle.cleaner.SubStationAlphaScrubber
import java.io.File
open class AssReader(file: File? = null): BaseReader(file) {
fun isItalic(value: SubstationAlphaDialogData): Boolean {
return hasOverride(value.text, 'i')
}
fun isBold(value: SubstationAlphaDialogData): Boolean {
return hasOverride(value.text, 'b')
}
private fun hasOverride(text: String, command: Char): Boolean {
// ASS allows multiple commands in one block, e.g. {\\an8\\i1}.
val commandRegex = Regex("\\\\${command}([01])", RegexOption.IGNORE_CASE)
return SubStationAlphaScrubberBlocks.findAll(text)
.flatMap { commandRegex.findAll(it.value) }
.any { it.groupValues[1] == "1" }
}
fun isSignOrSong(value: SubstationAlphaDialogData): Boolean {
val marker = Regex("(?i)(^|[^a-z])(op|ed|sign|song)([^a-z]|$)")
return marker.containsMatchIn(value.layer) || marker.containsMatchIn(value.style) ||
marker.containsMatchIn(value.name)
}
fun isDialog(line: String): Boolean {
return line.trimStart().startsWith("Dialogue:", ignoreCase = true) ||
line.trimStart().startsWith("Dialog:", ignoreCase = true)
}
fun applyCorrectNewLine(line: String): String {
return line.replace(Regex("\\\\[Nn]"), Configuration.carriageReturn)
.replace("\\h", "\u00a0")
}
fun deserialize(line: String): SubstationAlphaDialogData? {
if (!isDialog(line))
return null
val dialog = line.substringAfter(':', "")
val segment = dialog.split(',', limit = 10)
if (segment.size != 10)
return null
return SubstationAlphaDialogData(
layer = segment[0],
start = segment[1],
end = segment[2],
style = segment[3],
name = segment[4],
marginL = segment[5],
marginR = segment[6],
marginV = segment[7],
effect = segment[8],
text = segment[9]
)
}
fun transform(it: SubstationAlphaDialogData): Dialog {
val text = applyCorrectNewLine(SubStationAlphaScrubber.stripTagging(it.text))
return Dialog(
start = Time.fromAss(it.start),
end = Time.fromAss(it.end),
text = text,
type = if (isSignOrSong(it))
DialogType.SIGN_SONG
else if (isItalic(it))
DialogType.DIALOG_ITALIC
else if (isBold(it))
DialogType.DIALOG_BOLD
else
DialogType.DIALOG_NORMAL,
ignore = shouldIgnore(text)
)
}
fun shouldIgnore(text: String): Boolean {
val regex = "(?i)[a-z]\\s(?:-?\\d+\\.?\\d*\\s){1,2}(?:l\\s-?\\d+\\.?\\d*\\s-?\\d+\\.?\\d*\\s?)*"
return Regex(regex).matches(text)
}
override fun read(): List<Dialog> {
val ssad = getLines().mapNotNull { deserialize(it) }
return ssad.map { transform(it) }
}
private companion object {
// Keep classification limited to ASS override blocks so ordinary text such
// as "I" or "bold" cannot be mistaken for a formatting command.
val SubStationAlphaScrubberBlocks = Regex("\\{[^}]*}")
}
}
@@ -0,0 +1,29 @@
package no.iktdev.library.subtitle.reader
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.cleaner.scrubNoisyCharacters
import no.iktdev.library.subtitle.cleaner.scrubSlashers
import java.io.File
abstract class BaseReader(file: File? = null) {
private val lines: MutableList<String> = mutableListOf()
fun getLines(): List<String> {
return lines
}
init {
if (file != null) {
val _lines = file.readLines(charset = Charsets.UTF_8)
.map {
scrubSlashers(it)
}.map {
scrubNoisyCharacters(it)
}
lines.addAll(_lines)
}
}
abstract fun read(): List<Dialog>
}
@@ -0,0 +1,17 @@
package no.iktdev.library.subtitle.reader
import java.io.File
class Reader(val file: File) {
fun getSubtitleReader(): BaseReader? {
if (!file.exists())
return null
return when (file.extension) {
"ass" -> AssReader(file)
"srt" -> SrtReader(file)
else -> null
}
}
}
@@ -0,0 +1,84 @@
package no.iktdev.library.subtitle.reader
import no.iktdev.library.subtitle.Configuration
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.classes.DialogType
import no.iktdev.library.subtitle.classes.SubripData
import no.iktdev.library.subtitle.classes.Time
import java.io.File
class SrtReader(file: File? = null): BaseReader(file) {
fun isNumberLine(line: String): Boolean {
val lineNumberRegex = Regex("^[0-9]*\$")
return lineNumberRegex.containsMatchIn(line)
}
fun isTimeLine(line: String): Boolean {
val timeRegex = Regex("\\d{2}:\\d{2}:\\d{2},\\d{3}\\s-->\\s\\d{2}:\\d{2}:\\d{2},\\d{3}")
return timeRegex.containsMatchIn(line)
}
fun isSpaceLine(line: String): Boolean {
return line.isBlank()
}
fun group(lines: List<String>): MutableList<List<String>> {
val grouped: MutableList<List<String>> = mutableListOf()
val cache = mutableListOf<String>()
lines.forEach {
if (it.isNotEmpty() && it.isNotBlank())
cache.add(it)
if (it.isEmpty() || it.isBlank() || lines.last() == it) {
grouped.add(cache.toList())
cache.clear()
}
}
return grouped
}
fun deserialize(group: List<String>): SubripData? {
if (group.size < 3) {
return null
}
val number = group[0].also {
if (!isNumberLine(it) || it.isBlank()) {
return null
}
}
val timeLine = group[1].also {
if (!isTimeLine(it) || it.isBlank()) {
return null
}
}
val textLines = group.subList(2, group.size).also {
if (it.isEmpty()) {
return null
}
}
return SubripData(
number,
timeLine,
textLines.toMutableList()
)
}
fun transform(it: SubripData): Dialog {
val timeLines = it.time.split("-->")
val start = timeLines.first().trim()
val end = timeLines.last().trim()
var text = it.text.joinToString(Configuration.carriageReturn)
return Dialog(start = Time.fromSrt(start), end = Time.fromSrt(end), text = text, type = DialogType.DIALOG_NORMAL)
}
override fun read(): List<Dialog> {
val srts = group(getLines()).mapNotNull { deserialize(it) }
return srts.map { transform(it) }
}
}
@@ -0,0 +1,4 @@
package no.iktdev.library.subtitle.reader
class VttReader {
}
+55
View File
@@ -0,0 +1,55 @@
open class Resources {
open val path: List<String> = listOf("")
fun getText(path: List<String>): String? {
val fullPath = path.joinToString("/")
return this.javaClass.classLoader.getResource(fullPath)?.readText()
}
fun getLines(path: List<String>): List<String> {
val fullPath = path.joinToString("/")
return this.javaClass.getResourceAsStream(fullPath)?.bufferedReader()?.readLines() ?: emptyList()
}
open class Output: Resources() {
override val path: List<String> = listOf("output")
open class Encoder(): Output() {
override val path: List<String> = listOf("output", "encoder")
fun sample1(): List<String> {
val text = getText(listOf(*path.toTypedArray(), "sample1.txt")) ?: return emptyList()
return text.split(System.lineSeparator(), ignoreCase = true)
}
fun sample2(): List<String> {
val text = getText(listOf(*path.toTypedArray(), "sample2.txt")) ?: return emptyList()
return text.split(System.lineSeparator(), ignoreCase = true)
}
fun sample3(): List<String> {
val text = getText(listOf(*path.toTypedArray(), "sample3.txt")) ?: return emptyList()
return text.split(System.lineSeparator(), ignoreCase = true)
}
fun sample4(): List<String> {
val text = getText(listOf(*path.toTypedArray(), "sample4.txt")) ?: return emptyList()
return text.split(System.lineSeparator(), ignoreCase = true)
}
}
open class Streams: Output() {
override val path: List<String> = listOf("output", "streams")
fun getSample(number: Int): String {
return getText(listOf(*path.toTypedArray(), "sample$number.json"))!!
}
}
}
}
@@ -0,0 +1,31 @@
package no.iktdev.library.subtitle
/** Small synthetic inputs that preserve parser edge cases without shipping subtitle files. */
object SubtitleFixtures {
val ass: List<String> = """
[Script Info]
Title: Synthetic parser fixture
ScriptType: v4.00+
[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:03.00,Default,Traveler,0,0,0,,{\an8\i1}A fictional greeting, traveler.{\i0}\NWelcome.
Dialogue: 0,0:00:03.20,0:00:05.00,Default,Guide,0,0,0,,{\b1}This invented line is bold.{\b0}
Dialogue: 0,0:00:05.10,0:00:06.50,Sign_Scene,Sign,0,0,0,,A made-up sign
""".trimIndent().lines()
val srt: List<String> = """
1
00:00:01,000 --> 00:00:02,500
A fictional greeting.
2
00:00:03,000 --> 00:00:04,500
Another invented line,
split across two rows.
3
00:00:05,000 --> 00:00:06,000
The imaginary scene ends.
""".trimIndent().lines()
}
+138
View File
@@ -0,0 +1,138 @@
package no.iktdev.library.subtitle
import no.iktdev.library.subtitle.classes.Dialog
import no.iktdev.library.subtitle.classes.DialogType
import no.iktdev.library.subtitle.classes.Time
import no.iktdev.library.subtitle.reader.AssReader
import org.junit.Assert.*
import org.junit.Test
class SyncroTest: Syncro() {
private val baseSet1 = listOf<Dialog>(
Dialog(
start = Time(0, 0,0,0),
end = Time(0,0,10,0),
text = "Start item",
type = DialogType.DIALOG_NORMAL
),
Dialog(
id = "target",
start = Time(0, 0,10,0),
end = Time(0,0,20,0),
text = "Item #1A",
type = DialogType.DIALOG_NORMAL
),
Dialog(
start = Time(0, 0,15,0),
end = Time(0,0,20,0),
text = "Item #1B",
type = DialogType.DIALOG_NORMAL
),
Dialog(
start = Time(0, 0,25,0),
end = Time(0,0,30,0),
text = "Item #2",
type = DialogType.DIALOG_NORMAL
),
Dialog(
start = Time(0, 0,30,0),
end = Time(0,0,40,0),
text = "Item 3",
type = DialogType.DIALOG_NORMAL
)
)
get() = field.toList()
private val baseSet2 = listOf<Dialog>(
Dialog(
start = Time(0, 0,0,0),
end = Time(0,0,10,0),
text = "Start item",
type = DialogType.DIALOG_NORMAL
),
Dialog(
id = "target",
start = Time(0, 0,10,0),
end = Time(0,0,25,0),
text = "Item #1A",
type = DialogType.DIALOG_NORMAL
),
Dialog(
start = Time(0, 0,15,0),
end = Time(0,0,20,0),
text = "Item #1B",
type = DialogType.DIALOG_NORMAL
),
Dialog(
start = Time(0, 0,25,0),
end = Time(0,0,30,0),
text = "Item #2",
type = DialogType.DIALOG_NORMAL
),
Dialog(
start = Time(0, 0,30,0),
end = Time(0,0,40,0),
text = "Item 3",
type = DialogType.DIALOG_NORMAL
)
)
get() = field.toList()
@Test
fun testTrackSegmentation() {
val mockMergable = baseSet1
val segmentation = trackSegments(mockMergable)
assertTrue("List is empty", segmentation.isNotEmpty())
assertEquals(1, segmentation.size)
assertTrue(mockMergable.find { it.id == "target" }?.end?.seconds == 15)
}
@Test
fun testTrackSegmentation2() {
val mockMergable = baseSet2
val segmentation = trackSegments(mockMergable)
assertTrue("List is empty", segmentation.isNotEmpty())
assertEquals(2, segmentation.size)
assertTrue(mockMergable.find { it.id == "target" }?.end?.seconds == 15)
}
@Test
fun testTrackMerging() {
val mockMergable = baseSet2
val merged = tracks(mockMergable)
assertTrue("List is empty", merged.isNotEmpty())
assertEquals(6, merged.size)
}
@Test
fun testSync() {
val merged = sync(baseSet2)
assertTrue("List is empty", merged.isNotEmpty())
assertEquals(6, merged.size)
}
@Test
fun testAphocracy() {
val reader = AssReader();
val lines = SubtitleFixtures.ass
val deserialized = lines.mapNotNull { reader.deserialize(it) }
val mapped = deserialized.map { reader.transform(it) }
val merged = sync(mapped)
assertTrue(merged.size == mapped.size)
}
@Test
fun testSyncroNonDuplicates() {
}
}
@@ -0,0 +1,29 @@
package no.iktdev.library.subtitle
import org.junit.Assert.assertEquals
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import java.io.File
class TestFileExtensionChange {
@Test
fun testSrt() {
val outFile = File("/tmp/test/file/sample-subtitle.ass")
val fullpath = outFile.absolutePath
val srtFile = fullpath.replaceAfterLast(".", "srt")
assertEquals("/tmp/test/file/sample-subtitle.srt", srtFile)
}
@Test
fun extensionComparison() {
val ext = File("/tmp/test/file/sample-subtitle.ass").extension
assertEquals(ext, "ass")
assertDoesNotThrow {
if (!ext.contains("ass", true)) {
throw RuntimeException("Not supposed to end up here")
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
package no.iktdev.library.subtitle
import no.iktdev.library.subtitle.classes.DialogType
import no.iktdev.library.subtitle.reader.AssReader
import no.iktdev.library.subtitle.reader.SrtReader
import org.junit.Assert.*
import org.junit.Test
import org.assertj.core.api.Assertions.assertThat
class TestRun {
val reader = AssReader()
private fun hasDuplicates(items: List<String>): Boolean {
return items.size != items.distinct().size
}
@Test
fun testSyntheticAssSync() {
val reader = AssReader();
val ssad = SubtitleFixtures.ass.mapNotNull { reader.deserialize(it) }
val mapped = ssad.map { reader.transform(it) }
val synced = Syncro().sync(mapped)
synced.forEach {
val lines = it.text.split(Configuration.carriageReturn)
assert(!hasDuplicates(lines)) {
"Duplicates found with \r\n" + it.text
}
}
}
@Test
fun testShouldIgnore1() {
assertTrue(reader.shouldIgnore("m 0 0 l 50 0 l 50 88 l 0 88"))
}
@Test
fun testTransform() {
val text = "Dialogue: 0,0:03:28.06,0:03:34.06,Sign_Basic,Sign,0,0,0,,{\\fad(500,550)\\p1\\bord0\\xshad1\\yshad2\\c&H000024&\\4c&H25254C&\\pos(65,268)}m 0 0 l 50 0 l 45 13 l -5 13"
val dez = reader.deserialize(text)
val transformed = reader.transform(dez!!)
assertNotNull(transformed)
}
@Test
fun testSyntheticAssRead() {
val reader = AssReader();
val lines = SubtitleFixtures.ass
val deserialized = lines.mapNotNull { reader.deserialize(it) }
val mapped = deserialized.map { reader.transform(it) }
assertEquals(deserialized.first().start, mapped.first().start.toAss())
}
@Test
fun testAssFileParsing() {
val result = SubtitleFixtures.ass.mapNotNull { reader.deserialize(it) }.map { reader.transform(it) }
val filtered = result.filter { !it.ignore && it.type !in listOf(DialogType.SIGN_SONG, DialogType.CAPTION) }
// val syncOrNotSync = syncDialogs(filtered)
assertThat(result).isNotEmpty
assertThat(filtered.map { it.text }.joinToString("\n"))
.contains("A fictional greeting, traveler.")
}
@Test
fun testSyntheticSrt() {
val srtReader = SrtReader(null)
val reader = srtReader.group(SubtitleFixtures.srt)
.mapNotNull { srtReader.deserialize(it) }
.map { srtReader.transform(it) }
assertThat(reader).hasSize(3)
assertEquals("Another invented line,\r\nsplit across two rows.", reader[1].text)
}
@Test
fun inlineSubtitleFixturesRunThroughBothParsers() {
val assDialogs = SubtitleFixtures.ass.mapNotNull { reader.deserialize(it) }.map { reader.transform(it) }
val srtReader = SrtReader(null)
val srtDialogs = srtReader.group(SubtitleFixtures.srt)
.mapNotNull { srtReader.deserialize(it) }
.map { srtReader.transform(it) }
assertThat(assDialogs).isNotEmpty
assertThat(srtDialogs).hasSize(3)
}
}
+16
View File
@@ -0,0 +1,16 @@
package no.iktdev.library.subtitle
import org.junit.jupiter.api.Test
import no.iktdev.library.subtitle.classes.Time
import org.junit.jupiter.api.Assertions.assertEquals
class TestTime {
@Test
fun testParse() {
val time = Time.fromAss("0:00:56.82")
assertEquals(56, time.seconds)
assertEquals(820, time.milliSeconds)
}
}
@@ -0,0 +1,51 @@
package no.iktdev.library.subtitle.classes
import org.junit.Test
import org.junit.jupiter.api.Assertions.*
internal class TimeTest {
@Test
fun testFromAssToSrt1() {
val leadingZero = "0:00:14.04"
val testTime2 = "0:00:17.25"
val time = Time.fromAss(leadingZero)
assertEquals("00:00:14,040", time.toSrt())
}
@Test
fun testFromAssToSrt2() {
val leadingZero = "0:00:04.04"
val testTime2 = "0:00:17.25"
val time = Time.fromAss(leadingZero)
assertEquals("00:00:04,040", time.toSrt())
}
@Test
fun testFromSrtToSrt1() {
val leadingZero = "00:00:14,040"
val time = Time.fromSrt(leadingZero)
assertEquals("00:00:14,040", time.toSrt())
}
@Test
fun testFromSrtToSrt2() {
val leadingZero = "00:00:14,004"
val time = Time.fromSrt(leadingZero)
assertEquals("00:00:14,004", time.toSrt())
}
@Test
fun testFromSrtToSrt3() {
val leadingZero = "00:00:04,004"
val time = Time.fromSrt(leadingZero)
assertEquals("00:00:04,004", time.toSrt())
}
}
@@ -0,0 +1,15 @@
package no.iktdev.library.subtitle.cleaner
import org.junit.Test
import org.junit.jupiter.api.Assertions.*
class LineScrubbersKtTest {
@Test
fun removeTags() {
val tekst = "Name:\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\h\\hUnspecified"
val scr = scrubSlashers(tekst)
assertEquals("Name: Unspecified", scr)
}
}
@@ -0,0 +1,17 @@
package no.iktdev.library.subtitle.cleaner
import no.iktdev.library.subtitle.cleaner.SubStationAlphaScrubber
import org.junit.Assert.assertEquals
import org.junit.jupiter.api.Test
class SubStationAlphaScrubberTest {
@Test
fun textIsScrubbed() {
val text = "{\\c&H2DFFFF&\\3c&H176EDC&\\bord2\\fs17\\an8\\pos(480,206)\\fad(1000,1)}WANNA FIGHT"
val result = SubStationAlphaScrubber.stripTagging(text)
val excpected = "WANNA FIGHT"
assertEquals("Unsupported values is removed cleanly",excpected, result)
}
}
@@ -0,0 +1,99 @@
package no.iktdev.library.subtitle.reader
import no.iktdev.library.subtitle.Syncro
import no.iktdev.library.subtitle.SubtitleFixtures
import no.iktdev.library.subtitle.classes.DialogType
import no.iktdev.library.subtitle.classes.Time
import no.iktdev.library.subtitle.reader.AssReader
import org.junit.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.assertDoesNotThrow
class AssReaderTest {
private val reader = AssReader(null)
val text = "Dialogue: 0,0:00:52.11,0:00:55.65,Default,Speaker,0,0,0,,{\\i1}A fictional pilot is speaking.{\\i0}"
@Test
fun testIsDialog() {
assertTrue(reader.isDialog(text))
}
@Test
fun assertDeserialization() {
val deserialized = reader.deserialize(text)
assertNotNull(deserialized)
}
@Test
fun isAbleToDecodeTime() {
val deserialized = reader.deserialize(text) ?: throw Exception("Deserialized is null")
assertDoesNotThrow {
val parsed = Time.fromAss(deserialized.start)
assertEquals(52, parsed.seconds)
assertEquals(110, parsed.milliSeconds)
}
}
@Test
fun testSyntheticAss_ReadAndSync() {
val lines = SubtitleFixtures.ass
val ssad = lines.mapNotNull { reader.deserialize(it) }
val mapped = ssad.map { reader.transform(it) }
val synced = Syncro().sync(mapped.toList())
assertTrue(synced.isNotEmpty())
synced.forEach {
assertTrue(it.start.inMilli() <= it.end.inMilli(), "${it.text} has end time after start ${it.start.inMilli()}:${it.end.inMilli()}")
}
}
@Test
fun isDialog1() {
val line = "Dialogue: 0,0:04:11.05,0:04:15.62,Default,Speaker,0,0,0,,The map is ready, \\Nwe can leave now."
val deserialized = reader.deserialize(line)
val transformed = reader.transform(deserialized!!)
assertTrue(transformed.type != DialogType.SIGN_SONG)
}
@Test
fun stripsDenseInlineTagsKeepsCommaTextAndDetectsPartialItalics() {
val line = "Dialogue: 0,0:00:01.00,0:00:02.00,Default,Speaker,0,0,0,,{\\fad(100,200)\\an8\\bord2\\fs20\\pos(12,34)\\i1}A fictional phrase, with commas.{\\i0} Normal again."
val parsed = reader.transform(reader.deserialize(line)!!)
assertEquals("A fictional phrase, with commas. Normal again.", parsed.text)
assertEquals(DialogType.DIALOG_ITALIC, parsed.type)
}
@Test
fun convertsAssLineBreaksAndHardSpaces() {
val line = "Dialogue: 0,0:00:01.00,0:00:02.00,Default,Speaker,0,0,0,,First\\Nsecond\\nthird\\hword"
val parsed = reader.transform(reader.deserialize(line)!!)
assertEquals("First\r\nsecond\r\nthird\u00a0word", parsed.text)
}
@Test
fun stripsKaraokeAndAnimatedOverridesWithoutLosingLyricText() {
val line = "Dialogue: 0,0:00:01.00,0:00:02.00,Karaoke,Singer,0,0,0,,{\\k20}La{\\ko15\\t(0,100,\\fscx110)}la"
val parsed = reader.transform(reader.deserialize(line)!!)
assertEquals("Lala", parsed.text)
assertEquals(DialogType.DIALOG_NORMAL, parsed.type)
}
@Test
fun vectorDrawingEventsAreIgnoredAsNonDialogue() {
val line = "Dialogue: 0,0:00:01.00,0:00:02.00,Sign,Sign,0,0,0,,{\\p1}m 0 0 l 50 0 l 50 88 l 0 88{\\p0}"
val parsed = reader.transform(reader.deserialize(line)!!)
assertTrue(parsed.ignore)
}
}
@@ -0,0 +1,40 @@
package no.iktdev.library.subtitle.reader
import no.iktdev.library.subtitle.SubtitleFixtures
import org.junit.Test
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.assertDoesNotThrow
class SrtReaderTest {
private val reader = SrtReader(null)
@Test
fun groupTest() {
val grouped: MutableList<List<String>> = mutableListOf()
val cache = mutableListOf<String>()
val lines = SubtitleFixtures.srt
lines.forEach {
if (it.isNotEmpty() && it.isNotBlank())
cache.add(it)
if (it.isEmpty() || it.isBlank() || lines.last() == it) {
grouped.add(cache.toList())
cache.clear()
}
}
assertTrue(grouped.isNotEmpty())
}
@Test
fun testSyntheticSrt_Read() {
val lines = SubtitleFixtures.srt
val srts = reader.group(lines).mapNotNull { reader.deserialize(it) }
val mapped = srts.map { reader.transform(it) }
assertEquals(3, mapped.size)
}
}