Skip to content

Commit

Permalink
Add auto-preview of image as labels are changed
Browse files Browse the repository at this point in the history
This adds functionality to be able to live-preview translations as
they're changed in the form. To do this, it updates the API to be
able to first receive (via POST) a new set of translations, which
are then added to the SVG file and a PNG rendered out to the
filesystem. Then, when a 2nd request (GET this time) is received
for that rendered PNG file, it's returned. This is done by creating
a temporary unique identifier for the file (based on the submitted
translations).

The front-end is modified to make this first request, and when it
receives the response all it has to do is update the image's src
attribute and the 2nd request happens.

The process of adding these strings back into SvgFile was a bit
cumbersome, so a new method SvgFile::setTranslations() was
added, which handles adding the new text and tspan node info.

Tests were updated, but the broken controller test remains for
the time being.

Bug: https://phabricator.wikimedia.org/T207203
  • Loading branch information
samwilson committed Jan 7, 2019
1 parent 452f412 commit 298f240
Show file tree
Hide file tree
Showing 16 changed files with 181 additions and 68 deletions.
58 changes: 52 additions & 6 deletions assets/translate.js
Expand Up @@ -9,22 +9,29 @@ $( function () {
return;
}
function onSelectTargetLang( language ) {
// Save the language name and code in the widget.
var $imgElement, newImageUrl;
// 1. Save the language name and code in the widget.
this.setLabel( $.uls.data.languages[ language ][ 2 ] );
this.setData( language );
// Also switch what's displayed in the form when a new language is selected in the ULS.
this.setValue( language );
// 2. Switch what's displayed in the form when a new language is selected in the ULS.
$( '.translation-fields .oo-ui-fieldLayout' ).each( function () {
var field = OO.ui.infuse( $( this ) ).getField();
if ( appConfig.translations[ field.data.nodeId ] &&
appConfig.translations[ field.data.nodeId ][ language ]
var field = OO.ui.infuse( $( this ) ).getField(),
tspanId = field.data[ 'tspan-id' ];
if ( appConfig.translations[ tspanId ] &&
appConfig.translations[ tspanId ][ language ]
) {
// If there's a translation available, set the field's value.
field.setValue( appConfig.translations[ field.data.nodeId ][ language ].text );
field.setValue( appConfig.translations[ tspanId ][ language ].text );
} else {
// Otherwise, blank the field.
field.setValue( '' );
}
} );
// 3. Update the image.
$imgElement = $( '.image img' );
newImageUrl = $imgElement.attr( 'src' ).replace( /[a-z_-]*\.png.*$/, language + '.png' );
$imgElement.attr( 'src', newImageUrl );
}
targetLangButton = OO.ui.infuse( $targetLangButton );
targetLangButton.$element.uls( {
Expand Down Expand Up @@ -68,3 +75,42 @@ $( function () {
} );
} );
} );

/**
* When a translation field is changed, update the image preview.
*/
$( function () {
$( '.translation-fields .oo-ui-fieldLayout .oo-ui-inputWidget' ).each( function () {
var inputWiget = OO.ui.infuse( $( this ) ),
$imgElement = $( '.image img' ),
targetLangWidget = OO.ui.infuse( $( '.target-lang-widget' ) ),
targetLangCode = targetLangWidget.getValue(),
requestParams = {},
updatePreviewImage = function () {
// Go through all fields and construct the request parameters.
$( '.translation-fields .oo-ui-fieldLayout' ).each( function () {
var fieldLayout = OO.ui.infuse( $( this ) ),
tspanId = fieldLayout.getField().data[ 'tspan-id' ];
requestParams[ tspanId ] = fieldLayout.getField().getValue();
} );
// Update the image.
$.ajax( {
type: 'POST',
url: appConfig.baseUrl + 'api/translate/' + $imgElement.data( 'filename' ) + '/' + targetLangCode,
data: requestParams,
success: function ( result ) {
$imgElement.attr( 'src', result.imageSrc );
},
error: function () {
OO.ui.alert( $.i18n( 'preview-error-occurred' ) );
}
} );
};
// Update the preview image on field blur and after two seconds of no typing.
inputWiget.$input.on( 'blur', updatePreviewImage );
inputWiget.on( 'change', OO.ui.debounce( updatePreviewImage, 2000 ) );
} );
// Trigger a change, to force a refresh of the preview on page load (to catch browser-cached
// input values).
$( '.translation-fields .oo-ui-fieldLayout .oo-ui-inputWidget input' ).trigger( 'blur' );
} );
1 change: 1 addition & 0 deletions i18n/en.json
Expand Up @@ -33,6 +33,7 @@
"download-button-label": "Download",
"download-or-upload": "or",
"translation-image-alt": "The image that's currently being translated. No description available.",
"preview-error-occurred": "An error occurred when fetching the preview. Please continue translating, but if this error persists do report a bug (via the link in the footer below).",

"download-icon-alt": "An icon indicating file-download",
"pick-an-image-title": "Pick an image",
Expand Down
1 change: 1 addition & 0 deletions i18n/qqq.json
Expand Up @@ -33,6 +33,7 @@
"download-button-label": "Label for the button that downloads the translated SVG to the user's local computer.",
"download-or-upload": "Label positioned between the download and the upload buttons.",
"translation-image-alt": "Alt text for the image that's currently being translated. Doesn't explain the image, just explains that we don't explain it.",
"preview-error-occurred": "Error message displayed in a popup when fetching a preview failed. The only option is 'OK' to close the popup.",

"download-icon-alt": "Alt text for the download icon.",
"pick-an-image-title": "The title of the 'pick an image' step of the tutorial box.",
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion public/assets/entrypoints.json
Expand Up @@ -5,7 +5,7 @@
"assets/app.3acc02e0.css"
],
"js": [
"assets/app.bd58f67a.js"
"assets/app.b885261f.js"
]
}
}
Expand Down
1 change: 1 addition & 0 deletions public/assets/i18n/app/en.json
Expand Up @@ -33,6 +33,7 @@
"download-button-label": "Download",
"download-or-upload": "or",
"translation-image-alt": "The image that's currently being translated. No description available.",
"preview-error-occurred": "An error occurred when fetching the preview. Please continue translating, but if this error persists do report a bug (via the link in the footer below).",

"download-icon-alt": "An icon indicating file-download",
"pick-an-image-title": "Pick an image",
Expand Down
1 change: 1 addition & 0 deletions public/assets/i18n/app/qqq.json
Expand Up @@ -33,6 +33,7 @@
"download-button-label": "Label for the button that downloads the translated SVG to the user's local computer.",
"download-or-upload": "Label positioned between the download and the upload buttons.",
"translation-image-alt": "Alt text for the image that's currently being translated. Doesn't explain the image, just explains that we don't explain it.",
"preview-error-occurred": "Error message displayed in a popup when fetching a preview failed. The only option is 'OK' to close the popup.",

"download-icon-alt": "Alt text for the download icon.",
"pick-an-image-title": "The title of the 'pick an image' step of the tutorial box.",
Expand Down
2 changes: 1 addition & 1 deletion public/assets/manifest.json
@@ -1,6 +1,6 @@
{
"assets/app.css": "assets/app.3acc02e0.css",
"assets/app.js": "assets/app.bd58f67a.js",
"assets/app.js": "assets/app.b885261f.js",
"assets/grabbing.cur": "assets/a8c874b93b3d848f39a71260c57e3863.cur",
"assets/grab.cur": "assets/b06c243f534d9c5461d16528156cd5a8.cur",
"assets/i18n/app/af.json": "assets/i18n/app/af.json",
Expand Down
105 changes: 67 additions & 38 deletions src/Controller/ApiController.php
Expand Up @@ -8,8 +8,11 @@
use App\Service\FileCache;
use App\Service\Renderer;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Annotation\Route;

/**
Expand All @@ -31,45 +34,87 @@ public function __construct(FileCache $cache, Renderer $svgRenderer)
}

/**
* Serve a PNG rendering of the given SVG in the given language.
* Serve a PNG rendering of the given SVG in the given language (without any user-provided
* translation strings).
*
* @Route("/api/file/{filename}/{lang}.png", name="api_file", methods="GET")
*
* @param string $filename
* @param string $lang
* @return Response
*/
public function getFile(string $filename, string $lang): Response
{
$filename = Title::normalize($filename);
return $this->serveContent($this->cache->getPath($filename), $lang);
$content = $this->svgRenderer->render($this->cache->getPath($filename), $lang);
return new Response($content, 200, [
'Content-Type' => 'image/png',
'X-File-Hash' => sha1($content),
]);
}

/**
* Serve a PNG rendering of the given SVG in the given language, based on the POSTed translations.
* @Route("/api/file/{filename}/{lang}.png", name="api_file_translated", methods="POST")
* @param string $filename
* @param Request $request
* @return Response
* Get a full filesystem path to a temporary PNG file.
* @param string $filename The base SVG filename.
* @param string $key They unique key to append to the filename.
* @return string
*/
public function getFileWithTranslations(string $filename, string $lang, Request $request): Response
protected function getTempPngFilename(string $filename, string $key): string
{
$filename = Title::normalize($filename);
$json = $request->getContent();
if ('' === $json) {
return $this->getFile($filename, $lang);
}
return $this->cache->fullPath($filename.'_'.$key.'.png');
}

$translations = \GuzzleHttp\json_decode($json, true);
/**
* Take POST data, write new translations into the SVG file, render a resultant PNG out to the
* filesystem, and return an identifier for that PNG file.
*
* @Route("/api/translate/{filename}/{lang}", name="api_file_request", methods="POST")
*/
public function requestFileWIthTranslations(string $filename, string $lang, Request $request): Response
{
// Get the SVG file, and add in the new translations.
$filename = Title::normalize($filename);
$path = $this->cache->getPath($filename);
$file = new SvgFile($path);
$file->switchToTranslationSet($translations);

// Write SVG file to filesystem, so it can be converted by rsvg. Use a unique filename
// because multiple users can be translating the same file at the same time (whereas
// getFile() above only ever serves the one version of a file).
$tmpSvgFilename = $this->cache->getTempSvgFile();
$file->saveToPath($tmpSvgFilename);
$translations = $request->request->all();
$file->setTranslations($lang, $translations);

// Write the modified SVG out to the filesystem, named with a unique key. This is necessary
// both because multiple people could be translating the same file at the same time, and
// also means we can use the same key for the rendered PNG file. The key is generated from
// the translation set so that the
$fileKey = md5(serialize($translations));
$tempPngFilename = $this->getTempPngFilename($filename, $fileKey);
$file->saveToPath($tempPngFilename);

// Render the modified SVG to PNG, and return it's URL.
$renderedPngContents = $this->svgRenderer->render($tempPngFilename, $lang);
file_put_contents($tempPngFilename, $renderedPngContents);
$relativeUrl = $this->generateUrl('api_file_translated', [
'filename' => $filename,
'key' => $fileKey,
'lang' => $lang,
]);
return new JsonResponse(['imageSrc' => $relativeUrl]);
}

return $this->serveContent($tmpSvgFilename, $lang);
/**
* Get a request for a already-rendered, custom-translated PNG (identified by a key), and
* return that file.
*
* @Route("/api/file/{filename}/{lang}/{key}.png", name="api_file_translated", methods="GET")
*
* @param string $filename
* @param Request $request
* @return Response
*/
public function getFileWithTranslations(string $filename, string $lang, string $key, Request $request): Response
{
$tempPngFilename = $this->getTempPngFilename($filename, $key);
if (file_exists($tempPngFilename)) {
return new BinaryFileResponse($tempPngFilename);
}
throw new NotFoundHttpException();
}

/**
Expand Down Expand Up @@ -103,20 +148,4 @@ public function getLanguages(string $fileName): Response

return $this->json($langs);
}

/**
* Serves an SVG as a PNG.
*
* @param string $svgFilename Full path to the SVG file to convert and serve as a PNG.
* @param string $lang The language to use for the SVG's text.
* @return Response
*/
private function serveContent(string $svgFilename, string $lang): Response
{
$content = $this->svgRenderer->render($svgFilename, $lang);
return new Response($content, 200, [
'Content-Type' => 'image/png',
'X-File-Hash' => sha1($content),
]);
}
}
13 changes: 8 additions & 5 deletions src/Controller/TranslateController.php
Expand Up @@ -122,16 +122,19 @@ public function translate(
// Messages.
$translations = $svgFile->getInFileTranslations();
$formFields = [];
foreach ($translations as $nodeId => $langs) {
foreach ($translations as $tspanId => $translation) {
$fieldValue = isset($translation[$targetLang->getValue()])
? $translation[$targetLang->getValue()]['text']
: '';
$inputWidget = new TextInputWidget([
'name' => 'translation-field-'.$nodeId,
'value' => isset($langs[$targetLang->getValue()]) ? $langs[$targetLang->getValue()]['text'] : '',
'data' => ['nodeId' => $nodeId],
'name' => 'translation-field-'.$tspanId,
'value' => $fieldValue,
'data' => ['tspan-id' => $tspanId],
]);
$field = new FieldLayout(
$inputWidget,
[
'label' => $langs[$sourceLang->getValue()]['text'],
'label' => $translation[$sourceLang->getValue()]['text'],
'infusable' => true,
]
);
Expand Down
32 changes: 32 additions & 0 deletions src/Model/Svg/SvgFile.php
Expand Up @@ -430,6 +430,38 @@ public function getInFileTranslations(): array
return $this->inFileTranslations;
}

/**
* Set translations for a single language.
* @param string $lang Language code of the translations being provided.
* @param string[] $translations Array of tspan-IDs to message texts.
* @return string[][]
*/
public function setTranslations(string $lang, array $translations): array
{
// Load the existing translation structure, and go through it swapping the messages.
$inFileTranslations = $this->getInFileTranslations();
$filteredTextNodes = $this->getFilteredTextNodes();
foreach ($translations as $tspanId => $msg) {
// Set up the tspan node (including adding in the new message).
if (!isset($inFileTranslations[$tspanId][$lang])) {
$inFileTranslations[$tspanId][$lang] = $inFileTranslations[$tspanId]['fallback'];
$inFileTranslations[$tspanId][$lang]['id'] .= "-$lang";
}
if (!empty($msg)) {
$inFileTranslations[$tspanId][$lang]['text'] = $msg;
}
// Set up the text node (if this is a new language).
$textId = $inFileTranslations[$tspanId][$lang]['data-parent'];
if (!isset($filteredTextNodes[$textId][$lang])) {
$filteredTextNodes[$textId][$lang] = $filteredTextNodes[$textId]['fallback'];
$filteredTextNodes[$textId][$lang]['id'] .= "-$lang";
}
}
$allTranslations = array_merge($inFileTranslations, $filteredTextNodes);
// Add the updated translations back into the file.
return $this->switchToTranslationSet($allTranslations);
}

/**
* Try to return $this->savedLanguages (a list of languages which have one or more
* translations in-file). If it is not cached, analyse the SVG and hence generate it.
Expand Down
14 changes: 2 additions & 12 deletions src/Service/FileCache.php
Expand Up @@ -71,7 +71,7 @@ protected function tick(): void
return;
}

foreach (glob($this->fullPath('*.svg')) as $fileName) {
foreach (glob($this->fullPath('*.*')) as $fileName) {
$this->statFile($fileName);
}
}
Expand Down Expand Up @@ -103,18 +103,8 @@ protected function statFile(string $fileName): bool
* @param string $fileName
* @return string
*/
protected function fullPath(string $fileName): string
public function fullPath(string $fileName): string
{
return $this->directory.DIRECTORY_SEPARATOR.$fileName;
}

/**
* Create a file with a unique .svg filename, with access permission set to 0600,
* and return its name.
* @return string
*/
public function getTempSvgFile(): string
{
return tempnam($this->directory, 'temp_').'.svg';
}
}
1 change: 1 addition & 0 deletions templates/base.html.twig
Expand Up @@ -75,6 +75,7 @@

<script type="text/javascript">
var appConfig = {
baseUrl: "{{ asset('') }}",
assetsPath: "{{ asset('assets') }}",
{# This var is used for the Universal Language Selector. #}
languages: {{ all_langs()|json_encode|raw }}
Expand Down
4 changes: 3 additions & 1 deletion templates/translate.html.twig
Expand Up @@ -29,7 +29,9 @@
{{ download_button|raw }}
</div>
<div class="image">
<img src="{{ path('api_file', {filename: filename, lang: target_lang}) }}" alt="{{ msg('translation-image-alt') }}" />
<img src="{{ path('api_file', {filename: filename, lang: target_lang}) }}"
alt="{{ msg('translation-image-alt') }}"
data-filename="{{ filename }}" />
</div>
</div>
</form>
Expand Down

0 comments on commit 298f240

Please sign in to comment.