Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 567
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Score
0.00% covered (danger)
0.00%
0 / 567
0.00% covered (danger)
0.00%
0 / 24
23562
0.00% covered (danger)
0.00%
0 / 1
 throwCallException
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getLilypondVersion
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 fetchLilypondVersion
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 boxedCommand
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 createDirectory
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 getBaseUrl
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getBackend
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
20
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 renderScore
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 1
812
 generateHTML
0.00% covered (danger)
0.00%
0 / 137
0.00% covered (danger)
0.00%
0 / 1
1560
 generatePngAndMidi
0.00% covered (danger)
0.00%
0 / 132
0.00% covered (danger)
0.00%
0 / 1
992
 addScript
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 throwCompileException
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 throwSynthException
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 extractMessage
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
42
 imageSize
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getPaperCode
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 embedLilypondCode
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
12
 generateAudio
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
42
 getDurationFromScriptOutput
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 recordShellout
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 recordError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 eraseDirectory
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 debug
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/*
3    Score, a MediaWiki extension for rendering musical scores with LilyPond.
4    Copyright © 2011 Alexander Klauer
5
6    This program is free software: you can redistribute it and/or modify
7    it under the terms of the GNU General Public License as published by
8    the Free Software Foundation, either version 3 of the License, or
9    (at your option) any later version.
10
11    This program is distributed in the hope that it will be useful,
12    but WITHOUT ANY WARRANTY; without even the implied warranty of
13    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14    GNU General Public License for more details.
15
16    You should have received a copy of the GNU General Public License
17    along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19    To contact the author:
20    <Graf.Zahl@gmx.net>
21    http://en.wikisource.org/wiki/User_talk:GrafZahl
22    https://github.com/TheCount/score
23
24 */
25
26namespace MediaWiki\Extension\Score;
27
28use Exception;
29use MediaWiki\Html\Html;
30use MediaWiki\Json\FormatJson;
31use MediaWiki\Logger\LoggerFactory;
32use MediaWiki\MediaWikiServices;
33use MediaWiki\Message\Message;
34use MediaWiki\Parser\Parser;
35use MediaWiki\Parser\PPFrame;
36use MediaWiki\Title\Title;
37use MediaWiki\WikiMap\WikiMap;
38use NullLockManager;
39use Shellbox\Command\BoxedCommand;
40use Wikimedia\FileBackend\FileBackend;
41use Wikimedia\FileBackend\FSFileBackend;
42use Wikimedia\ScopedCallback;
43
44/**
45 * Score class.
46 */
47class Score {
48    /**
49     * Version for cache invalidation.
50     */
51    private const CACHE_VERSION = 1;
52
53    /**
54     * Cache expiry time for the LilyPond version
55     */
56    private const VERSION_TTL = 3600;
57
58    /**
59     * Supported score languages.
60     */
61    private const SUPPORTED_LANGS = [ 'lilypond', 'ABC' ];
62
63    /**
64     * Supported note languages.
65     * Key is LilyPond filename. Value is language code
66     */
67    public const SUPPORTED_NOTE_LANGUAGES = [
68        'arabic' => 'ar',
69        'catalan' => 'ca',
70        'deutsch' => 'de',
71        'english' => 'en',
72        'espanol' => 'es',
73        'italiano' => 'it',
74        'nederlands' => 'nl',
75        'norsk' => 'no',
76        'portugues' => 'pt',
77        'suomi' => 'fi',
78        'svenska' => 'sv',
79        'vlaams' => 'vls',
80    ];
81
82    /**
83     * Default language used for notes.
84     */
85    private const DEFAULT_NOTE_LANGUAGE = 'nederlands';
86
87    /**
88     * LilyPond version string.
89     * It defaults to null and is set the first time it is required.
90     * @var string|null
91     */
92    private static $lilypondVersion = null;
93
94    /**
95     * FileBackend instance cache
96     * @var FileBackend|null
97     */
98    private static $backend;
99
100    /**
101     * Throws proper ScoreException in case of failed shell executions.
102     *
103     * @param string $message Message key to display
104     * @param array $params Message parameters
105     * @param string $output collected output from stderr.
106     * @param string|bool $factoryDir The factory directory to replace with "..."
107     *
108     * @throws ScoreException always.
109     * @return never
110     */
111    private static function throwCallException( $message, array $params, $output, $factoryDir = false ) {
112        /* clean up the output a bit */
113        if ( $factoryDir ) {
114            $output = str_replace( $factoryDir, '...', $output );
115        }
116        $params[] = Html::rawElement( 'pre',
117            // Error messages from LilyPond & abc2ly are always English
118            [ 'lang' => 'en', 'dir' => 'ltr' ],
119            htmlspecialchars( $output )
120        );
121        throw new ScoreException( $message, $params );
122    }
123
124    /**
125     * @return string
126     * @throws ScoreException if LilyPond could not be executed properly.
127     */
128    public static function getLilypondVersion() {
129        global $wgScoreLilyPondFakeVersion;
130
131        if ( strlen( $wgScoreLilyPondFakeVersion ) ) {
132            return $wgScoreLilyPondFakeVersion;
133        }
134        if ( self::$lilypondVersion === null ) {
135            // In case fetchLilypondVersion() throws an exception
136            self::$lilypondVersion = 'disabled';
137
138            $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache();
139            self::$lilypondVersion = $cache->getWithSetCallback(
140                $cache->makeGlobalKey( __CLASS__, 'lilypond-version' ),
141                self::VERSION_TTL,
142                function () {
143                    return self::fetchLilypondVersion();
144                }
145            );
146        }
147
148        return self::$lilypondVersion;
149    }
150
151    /**
152     * Determines the version of LilyPond in use without caching
153     *
154     * @throws ScoreException if LilyPond could not be executed properly.
155     * @return string
156     */
157    private static function fetchLilypondVersion() {
158        global $wgScoreLilyPond, $wgScoreEnvironment;
159
160        $result = self::boxedCommand()
161            ->routeName( 'score-lilypond' )
162            ->params( $wgScoreLilyPond, '--version' )
163            ->environment( $wgScoreEnvironment )
164            ->includeStderr()
165            ->execute();
166        self::recordShellout( 'lilypond_version' );
167
168        $output = $result->getStdout();
169        if ( $result->getExitCode() != 0 ) {
170            self::throwCallException( 'score-versionerr', [], $output );
171        }
172
173        if ( !preg_match( '/^GNU LilyPond (\S+)/', $output, $m ) ) {
174            self::throwCallException( 'score-versionerr', [], $output );
175        }
176        return $m[1];
177    }
178
179    /**
180     * Return a BoxedCommand object, or throw an exception if shell execution is
181     * disabled.
182     *
183     * The check for $wgScoreDisableExec should be redundant with checks in the
184     * callers, since the callers generally need to avoid writing input files.
185     * We check twice to be safe.
186     *
187     * @return BoxedCommand
188     * @throws ScoreDisabledException
189     */
190    private static function boxedCommand() {
191        global $wgScoreDisableExec;
192
193        if ( $wgScoreDisableExec ) {
194            throw new ScoreDisabledException();
195        }
196
197        return MediaWikiServices::getInstance()->getShellCommandFactory()
198            ->createBoxed( 'score' )
199            ->disableNetwork()
200            ->firejailDefaultSeccomp();
201    }
202
203    /**
204     * Creates the specified local directory if it does not exist yet.
205     * Otherwise does nothing.
206     *
207     * @param string $path Local path to directory to be created.
208     * @param int|null $mode Chmod value of the new directory.
209     *
210     * @throws ScoreException if the directory does not exist and could not
211     *     be created.
212     */
213    private static function createDirectory( $path, $mode = null ) {
214        if ( !is_dir( $path ) ) {
215            $rc = wfMkdirParents( $path, $mode, __METHOD__ );
216            if ( !$rc ) {
217                throw new ScoreException( 'score-nooutput', [ $path ] );
218            }
219        }
220    }
221
222    /**
223     * @return bool|string
224     */
225    private static function getBaseUrl() {
226        global $wgScorePath, $wgUploadPath;
227        if ( $wgScorePath === false ) {
228            return "{$wgUploadPath}/lilypond";
229        }
230
231        return $wgScorePath;
232    }
233
234    /**
235     * @return FileBackend
236     */
237    public static function getBackend() {
238        global $wgScoreFileBackend;
239
240        if ( $wgScoreFileBackend ) {
241            return MediaWikiServices::getInstance()->getFileBackendGroup()
242                ->get( $wgScoreFileBackend );
243        }
244
245        if ( !self::$backend ) {
246            global $wgScoreDirectory, $wgUploadDirectory;
247            if ( $wgScoreDirectory === false ) {
248                $dir = "{$wgUploadDirectory}/lilypond";
249            } else {
250                $dir = $wgScoreDirectory;
251            }
252            self::$backend = new FSFileBackend( [
253                'name' => 'score-backend',
254                'wikiId' => WikiMap::getCurrentWikiId(),
255                'lockManager' => new NullLockManager( [] ),
256                'containerPaths' => [ 'score-render' => $dir ],
257                'fileMode' => 0777,
258                'obResetFunc' => 'wfResetOutputBuffers',
259                'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ],
260                'statusWrapper' => [ 'Status', 'wrap' ],
261                'logger' => LoggerFactory::getInstance( 'score' ),
262            ] );
263        }
264
265        return self::$backend;
266    }
267
268    /**
269     * Callback for Parser's hook on 'score' tags. Renders the score code.
270     *
271     * @param string|null $code Score code.
272     * @param array $args Array of score tag attributes.
273     * @param Parser $parser
274     * @param PPFrame $frame Expansion frame, not used by this extension.
275     *
276     * @throws ScoreException
277     * @return string Image link HTML, and possibly anchor to MIDI file.
278     */
279    public static function render( $code, array $args, Parser $parser, PPFrame $frame ) {
280        return self::renderScore( $code, $args, $parser );
281    }
282
283    /**
284     * Renders the score code (LilyPond, ABC, etc.) in a <score>…</score> tag.
285     *
286     * @param string|null $code Score code.
287     * @param array $args Array of score tag attributes.
288     * @param Parser|null $parser Parser must be set when called during a wiki page parse.
289     *
290     * @throws ScoreException
291     * @return string Image link HTML, and possibly anchor to MIDI file.
292     */
293    public static function renderScore( $code, array $args, ?Parser $parser = null ) {
294        global $wgTmpDirectory;
295
296        // T388821
297        if ( $code === null ) {
298            return '';
299        }
300
301        try {
302            $baseUrl = self::getBaseUrl();
303            $baseStoragePath = self::getBackend()->getRootStoragePath() . '/score-render';
304
305            // options to self::generateHTML()
306            $options = [];
307
308            if ( isset( $args['line_width_inches'] ) ) {
309                $lineWidthInches = abs( (float)$args[ 'line_width_inches' ] );
310                if ( $lineWidthInches > 0 ) {
311                    $options['line_width_inches'] = $lineWidthInches;
312                }
313            }
314
315            /* temporary working directory to use */
316            $fuzz = md5( (string)mt_rand() );
317            $options['factory_directory'] = $wgTmpDirectory . "/MWLP.$fuzz";
318
319            /* Score language selection */
320            if ( array_key_exists( 'lang', $args ) ) {
321                $options['lang'] = $args['lang'];
322            } else {
323                $options['lang'] = 'lilypond';
324            }
325            if ( !in_array( $options['lang'], self::SUPPORTED_LANGS, true ) ) {
326                throw new ScoreException( 'score-invalidlang',
327                    [ htmlspecialchars( $options['lang'] ) ] );
328            }
329
330            /* Override MIDI file? */
331            if ( array_key_exists( 'override_midi', $args ) ) {
332                $file = MediaWikiServices::getInstance()->getRepoGroup()
333                    ->findFile( $args['override_midi'] );
334                if ( $file === false ) {
335                    throw new ScoreException( 'score-midioverridenotfound',
336                        [ htmlspecialchars( $args['override_midi'] ) ] );
337                }
338                if ( $parser && $parser->getOutput() !== null ) {
339                    $parser->getOutput()->addImage( $file->getName() );
340                }
341
342                $options['override_midi'] = true;
343                $options['midi_file'] = $file;
344                /* Set output stuff in case audio rendering is requested */
345                $sha1 = $file->getSha1();
346                $audioRelDir = "override-midi/{$sha1[0]}/{$sha1[1]}";
347                $audioRel = "$audioRelDir/$sha1.mp3";
348                $options['audio_storage_dir'] = "$baseStoragePath/$audioRelDir";
349                $options['audio_storage_path'] = "$baseStoragePath/$audioRel";
350                $options['audio_url'] = "$baseUrl/$audioRel";
351                $options['audio_sha_name'] = "$sha1.mp3";
352                if ( $parser ) {
353                    $parser->addTrackingCategory( 'score-deprecated-category' );
354                }
355            } else {
356                $options['override_midi'] = false;
357            }
358
359            // Raw rendering?
360            $options['raw'] = array_key_exists( 'raw', $args );
361
362            /* Note language selection */
363            if ( array_key_exists( 'note-language', $args ) ) {
364                if ( !$options['raw'] ) {
365                    $options['note-language'] = $args['note-language'];
366                } else {
367                    throw new ScoreException( 'score-notelanguagewithraw' );
368                }
369            } else {
370                $options['note-language'] = self::DEFAULT_NOTE_LANGUAGE;
371            }
372            if ( !isset( self::SUPPORTED_NOTE_LANGUAGES[$options['note-language']] ) ) {
373                throw new ScoreException(
374                    'score-invalidnotelanguage', [
375                        Message::plaintextParam( $options['note-language'] ),
376                        Message::plaintextParam( implode( ', ', array_keys( self::SUPPORTED_NOTE_LANGUAGES ) ) )
377                    ]
378                );
379            }
380
381            /* Override audio file? */
382            if ( array_key_exists( 'override_audio', $args )
383                || array_key_exists( 'override_ogg', $args ) ) {
384                $overrideAudio = $args['override_ogg'] ?? $args['override_audio'];
385                $t = Title::newFromText( $overrideAudio, NS_FILE );
386                if ( $t === null ) {
387                    throw new ScoreException( 'score-invalidaudiooverride',
388                        [ htmlspecialchars( $overrideAudio ) ] );
389                }
390                if ( !$t->isKnown() ) {
391                    throw new ScoreException( 'score-audiooverridenotfound',
392                        [ htmlspecialchars( $overrideAudio ) ] );
393                }
394                $options['override_audio'] = true;
395                $options['audio_name'] = $overrideAudio;
396                if ( $parser ) {
397                    $parser->addTrackingCategory( 'score-deprecated-category' );
398                }
399            } else {
400                $options['override_audio'] = false;
401            }
402
403            /* Audio rendering? */
404            $options['generate_audio'] = array_key_exists( 'sound', $args )
405                || array_key_exists( 'vorbis', $args );
406
407            if ( $options['generate_audio'] && $options['override_audio'] ) {
408                throw new ScoreException( 'score-convertoverrideaudio' );
409            }
410
411            // Input for cache key
412            $cacheOptions = [
413                'code' => $code,
414                'lang' => $options['lang'],
415                'note-language' => $options['note-language'],
416                'raw' => $options['raw'],
417                'ExtVersion' => self::CACHE_VERSION,
418                'LyVersion' => self::getLilypondVersion(),
419            ];
420
421            /* image file path and URL prefixes */
422            $imageCacheName = \Wikimedia\base_convert( sha1( serialize( $cacheOptions ) ), 16, 36, 31 );
423            $imagePrefixEnd = "{$imageCacheName[0]}/" .
424                "{$imageCacheName[1]}/$imageCacheName";
425            $options['dest_storage_path'] = "$baseStoragePath/$imagePrefixEnd";
426            $options['dest_url'] = "$baseUrl/$imagePrefixEnd";
427            $options['file_name_prefix'] = substr( $imageCacheName, 0, 8 );
428
429            $html = self::generateHTML( $parser, $code, $options );
430        } catch ( ScoreException $e ) {
431            if ( $parser && $parser->getOutput() !== null ) {
432                if ( $e->isTracked() ) {
433                    $parser->addTrackingCategory( 'score-error-category' );
434                }
435                self::recordError( $e );
436            }
437            $html = $e->getHtml();
438        }
439
440        // Mark the page as using the score extension, it makes easier
441        // to track all those pages.
442        if ( $parser && $parser->getOutput() !== null ) {
443            $parser->addTrackingCategory( 'score-use-category' );
444        }
445
446        return $html;
447    }
448
449    /**
450     * Generates the HTML code for a score tag.
451     *
452     * @param Parser|null $parser MediaWiki parser, provide when inside parse of wiki page
453     * @param string $code Score code.
454     * @param array $options array of rendering options.
455     *     The options keys are:
456     *     - factory_directory: string Path to directory in which files
457     *         may be generated without stepping on someone else's
458     *         toes. The directory may not exist yet. Required.
459     *     - generate_audio: bool Whether to create an audio file in
460     *         TimedMediaHandler. If set to true, the override_audio option
461     *         must be set to false. Required.
462     *  - dest_storage_path: The path of the destination directory relative to
463     *      the current backend. Required.
464     *  - dest_url: The default destination URL. Required.
465     *  - file_name_prefix: The filename prefix used for all files
466     *      in the default destination directory. Required.
467     *     - lang: string Score language. Required.
468     *     - override_midi: bool Whether to use a user-provided MIDI file.
469     *         Required.
470     *     - midi_file: If override_midi is true, MIDI file object.
471     *     - audio_storage_dir: If override_midi and generate_audio are true, the
472     *         backend directory in which the audio file is to be stored.
473     *     - audio_storage_path: string If override_midi and generate_audio are true,
474     *         the backend path at which the generated audio file is to be
475     *         stored.
476     *     - audio_url: string If override_midi and generate_audio is true,
477     *         the URL corresponding to audio_storage_path
478     *  - audio_sha_name: string If override_midi, generated audio file name.
479     *     - override_audio: bool Whether to generate a wikilink to a
480     *         user-provided audio file. If set to true, the sound
481     *         option must be set to false. Required.
482     *     - audio_name: string If override_audio is true, the audio file name
483     *     - raw: bool Whether to assume raw LilyPond code. Ignored if the
484     *         language is not lilypond, required otherwise.
485     *    - note-language: language to use for notes (one of supported by LilyPond)
486     *
487     * @return string HTML.
488     *
489     * @throws Exception
490     * @throws ScoreException if an error occurs.
491     */
492    private static function generateHTML( ?Parser $parser, $code, $options ) {
493        global $wgScoreOfferSourceDownload, $wgScoreUseSvg;
494
495        $cleanup = new ScopedCallback( function () use ( $options ) {
496            self::eraseDirectory( $options['factory_directory'] );
497        } );
498        if ( $parser && $parser->getOutput() !== null ) {
499            $parser->getOutput()->addModuleStyles( [ 'ext.score.styles' ] );
500            $parser->getOutput()->addModules( [ 'ext.score.popup' ] );
501        }
502
503        $backend = self::getBackend();
504        $fileIter = $backend->getFileList(
505            [ 'dir' => $options['dest_storage_path'], 'topOnly' => true ] );
506        if ( $fileIter === null ) {
507            throw new ScoreException( 'score-file-list-error' );
508        }
509        $existingFiles = [];
510        foreach ( $fileIter as $file ) {
511            $existingFiles[$file] = true;
512        }
513
514        /* Generate SVG, PNG and MIDI files if necessary */
515        $imageFileName = "{$options['file_name_prefix']}.png";
516        $imageSvgFileName = "{$options['file_name_prefix']}.svg";
517        $multi1FileName = "{$options['file_name_prefix']}-page1.png";
518        $multi1SvgFileName = "{$options['file_name_prefix']}-1.svg";
519        $midiFileName = "{$options['file_name_prefix']}.midi";
520        $metaDataFileName = "{$options['file_name_prefix']}.json";
521        $audioUrl = '';
522
523        if ( isset( $existingFiles[$metaDataFileName] ) ) {
524            $metaDataFile = $backend->getFileContents(
525                [ 'src' => "{$options['dest_storage_path']}/$metaDataFileName" ] );
526            if ( $metaDataFile === false ) {
527                throw new ScoreException( 'score-nocontent', [ $metaDataFileName ] );
528            }
529            $metaData = FormatJson::decode( $metaDataFile, true );
530        } else {
531            $metaData = [];
532        }
533
534        if (
535            !isset( $existingFiles[$metaDataFileName] )
536            || (
537                !isset( $existingFiles[$imageFileName] )
538                && !isset( $existingFiles[$multi1FileName] )
539            )
540            || (
541                $wgScoreUseSvg
542                && !isset( $existingFiles[$multi1SvgFileName] )
543                && !isset( $existingFiles[$imageSvgFileName] )
544            )
545            || (
546                !isset( $metaData[$imageFileName]['size'] )
547                && !isset( $metaData[$multi1FileName]['size'] )
548            )
549            || !isset( $existingFiles[$midiFileName] ) ) {
550            $existingFiles += self::generatePngAndMidi( $code, $options, $metaData );
551        }
552
553        /* Generate audio file if necessary */
554        if ( $options['generate_audio'] ) {
555            $audioFileName = "{$options['file_name_prefix']}.mp3";
556            if ( $options['override_midi'] ) {
557                $audioUrl = $options['audio_url'];
558                $audioPath = $options['audio_storage_path'];
559                $exists = $backend->fileExists( [ 'src' => $options['audio_storage_path'] ] );
560                if (
561                    !$exists ||
562                    !isset( $metaData[ $options['audio_sha_name'] ]['length'] ) ||
563                    !$metaData[ $options['audio_sha_name'] ]['length']
564                ) {
565                    $backend->prepare( [ 'dir' => $options['audio_storage_dir'] ] );
566                    $sourcePath = $options['midi_file']->getLocalRefPath();
567                    self::generateAudio( $sourcePath, $options, $audioPath, $metaData );
568                }
569            } else {
570                $audioUrl = "{$options['dest_url']}/$audioFileName";
571                $audioPath = "{$options['dest_storage_path']}/$audioFileName";
572                if (
573                    !isset( $existingFiles[$audioFileName] ) ||
574                    !isset( $metaData[$audioFileName]['length'] ) ||
575                    !$metaData[$audioFileName]['length']
576                ) {
577                    // Maybe we just generated it
578                    $sourcePath = "{$options['factory_directory']}/file.midi";
579                    if ( !file_exists( $sourcePath ) ) {
580                        // No, need to fetch it from the backend
581                        $sourceFileRef = $backend->getLocalReference(
582                            [ 'src' => "{$options['dest_storage_path']}/$midiFileName" ] );
583                        $sourcePath = $sourceFileRef->getPath();
584                    }
585                    self::generateAudio( $sourcePath, $options, $audioPath, $metaData );
586                }
587            }
588        }
589
590        /* return output link(s) */
591        if ( isset( $existingFiles[$imageFileName] ) ) {
592            [ $width, $height ] = $metaData[$imageFileName]['size'];
593            $attribs = [
594                'src' => "{$options['dest_url']}/$imageFileName",
595                'width' => $width,
596                'height' => $height,
597                'alt' => $code,
598            ];
599            if ( $wgScoreUseSvg ) {
600                $attribs['srcset'] = "{$options['dest_url']}/$imageSvgFileName 1x";
601            }
602            $link = Html::rawElement( 'img', $attribs );
603        } elseif ( isset( $existingFiles[$multi1FileName] ) ) {
604            $link = '';
605            for ( $i = 1; ; ++$i ) {
606                $fileName = "{$options['file_name_prefix']}-page$i.png";
607                if ( !isset( $existingFiles[$fileName] ) ) {
608                    break;
609                }
610                $pageNumb = wfMessage( 'score-page' )
611                    ->inContentLanguage()
612                    ->numParams( $i )
613                    ->plain();
614                [ $width, $height ] = $metaData[$fileName]['size'];
615                $attribs = [
616                    'src' => "{$options['dest_url']}/$fileName",
617                    'width' => $width,
618                    'height' => $height,
619                    'alt' => $pageNumb,
620                    'title' => $pageNumb,
621                    'style' => "margin-bottom:1em"
622                ];
623                if ( $wgScoreUseSvg ) {
624                    $svgFileName = "{$options['file_name_prefix']}-$i.svg";
625                    $attribs['srcset'] = "{$options['dest_url']}/$svgFileName 1x";
626                }
627                $link .= Html::rawElement( 'img', $attribs );
628            }
629        } else {
630            $link = '';
631        }
632        if ( $options['generate_audio'] ) {
633            $link .= '<div style="margin-top: 3px;">' .
634                Html::rawElement(
635                    'audio',
636                    [
637                        'controls' => true
638                    ],
639                    Html::openElement(
640                        'source',
641                        [
642                            'src' => $audioUrl,
643                            'type' => 'audio/mpeg',
644                        ]
645                    ) .
646                    "<div>" .
647                    wfMessage( 'score-audio-alt' )
648                        ->rawParams(
649                            Html::element( 'a', [ 'href' => $audioUrl ],
650                                wfMessage( 'score-audio-alt-link' )->text()
651                            )
652                        )
653                        ->escaped() .
654                    '</div>'
655                ) .
656                '</div>';
657        }
658        if ( $parser && $options['override_audio'] !== false ) {
659            $link .= $parser->recursiveTagParse( "[[File:{$options['audio_name']}]]" );
660        }
661
662        // Clean up the factory directory now
663        ScopedCallback::consume( $cleanup );
664
665        $attributes = [
666            'class' => 'mw-ext-score noresize'
667        ];
668
669        if ( $options['override_midi']
670            || isset( $existingFiles["{$options['file_name_prefix']}.midi"] ) ) {
671            $attributes['data-midi'] = $options['override_midi'] ?
672                $options['midi_file']->getUrl()
673                : "{$options['dest_url']}/{$options['file_name_prefix']}.midi";
674        }
675
676        if ( $wgScoreOfferSourceDownload
677            && isset( $existingFiles["{$options['file_name_prefix']}.ly"] )
678        ) {
679            $attributes['data-source'] = "{$options['dest_url']}/{$options['file_name_prefix']}.ly";
680        }
681
682        // Wrap score in div container.
683        return Html::rawElement( 'div', $attributes, $link );
684    }
685
686    /**
687     * Generates score PNG file(s) and a MIDI file. Stores lilypond file.
688     *
689     * @param string $code Score code.
690     * @param array $options Rendering options. They are the same as for
691     *     Score::generateHTML().
692     * @param array &$metaData array to hold information about images
693     *
694     * @return array of file names placed in the remote dest dir, with the
695     *     file names in each key.
696     *
697     * @throws ScoreException on error.
698     */
699    private static function generatePngAndMidi( $code, $options, &$metaData ) {
700        global $wgScoreLilyPond, $wgScoreTrim, $wgScoreSafeMode, $wgScoreDisableExec,
701            $wgScoreGhostscript, $wgScoreAbc2Ly, $wgImageMagickConvertCommand, $wgScoreUseSvg,
702            $wgShellboxShell, $wgPhpCli, $wgScoreEnvironment, $wgScoreImageMagickConvert;
703
704        if ( $wgScoreDisableExec ) {
705            throw new ScoreDisabledException();
706        }
707
708        if ( $wgScoreSafeMode
709            && version_compare( self::getLilypondVersion(), '2.23.12', '>=' )
710        ) {
711            throw new ScoreException( 'score-safe-mode' );
712        }
713
714        /* Create the working environment */
715        $factoryDirectory = $options['factory_directory'];
716        self::createDirectory( $factoryDirectory, 0700 );
717        $factoryMidi = "$factoryDirectory/file.midi";
718
719        $command = self::boxedCommand()
720            ->routeName( 'score-lilypond' )
721            ->params(
722                $wgShellboxShell,
723                'scripts/generatePngAndMidi.sh' )
724            ->outputFileToFile( 'file.midi', $factoryMidi )
725            ->outputGlobToFile( 'file', 'png', $factoryDirectory )
726            ->outputGlobToFile( 'file', 'svg', $factoryDirectory )
727            ->includeStderr()
728            ->environment( [
729                'SCORE_ABC2LY' => $wgScoreAbc2Ly,
730                'SCORE_LILYPOND' => $wgScoreLilyPond,
731                'SCORE_USESVG' => $wgScoreUseSvg ? 'yes' : 'no',
732                'SCORE_SAFE' => $wgScoreSafeMode ? 'yes' : 'no',
733                'SCORE_GHOSTSCRIPT' => $wgScoreGhostscript,
734                'SCORE_CONVERT' => $wgScoreImageMagickConvert ?: $wgImageMagickConvertCommand,
735                'SCORE_TRIM' => $wgScoreTrim ? 'yes' : 'no',
736                'SCORE_PHP' => $wgPhpCli
737            ] + $wgScoreEnvironment );
738        self::addScript( $command, 'generatePngAndMidi.sh' );
739        if ( !$wgScoreUseSvg ) {
740            self::addScript( $command, 'extractPostScriptPageSize.php' );
741        }
742        if ( $options['lang'] === 'lilypond' ) {
743            if ( $options['raw'] ) {
744                $lilypondCode = $code;
745            } else {
746                $paperConfig = [];
747                if ( isset( $options['line_width_inches'] ) ) {
748                    $paperConfig['line-width'] = $options['line_width_inches'] . "\in";
749                }
750                $paperCode = self::getPaperCode( $paperConfig );
751
752                $lilypondCode = self::embedLilypondCode( $code, $options['note-language'], $paperCode );
753            }
754            $command->inputFileFromString( 'file.ly', $lilypondCode );
755        } else {
756            self::addScript( $command, 'removeTagline.php' );
757            $command->inputFileFromString( 'file.abc', $code );
758            $command->outputFileToString( 'file.ly' );
759            $lilypondCode = '';
760        }
761        $result = $command->execute();
762        self::recordShellout( 'generate_png_and_midi' );
763
764        if ( $result->getExitCode() != 0 ) {
765            self::throwCompileException( $result->getStdout(), $options );
766        }
767
768        if ( $result->wasReceived( 'file.ly' ) ) {
769            $lilypondCode = $result->getFileContents( 'file.ly' );
770        }
771
772        $numPages = 0;
773        for ( $i = 1; ; $i++ ) {
774            if ( !$result->wasReceived( "file-page$i.png" ) ) {
775                $numPages = $i - 1;
776                break;
777            }
778        }
779
780        # LilyPond 2.24+ generates file.png and file.svg if there is only one page
781        if ( $wgScoreUseSvg && $result->wasReceived( 'file.svg' ) ) {
782            $numPages = 1;
783        }
784
785        if ( $numPages === 0 ) {
786            throw new ScoreException( 'score-noimages' );
787        }
788
789        $needMidi = false;
790        $haveMidi = $result->wasReceived( 'file.midi' );
791        if ( !$options['raw'] || ( $options['generate_audio'] && !$options['override_midi'] ) ) {
792            $needMidi = true;
793            if ( !$haveMidi ) {
794                throw new ScoreException( 'score-nomidi' );
795            }
796        }
797
798        // Create the destination directory if it doesn't exist
799        $backend = self::getBackend();
800        $status = $backend->prepare( [ 'dir' => $options['dest_storage_path'] ] );
801        if ( !$status->isOK() ) {
802            throw new ScoreBackendException( $status );
803        }
804
805        // File names of generated files
806        $newFiles = [];
807        // Backend operation batch
808        $ops = [];
809
810        // Add LY source to its file
811        $ops[] = [
812            'op' => 'create',
813            'content' => $lilypondCode,
814            'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.ly",
815            'headers' => [
816                'Content-Type' => 'text/x-lilypond; charset=utf-8'
817            ]
818        ];
819        $newFiles["{$options['file_name_prefix']}.ly"] = true;
820
821        if ( $needMidi ) {
822            // Add the MIDI file to the batch
823            $ops[] = [
824                'op' => 'store',
825                'src' => $factoryMidi,
826                'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.midi" ];
827            $newFiles["{$options['file_name_prefix']}.midi"] = true;
828            if ( !$status->isOK() ) {
829                throw new ScoreBackendException( $status );
830            }
831        }
832
833        // Add the PNG and SVG image files
834        for ( $i = 1; $i <= $numPages; ++$i ) {
835            $srcPng = "$factoryDirectory/file-page$i.png";
836            $srcSvg = "$factoryDirectory/file-$i.svg";
837            $dstPngFileName = "{$options['file_name_prefix']}-page$i.png";
838            $dstSvgFileName = "{$options['file_name_prefix']}-$i.svg";
839            if ( $numPages === 1 ) {
840                $dstPngFileName = "{$options['file_name_prefix']}.png";
841                if ( $wgScoreUseSvg ) {
842                    $srcPng = "$factoryDirectory/file.png";
843                    $srcSvg = "$factoryDirectory/file.svg";
844                    $dstSvgFileName = "{$options['file_name_prefix']}.svg";
845                }
846            }
847            $destPng = "{$options['dest_storage_path']}/$dstPngFileName";
848            $ops[] = [
849                'op' => 'store',
850                'src' => $srcPng,
851                'dst' => $destPng
852            ];
853            [ $width, $height ] = self::imageSize( $srcPng );
854            $metaData[$dstPngFileName]['size'] = [ $width, $height ];
855            $newFiles[$dstPngFileName] = true;
856
857            if ( $wgScoreUseSvg ) {
858                $destSvg = "{$options['dest_storage_path']}/$dstSvgFileName";
859                $ops[] = [
860                    'op' => 'store',
861                    'src' => $srcSvg,
862                    'dst' => $destSvg,
863                    'headers' => [
864                        'Content-Type' => 'image/svg+xml'
865                    ]
866                ];
867                $newFiles[$dstSvgFileName] = true;
868            }
869        }
870
871        $dstFileName = "{$options['file_name_prefix']}.json";
872        $dest = "{$options['dest_storage_path']}/$dstFileName";
873        $ops[] = [
874            'op' => 'create',
875            'content' => FormatJson::encode( $metaData ),
876            'dst' => $dest ];
877
878        $newFiles[$dstFileName] = true;
879
880        // Execute the batch
881        $status = $backend->doQuickOperations( $ops );
882        if ( !$status->isOK() ) {
883            throw new ScoreBackendException( $status );
884        }
885        return $newFiles;
886    }
887
888    /**
889     * Add an input file from the scripts directory
890     *
891     * @param BoxedCommand $command
892     * @param string $script
893     */
894    private static function addScript( BoxedCommand $command, string $script ) {
895        $command->inputFileFromFile( "scripts/$script",
896            __DIR__ . "/../scripts/$script" );
897    }
898
899    /**
900     * Get error information from the output returned by scripts/generatePngAndMidi.sh
901     * and throw a relevant error.
902     *
903     * @param string $stdout
904     * @param array $options
905     * @throws ScoreException
906     */
907    private static function throwCompileException( $stdout, $options ) {
908        global $wgScoreDebugOutput;
909
910        $message = self::extractMessage( $stdout );
911        if ( !$message ) {
912            $message = [ 'score-compilererr', [] ];
913        } elseif ( !$wgScoreDebugOutput && $message[0] === 'score-compilererr' ) {
914            // when input is not raw, we build the final lilypond file content
915            // in self::embedLilypondCode. The user input then is not inserted
916            // on the first line in the file we pass to lilypond and so we need
917            // to offset error messages back.
918            $scoreFirstLineOffset = $options['raw'] ? 0 : 7;
919            $errMsgBeautifier = new LilypondErrorMessageBeautifier( $scoreFirstLineOffset );
920
921            $stdout = $errMsgBeautifier->beautifyMessage( $stdout );
922        }
923        self::throwCallException(
924            $message[0],
925            $message[1],
926            $stdout
927        );
928    }
929
930    /**
931     * Get error information from the output returned by scripts/synth.sh
932     * and throw a relevant error.
933     *
934     * @param string $stdout
935     * @throws ScoreException
936     */
937    private static function throwSynthException( $stdout ) {
938        $message = self::extractMessage( $stdout );
939        if ( !$message ) {
940            $message = [ 'score-audioconversionerr', [] ];
941        }
942        self::throwCallException(
943            $message[0],
944            $message[1],
945            $stdout
946        );
947    }
948
949    /**
950     * Parse the script return value and extract any mw-msg lines. Modify the
951     * text to remove the lines. Return the first mw-msg line as a message
952     * key and parameters. If there was no mw-msg line, return null.
953     *
954     * @param string &$stdout
955     * @return array|null
956     */
957    private static function extractMessage( &$stdout ) {
958        $filteredStdout = '';
959        $messageParams = [];
960        foreach ( explode( "\n", $stdout ) as $line ) {
961            if ( preg_match( '/^mw-msg:\t/', $line ) ) {
962                if ( !$messageParams ) {
963                    $messageParams = array_slice( explode( "\t", $line ), 1 );
964                }
965            } else {
966                if ( $filteredStdout !== '' ) {
967                    $filteredStdout .= "\n";
968                }
969                $filteredStdout .= $line;
970            }
971        }
972        $stdout = $filteredStdout;
973        if ( $messageParams ) {
974            $messageName = array_shift( $messageParams );
975            // Used messages:
976            // - score-abc2lynotexecutable
977            // - score-abcconversionerr
978            // - score-notexecutable
979            // - score-compilererr
980            // - score-nops
981            // - score-scripterr
982            // - score-gs-error
983            // - score-trimerr
984            // - score-readerr
985            // - score-pregreplaceerr
986            // - score-audioconversionerr
987            // - score-soundfontnotexists
988            // - score-fallbacknotexecutable
989            // - score-lamenotexecutable
990            return [ $messageName, $messageParams ];
991        } else {
992            return null;
993        }
994    }
995
996    /**
997     * Extract the size of one of our generated PNG images
998     *
999     * @param string $filename
1000     * @return array of ints (width, height)
1001     */
1002    private static function imageSize( $filename ) {
1003        [ $width, $height ] = getimagesize( $filename );
1004        return [ $width, $height ];
1005    }
1006
1007    /**
1008     * @param array $paperConfig
1009     * @return string
1010     */
1011    private static function getPaperCode( $paperConfig = [] ) {
1012        $config = array_merge( [
1013            "indent" => "0\\mm",
1014        ], $paperConfig );
1015
1016        $paperCode = "\\paper {\n";
1017        foreach ( $config as $key => $value ) {
1018            $paperCode .= "\t$key = $value\n";
1019        }
1020        $paperCode .= "}";
1021
1022        return $paperCode;
1023    }
1024
1025    /**
1026     * Embeds simple LilyPond code in a score block.
1027     *
1028     * @param string $lilypondCode Simple LilyPond code.
1029     * @param string $noteLanguage Language of notes.
1030     * @param string $paperCode
1031     *
1032     * @return string Raw lilypond code.
1033     *
1034     * @throws ScoreException if determining the LilyPond version fails.
1035     */
1036    private static function embedLilypondCode( $lilypondCode, $noteLanguage, $paperCode ) {
1037        $version = self::getLilypondVersion();
1038
1039        // Check if parameters have already been supplied (hybrid-raw mode)
1040        $options = "";
1041        if ( strpos( $lilypondCode, "\\layout" ) === false ) {
1042            $options .= "\\layout { }\n";
1043        }
1044        if ( strpos( $lilypondCode, "\\midi" ) === false ) {
1045            $options .= <<<LY
1046    \\midi {
1047        \\context { \Score tempoWholesPerMinute = #(ly:make-moment 100 4) }
1048    }
1049LY;
1050        }
1051
1052        /* Raw code. In Scheme, ##f is false and ##t is true. */
1053        /* Set the default MIDI tempo to 100, 60 is a bit too slow */
1054        $raw = <<<LILYPOND
1055\\header {
1056    tagline = ##f
1057}
1058\\version "$version"
1059\\language "$noteLanguage"
1060\\score {
1061
1062$lilypondCode
1063$options
1064
1065}
1066$paperCode
1067LILYPOND;
1068
1069        return $raw;
1070    }
1071
1072    /**
1073     * Generates an audio file from a MIDI file using fluidsynth with TiMidity as fallback.
1074     *
1075     * @param string $sourceFile The local filename of the MIDI file
1076     * @param array $options array of rendering options.
1077     * @param string $remoteDest The backend storage path to upload the audio file to
1078     * @param array &$metaData Array with metadata information
1079     *
1080     * @throws ScoreException if an error occurs.
1081     */
1082    private static function generateAudio( $sourceFile, $options, $remoteDest, &$metaData ) {
1083        global $wgScoreFluidsynth, $wgScoreSoundfont, $wgScoreLame, $wgScoreDisableExec,
1084            $wgScoreEnvironment, $wgShellboxShell, $wgPhpCli;
1085
1086        if ( $wgScoreDisableExec ) {
1087            throw new ScoreDisabledException();
1088        }
1089
1090        // Working environment
1091        $factoryDir = $options['factory_directory'];
1092        self::createDirectory( $factoryDir, 0700 );
1093        $factoryFile = "$factoryDir/file.mp3";
1094
1095        // Run FluidSynth and LAME
1096        $command = self::boxedCommand()
1097            ->routeName( 'score-fluidsynth' )
1098            ->params(
1099                $wgShellboxShell,
1100                'scripts/synth.sh'
1101            )
1102            ->environment( [
1103                'SCORE_FLUIDSYNTH' => $wgScoreFluidsynth,
1104                'SCORE_SOUNDFONT' => $wgScoreSoundfont,
1105                'SCORE_LAME' => $wgScoreLame,
1106                'SCORE_PHP' => $wgPhpCli
1107            ] + $wgScoreEnvironment )
1108            ->inputFileFromFile( 'file.midi', $sourceFile )
1109            ->outputFileToFile( 'file.mp3', $factoryFile )
1110            ->includeStderr()
1111            // 150 MB max. filesize (for large MIDIs)
1112            ->fileSizeLimit( 150 * 1024 * 1024 );
1113
1114        self::addScript( $command, 'synth.sh' );
1115        self::addScript( $command, 'getWavDuration.php' );
1116
1117        $result = $command->execute();
1118        self::recordShellout( 'generate_audio' );
1119
1120        if ( ( $result->getExitCode() != 0 ) || !$result->wasReceived( 'file.mp3' ) ) {
1121            self::throwSynthException( $result->getStdout() );
1122        }
1123
1124        // Move file to the final destination
1125        $backend = self::getBackend();
1126        $status = $backend->doQuickOperation( [
1127            'op' => 'store',
1128            'src' => $factoryFile,
1129            'dst' => $remoteDest
1130        ] );
1131
1132        if ( !$status->isOK() ) {
1133            throw new ScoreBackendException( $status );
1134        }
1135
1136        // Create metadata json
1137        $metaData[basename( $remoteDest )]['length'] = self::getDurationFromScriptOutput(
1138            $result->getStdout() );
1139        $dstFileName = "{$options['file_name_prefix']}.json";
1140        $dest = "{$options['dest_storage_path']}/$dstFileName";
1141
1142        // Store metadata in backend
1143        $backend = self::getBackend();
1144        $status = $backend->doQuickOperation( [
1145            'op' => 'create',
1146            'content' => FormatJson::encode( $metaData ),
1147            'dst' => $dest
1148        ] );
1149
1150        if ( !$status->isOK() ) {
1151            throw new ScoreBackendException( $status );
1152        }
1153    }
1154
1155    /**
1156     * Get the duration of the audio file from the script stdout
1157     *
1158     * @param string $stdout The script output
1159     * @return float duration in seconds
1160     */
1161    private static function getDurationFromScriptOutput( $stdout ) {
1162        if ( preg_match( '/^wavDuration: ([0-9.]+)$/m', $stdout, $m ) ) {
1163            return (float)$m[1];
1164        } else {
1165            return 0.0;
1166        }
1167    }
1168
1169    /**
1170     * Track how often we do each type of shellout in statsd
1171     *
1172     * @param string $type Type of shellout
1173     */
1174    private static function recordShellout( $type ) {
1175        $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
1176        $statsd->increment( "score.$type" );
1177    }
1178
1179    /**
1180     * Track how often each error occurs in statsd
1181     *
1182     * @param ScoreException $ex
1183     */
1184    private static function recordError( ScoreException $ex ) {
1185        $key = $ex->getStatsdKey();
1186        if ( $key === false ) {
1187            return;
1188        }
1189        $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory();
1190        $statsd->increment( "score_error.$key" );
1191    }
1192
1193    /**
1194     * Deletes a local directory with no subdirectories with all files in it.
1195     *
1196     * @param string $dir Local path to the directory that is to be deleted.
1197     *
1198     * @return bool true on success, false on error
1199     */
1200    private static function eraseDirectory( $dir ) {
1201        if ( file_exists( $dir ) ) {
1202            // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown
1203            array_map( 'unlink', glob( "$dir/*", GLOB_NOSORT ) );
1204            $rc = rmdir( $dir );
1205            if ( !$rc ) {
1206                self::debug( "Unable to remove directory $dir\n." );
1207            }
1208            return $rc;
1209        }
1210
1211        /* Nothing to do */
1212        return true;
1213    }
1214
1215    /**
1216     * Writes the specified message to the Score debug log.
1217     *
1218     * @param string $msg message to log.
1219     */
1220    private static function debug( $msg ) {
1221        wfDebugLog( 'Score', $msg );
1222    }
1223
1224}