srt / translation-validator
Validates subtitle translations: detects missing captions, partial translation, untranslated copies, foreign-script characters, timestamp mismatch, and invalid SRT/WebVTT format
Requires
- php: ^8.0
- mantas-done/subtitles: ^0.3.10 || ^1.0
- patrickschur/language-detection: ^5.3
Requires (Dev)
- phpunit/phpunit: ^9.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A PHP tool that validates subtitle translations. It compares an original subtitle file against its translation, aligns the two caption lists on the timeline and reports these defect types:
| Defect | Severity | What it detects |
|---|---|---|
invalid_format |
error | Malformed SRT/WebVTT structure (bad or missing timestamp lines, garbage, NUL bytes) |
missing_caption |
error | Source captions with no translation counterpart at all (real content loss; music/annotation-only cues are excluded) |
partial_translation |
warning | Large caption blocks detected in the wrong language. Advisory only - the detector label is decoration and never fails the verdict |
timestamp_mismatch |
error | Aligned captions whose start/end times drifted beyond a tolerance |
untranslated_copy |
error | Nearly all captions are verbatim copies of the source - the model returned the original untranslated. Skipped when the source is already in the target language (same-language passthrough) |
edited_copy |
error | Nearly all captions are identical or >=90% similar to the source, yet not exactly identical - the model returned the original with light cosmetic edits. A different failure mode than untranslated_copy |
unexpected_script |
error | Translation letters in a script foreign to the target language (character hallucination, e.g. Cyrillic in Hungarian); letters already spelled that way in the source are exempt |
merged_captions |
warning | Source captions merged into a neighbouring translation caption (re-segmentation) |
split_captions |
warning | One source caption split across multiple translation captions |
extra_caption |
warning | Translation captions with no counterpart in the original |
format_mismatch |
warning | Source and translation use different container formats (compared on cue structure anyway) |
source_parse_failed |
warning | The source file itself is malformed; the translation verdict is skipped |
same_language_passthrough |
warning | The source is already written in the target language (declared with --source-lang, or evident from its script); the verbatim/untranslated gates are skipped |
The verdict: "usable", not "identical"
Translation engines like DeepL routinely re-segment subtitles: they merge 2-3 short source captions into one translation caption (or split long ones), which changes the caption count without losing any content. An index-by-index comparison misreads that as hundreds of timestamp mismatches and missing captions. This validator instead aligns captions on the timeline first, then judges the translation by how much is actually wrong:
| Ratio | Measures | Default limit |
|---|---|---|
content_loss |
source captions with no translation counterpart (content captions only - music/annotation cues excluded) | 1% |
timestamp_drift |
aligned captions drifting beyond the tolerance | 2% |
partial_translation |
translation characters detected in the wrong language (base-code comparison: es-mx target matches es detection) |
advisory (no limit) |
merged |
source captions merged into neighbouring captions (content captions only) | 10% |
verbatim_copy |
aligned captions identical to the source | 50% (advisory during a same-language passthrough) |
near_verbatim_copy |
aligned captions identical or >= 90% similar to the source (lightly edited passthrough) | 50% (advisory during a same-language passthrough) |
unexpected_script |
translation letters in a foreign script (not counting letters inherited from the source) | 0% (zero tolerance) |
unaligned |
source captions with no aligned translation pair | advisory (no limit) |
- A translation is usable (
valid: true, exit 0) when no ratio exceeds its limit. Merges and splits are always reported as warnings and never fail on their own. --strictfails on any error-severity defect, ignoring the limits.- The measured ratios are always included in the report (
qualityblock), so thresholds can be calibrated from real data. Each limit can be overridden per run with--max-loss-ratio,--max-drift-ratio,--max-merge-ratio,--max-verbatim-ratioand--max-script-ratio(values between0and1);--max-errorsfails a run with more than N error-severity defects regardless of the ratios. - Same-language passthrough: when the source file is already written in
the target language, a verbatim copy is expected output, not a failure.
Declare the source language with
--source-lang(e.g.--source-lang=bgwith-l bg); for non-Latin targets the validator also infers this from the source script automatically (Cyrillic source,bgtarget). Asame_language_passthroughwarning is emitted and the verbatim gates are skipped. - Source vs. model: the translation is the artefact under review, so
format failures in the translation fail the job. A malformed source is
the user's input, not the model's failure: it is reported as a
source_parse_failedwarning and the verdict is skipped, never failed. - Reading speed (cps) and line length are measured on the translation and
reported in the
quality.readabilityblock (avg/max cps, longest line) as pure statistics - they never produce defects or affect the verdict.
This project is used in two different ways, so this README is split in two:
| Who you are | Jump to |
|---|---|
| You just want to check subtitle files from a terminal | For command-line users |
| You want to call the validator from your own PHP code | For PHP developers |
For command-line users
You don't need to write any code. Download the pre-built srt-translation-validator.phar
(a single executable file), run it against the two subtitle files, and read the report.
Requirements
- PHP 8.0+ on your machine (no Composer, no composer packages needed)
mantas-done/subtitles1.x or 0.3.10+ — the PHAR bundles its own copy, so this only matters when installing the package via Composer into a project that pins the 0.3.x line. On 0.3.x the parser is slightly more lenient (malformed cues are skipped instead of reported) and SRT timestamps written with.instead of,lose their milliseconds.
Downloading
Get the PHAR from the Releases page of this project's GitHub repository
(each release is named v1.0.0, v1.0.1, ... and carries the PHAR as an
asset):
# download srt-translation-validator.phar from the latest release, then:
chmod +x srt-translation-validator.phar
sudo mv srt-translation-validator.phar /usr/local/bin/srt-translation-validator
After that, srt-translation-validator works as a normal command.
Installing via Composer
The tool is also on Packagist, so you can install it with Composer and get it as a command-line tool without downloading the PHAR:
composer global require srt/translation-validator
Add Composer's global bin directory to your PATH (e.g.
~/.config/composer/vendor/bin on Linux, ~/.composer/vendor/bin on macOS),
then run it as srt-validator. Or require it in a project for its PHP API and
use vendor/bin/srt-validator. Composer installs from the source via git tags;
the PHAR from the Releases page is the always-current compiled build.
Usage
srt-translation-validator <original-file> <translation-file> [options]
The CLI accepts exactly two positional arguments – the original subtitle file (also called the reference) and the file being validated (the translation) – compares them, and prints a human-readable report.
Options
| Option | Description |
|---|---|
-l, --lang=CODE |
Expected language of the translation (ISO 639-1, e.g. de). Default: auto-detected from the translation text. |
--source-lang=CODE |
Declared language of the original file (e.g. from the job request). When it matches --lang, the verbatim/untranslated gates are skipped (same-language passthrough). For non-Latin targets this is also inferred from the source script automatically. |
-t, --tolerance=SEC |
Timestamp drift tolerance in seconds (default: 0.5). |
-j, --json |
Print the report as JSON (for scripts, agents and LLMs). Exit codes are unchanged. |
--strict |
Fail on any error-severity defect, ignoring the quality-ratio limits. |
--max-loss-ratio=F |
Max share of source captions with no translation at all (default: 0.01). |
--max-drift-ratio=F |
Max share of aligned captions with timestamp drift beyond the tolerance (default: 0.02). |
--max-merge-ratio=F |
Max share of source captions merged into neighbouring captions (default: 0.10). |
--max-verbatim-ratio=F |
Max share of aligned captions that may be verbatim copies of the source (default: 0.50). |
--max-near-verbatim-ratio=F |
Max share of aligned captions that may be identical or >=90% similar to the source; catches copies with light cosmetic edits as edited_copy (default: 0.50). |
--max-script-ratio=F |
Max share of translation letters in a script foreign to the target language, not counting letters inherited from the source (default: 0, zero tolerance). |
--max-errors=N |
Fail when more than N error-severity defects are found, regardless of the ratios (default: no limit). |
--readability |
Readability audit of a single subtitle file: runs a per-caption check against readability limits and lists every caption that exceeds them. Purely advisory - no verdict, no defects, exit code is 0 when every caption is clean and 1 when problems are found. Takes exactly one file. |
--max-cps=F |
Max characters per second for the --readability audit (default: 20.0). |
--max-cpl=N |
Max characters per line for the --readability audit (default: 42). |
--max-lines=N |
Max lines per caption for the --readability audit (default: 2). |
--limit=N |
Cap the --readability listing to the first N problematic captions. File-wide stats and problems_by_type still cover every caption (default: show all). |
--worst-first |
In the --readability listing, order problems for triage: critical before minor, then fastest reading speed first. |
-h, --help |
Show usage help. |
-V, --version |
Print the version and exit. |
--update[=version] |
Update the tool to the latest release (or a specific version, e.g. --update=1.0.1). |
Exit codes
| Code | Meaning |
|---|---|
0 |
The translation is usable (no quality limit exceeded). |
1 |
The translation is not usable (a limit was exceeded, or errors in --strict mode). |
2 |
Usage error, or the validator could not run. |
Examples
# Compare and auto-detect the translation language srt-translation-validator original.srt translation.de.srt # Explicit expected language srt-translation-validator original.srt translation.srt -l de # Stricter timestamp tolerance (100 ms) srt-translation-validator -t 0.1 -l de Movie.en.srt Movie.pt.srt # Machine-readable output for scripts, agents and LLMs srt-translation-validator original.srt translation.srt -l de --json
JSON output
With --json the report is printed as a single JSON object on stdout (nothing
else is written, so it is safe to pipe into jq or parse directly). Errors are
reported as {"error": "..."} with exit code 2.
{
"valid": true,
"result": "passed",
"original": "original.srt",
"translation": "translation.srt",
"language": "de",
"timestamp_tolerance": 0.5,
"defect_count": 2,
"error_count": 0,
"warning_count": 2,
"defects_by_type": {
"merged_captions": 2
},
"defects": [
{
"type": "merged_captions",
"severity": "warning",
"message": "Original captions #316-317 are merged into translation caption #316",
"source_start_caption": 316,
"source_end_caption": 317,
"translation_caption": 316,
"caption_count": 2
}
],
"quality": {
"source_captions": 765,
"aligned_pairs": 761,
"partial_chars_analyzed": 18220,
"ratios": {
"content_loss": 0.0,
"timestamp_drift": 0.0,
"partial_translation": 0.0,
"merged": 0.0052,
"verbatim_copy": 0.0026,
"near_verbatim_copy": 0.0065,
"unexpected_script": 0.0,
"unaligned": 0.0052
},
"thresholds": {
"content_loss": 0.01,
"timestamp_drift": 0.02,
"partial_translation": null,
"merged": 0.1,
"verbatim_copy": 0.5,
"near_verbatim_copy": 0.5,
"unexpected_script": 0.0,
"unaligned": null
},
"readability": {
"avg_cps": 12.4,
"max_cps": 21.7,
"max_cps_caption": 183,
"max_cpl": 42,
"max_cpl_caption": 402
},
"strict": false,
"reasons": []
}
}
valid: false reports carry non-empty quality.reasons naming every exceeded
limit (e.g. "verbatim copy 70.52% exceeds the threshold 50.00%"). Ratios
with a null threshold (partial_translation, unaligned) are advisory:
they are measured and reported but never flip the verdict.
Example report
==================================================================
Subtitle Translation Validator
==================================================================
Original: original.srt
Translation: translation.nl.srt
Expected language: nl
Timestamp tolerance: 0.5s
Quality
------------------------------------------------------------------
content loss: 0.0% (max 1.0%)
timestamp drift: 0.0% (max 2.0%)
partial translation: 0.0% (advisory)
merged: 0.5% (max 10.0%)
verbatim copy: 0.3% (max 50.0%)
near verbatim copy: 0.7% (max 50.0%)
unexpected script: 0.0% (max 0.0%)
unaligned: 0.5% (advisory)
reading speed: 12.4 cps avg, 21.7 cps max (caption #183)
line length: 42 chars max (caption #402)
Defects (4)
------------------------------------------------------------------
1. MERGED CAPTIONS [warning]
Original captions #316 are merged into translation caption #316
...
==================================================================
RESULT: PASSED - translation is usable (0 error(s), 4 warning(s) within limits)
==================================================================
A failed validation prints RESULT: FAILED, the error/warning counts and one
line per exceeded quality limit.
Readability audit
Pass --readability with exactly one subtitle file to list every caption
that is hard to read. It is a separate, advisory mode: no verdict, no defects,
no language detection - just the captions that exceed the readability limits,
with their exact values against the limits.
srt-translation-validator Movie.de.srt --readability
srt-translation-validator Movie.de.srt --readability --json # for scripts/LLMs
srt-translation-validator Movie.de.srt --readability --max-cps 15 --max-cpl 37
srt-translation-validator Movie.de.srt --readability --limit 50 --worst-first
Limits (ISO-style subtitle guidelines): --max-cps characters per second
(default 20), --max-cpl characters per line (default 42), --max-lines
(default 2).
Every problem carries a severity: critical when a value exceeds twice
its limit, otherwise minor. A caption is critical when any of its
issues is critical. --limit N caps the listing (stats still cover every
caption); --worst-first orders the listing for triage - critical first,
then fastest reading speed.
Exit codes: 0 every caption is clean, 1 at least one caption exceeds a
limit, 2 usage error or the file could not be read/parsed.
Captions shorter than 0.2s are too brief to measure reading speed reliably:
they count towards captions but are excluded from the cps stats and
avg_cps (see analyzed in the JSON).
Human-readable output:
==================================================================
Readability Audit
==================================================================
File: Movie.de.srt
Captions: 1834
Avg reading speed: 12.4 cps
Max reading speed: 48.6 cps (caption #1485)
Limits: 20 cps, 42 chars/line, 2 lines
Problematic captions: 259
Problematic captions (259)
------------------------------------------------------------------
1. caption #1485 [critical] [01:37:06,520 --> 01:37:07,755] (1.2s, 60 chars)
- critical - reading speed 48.6 cps exceeds the 20.0 cps limit
| ICH GLAUBE, WENN DU ES WIRKLICH
| ERNST MEINST, IHN ZU RETTEN,
...
JSON output omits nothing: each problem carries caption, severity,
start_seconds, end_seconds, duration_seconds, chars, cps, the full
text and an issues array (type, value, limit, severity), plus a
problems_by_type summary and the thresholds used. With --limit, shown
holds the capped count and truncated is true when problems were dropped;
problems_by_type and the stats always describe every caption.
Updating the tool
srt-translation-validator --update # update to the latest release srt-translation-validator --update=1.0.1 # update to a specific version
This fetches the release from GitHub, downloads the new PHAR, sanity-checks it
and atomically replaces the running file. If the file is not writable (e.g.
installed in /usr/local/bin as root), run it with sudo.
For PHP developers
Call the validation logic directly from your own PHP code via Composer.
Requirements
- PHP 8.0+
- Composer
- Extension
mbstring
Installation
composer require srt/translation-validator
This also publishes the srt-validator binary, so the command-line tool from
the previous section is available in vendor/bin/ too.
Full validation
Compare an original subtitle file against its translation, all in one call:
<?php require 'vendor/autoload.php'; use SrtValidator\SrtTranslationValidator; $validator = new SrtTranslationValidator(); // Tune timestamp drift tolerance (default is 0.5 seconds) $validator->setTimestampTolerance(0.2); // Fail on any error-severity defect instead of judging by ratios $validator->setStrict(true); // Override any quality threshold // (defaults: loss 0.01 / drift 0.02 / merge 0.10 / // verbatim 0.50 / near-verbatim 0.50 / script 0.00; // partial_translation is advisory) $validator->setMaxLossRatio(0.02); $validator->setMaxDriftRatio(0.05); $validator->setMaxMergeRatio(0.20); $validator->setMaxVerbatimRatio(0.60); $validator->setMaxNearVerbatimRatio(0.60); $validator->setMaxScriptRatio(0.001); // Declare the source language (e.g. from the job request): when it // matches the target, the verbatim/untranslated gates are skipped // (same-language passthrough). For non-Latin targets this is also // inferred from the source script automatically. $validator->setSourceLanguage('en'); // Fail when more than N error-severity defects are found, // regardless of the ratios $validator->setMaxErrors(10); $result = $validator->validate( 'path/to/original.srt', // reference subtitles 'path/to/translation.srt', // the translation being checked 'de' // expected translation language ); if ($result['valid']) { echo "Translation is OK.\n"; exit(0); } foreach ($result['defects'] as $i => $defect) { printf( "%d. [%s] %s\n", $i + 1, strtoupper(str_replace('_', ' ', $defect['type'])), $defect['message'] ); }
Result structure
validate() returns a verdict plus every defect, each tagged with a severity:
[
'valid' => false, // "usable": no quality ratio exceeded
'error_count' => 26,
'warning_count' => 11,
'defects' => [
[
'type' => 'missing_caption',
'severity' => 'error',
'message' => 'Caption #1178 is missing in translation',
'caption_number' => 1178,
'original_text' => 'We need to go now.',
],
[
'type' => 'merged_captions',
'severity' => 'warning',
'message' => 'Original captions #316-317 are merged into translation caption #316',
'source_start_caption' => 316,
'source_end_caption' => 317,
'translation_caption' => 316,
'caption_count' => 2,
],
[
'type' => 'partial_translation',
'severity' => 'warning', // advisory: never fails the verdict
'message' => 'Large block (captions 901-1400) detected as en instead of de - ...',
'start_caption' => 901,
'end_caption' => 1400,
'detected_language' => 'en',
'confidence' => 0.88,
],
[
'type' => 'timestamp_mismatch',
'severity' => 'error',
'caption_number' => 301,
'message' => 'Caption #301 has timestamp drift',
'original_start' => 1334.627,
'translation_start' => 1336.627,
'start_diff' => 2.0,
],
[
'type' => 'invalid_format',
'severity' => 'error',
'line' => 2112,
'subtype' => 'missing_timestamp',
'message' => 'Line 2112: expected a timing line (...) ...',
],
[
'type' => 'unexpected_script',
'severity' => 'error',
'message' => '42 of 18355 letters (0.23%) are written in a script that does not belong to language hu: cyrillic (42). Example characters: д л о',
'foreign_chars' => 42,
'total_letters' => 18355,
'scripts' => ['cyrillic' => 42],
'examples' => 'д л о',
'language' => 'hu',
'ratio' => 0.0023,
],
],
'quality' => [
'source_captions' => 1834,
'aligned_pairs' => 1808,
'ratios' => [
'content_loss' => 0.0149, // vs thresholds below (content captions only)
'timestamp_drift' => 0.0,
'partial_translation' => 0.0, // advisory, no threshold
'merged' => 0.006,
'verbatim_copy' => 0.0026,
'near_verbatim_copy' => 0.0065,
'unexpected_script' => 0.0,
'unaligned' => 0.0142, // advisory, no threshold
],
'thresholds' => [
'content_loss' => 0.01,
'timestamp_drift' => 0.02,
'partial_translation' => null,
'merged' => 0.10,
'verbatim_copy' => 0.50,
'near_verbatim_copy' => 0.50,
'unexpected_script' => 0.0,
'unaligned' => null,
],
// Informational reading statistics - never affect the verdict
'readability' => [
'avg_cps' => 12.4,
'max_cps' => 21.7,
'max_cps_caption' => 183,
'max_cpl' => 42,
'max_cpl_caption' => 402,
],
'max_errors' => null,
'strict' => false,
'reasons' => ['content loss 1.36% exceeds the threshold 1.00%'],
],
]
The captions are aligned on the timeline before comparison (see
src/CaptionAligner.php), so re-segmentation by the translation engine is
reported as merged_captions/split_captions warnings instead of cascades
of false mismatches.
JSON output from PHP
There is no separate JSON model to learn: the CLI's --json output is
exactly the validate() array above, run through json_encode() with three
flags. In your own code you normally skip JSON entirely and work with the
array - but when you do need the same output (to store a report, feed a
pipeline, or serve it from an API), it is one call:
<?php require 'vendor/autoload.php'; use SrtValidator\SrtTranslationValidator; $result = (new SrtTranslationValidator())->validate( 'path/to/original.srt', 'path/to/translation.srt', 'de' ); // Same encoding the CLI uses. JSON_UNESCAPED_UNICODE keeps umlauts, // cyrillic etc. readable; drop JSON_PRETTY_PRINT for compact output. $json = json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); file_put_contents('report.json', $json . "\n"); // Or serve it directly: // header('Content-Type: application/json'); // echo $json;
The one difference: the CLI wraps the array in a small envelope before
encoding, adding result ('passed'/'failed'), the file paths, language,
timestamp_tolerance, defect_count and a defects_by_type summary. If a
consumer of yours expects that exact CLI shape, replicate it in a few lines:
$defects = array_values($result['defects']); $byType = []; foreach ($defects as $defect) { $byType[$defect['type']] = ($byType[$defect['type']] ?? 0) + 1; } ksort($byType); $envelope = [ 'valid' => (bool)$result['valid'], 'result' => $result['valid'] ? 'passed' : 'failed', 'original' => 'path/to/original.srt', 'translation' => 'path/to/translation.srt', 'language' => 'de', 'defect_count' => count($defects), 'error_count' => $result['error_count'], 'warning_count' => $result['warning_count'], 'defects_by_type' => $byType, 'defects' => $defects, 'quality' => $result['quality'], ];
The same applies to the readability audit: ReadabilityChecker::analyze()
returns an array, and json_encode($analysis, ...same flags...) gives you
the --readability --json payload.
Validation of file format only
If you only need to check the SRT/WebVTT structure of one file:
<?php require 'vendor/autoload.php'; use SrtValidator\SubtitleFormatValidator; $validator = new SubtitleFormatValidator(); // Content already in memory: $result = $validator->validateContent($srtContent, 'srt'); // Or a file on disk: $result = $validator->validateFile('subtitles.srt'); printf( "format=%s valid=%s cues=%d\n", $result['format'], // 'srt' or 'vtt' $result['valid'] ? 'yes' : 'no', $result['cue_count'] ); foreach ($result['errors'] as $error) { printf(" line %d [%s] %s\n", $error['line'], $error['subtype'], $error['message']); }
The format validator understands every widespread SRT/WebVTT variant:
comma or dot milliseconds, sequence numbers on their own or the timing line,
un-numbered cues, X1:Y1 coordinate extensions, BOM and CRLF/CR line
endings, and for WebVTT: cue identifiers/settings, NOTE, STYLE, REGION
blocks and negative timestamps.
Readability audit from PHP
The advisory per-caption audit behind the --readability CLI mode (see
above) is its own class, ReadabilityChecker. It never touches the verdict
machinery: give it a subtitle file and get back every caption that exceeds
the limits. Parsing (.srt and .vtt) is built in - no need to touch the
subtitles library yourself:
<?php require 'vendor/autoload.php'; use SrtValidator\ReadabilityChecker; // Option 1: bind a file at construction; analyze() then needs no arguments $checker = new ReadabilityChecker('Movie.de.srt'); $analysis = $checker->analyze(); // Option 2: one-shot on a file $analysis = (new ReadabilityChecker())->analyzeFile('Movie.de.srt'); // Option 3: content already in memory (downloaded, generated, ...) $analysis = (new ReadabilityChecker())->analyzeContent($srtString);
All three accept the same optional limits - $maxCps (float, reading speed
in characters/second, default 20.0), $maxCpl (int, characters per line,
default 42), $maxLines (int, lines per caption, default 2); pass null
(or omit trailing ones) to keep a default:
$analysis = (new ReadabilityChecker())->analyzeFile('Movie.de.srt', 17.0, 37);
If you already hold parsed caption blocks (the internal format used
throughout the library), analyze($blocks) still runs on them directly.
Input errors throw instead of returning partial results: a file that does
not exist or is not readable throws InvalidArgumentException; content the
parser rejects throws RuntimeException.
foreach ($analysis['problems'] as $problem) { printf( "#%d [%s] %s\n", $problem['caption'], // 1-based caption number $problem['severity'], // 'critical' or 'minor' $problem['text'] ); foreach ($problem['issues'] as $issue) { printf( " - %s: %s exceeds limit %s (%s)\n", $issue['type'], // reading_speed | line_length | line_count $issue['value'], $issue['limit'], $issue['severity'] ); } } if ($analysis['problems'] === []) { echo "Every caption is within the limits.\n"; }
The return value also carries the file-wide summary: captions, analyzed
(captions long enough to measure reading speed — cues under 0.2s have
cps null), avg_cps, max_cps/max_cps_caption, max_cpl/
max_cpl_caption, problems_by_type (counts per issue type) and the
thresholds actually used. Severity works as in the CLI: a value above
twice its limit is critical, otherwise minor, and a caption is
critical when any of its issues is.
The --limit and --worst-first CLI options are presentation only — slice
and sort $analysis['problems'] yourself if you want the same behavior.
Development
Everything below is for people working on the validator itself.
Building the PHAR
A self-contained srt-translation-validator.phar (no Composer needed to run
it) can be built from this repository:
composer install # dev dependencies (only needed for tests)
php -d phar.readonly=0 build/build-phar.php
or via the Composer script:
composer run build-phar
The PHAR bundles src/, bin/ and the production vendor/ tree, so it runs
on any machine with PHP 8.0+. The GitHub repository used by --update is
auto-detected from the origin remote and embedded into the PHAR; override it
in build/build-phar.php if needed.
Running the tests
composer test # or vendor/bin/phpunit tests/
The suite contains structure/format tests, library integration tests, and end-to-end CLI tests. Most are self-contained; the tests that rely on the large example subtitle files automatically skip when those local fixtures are not checked out.
Releasing a new version
The version lives in the VERSION file at the repository root (plain semver,
e.g. 1.0.0). The release process:
- Bump
VERSION(patch for fixes, minor for features). - Commit the bump together with the change, and push to
main. - A GitHub Actions workflow (
.github/workflows/build-phar.yml) runs the tests on PHP 8.1/8.2/8.3 and, only whenVERSIONchanged compared to the latest released tag, builds the PHAR, verifies it, and creates/updates the GitHub Releasev<VERSION>with the PHAR attached.
If you push a change without bumping VERSION, only the tests run — no PHAR
is built and no release is touched. (Via "Run workflow" in the Actions tab you
can force a rebuild of the current version at any time.)
Project layout
src/ Library classes (PSR-4: SrtValidator\)
SrtTranslationValidator.php Full translation validation logic
SubtitleFormatValidator.php Regex-based SRT/WebVTT structure validation
CaptionAligner.php Aligns source/translation captions on the timeline
ScriptChecker.php Foreign-script ("character hallucination") detection
ReadabilityChecker.php Per-caption readability audit (--readability)
Cli.php CLI parsing, report rendering, --version/--update
bin/srt-validator Executable command-line entry point
build/build-phar.php PHAR builder (embeds VERSION + release repo)
VERSION Current release version (drives the GitHub release)
examples/ Local (gitignored) example fixtures + defect files
tests/ PHPUnit test suite
.github/workflows/ CI + release workflow
AGENTS.md Workflow rules for AI agents (git/VERSION are user-owned)