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