Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix for issue 1011: add 3rd input encoding parameter to json_encode modifier and let it default to \Smarty\Smarty::$_CHARSET #1016

Open
wants to merge 10 commits into
base: master
Choose a base branch
from
Open
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ Depending on the value of `$user` this would return a string in JSON-format, e.g

## Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------------------------------------------------------------------------------------|
| 1 | int | No | bitmask of flags, directly passed to [PHP's json_encode](https://www.php.net/json_encode) |
| Parameter | Type | Required | Description |
|-----------|--------|----------|-------------------------------------------------------------------------------------------|
| 1 | int | No | bitmask of flags, directly passed to [PHP's json_encode](https://www.php.net/json_encode) |
| 2 | string | No | input encoding; defaults to \Smarty\Smarty::$_CHARSET which defaults to UTF-8 |


## Examples
Expand All @@ -24,4 +25,4 @@ Without it, an array `$myArray = ["a","b"]` would be formatted as a javascript a
```smarty
{$myArray|json_encode} # renders: ["a","b"]
{$myArray|json_encode:16} # renders: {"0":"a","1":"b"}
```
```
10 changes: 0 additions & 10 deletions docs/designers/language-modifiers/language-modifier-round.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,3 @@ If 'precision' is negative, the number is rounded to the nearest power of 10. Se
The parameter 'mode' defines how the rounding is done. By default, 2.5 is rounded to 3, whereas 2.45 is rounded to 2.
You usually don't need to change this. For more details on rounding modes,
see [PHP's documentation on round](https://www.php.net/manual/en/function.round).

## Examples

By passing `16` as the second parameter, you can force json_encode to always format the JSON-string as an object.
Without it, an array `$myArray = ["a","b"]` would be formatted as a javascript array:

```smarty
{$myArray|json_encode} # renders: ["a","b"]
{$myArray|json_encode:16} # renders: {"0":"a","1":"b"}
```
14 changes: 0 additions & 14 deletions src/Compile/Modifier/JsonEncodeModifierCompiler.php

This file was deleted.

94 changes: 93 additions & 1 deletion src/Extension/DefaultExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ public function getModifierCompiler(string $modifier): ?\Smarty\Compile\Modifier
case 'indent': $this->modifiers[$modifier] = new \Smarty\Compile\Modifier\IndentModifierCompiler(); break;
case 'is_array': $this->modifiers[$modifier] = new \Smarty\Compile\Modifier\IsArrayModifierCompiler(); break;
case 'isset': $this->modifiers[$modifier] = new \Smarty\Compile\Modifier\IssetModifierCompiler(); break;
case 'json_encode': $this->modifiers[$modifier] = new \Smarty\Compile\Modifier\JsonEncodeModifierCompiler(); break;
case 'lower': $this->modifiers[$modifier] = new \Smarty\Compile\Modifier\LowerModifierCompiler(); break;
case 'nl2br': $this->modifiers[$modifier] = new \Smarty\Compile\Modifier\Nl2brModifierCompiler(); break;
case 'noprint': $this->modifiers[$modifier] = new \Smarty\Compile\Modifier\NoPrintModifierCompiler(); break;
Expand Down Expand Up @@ -62,6 +61,7 @@ public function getModifierCallback(string $modifierName) {
case 'implode': return [$this, 'smarty_modifier_implode'];
case 'in_array': return [$this, 'smarty_modifier_in_array'];
case 'join': return [$this, 'smarty_modifier_join'];
case 'json_encode': return [$this, 'smarty_modifier_json_encode'];
case 'mb_wordwrap': return [$this, 'smarty_modifier_mb_wordwrap'];
case 'number_format': return [$this, 'smarty_modifier_number_format'];
case 'regex_replace': return [$this, 'smarty_modifier_regex_replace'];
Expand Down Expand Up @@ -605,6 +605,98 @@ public function smarty_modifier_join($values, $separator = '')
return implode((string) ($separator ?? ''), (array) $values);
}

/**
* Smarty json_encode modifier plugin.
* Type: modifier
* Name: json_encode
* Purpose: Returns the JSON representation of the given value or false on error. The resulting string will be UTF-8 encoded.
*
* @param mixed $value
* @param int $flags
* @param string $input_encoding of $value; defaults to \Smarty\Smarty::$_CHARSET
*
* @return string|false
*/
public function smarty_modifier_json_encode($value, $flags = 0, string $input_encoding = null)
{
if (!$input_encoding) {
$input_encoding = \Smarty\Smarty::$_CHARSET;
}

# json_encode() expects UTF-8 input, so recursively encode $value if necessary into UTF-8
if ($value && strcasecmp($input_encoding, 'UTF-8')) {
if (is_string($value)) { # shortcut for the most common case
$value = mb_convert_encoding($value, 'UTF-8', $input_encoding);
}
elseif (is_array($value) || is_object($value)) {
static $transcoder; # this closure will be assigned once, and then persist in memory
if (is_null($transcoder)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You probably did this because of this comment but I'm afraid this is a bit much. Can you refactor the transcoder into a class file under src/Extension/DefaultExtension/DeepTranscoder.php or something like that?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, perhaps DeepJsonTranscode.php then since it's specific to transcoding for json_encode() as indicated by a comment in the code where JsonSerializable is checked.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just pushed a new commit where the json_encode() modifier uses the new class src/Extension/DefaultExtension/RecursiveTranscoder.php, and added a little to the json_encode unit tests.

/**
* Similar to mb_convert_encoding(), but operates on keys and values of arrays, and on objects too.
* Objects implementing \JsonSerializable and unsupported types are returned unchanged.
*
* @param string $from_encoding
* @param string $to_encoding
* @param mixed $data
* @return mixed
*/
$transcoder = function($data, string $to_encoding, string $from_encoding) use(&$transcoder) {
if (empty($data)) {
return $data;
}
elseif (is_string($data)) {
return mb_convert_encoding($data, $to_encoding, $from_encoding);
}
elseif (is_scalar($data)) {
return $data;
}

# convert object to array if necessary
if (is_object($data)) {
if (is_a($data, '\JsonSerializable')) { # this is the only reason why this function is not generic
wisskid marked this conversation as resolved.
Show resolved Hide resolved
return $data;
}
$data = get_object_vars($data); # public properties as key => value pairs
}

if (is_array($data)) {
$result = [];
foreach ($data as $k => $v) {
if (is_string($k)) {
$k = mb_convert_encoding($k, $to_encoding, $from_encoding);
if ($k === false) {
return false;
}
}
if (empty($v) || (is_scalar($v) && !is_string($v))) {
$result[$k] = $v; # $v can be false and that's not an error
}
else {
# recurse
$v = $transcoder($v, $to_encoding, $from_encoding);
if ($v === false) {
return false;
}
$result[$k] = $v;
}
}
return $result;
}

return $data; # anything except string, object, or array
}; # / $transcoder function
} # / if is_null($transcoder)

$value = $transcoder($value, 'UTF-8', $input_encoding);
if ($value === false) {
return $value; # failure; this must not be passed to json_encode!; this is part of what the !empty() check is for at the top of this block
}
} # / elseif (is_array($value) || is_object($value))
} # / if input encoding != UTF-8

return \json_encode($value, $flags); # string|false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My IDE tells me that \json_encode (and \JsonSerializable::class) are not part of the core of PHP prior to PHP8. We should probably check for function_exists('json_encode') and do something accordingly.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's strange that someone would want to use the |json_encode modifier without having the json extension installed. But what for exception (and message) to you suggest to throw if it's not present? The check can be done once (using a static boolean var) the first time the modifier is called.

}

/**
* Smarty wordwrap modifier plugin
* Type: modifier
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php
/**
* Smarty PHPunit tests of modifier.
* This file should be saved in UTF-8 encoding for comment legibility.
*/

namespace UnitTests\TemplateSource\TagTests\PluginModifier;
use PHPUnit_Smarty;

class PluginModifierJsonEncodeCp1252Test extends PHPUnit_Smarty
{
public function setUp(): void
{
$this->setUpSmarty(__DIR__);
\Smarty\Smarty::$_CHARSET = 'cp1252';
}

public function tearDown(): void
{
\Smarty\Smarty::$_CHARSET = 'UTF-8';
}

/**
* @dataProvider dataForDefault
*/
public function testDefault($value, $expected)
{
$tpl = $this->smarty->createTemplate('string:{$v|json_encode}');
$tpl->assign("v", $value);
$this->assertEquals($expected, $this->smarty->fetch($tpl));
}

/**
* @dataProvider dataForDefault
*/
public function testDefaultAsFunction($value, $expected)
{
$tpl = $this->smarty->createTemplate('string:{json_encode($v)}');
$tpl->assign("v", $value);
$this->assertEquals($expected, $this->smarty->fetch($tpl));
}

public function dataForDefault() {
return [
["abc", '"abc"'],
[["abc"], '["abc"]'],
[["abc",["a"=>2]], '["abc",{"a":2}]'],
[["\x80uro",["Schl\xFCssel"=>"Stra\xDFe"]], '["\u20acuro",{"Schl\u00fcssel":"Stra\u00dfe"}]'], # x80 = � = euro, xFC = � = uuml, xDF = � = szlig
];
}

/**
* @dataProvider dataForForceObject
*/
public function testForceObject($value, $expected)
{
$tpl = $this->smarty->createTemplate('string:{$v|json_encode:16}');
$tpl->assign("v", $value);
$this->assertEquals($expected, $this->smarty->fetch($tpl));
}

/**
* @dataProvider dataForForceObject
*/
public function testForceObjectAsFunction($value, $expected)
{
$tpl = $this->smarty->createTemplate('string:{json_encode($v,16)}');
$tpl->assign("v", $value);
$this->assertEquals($expected, $this->smarty->fetch($tpl));
}

public function dataForForceObject() {
return [
["abc", '"abc"'],
[["abc"], '{"0":"abc"}'],
[["abc",["a"=>2]], '{"0":"abc","1":{"a":2}}'],
[["\x80uro"], '{"0":"\u20acuro"}'],
];
}

}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php
/**
* Smarty PHPunit tests of modifier
* Smarty PHPunit tests of modifier.
* This file must be saved in UTF-8 encoding!
*/

namespace UnitTests\TemplateSource\TagTests\PluginModifier;
Expand Down Expand Up @@ -38,6 +39,7 @@ public function dataForDefault() {
["abc", '"abc"'],
[["abc"], '["abc"]'],
[["abc",["a"=>2]], '["abc",{"a":2}]'],
[["€uro",["Schlüssel"=>"Straße"]], '["\u20acuro",{"Schl\u00fcssel":"Stra\u00dfe"}]'], # \u{20ac} = € = euro, \u{00fc} = ü = uuml, xDF = \u{00df} = szlig
];
}

Expand Down Expand Up @@ -66,6 +68,7 @@ public function dataForForceObject() {
["abc", '"abc"'],
[["abc"], '{"0":"abc"}'],
[["abc",["a"=>2]], '{"0":"abc","1":{"a":2}}'],
[["€uro"], '{"0":"\u20acuro"}'],
];
}

Expand Down
Loading