WCAG 4.1.2: add WebVTT caption support for <video> elements on tfe.php

Problem: <video> elements on tfe.php had no <track kind="captions"> element,
violating WCAG 4.1.2 (name, role, value) for video content.

Changes:
- public/tfe.php: collect all text/vtt files from the thesis file list before
  rendering; skip standalone rendering of .vtt entries; for each MP4 emit a
  <track kind="captions" srclang="fr" label="Sous-titres" default> pointing
  to the N-th VTT file (N-th video paired with N-th caption in document order)
- public/media.php: add text/vtt to allowed MIME list; normalise finfo
  text/plain -> text/vtt for .vtt files; add vtt branch to cache/header
  block (Content-Type: text/vtt; charset=utf-8, 1-day cache)
- public/admin/actions/formulaire.php: allow .vtt uploads (text/vtt MIME,
  vtt extension); normalise text/plain finfo result; set file_type='caption'
  for VTT files so they are distinguishable from other thesis files
- public/admin/add.php: extend files field accept attr to include .vtt;
  update hint text to document the VTT sidecar convention

VTT files uploaded under theses/ inherit the same access_type visibility
gate in media.php as all other thesis content (403 for access_type_id=3).
This commit is contained in:
Pontoporeia
2026-04-03 13:24:26 +02:00
parent 6e68edfbff
commit 94e9060dc7
6 changed files with 58 additions and 8 deletions

View File

@@ -11,6 +11,9 @@ Pending tasks have been split into topic files under [`todo/`](todo/README.md):
## Recently completed (this session) ## Recently completed (this session)
- [x] WCAG 4.1.2 `<video>` captions — `tfe.php` now emits `<track kind="captions">` for each MP4 when a `.vtt` sidecar exists (N-th VTT paired with N-th video). `formulaire.php` accepts `.vtt` uploads (`file_type='caption'`, MIME normalised). `media.php` serves `text/vtt` with correct headers and visibility gating. Admin `add.php` file-field hint documents the `.vtt` upload convention.
- [x] `admin/edit.php` — WCAG 4.1.2: removed `mb_strimwidth` truncation from `$accessOptions` mapping; access type `<select>` options now include full description text (`name — description`) so the accessible name is unambiguous for screen readers - [x] `admin/edit.php` — WCAG 4.1.2: removed `mb_strimwidth` truncation from `$accessOptions` mapping; access type `<select>` options now include full description text (`name — description`) so the accessible name is unambiguous for screen readers
- [x] `public/assets/favicon.svg` — created public favicon: brand-purple (`#9557b5`) rounded square with white "P" lettermark; distinct from `admin_favicon.svg` (archive-restore icon in `#c104fc`) - [x] `public/assets/favicon.svg` — created public favicon: brand-purple (`#9557b5`) rounded square with white "P" lettermark; distinct from `admin_favicon.svg` (archive-restore icon in `#c104fc`)
- [x] `templates/head.php` — favicon `<link>` now selects `favicon.svg` (public) vs `admin_favicon.svg` (admin) based on `$isAdmin`; closes `todo/01-css-semantic-refactor.md` favicon task - [x] `templates/head.php` — favicon `<link>` now selects `favicon.svg` (public) vs `admin_favicon.svg` (admin) based on `$isAdmin`; closes `todo/01-css-semantic-refactor.md` favicon task

View File

@@ -186,8 +186,8 @@ try {
} }
// Define security constraints // Define security constraints
$allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'application/zip']; $allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'application/zip', 'text/vtt'];
$allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip']; $allowedExtensions = ['jpg', 'jpeg', 'png', 'pdf', 'mp4', 'zip', 'vtt'];
$maxFileSize = 50 * 1024 * 1024; // 50 MB $maxFileSize = 50 * 1024 * 1024; // 50 MB
// Process cover image // Process cover image
@@ -248,6 +248,11 @@ try {
$mimeType = $finfo->file($files["tmp_name"][$i]); $mimeType = $finfo->file($files["tmp_name"][$i]);
$fileExtension = strtolower(pathinfo($files["name"][$i], PATHINFO_EXTENSION)); $fileExtension = strtolower(pathinfo($files["name"][$i], PATHINFO_EXTENSION));
// finfo may return 'text/plain' for WebVTT on some systems; normalise by extension.
if ($mimeType === 'text/plain' && $fileExtension === 'vtt') {
$mimeType = 'text/vtt';
}
if (!in_array($mimeType, $allowedMimeTypes) || !in_array($fileExtension, $allowedExtensions)) { if (!in_array($mimeType, $allowedMimeTypes) || !in_array($fileExtension, $allowedExtensions)) {
error_log("Invalid file type: " . $files["name"][$i] . " (MIME: $mimeType)"); error_log("Invalid file type: " . $files["name"][$i] . " (MIME: $mimeType)");
continue; continue;
@@ -266,9 +271,11 @@ try {
if (move_uploaded_file($files["tmp_name"][$i], $targetFile)) { if (move_uploaded_file($files["tmp_name"][$i], $targetFile)) {
chmod($targetFile, 0644); chmod($targetFile, 0644);
// Determine file type (simplified - could be enhanced) // Determine file type
$fileType = 'other'; $fileType = 'other';
if (strpos(strtolower($files["name"][$i]), 'annex') !== false) { if ($fileExtension === 'vtt') {
$fileType = 'caption'; // WebVTT caption sidecar
} elseif (strpos(strtolower($files["name"][$i]), 'annex') !== false) {
$fileType = 'annex'; $fileType = 'annex';
} elseif ($fileExtension === 'pdf') { } elseif ($fileExtension === 'pdf') {
$fileType = 'main'; $fileType = 'main';

View File

@@ -94,7 +94,7 @@ function wasSelected($key, $value) {
<?php $name = 'banner'; $label = 'Image bannière (accueil) :'; $accept = 'image/jpeg,image/png,image/webp'; $hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?> <?php $name = 'banner'; $label = 'Image bannière (accueil) :'; $accept = 'image/jpeg,image/png,image/webp'; $hint = 'JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.'; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<?php $name = 'files'; $label = 'Fichiers du TFE :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier.'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?> <?php $name = 'files'; $label = 'Fichiers du TFE :'; $accept = '.pdf,.jpg,.jpeg,.png,.mp4,.zip,.vtt'; $hint = 'PDF, JPG, PNG, MP4, ZIP. Max 50 MB par fichier. Pour les vidéos, un fichier .vtt de sous-titres peut être joint (il sera associé automatiquement à la vidéo correspondante).'; $multiple = true; include APP_ROOT . '/templates/partials/form/file-field.php'; ?>
<div class="admin-form-footer"> <div class="admin-form-footer">
<button type="submit" name="go" class="admin-btn">Soumettre</button> <button type="submit" name="go" class="admin-btn">Soumettre</button>

View File

@@ -78,8 +78,15 @@ $allowedMimes = [
'application/pdf', 'application/pdf',
'video/mp4', 'video/mp4',
'application/zip', 'application/zip',
'text/vtt', // WebVTT caption sidecar files
]; ];
// finfo may return 'text/plain' for WebVTT files on some systems;
// re-classify by extension so we don't block them.
if ($mimeType === 'text/plain' && strtolower(pathinfo($realFull, PATHINFO_EXTENSION)) === 'vtt') {
$mimeType = 'text/vtt';
}
if (!in_array($mimeType, $allowedMimes, true)) { if (!in_array($mimeType, $allowedMimes, true)) {
http_response_code(403); http_response_code(403);
exit; exit;
@@ -100,6 +107,10 @@ if (in_array($ext, ['jpg', 'jpeg', 'png', 'gif'], true)) {
// PDFs: cache for 1 day, display inline // PDFs: cache for 1 day, display inline
header('Cache-Control: public, max-age=86400'); header('Cache-Control: public, max-age=86400');
header('Content-Disposition: inline'); header('Content-Disposition: inline');
} elseif ($ext === 'vtt') {
// WebVTT captions: serve as text/vtt, cache 1 day
header('Content-Type: text/vtt; charset=utf-8');
header('Cache-Control: public, max-age=86400');
} else { } else {
// Everything else: no public caching // Everything else: no public caching
header('Cache-Control: private, no-store'); header('Cache-Control: private, no-store');

View File

@@ -196,6 +196,19 @@ $bodyClass = 'tfe-body';
<?php <?php
$accessTypeId = $db->getThesisAccessTypeId($thesisId) ?? 1; $accessTypeId = $db->getThesisAccessTypeId($thesisId) ?? 1;
$isInterdit = ($accessTypeId === 3); $isInterdit = ($accessTypeId === 3);
// Collect any WebVTT caption files uploaded for this thesis.
// The N-th VTT file is paired with the N-th video in document order.
$_captionFiles = [];
foreach ($data['files'] ?? [] as $_cf) {
$__mime = $_cf['mime_type'] ?? '';
$__ext2 = strtolower(pathinfo($_cf['file_path'], PATHINFO_EXTENSION));
if ($__mime === 'text/vtt' || $__ext2 === 'vtt') {
$_captionFiles[] = $_cf['file_path'];
}
}
$_videoIndex = 0;
unset($_cf, $__mime, $__ext2);
?> ?>
<?php if ($isInterdit): ?> <?php if ($isInterdit): ?>
<p class="tfe-restricted"> <p class="tfe-restricted">
@@ -203,7 +216,11 @@ $bodyClass = 'tfe-body';
</p> </p>
<?php elseif (!empty($data['files'])): ?> <?php elseif (!empty($data['files'])): ?>
<?php foreach ($data['files'] as $file): ?> <?php foreach ($data['files'] as $file): ?>
<?php $ext = strtolower(pathinfo($file['file_path'], PATHINFO_EXTENSION)); ?> <?php
$ext = strtolower(pathinfo($file['file_path'], PATHINFO_EXTENSION));
// VTT caption files are consumed inline by <video>; skip standalone rendering.
if ($ext === 'vtt') continue;
?>
<figure> <figure>
<?php if ($ext === 'pdf'): ?> <?php if ($ext === 'pdf'): ?>
<embed src="/media.php?path=<?= urlencode($file['file_path']) ?>" <embed src="/media.php?path=<?= urlencode($file['file_path']) ?>"
@@ -221,8 +238,20 @@ $bodyClass = 'tfe-body';
: ($data['title'] . ' — ' . ($data['authors'] ?? '')) : ($data['title'] . ' — ' . ($data['authors'] ?? ''))
) ?>"> ) ?>">
<?php elseif ($ext === 'mp4'): ?> <?php elseif ($ext === 'mp4'): ?>
<?php
// Pair this video with the N-th VTT file (if one was uploaded).
$_vttPath = $_captionFiles[$_videoIndex] ?? null;
$_videoIndex++;
?>
<video width="100%" controls> <video width="100%" controls>
<source src="/media.php?path=<?= urlencode($file['file_path']) ?>" type="video/mp4"> <source src="/media.php?path=<?= urlencode($file['file_path']) ?>" type="video/mp4">
<?php if ($_vttPath): ?>
<track kind="captions"
src="/media.php?path=<?= urlencode($_vttPath) ?>"
srclang="fr"
label="Sous-titres"
default>
<?php endif; ?>
</video> </video>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($file['description'])): ?> <?php if (!empty($file['description'])): ?>

View File

@@ -68,7 +68,7 @@
- [x] **Custom "Externe" checkbox for jury members has no group context** — all jury "Externe" checkboxes now carry explicit `aria-label` (e.g. `"Promoteur·ice — externe"`, `"Lecteur·ice N — externe"`); both static PHP-rendered rows and dynamically added rows via `addJuryRow()` receive the label - [x] **Custom "Externe" checkbox for jury members has no group context** — all jury "Externe" checkboxes now carry explicit `aria-label` (e.g. `"Promoteur·ice — externe"`, `"Lecteur·ice N — externe"`); both static PHP-rendered rows and dynamically added rows via `addJuryRow()` receive the label
- [ ] **`<video>` elements on `tfe.php` have no captions** — add `<track kind="captions">` slot in template; document caption requirement in admin upload form - [x] **`<video>` elements on `tfe.php` have no captions** — `<track kind="captions">` now emitted for each MP4 when a `.vtt` sidecar has been uploaded alongside it; N-th VTT file is paired with N-th video in document order. `formulaire.php` accepts `.vtt` uploads (MIME `text/vtt`, `file_type = 'caption'`); `media.php` serves VTT with correct `Content-Type`; admin `add.php` file-field hint documents the `.vtt` convention.
- [x] **Admin `<select>` for visibility/access in `edit.php` uses truncated option text** — removed `mb_strimwidth` call; option text now uses full description (`name — description`) so screen-reader accessible name is complete and unambiguous - [x] **Admin `<select>` for visibility/access in `edit.php` uses truncated option text** — removed `mb_strimwidth` call; option text now uses full description (`name — description`) so screen-reader accessible name is complete and unambiguous