commit b2e0341f603b383b2b62ff1bd9653187fad1ab0d Author: Brage Skjønborg Date: Sat Oct 10 13:18:18 2026 +0200 Init Git-hub/ea diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..0a1615b --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/.gitea/workflows/publish.yaml b/.gitea/workflows/publish.yaml new file mode 100644 index 0000000..ad83ddf --- /dev/null +++ b/.gitea/workflows/publish.yaml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..72c27ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..bbd0b86 --- /dev/null +++ b/.github/workflows/publish.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100755 index 0000000..5db5e43 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100755 index 0000000..bd227dd --- /dev/null +++ b/build.gradle.kts @@ -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().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("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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100755 index 0000000..29e08e8 --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100755 index 0000000..7454180 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100755 index 0000000..e1adfb4 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..c53aefa --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100755 index 0000000..67b05bc --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "subtitle" + diff --git a/src/main/kotlin/no/iktdev/library/subtitle/Configuration.kt b/src/main/kotlin/no/iktdev/library/subtitle/Configuration.kt new file mode 100755 index 0000000..5ee6299 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/Configuration.kt @@ -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" + } +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/Syncro.kt b/src/main/kotlin/no/iktdev/library/subtitle/Syncro.kt new file mode 100755 index 0000000..d7c82e6 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/Syncro.kt @@ -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 of overlapping dialogs + */ + protected fun getOverlapping(items: List): List>> { + /* 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>> = 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 list of overlapping + * @return List of newly created dialogs based on overlapping + */ + fun sync(items: List): List { + // 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): Boolean { + items.forEach { c -> + val isConflict = items.any { isInConflictOfRef(c, it) } + if (isConflict) + return true + } + return false + } + + + protected fun trackSegments(items: List): List { + val segments = mutableListOf() + 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): List { + 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, start: Time, end: Time): Dialog { + return Dialog( + start = start, + end = end, + text = texts.joinToString(Configuration.carriageReturn), + type = DialogType.DIALOG_NORMAL + ) + } + +} + +fun List.after(item: T): List { + if (!this.contains(item)) + throw RuntimeException("Item does not exist in list!") + return if (this.indexOf(item) == this.lastIndex) + emptyList() + else + this.subList(this.indexOf(item)+1, this.size) +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/classes/Dialog.kt b/src/main/kotlin/no/iktdev/library/subtitle/classes/Dialog.kt new file mode 100755 index 0000000..a3cc874 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/classes/Dialog.kt @@ -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 +) \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/classes/DialogType.kt b/src/main/kotlin/no/iktdev/library/subtitle/classes/DialogType.kt new file mode 100755 index 0000000..d2c98b7 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/classes/DialogType.kt @@ -0,0 +1,9 @@ +package no.iktdev.library.subtitle.classes + +enum class DialogType { + DIALOG_NORMAL, + DIALOG_ITALIC, + DIALOG_BOLD, + SIGN_SONG, + CAPTION +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/classes/SubripData.kt b/src/main/kotlin/no/iktdev/library/subtitle/classes/SubripData.kt new file mode 100755 index 0000000..e779a37 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/classes/SubripData.kt @@ -0,0 +1,7 @@ +package no.iktdev.library.subtitle.classes + +data class SubripData( + val number: String, + val time: String, + val text: MutableList +) \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/classes/SubstationAlphaData.kt b/src/main/kotlin/no/iktdev/library/subtitle/classes/SubstationAlphaData.kt new file mode 100755 index 0000000..12b161b --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/classes/SubstationAlphaData.kt @@ -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 +) \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/classes/Time.kt b/src/main/kotlin/no/iktdev/library/subtitle/classes/Time.kt new file mode 100755 index 0000000..c6cdc92 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/classes/Time.kt @@ -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): Array { + return arrayOf( + 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): 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 + } +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/cleaner/LineScrubbers.kt b/src/main/kotlin/no/iktdev/library/subtitle/cleaner/LineScrubbers.kt new file mode 100755 index 0000000..0c342be --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/cleaner/LineScrubbers.kt @@ -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("(? "" + * 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+"), " ") +} diff --git a/src/main/kotlin/no/iktdev/library/subtitle/cleaner/SubStationAlphaScrubber.kt b/src/main/kotlin/no/iktdev/library/subtitle/cleaner/SubStationAlphaScrubber.kt new file mode 100755 index 0000000..0722b55 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/cleaner/SubStationAlphaScrubber.kt @@ -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, "") + } + + } +} diff --git a/src/main/kotlin/no/iktdev/library/subtitle/export/Export.kt b/src/main/kotlin/no/iktdev/library/subtitle/export/Export.kt new file mode 100755 index 0000000..5defa9e --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/export/Export.kt @@ -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): MutableList { + val writtenFiles: MutableList = 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): File { + return Subrip(folder = folder, baseName = baseName).write(dialogs) + } + fun writeVtt(dialogs: List,): File { + return VideoTextTracks(folder = folder, baseName = baseName).write(dialogs) + } + fun writeSmi(dialogs: List,): File { + val smi = SynchronizedAccessibleMediaInterchange(folder = folder, baseName = baseName) + return smi.write(dialogs) + } + + + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/export/Exporter.kt b/src/main/kotlin/no/iktdev/library/subtitle/export/Exporter.kt new file mode 100755 index 0000000..a2dd088 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/export/Exporter.kt @@ -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): List + abstract fun write(dialogs: List): File + + fun deleteOnExistAndOverwriteAllowed(file: File) { + if (file.exists() && Configuration.allowOverwrite) { + file.delete() + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/export/formats/JsonDocument.kt b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/JsonDocument.kt new file mode 100755 index 0000000..d92e7f2 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/JsonDocument.kt @@ -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): List { + // DO nothing + return listOf() + } + + override fun write(dialogs: List): File { + val file = outFile("json") + deleteOnExistAndOverwriteAllowed(file) + + val data = Gson().toJson(dialogs) + file.printWriter().use { out -> + out.print(data) + } + return file + } +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/export/formats/Subrip.kt b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/Subrip.kt new file mode 100755 index 0000000..ff8d8c3 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/Subrip.kt @@ -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): List { + val formatted: MutableList = 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): File { + val file = outFile("srt") + deleteOnExistAndOverwriteAllowed(file) + val formatted = format(dialogs) + file.printWriter().use { out -> + formatted.forEach { + out.println(it) + } + } + return file + } + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/export/formats/SynchronizedAccessibleMediaInterchange.kt b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/SynchronizedAccessibleMediaInterchange.kt new file mode 100755 index 0000000..4b14bdf --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/SynchronizedAccessibleMediaInterchange.kt @@ -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): List { + val formatted: MutableList = mutableListOf() + formatted.add("") + formatted.add("\t") + formatted.add("\t\t${baseName}") + formatted.add("\t") + formatted.add("\t") + dialogs.forEach { dialog -> + getStartItem(dialog).forEach { line -> + formatted.add(line) + } + getEndItem(dialog.end).forEach { line -> + formatted.add(line) + } + } + + formatted.add("\t") + formatted.add("") + return formatted + } + + private fun getStartItem(dialog: Dialog): List { + return listOf( + "\t\t", + "\t\t\t

${dialog.text}

", + "\t\t
", + ) + } + + private fun getEndItem(time: Time): List { + return listOf( + "\t\t", + "\t\t\t

&nbsp;

", + "\t\t
", + ) + } + + override fun write(dialogs: List): File { + val file = outFile("smi") + deleteOnExistAndOverwriteAllowed(file) + val formatted = format(dialogs) + file.printWriter().use { out -> + formatted.forEach { + out.println(it) + } + } + return file + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/export/formats/VideoTextTracks.kt b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/VideoTextTracks.kt new file mode 100755 index 0000000..8c339a3 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/export/formats/VideoTextTracks.kt @@ -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): List { + val formatted: MutableList = 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): File { + val file = outFile("vtt") + deleteOnExistAndOverwriteAllowed(file) + val formatted = format(dialogs) + file.printWriter().use { out -> + formatted.forEach { + out.println(it) + } + } + return file + } + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/reader/AssReader.kt b/src/main/kotlin/no/iktdev/library/subtitle/reader/AssReader.kt new file mode 100755 index 0000000..60a6b61 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/reader/AssReader.kt @@ -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 { + 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("\\{[^}]*}") + } +} diff --git a/src/main/kotlin/no/iktdev/library/subtitle/reader/BaseReader.kt b/src/main/kotlin/no/iktdev/library/subtitle/reader/BaseReader.kt new file mode 100755 index 0000000..95230f2 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/reader/BaseReader.kt @@ -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 = mutableListOf() + fun getLines(): List { + 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 + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/reader/Reader.kt b/src/main/kotlin/no/iktdev/library/subtitle/reader/Reader.kt new file mode 100755 index 0000000..f068b4b --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/reader/Reader.kt @@ -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 + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/reader/SrtReader.kt b/src/main/kotlin/no/iktdev/library/subtitle/reader/SrtReader.kt new file mode 100755 index 0000000..e654c90 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/reader/SrtReader.kt @@ -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): MutableList> { + val grouped: MutableList> = mutableListOf() + val cache = mutableListOf() + + 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): 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 { + val srts = group(getLines()).mapNotNull { deserialize(it) } + return srts.map { transform(it) } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/no/iktdev/library/subtitle/reader/VttReader.kt b/src/main/kotlin/no/iktdev/library/subtitle/reader/VttReader.kt new file mode 100755 index 0000000..63d2c35 --- /dev/null +++ b/src/main/kotlin/no/iktdev/library/subtitle/reader/VttReader.kt @@ -0,0 +1,4 @@ +package no.iktdev.library.subtitle.reader + +class VttReader { +} \ No newline at end of file diff --git a/src/test/kotlin/Resources.kt b/src/test/kotlin/Resources.kt new file mode 100755 index 0000000..e2929e6 --- /dev/null +++ b/src/test/kotlin/Resources.kt @@ -0,0 +1,55 @@ +open class Resources { + open val path: List = listOf("") + + fun getText(path: List): String? { + val fullPath = path.joinToString("/") + return this.javaClass.classLoader.getResource(fullPath)?.readText() + } + + fun getLines(path: List): List { + val fullPath = path.joinToString("/") + return this.javaClass.getResourceAsStream(fullPath)?.bufferedReader()?.readLines() ?: emptyList() + } + + open class Output: Resources() { + override val path: List = listOf("output") + open class Encoder(): Output() { + override val path: List = listOf("output", "encoder") + + fun sample1(): List { + val text = getText(listOf(*path.toTypedArray(), "sample1.txt")) ?: return emptyList() + return text.split(System.lineSeparator(), ignoreCase = true) + } + + fun sample2(): List { + val text = getText(listOf(*path.toTypedArray(), "sample2.txt")) ?: return emptyList() + return text.split(System.lineSeparator(), ignoreCase = true) + + } + + fun sample3(): List { + val text = getText(listOf(*path.toTypedArray(), "sample3.txt")) ?: return emptyList() + return text.split(System.lineSeparator(), ignoreCase = true) + + } + + fun sample4(): List { + 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 = listOf("output", "streams") + + fun getSample(number: Int): String { + return getText(listOf(*path.toTypedArray(), "sample$number.json"))!! + } + } + + } + + + +} diff --git a/src/test/kotlin/no/iktdev/library/subtitle/SubtitleFixtures.kt b/src/test/kotlin/no/iktdev/library/subtitle/SubtitleFixtures.kt new file mode 100644 index 0000000..87fed33 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/SubtitleFixtures.kt @@ -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 = """ + [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 = """ + 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() +} diff --git a/src/test/kotlin/no/iktdev/library/subtitle/SyncroTest.kt b/src/test/kotlin/no/iktdev/library/subtitle/SyncroTest.kt new file mode 100755 index 0000000..3ff1325 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/SyncroTest.kt @@ -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( + 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( + 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() { + + } + + + +} diff --git a/src/test/kotlin/no/iktdev/library/subtitle/TestFileExtensionChange.kt b/src/test/kotlin/no/iktdev/library/subtitle/TestFileExtensionChange.kt new file mode 100755 index 0000000..5819e28 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/TestFileExtensionChange.kt @@ -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") + } + + } + } +} diff --git a/src/test/kotlin/no/iktdev/library/subtitle/TestRun.kt b/src/test/kotlin/no/iktdev/library/subtitle/TestRun.kt new file mode 100755 index 0000000..808b8e4 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/TestRun.kt @@ -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): 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) + } + +} diff --git a/src/test/kotlin/no/iktdev/library/subtitle/TestTime.kt b/src/test/kotlin/no/iktdev/library/subtitle/TestTime.kt new file mode 100755 index 0000000..b0894c4 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/TestTime.kt @@ -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) + } + +} diff --git a/src/test/kotlin/no/iktdev/library/subtitle/classes/TimeTest.kt b/src/test/kotlin/no/iktdev/library/subtitle/classes/TimeTest.kt new file mode 100755 index 0000000..4ed4505 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/classes/TimeTest.kt @@ -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()) + } + + + + +} \ No newline at end of file diff --git a/src/test/kotlin/no/iktdev/library/subtitle/cleaner/LineScrubbersKtTest.kt b/src/test/kotlin/no/iktdev/library/subtitle/cleaner/LineScrubbersKtTest.kt new file mode 100755 index 0000000..4a0f4b3 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/cleaner/LineScrubbersKtTest.kt @@ -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) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/no/iktdev/library/subtitle/cleaner/SubStationAlphaScrubberTest.kt b/src/test/kotlin/no/iktdev/library/subtitle/cleaner/SubStationAlphaScrubberTest.kt new file mode 100755 index 0000000..5e8bbb9 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/cleaner/SubStationAlphaScrubberTest.kt @@ -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) + } + +} \ No newline at end of file diff --git a/src/test/kotlin/no/iktdev/library/subtitle/reader/AssReaderTest.kt b/src/test/kotlin/no/iktdev/library/subtitle/reader/AssReaderTest.kt new file mode 100755 index 0000000..ec0b42b --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/reader/AssReaderTest.kt @@ -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) + } + +} diff --git a/src/test/kotlin/no/iktdev/library/subtitle/reader/SrtReaderTest.kt b/src/test/kotlin/no/iktdev/library/subtitle/reader/SrtReaderTest.kt new file mode 100755 index 0000000..b9c5db0 --- /dev/null +++ b/src/test/kotlin/no/iktdev/library/subtitle/reader/SrtReaderTest.kt @@ -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> = mutableListOf() + val cache = mutableListOf() + + 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) + } + + +}