mirror of
https://github.com/qgis/QGIS.git
synced 2025-11-05 00:05:57 -05:00
Color work, some tests
This commit is contained in:
parent
96c2ddbade
commit
c4858b4231
@ -23,7 +23,7 @@ settings.
|
||||
%End
|
||||
public:
|
||||
|
||||
QgsMapBoxGlStyleConverter( const QVariantMap &style );
|
||||
QgsMapBoxGlStyleConverter( const QVariantMap &style, const QString &styleName = QString() );
|
||||
%Docstring
|
||||
Constructor for QgsMapBoxGlStyleConverter.
|
||||
|
||||
@ -53,7 +53,51 @@ or ``None`` if the style could not be converted successfully.
|
||||
|
||||
protected:
|
||||
|
||||
void parseLayers( const QVariantList &layers );
|
||||
void parseLayers( const QVariantList &layers, const QString &styleName );
|
||||
%Docstring
|
||||
Parse list of ``layers`` from JSON
|
||||
%End
|
||||
|
||||
static bool parseFillLayer( const QVariantMap &jsonLayer, const QString &styleName, QgsVectorTileBasicRendererStyle &style /Out/ );
|
||||
%Docstring
|
||||
Parses a fill layer.
|
||||
|
||||
:param jsonLayer: fill layer to parse
|
||||
:param styleName: style name
|
||||
|
||||
:return: - ``True`` if the layer was successfully parsed.
|
||||
- style: generated QGIS vector tile style
|
||||
%End
|
||||
|
||||
static QgsProperty parseInterpolateColorByZoom( const QVariantMap &json );
|
||||
|
||||
static QColor parseColor( const QVariant &color );
|
||||
%Docstring
|
||||
Parses a ``color`` in one of these supported formats:
|
||||
|
||||
- #fff or #ffffff
|
||||
- hsl(30, 19%, 90%) or hsla(30, 19%, 90%, 0.4)
|
||||
- rgb(10, 20, 30) or rgba(10, 20, 30, 0.5)
|
||||
|
||||
Returns an invalid color if the color could not be parsed.
|
||||
%End
|
||||
|
||||
static void colorAsHslaComponents( const QColor &color, int &hue, int &saturation, int &lightness, int &alpha );
|
||||
%Docstring
|
||||
Takes a QColor object and returns HSLA components in required format for QGIS :py:func:`~QgsMapBoxGlStyleConverter.color_hsla` expression function.
|
||||
|
||||
:param color: input color
|
||||
:param hue: an integer value from 0 to 360
|
||||
:param saturation: an integer value from 0 to 100
|
||||
:param lightness: an integer value from 0 to 100
|
||||
:param alpha: an integer value from 0 (completely transparent) to 255 (opaque).
|
||||
%End
|
||||
|
||||
static QString interpolateExpression( int zoomMin, int zoomMax, double valueMin, double valueMax, double base );
|
||||
%Docstring
|
||||
Generates an interpolation for values between ``valueMin`` and ``valueMax``, scaled between the
|
||||
ranges ``zoomMin`` to ``zoomMax``.
|
||||
%End
|
||||
|
||||
private:
|
||||
QgsMapBoxGlStyleConverter( const QgsMapBoxGlStyleConverter &other );
|
||||
|
||||
@ -16,13 +16,17 @@
|
||||
#include "qgsmapboxglstyleconverter.h"
|
||||
#include "qgsvectortilebasicrenderer.h"
|
||||
#include "qgsvectortilebasiclabeling.h"
|
||||
#include "qgssymbollayer.h"
|
||||
#include "qgssymbollayerutils.h"
|
||||
#include "qgslogger.h"
|
||||
|
||||
QgsMapBoxGlStyleConverter::QgsMapBoxGlStyleConverter( const QVariantMap &style )
|
||||
|
||||
QgsMapBoxGlStyleConverter::QgsMapBoxGlStyleConverter( const QVariantMap &style, const QString &styleName )
|
||||
: mStyle( style )
|
||||
{
|
||||
if ( mStyle.contains( QStringLiteral( "layers" ) ) )
|
||||
{
|
||||
parseLayers( mStyle.value( QStringLiteral( "layers" ) ).toList() );
|
||||
parseLayers( mStyle.value( QStringLiteral( "layers" ) ).toList(), styleName );
|
||||
}
|
||||
else
|
||||
{
|
||||
@ -32,9 +36,254 @@ QgsMapBoxGlStyleConverter::QgsMapBoxGlStyleConverter( const QVariantMap &style )
|
||||
|
||||
QgsMapBoxGlStyleConverter::~QgsMapBoxGlStyleConverter() = default;
|
||||
|
||||
void QgsMapBoxGlStyleConverter::parseLayers( const QVariantList &layers )
|
||||
void QgsMapBoxGlStyleConverter::parseLayers( const QVariantList &layers, const QString &styleName )
|
||||
{
|
||||
QList<QgsVectorTileBasicRendererStyle> rendererStyles;
|
||||
QList<QgsVectorTileBasicLabelingStyle> labelingStyles;
|
||||
|
||||
for ( const QVariant &layer : layers )
|
||||
{
|
||||
const QVariantMap jsonLayer = layer.toMap();
|
||||
|
||||
const QString layerType = jsonLayer.value( QStringLiteral( "type" ) ).toString();
|
||||
if ( layerType == QLatin1String( "background" ) )
|
||||
continue;
|
||||
|
||||
const QString styleId = jsonLayer.value( QStringLiteral( "id" ) ).toString();
|
||||
const QString layerName = jsonLayer.value( QStringLiteral( "source-layer" ) ).toString();
|
||||
|
||||
const int minZoom = jsonLayer.value( QStringLiteral( "minzoom" ), QStringLiteral( "-1" ) ).toInt();
|
||||
const int maxZoom = jsonLayer.value( QStringLiteral( "maxzoom" ), QStringLiteral( "-1" ) ).toInt();
|
||||
|
||||
const bool enabled = jsonLayer.value( QStringLiteral( "visibility" ) ).toString() != QLatin1String( "none" );
|
||||
|
||||
QString filterExpression;
|
||||
if ( jsonLayer.contains( QStringLiteral( "filter" ) ) )
|
||||
{
|
||||
// filterExpression = parseExpression( jsonLayer.value( QStringLiteral( "filter" ) ) );
|
||||
}
|
||||
|
||||
QgsVectorTileBasicRendererStyle rendererStyle;
|
||||
QgsVectorTileBasicLabelingStyle labelingStyle;
|
||||
|
||||
bool hasRendererStyle = false;
|
||||
bool hasLabelingStyle = false;
|
||||
if ( layerType == QLatin1String( "fill" ) )
|
||||
{
|
||||
hasRendererStyle = parseFillLayer( jsonLayer, styleName, rendererStyle );
|
||||
}
|
||||
else if ( layerType == QLatin1String( "line" ) )
|
||||
{
|
||||
// hasRendererStyle = parseLineLayer( jsonLayer, styleName );
|
||||
}
|
||||
else if ( layerType == QLatin1String( "symbol" ) )
|
||||
{
|
||||
// hasLabelingStyle = parseSymbolLayer( jsonLayer, styleName );
|
||||
}
|
||||
else
|
||||
{
|
||||
QgsDebugMsg( QStringLiteral( "Skipping unknown layer type: %1" ).arg( layerType ) );
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( hasRendererStyle )
|
||||
{
|
||||
rendererStyle.setStyleName( styleId );
|
||||
rendererStyle.setLayerName( layerName );
|
||||
rendererStyle.setFilterExpression( filterExpression );
|
||||
rendererStyle.setMinZoomLevel( minZoom );
|
||||
rendererStyle.setMaxZoomLevel( maxZoom );
|
||||
rendererStyle.setEnabled( enabled );
|
||||
rendererStyles.append( rendererStyle );
|
||||
}
|
||||
|
||||
if ( hasLabelingStyle )
|
||||
{
|
||||
labelingStyle.setStyleName( styleId );
|
||||
labelingStyle.setLayerName( layerName );
|
||||
labelingStyle.setFilterExpression( filterExpression );
|
||||
labelingStyle.setMinZoomLevel( minZoom );
|
||||
labelingStyle.setMaxZoomLevel( maxZoom );
|
||||
labelingStyle.setEnabled( enabled );
|
||||
labelingStyles.append( labelingStyle );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
mRenderer = qgis::make_unique< QgsVectorTileBasicRenderer >();
|
||||
QgsVectorTileBasicRenderer *renderer = dynamic_cast< QgsVectorTileBasicRenderer *>( mRenderer.get() );
|
||||
renderer->setStyles( rendererStyles );
|
||||
|
||||
mLabeling = qgis::make_unique< QgsVectorTileBasicLabeling >();
|
||||
QgsVectorTileBasicLabeling *labeling = dynamic_cast< QgsVectorTileBasicLabeling * >( mLabeling.get() );
|
||||
labeling->setStyles( labelingStyles );
|
||||
}
|
||||
|
||||
bool QgsMapBoxGlStyleConverter::parseFillLayer( const QVariantMap &jsonLayer, const QString &styleName, QgsVectorTileBasicRendererStyle &style )
|
||||
{
|
||||
if ( !jsonLayer.contains( QStringLiteral( "paint" ) ) )
|
||||
{
|
||||
QgsDebugMsg( QStringLiteral( "Style layer %1 has no paint property, skipping" ).arg( jsonLayer.value( QStringLiteral( "id" ) ).toString() ) );
|
||||
return false;
|
||||
}
|
||||
|
||||
const QVariantMap jsonPaint = jsonLayer.value( QStringLiteral( "paint" ) ).toMap();
|
||||
|
||||
QgsPropertyCollection ddProperties;
|
||||
|
||||
// fill color
|
||||
QColor fillColor;
|
||||
if ( jsonLayer.contains( QStringLiteral( "fill-color" ) ) )
|
||||
{
|
||||
const QVariant jsonFillColor = jsonPaint.value( QStringLiteral( "fill-color" ) );
|
||||
switch ( jsonFillColor.type() )
|
||||
{
|
||||
case QVariant::Map:
|
||||
ddProperties.setProperty( QgsSymbolLayer::PropertyFillColor, parseInterpolateColorByZoom( jsonFillColor.toMap() ) );
|
||||
break;
|
||||
|
||||
case QVariant::List:
|
||||
case QVariant::StringList:
|
||||
|
||||
break;
|
||||
|
||||
case QVariant::String:
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
QgsProperty QgsMapBoxGlStyleConverter::parseInterpolateColorByZoom( const QVariantMap &json )
|
||||
{
|
||||
const double base = json.value( QStringLiteral( "base" ), QStringLiteral( "1" ) ).toDouble();
|
||||
const QVariantList stops = json.value( QStringLiteral( "stops" ) ).toList();
|
||||
if ( stops.empty() )
|
||||
return QgsProperty();
|
||||
|
||||
QString caseString = QStringLiteral( "CASE " );
|
||||
|
||||
if ( base == 1 )
|
||||
{
|
||||
// base = 1 -> scale linear
|
||||
for ( int i = 0; i < stops.length() - 1; ++i )
|
||||
{
|
||||
// step bottom zoom
|
||||
const QString bz = stops.at( i ).toList().value( 0 ).toString();
|
||||
// step top zoom
|
||||
const QString tz = stops.at( i + 1 ).toList().value( 0 ).toString();
|
||||
|
||||
const QColor bottomColor = parseColor( stops.at( i ).toList().value( 1 ) );
|
||||
const QColor topColor = parseColor( stops.at( i + 1 ).toList().value( 1 ) );
|
||||
|
||||
int bcHue;
|
||||
int bcSat;
|
||||
int bcLight;
|
||||
int bcAlpha;
|
||||
colorAsHslaComponents( bottomColor, bcHue, bcSat, bcLight, bcAlpha );
|
||||
int tcHue;
|
||||
int tcSat;
|
||||
int tcLight;
|
||||
int tcAlpha;
|
||||
colorAsHslaComponents( topColor, tcHue, tcSat, tcLight, tcAlpha );
|
||||
|
||||
caseString += QStringLiteral( "WHEN @zoom_level >= %1 AND @zoom_level < %2 THEN color_hsla("
|
||||
"scale_linear(@zoom_level, %1, %2, %3, %4), "
|
||||
"scale_linear(@zoom_level, %1, %2, %5, %6), "
|
||||
"scale_linear(@zoom_level, %1, %2, %7, %8), "
|
||||
"scale_linear(@zoom_level, %1, %2, %9, %10)) "
|
||||
).arg( bz, tz )
|
||||
.arg( bcHue )
|
||||
.arg( tcHue )
|
||||
.arg( bcSat )
|
||||
.arg( tcSat )
|
||||
.arg( bcLight )
|
||||
.arg( tcLight )
|
||||
.arg( bcAlpha )
|
||||
.arg( tcAlpha );
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Base != 1 -> scale_exp()
|
||||
for ( int i = 0; i < stops.length() - 1; ++i )
|
||||
{
|
||||
// step bottom zoom
|
||||
const QString bz = stops.at( i ).toList().value( 0 ).toString();
|
||||
// step top zoom
|
||||
const QString tz = stops.at( i + 1 ).toList().value( 0 ).toString();
|
||||
|
||||
const QColor bottomColor = parseColor( stops.at( i ).toList().value( 1 ) );
|
||||
const QColor topColor = parseColor( stops.at( i + 1 ).toList().value( 1 ) );
|
||||
|
||||
int bcHue;
|
||||
int bcSat;
|
||||
int bcLight;
|
||||
int bcAlpha;
|
||||
colorAsHslaComponents( bottomColor, bcHue, bcSat, bcLight, bcAlpha );
|
||||
int tcHue;
|
||||
int tcSat;
|
||||
int tcLight;
|
||||
int tcAlpha;
|
||||
colorAsHslaComponents( topColor, tcHue, tcSat, tcLight, tcAlpha );
|
||||
|
||||
caseString += QStringLiteral( "WHEN @zoom_level >= %1 AND @zoom_level < %2 THEN color_hsla("
|
||||
"%3, %4, %5, %6) " ).arg( bz, tz,
|
||||
interpolateExpression( bz.toInt(), tz.toInt(), bcHue, tcHue, base ),
|
||||
interpolateExpression( bz.toInt(), tz.toInt(), bcSat, tcSat, base ),
|
||||
interpolateExpression( bz.toInt(), tz.toInt(), bcLight, tcLight, base ),
|
||||
interpolateExpression( bz.toInt(), tz.toInt(), bcAlpha, tcAlpha, base ) );
|
||||
}
|
||||
}
|
||||
|
||||
// top color
|
||||
const QString tz = stops.last().toList().value( 0 ).toString();
|
||||
const QColor topColor = parseColor( stops.last().toList().value( 1 ) );
|
||||
int tcHue;
|
||||
int tcSat;
|
||||
int tcLight;
|
||||
int tcAlpha;
|
||||
colorAsHslaComponents( topColor, tcHue, tcSat, tcLight, tcAlpha );
|
||||
|
||||
caseString += QStringLiteral( "WHEN @zoom_level >= %1 THEN color_hsla(%2, %3, %4, %5) "
|
||||
"ELSE color_hsla(%2, %3, %4, %5) END" ).arg( tz )
|
||||
.arg( tcHue ).arg( tcSat ).arg( tcLight ).arg( tcAlpha );
|
||||
|
||||
return QgsProperty::fromExpression( caseString );
|
||||
}
|
||||
|
||||
QColor QgsMapBoxGlStyleConverter::parseColor( const QVariant &color )
|
||||
{
|
||||
if ( color.type() != QVariant::String )
|
||||
{
|
||||
QgsDebugMsg( QStringLiteral( "Could not parse non-string color %1, skipping" ).arg( color.toString() ) );
|
||||
return QColor();
|
||||
}
|
||||
|
||||
return QgsSymbolLayerUtils::parseColor( color.toString() );
|
||||
}
|
||||
|
||||
void QgsMapBoxGlStyleConverter::colorAsHslaComponents( const QColor &color, int &hue, int &saturation, int &lightness, int &alpha )
|
||||
{
|
||||
hue = std::max( 0, color.hslHue() );
|
||||
saturation = color.hslSaturation() / 255.0 * 100;
|
||||
lightness = color.lightness() / 255.0 * 100;
|
||||
alpha = color.alpha();
|
||||
}
|
||||
|
||||
QString QgsMapBoxGlStyleConverter::interpolateExpression( int zoomMin, int zoomMax, double valueMin, double valueMax, double base )
|
||||
{
|
||||
return QStringLiteral( "%1 + %2 * (%3^(@zoom_level-%4)-1)/(%3^(%5-%4)-1)" ).arg( valueMin )
|
||||
.arg( valueMax - valueMin )
|
||||
.arg( base )
|
||||
.arg( zoomMin )
|
||||
.arg( zoomMax );
|
||||
}
|
||||
|
||||
QgsVectorTileRenderer *QgsMapBoxGlStyleConverter::renderer() const
|
||||
|
||||
@ -18,11 +18,13 @@
|
||||
|
||||
#include "qgis_core.h"
|
||||
#include "qgis_sip.h"
|
||||
#include "qgsproperty.h"
|
||||
#include <QVariantMap>
|
||||
#include <memory>
|
||||
|
||||
class QgsVectorTileRenderer;
|
||||
class QgsVectorTileLabeling;
|
||||
class QgsVectorTileBasicRendererStyle;
|
||||
|
||||
/**
|
||||
* \ingroup core
|
||||
@ -40,7 +42,7 @@ class CORE_EXPORT QgsMapBoxGlStyleConverter
|
||||
*
|
||||
* The specified MapBox GL \a style configuration will be converted.
|
||||
*/
|
||||
QgsMapBoxGlStyleConverter( const QVariantMap &style );
|
||||
QgsMapBoxGlStyleConverter( const QVariantMap &style, const QString &styleName = QString() );
|
||||
|
||||
//! QgsMapBoxGlStyleConverter cannot be copied
|
||||
QgsMapBoxGlStyleConverter( const QgsMapBoxGlStyleConverter &other ) = delete;
|
||||
@ -69,7 +71,49 @@ class CORE_EXPORT QgsMapBoxGlStyleConverter
|
||||
|
||||
protected:
|
||||
|
||||
void parseLayers( const QVariantList &layers );
|
||||
/**
|
||||
* Parse list of \a layers from JSON
|
||||
*/
|
||||
void parseLayers( const QVariantList &layers, const QString &styleName );
|
||||
|
||||
/**
|
||||
* Parses a fill layer.
|
||||
*
|
||||
* \param jsonLayer fill layer to parse
|
||||
* \param styleName style name
|
||||
* \param style generated QGIS vector tile style
|
||||
* \returns TRUE if the layer was successfully parsed.
|
||||
*/
|
||||
static bool parseFillLayer( const QVariantMap &jsonLayer, const QString &styleName, QgsVectorTileBasicRendererStyle &style SIP_OUT );
|
||||
|
||||
static QgsProperty parseInterpolateColorByZoom( const QVariantMap &json );
|
||||
|
||||
/**
|
||||
* Parses a \a color in one of these supported formats:
|
||||
*
|
||||
* - #fff or #ffffff
|
||||
* - hsl(30, 19%, 90%) or hsla(30, 19%, 90%, 0.4)
|
||||
* - rgb(10, 20, 30) or rgba(10, 20, 30, 0.5)
|
||||
*
|
||||
* Returns an invalid color if the color could not be parsed.
|
||||
*/
|
||||
static QColor parseColor( const QVariant &color );
|
||||
|
||||
/**
|
||||
* Takes a QColor object and returns HSLA components in required format for QGIS color_hsla() expression function.
|
||||
* \param color input color
|
||||
* \param hue an integer value from 0 to 360
|
||||
* \param saturation an integer value from 0 to 100
|
||||
* \param lightness an integer value from 0 to 100
|
||||
* \param alpha an integer value from 0 (completely transparent) to 255 (opaque).
|
||||
*/
|
||||
static void colorAsHslaComponents( const QColor &color, int &hue, int &saturation, int &lightness, int &alpha );
|
||||
|
||||
/**
|
||||
* Generates an interpolation for values between \a valueMin and \a valueMax, scaled between the
|
||||
* ranges \a zoomMin to \a zoomMax.
|
||||
*/
|
||||
static QString interpolateExpression( int zoomMin, int zoomMax, double valueMin, double valueMax, double base );
|
||||
|
||||
private:
|
||||
|
||||
@ -78,7 +122,6 @@ class CORE_EXPORT QgsMapBoxGlStyleConverter
|
||||
#endif
|
||||
|
||||
|
||||
|
||||
QVariantMap mStyle;
|
||||
QString mError;
|
||||
|
||||
|
||||
@ -44,12 +44,36 @@ TEST_DATA_DIR = unitTestDataPath()
|
||||
|
||||
class TestQgsMapBoxGlStyleConverter(unittest.TestCase):
|
||||
|
||||
maxDiff = 100000
|
||||
|
||||
def testNoLayer(self):
|
||||
c = QgsMapBoxGlStyleConverter({'x': 'y'})
|
||||
self.assertEqual(c.errorMessage(), 'Could not find layers list in JSON')
|
||||
self.assertIsNone(c.renderer())
|
||||
self.assertIsNone(c.labeling())
|
||||
|
||||
def testInterpolateExpression(self):
|
||||
self.assertEqual(QgsMapBoxGlStyleConverter.interpolateExpression(5, 13, 27, 29, 1),
|
||||
'27 + 2 * (1^(@zoom_level-5)-1)/(1^(13-5)-1)')
|
||||
self.assertEqual(QgsMapBoxGlStyleConverter.interpolateExpression(5, 13, 27, 29, 1.5),
|
||||
'27 + 2 * (1.5^(@zoom_level-5)-1)/(1.5^(13-5)-1)')
|
||||
|
||||
def testColorAsHslaComponents(self):
|
||||
self.assertEqual(QgsMapBoxGlStyleConverter.colorAsHslaComponents(QColor.fromHsl(30, 50, 70)), (30, 19, 27, 255))
|
||||
|
||||
def testParseInterpolateColorByZoom(self):
|
||||
self.assertEqual(QgsMapBoxGlStyleConverter.parseInterpolateColorByZoom({}).isActive(), False)
|
||||
self.assertEqual(QgsMapBoxGlStyleConverter.parseInterpolateColorByZoom({'base': 1,
|
||||
'stops': [[0, '#f1f075'],
|
||||
[150, '#b52e3e'],
|
||||
[250, '#e55e5e']]
|
||||
}).expressionString(), 'CASE WHEN @zoom_level >= 0 AND @zoom_level < 150 THEN color_hsla(scale_linear(@zoom_level, 0, 150, 59, 352), scale_linear(@zoom_level, 0, 150, 81, 59), scale_linear(@zoom_level, 0, 150, 70, 44), scale_linear(@zoom_level, 0, 150, 255, 255)) WHEN @zoom_level >= 150 AND @zoom_level < 250 THEN color_hsla(scale_linear(@zoom_level, 150, 250, 352, 0), scale_linear(@zoom_level, 150, 250, 59, 72), scale_linear(@zoom_level, 150, 250, 44, 63), scale_linear(@zoom_level, 150, 250, 255, 255)) WHEN @zoom_level >= 250 THEN color_hsla(0, 72, 63, 255) ELSE color_hsla(0, 72, 63, 255) END')
|
||||
self.assertEqual(QgsMapBoxGlStyleConverter.parseInterpolateColorByZoom({'base': 2,
|
||||
'stops': [[0, '#f1f075'],
|
||||
[150, '#b52e3e'],
|
||||
[250, '#e55e5e']]
|
||||
}).expressionString(), 'CASE WHEN @zoom_level >= 0 AND @zoom_level < 150 THEN color_hsla(59 + 293 * (2^(@zoom_level-0)-1)/(2^(150-0)-1), 81 + -22 * (2^(@zoom_level-0)-1)/(2^(150-0)-1), 70 + -26 * (2^(@zoom_level-0)-1)/(2^(150-0)-1), 255 + 0 * (2^(@zoom_level-0)-1)/(2^(150-0)-1)) WHEN @zoom_level >= 150 AND @zoom_level < 250 THEN color_hsla(352 + -352 * (2^(@zoom_level-150)-1)/(2^(250-150)-1), 59 + 13 * (2^(@zoom_level-150)-1)/(2^(250-150)-1), 44 + 19 * (2^(@zoom_level-150)-1)/(2^(250-150)-1), 255 + 0 * (2^(@zoom_level-150)-1)/(2^(250-150)-1)) WHEN @zoom_level >= 250 THEN color_hsla(0, 72, 63, 255) ELSE color_hsla(0, 72, 63, 255) END')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user