40
40
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
41
41
*/
42
42
class question_ui_renderer {
43
- /** @var DOMDocument $xml */
44
- private DOMDocument $ xml ;
45
-
46
- /** @var DOMXPath $xpath */
47
- private DOMXPath $ xpath ;
48
-
49
- /** @var array $placeholders */
50
- private array $ placeholders ;
51
-
52
- /** @var question_display_options $options */
53
- private question_display_options $ options ;
54
-
55
- /** @var question_attempt $attempt */
56
- private question_attempt $ attempt ;
57
-
58
43
/** @var string[]|null $roles names of roles that the current user has (use {@see get_user_roles()} to get the roles) */
59
44
private ?array $ roles = null ;
60
45
61
- /** @var render_result|null $result */
62
- private ?render_result $ result = null ;
46
+ /** @var string $html resulting rendered html */
47
+ public string $ html ;
48
+
49
+ /** @var invalid_option_warning[] $warnings warnings emitted during rendering */
50
+ public array $ warnings ;
63
51
64
52
/**
65
53
* @var string[] $unmappableduplicatefieldnames contains duplicate input field names that cannot be mapped with certainty to
@@ -74,82 +62,88 @@ class question_ui_renderer {
74
62
public array $ mappableduplicatefieldnames = [];
75
63
76
64
/**
77
- * Parses the given XML and initializes a new {@see question_ui_renderer} instance .
65
+ * Private constructor. Use {@see question_ui_renderer::render()} .
78
66
*
79
- * @param string $xml XML as returned by the QPy Server
80
- * @param array $placeholders string to string mapping of placeholder names to the values
67
+ * @param DOMDocument $xml XML document to operate on
68
+ * @param DOMXPath $xpath
81
69
* @param question_display_options $options
82
- * @param question_attempt $attempt
83
70
*/
84
- public function __construct (string $ xml , array $ placeholders , question_display_options $ options ,
85
- question_attempt $ attempt ) {
86
- $ this ->placeholders = $ placeholders ;
87
- $ this ->options = $ options ;
88
- $ this ->attempt = $ attempt ;
89
-
90
- $ xml = $ this ->replace_qpy_urls ($ xml );
91
-
92
- $ this ->xml = new DOMDocument ();
93
- $ this ->xml ->preserveWhiteSpace = false ;
94
- $ this ->xml ->loadXML ($ xml );
95
- $ this ->xml ->normalizeDocument ();
96
-
97
- $ this ->xpath = new DOMXPath ($ this ->xml );
98
- $ this ->xpath ->registerNamespace ('xhtml ' , constants::NAMESPACE_XHTML );
99
- $ this ->xpath ->registerNamespace ('qpy ' , constants::NAMESPACE_QPY );
100
-
101
- $ this ->populate_duplicate_field_names ();
71
+ private function __construct (
72
+ /** @var DOMDocument $xml */
73
+ private readonly DOMDocument $ xml ,
74
+ /** @var DOMXPath $xpath */
75
+ private readonly DOMXPath $ xpath ,
76
+ /** @var question_display_options $options */
77
+ private readonly question_display_options $ options
78
+ ) {
102
79
}
103
80
104
81
/**
105
- * Renders the given XML to HTML.
82
+ * Renders the given QuestionPy XHTML to HTML.
106
83
*
107
- * @return render_result
84
+ * @param string $xml XML as returned by the QPy Server
85
+ * @param array $placeholders string to string mapping of placeholder names to the values
86
+ * @param question_display_options $options
87
+ * @param question_attempt $attempt
88
+ * @return question_ui_renderer object containing {@see question_ui_renderer::$html rendered html} and
89
+ * {@see question_ui_renderer::$warnings emitted warnings}.
108
90
* @throws coding_exception
109
91
*/
110
- public function render (): render_result {
111
- if ($ this ->result ) {
112
- return $ this ->result ;
113
- }
92
+ public static function render (string $ xml , array $ placeholders , question_display_options $ options ,
93
+ question_attempt $ attempt ): static {
94
+ $ xml = static ::replace_qpy_urls ($ xml , $ attempt );
95
+
96
+ $ doc = new DOMDocument ();
97
+ $ doc ->preserveWhiteSpace = false ;
98
+ $ doc ->loadXML ($ xml );
99
+ $ doc ->normalizeDocument ();
100
+
101
+ $ xpath = new DOMXPath ($ doc );
102
+ $ xpath ->registerNamespace ('xhtml ' , constants::NAMESPACE_XHTML );
103
+ $ xpath ->registerNamespace ('qpy ' , constants::NAMESPACE_QPY );
104
+
105
+ $ renderer = new static ($ doc , $ xpath , $ options );
106
+ $ renderer ->populate_duplicate_field_names ();
114
107
115
108
$ nextseed = mt_rand ();
116
- $ id = $ this -> attempt ->get_database_id ();
109
+ $ id = $ attempt ->get_database_id ();
117
110
if ($ id === null ) {
118
111
throw new coding_exception ('question_attempt does not have an id ' );
119
112
}
120
113
121
114
mt_srand ($ id );
122
115
try {
123
116
// Handle our custom elements and attributes.
124
- $ this ->hide_unwanted_feedback ();
125
- $ this ->hide_if_role ();
126
- $ this ->shuffle_contents ();
127
- $ this ->format_floats ();
117
+ $ renderer ->hide_unwanted_feedback ();
118
+ $ renderer ->hide_if_role ();
119
+ $ renderer ->shuffle_contents ();
120
+ $ renderer ->format_floats ();
128
121
129
- $ availableoptions = $ this ->extract_available_options ();
122
+ $ availableoptions = $ renderer ->extract_available_options ();
130
123
131
124
// Remove all unhandled custom elements, attributes, comments, and non-default xmlns declarations.
132
- $ this ->clean_up ();
125
+ $ renderer ->clean_up ();
133
126
134
127
// Modify standard HTML.
135
- $ this ->set_input_values_and_readonly ();
136
- $ this ->soften_validation ();
137
- $ this ->defuse_buttons ();
128
+ $ renderer ->set_input_values_and_readonly ($ attempt );
129
+ $ renderer ->soften_validation ();
130
+ $ renderer ->defuse_buttons ();
138
131
139
- $ this ->add_styles ();
132
+ $ renderer ->add_styles ();
140
133
141
134
// We don't want to support QPy elements (and attributes, etc.) in placeholder expansions, so we resolve
142
135
// them after replacing QPy elements.
143
- $ this ->resolve_placeholders ();
136
+ $ renderer ->resolve_placeholders ($ placeholders );
144
137
} finally {
145
138
// I'm not sure whether it is strictly necessary to reset the PRNG seed here, but it feels safer.
146
139
// Resetting it to its original state would be ideal, but that doesn't seem to be possible.
147
140
mt_srand ($ nextseed );
148
141
}
149
142
150
- $ warnings = $ this ->check_for_and_preserve_unknown_options ($ availableoptions );
151
- $ this ->result = new render_result ($ this ->xml ->saveHTML (), $ warnings );
152
- return $ this ->result ;
143
+ $ warnings = $ renderer ->check_for_and_preserve_unknown_options ($ availableoptions , $ attempt );
144
+ $ renderer ->html = $ renderer ->xml ->saveHTML ();
145
+ $ renderer ->warnings = $ warnings ;
146
+ return $ renderer ;
153
147
}
154
148
155
149
/**
@@ -270,11 +264,12 @@ private function replace_shuffled_indices(DOMElement $container, DOMElement $ele
270
264
* - If {@see question_display_options::$readonly} is set, the input is disabled.
271
265
* - If a value was saved for the input in a previous step, the latest value is added to the HTML.
272
266
*
267
+ * @param question_attempt $attempt
273
268
* @return void
274
269
* @throws coding_exception
275
270
*/
276
- private function set_input_values_and_readonly (): void {
277
- $ lastresponse = utils::get_qpy_response ($ this -> attempt );
271
+ private function set_input_values_and_readonly (question_attempt $ attempt ): void {
272
+ $ lastresponse = utils::get_qpy_response ($ attempt );
278
273
279
274
/** @var DOMElement $element */
280
275
foreach ($ this ->xpath ->query ('//xhtml:button | //xhtml:input | //xhtml:select | //xhtml:textarea ' ) as $ element ) {
@@ -395,22 +390,23 @@ private function clean_up(): void {
395
390
* Since QPy transformations should not be applied to the content of the placeholders, this method should be called
396
391
* near the end (after {@see clean_up()}).
397
392
*
393
+ * @param array $placeholders
398
394
* @return void
399
395
*/
400
- private function resolve_placeholders (): void {
396
+ private function resolve_placeholders (array $ placeholders ): void {
401
397
/** @var DOMProcessingInstruction $pi */
402
398
foreach (iterator_to_array ($ this ->xpath ->query ("//processing-instruction('p') " )) as $ pi ) {
403
399
$ parts = preg_split ('/\s+/ ' , trim ($ pi ->data ));
404
400
$ key = $ parts [0 ];
405
401
$ cleanoption = $ parts [1 ] ?? 'clean ' ;
406
402
407
- if (!isset ($ this -> placeholders [$ key ])) {
403
+ if (!isset ($ placeholders [$ key ])) {
408
404
// No value for this placeholder, so we just remove the PI.
409
405
$ pi ->parentNode ->removeChild ($ pi );
410
406
continue ;
411
407
}
412
408
413
- $ rawvalue = $ this -> placeholders [$ key ];
409
+ $ rawvalue = $ placeholders [$ key ];
414
410
if (strtolower ($ cleanoption ) === 'clean ' ) {
415
411
// Allow HTML, but clean using Moodle's clean_text to prevent XSS.
416
412
$ element = dom_utils::html_to_fragment ($ this ->xml , clean_text ($ rawvalue ));
@@ -623,10 +619,11 @@ private function format_floats(): void {
623
619
* Replaces QPy-URIs such as `qpy:acme/great_package/static/css/styles.css` with functioning pluginfile URLs.
624
620
*
625
621
* @param string $input
622
+ * @param question_attempt $attempt
626
623
* @return string
627
624
*/
628
- private function replace_qpy_urls (string $ input ): string {
629
- $ question = $ this -> attempt ->get_question ();
625
+ private static function replace_qpy_urls (string $ input, question_attempt $ attempt ): string {
626
+ $ question = $ attempt ->get_question ();
630
627
assert ($ question instanceof qtype_questionpy_question);
631
628
632
629
return preg_replace_callback (
@@ -775,13 +772,14 @@ private function extract_available_options(): array {
775
772
* duplicates.
776
773
*
777
774
* @param available_opts_info[] $availableoptsinfobyname
775
+ * @param question_attempt $attempt
778
776
* @return invalid_option_warning[]
779
777
* @throws coding_exception
780
778
* @see extract_available_options
781
779
* @throws \core\exception\coding_exception
782
780
*/
783
- private function check_for_and_preserve_unknown_options (array $ availableoptsinfobyname ): array {
784
- $ response = utils::get_qpy_response ($ this -> attempt );
781
+ private function check_for_and_preserve_unknown_options (array $ availableoptsinfobyname, question_attempt $ attempt ): array {
782
+ $ response = utils::get_qpy_response ($ attempt );
785
783
786
784
$ warnings = [];
787
785
foreach ($ availableoptsinfobyname as $ name => $ info ) {
0 commit comments