diff options
56 files changed, 5136 insertions, 3653 deletions
@@ -3,10 +3,14 @@ __pycache__ /*.egg-info /dist /.spyproject +/.tool-versions +/.python-version +/*.kdev4 /build /docs/_build /venv /README.html /.pytest_cache +/.python-version /docs/_build diff --git a/.python-version b/.python-version deleted file mode 100644 index 0b2eb36..0000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.7.2 diff --git a/LICENSE.txt b/LICENSE.txt index d1defab..bbb3793 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (C) 2018-2019 Thomas Touhey <thomas@touhey.fr> +Copyright (C) 2018-2022 Thomas Touhey <thomas@touhey.fr> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal diff --git a/MANIFEST.in b/MANIFEST.in index c0eb105..cd7c82e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,8 +3,12 @@ include LICENSE.txt include MANIFEST.in include setup.py include setup.cfg +include requirements.txt +include docs/*.txt include docs/*.rst +include docs/**/*.rst +include docs/*.png include docs/conf.py include docs/Makefile include docs/make.bat @@ -1,28 +1,19 @@ #!/usr/bin/make -f - PE := pipenv run - ST := $(PE) ./setup.py - DNAME := dist/$(shell $(ST) --name)-$(shell $(ST) --version).tar.gz + DNAME := dist/$(shell ./setup.py --name)-$(shell ./setup.py --version).tar.gz test tests: - @$(PE) pytest -s -q - -prepare: - @pipenv install --dev -update: - @pipenv update --dev + @pytest -s docs: - @$(ST) build_sphinx - -checkdocs: - @$(ST) checkdocs + @./setup.py build_sphinx dist: $(DNAME) $(DNAME): - @$(ST) sdist + @./setup.py sdist upload: $(DNAME) @twine upload $(DNAME) .PHONY: test tests dist docs + # End of file. diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 2b67cce..0000000 --- a/Pipfile +++ /dev/null @@ -1,16 +0,0 @@ -[[source]] -url = 'https://pypi.python.org/simple' -verify_ssl = true -name = 'pypi' - -[requires] -python_version = '3.7' - -[packages] -regex = '*' - -[dev-packages] -sphinx = '*' -"collective.checkdocs" = '*' -pudb = '*' -pytest = '*' diff --git a/Pipfile.lock b/Pipfile.lock deleted file mode 100644 index 9758a34..0000000 --- a/Pipfile.lock +++ /dev/null @@ -1,297 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "0376534457dcacd720d6a912bf87bdf10739752646bf34d5c06ef3de9863f538" - }, - "pipfile-spec": 6, - "requires": { - "python_version": "3.7" - }, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.python.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "regex": { - "hashes": [ - "sha256:020429dcf9b76cc7648a99c81b3a70154e45afebc81e0b85364457fe83b525e4", - "sha256:0552802b1c3f3c7e4fee8c85e904a13c48226020aa1a0593246888a1ac55aaaf", - "sha256:308965a80b92e1fec263ac1e4f1094317809a72bc4d26be2ec8a5fd026301175", - "sha256:4d627feef04eb626397aa7bdec772774f53d63a1dc7cc5ee4d1bd2786a769d19", - "sha256:93d1f9fcb1d25e0b4bd622eeba95b080262e7f8f55e5b43c76b8a5677e67334c", - "sha256:c3859bbf29b1345d694f069ddfe53d6907b0393fda5e3794c800ad02902d78e9", - "sha256:d56ce4c7b1a189094b9bee3b81c4aeb3f1ba3e375e91627ec8561b6ab483d0a8", - "sha256:ebc5ef4e10fa3312fa1967dc0a894e6bd985a046768171f042ac3974fadc9680", - "sha256:f9cd39066048066a4abe4c18fb213bc541339728005e72263f023742fb912585" - ], - "index": "pypi", - "version": "==2019.4.14" - } - }, - "develop": { - "alabaster": { - "hashes": [ - "sha256:446438bdcca0e05bd45ea2de1668c1d9b032e1a9154c2c259092d77031ddd359", - "sha256:a661d72d58e6ea8a57f7a86e37d86716863ee5e92788398526d58b26a4e4dc02" - ], - "version": "==0.7.12" - }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, - "attrs": { - "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" - ], - "version": "==19.1.0" - }, - "babel": { - "hashes": [ - "sha256:6778d85147d5d85345c14a26aada5e478ab04e39b078b0745ee6870c2b5cf669", - "sha256:8cba50f48c529ca3fa18cf81fa9403be176d374ac4d60738b839122dfaaa3d23" - ], - "version": "==2.6.0" - }, - "certifi": { - "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" - ], - "version": "==2019.3.9" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "collective.checkdocs": { - "hashes": [ - "sha256:3a5328257c5224bc72753820c182910d7fb336bc1dba5e09113d48566655e46e" - ], - "index": "pypi", - "version": "==0.2" - }, - "docutils": { - "hashes": [ - "sha256:02aec4bd92ab067f6ff27a38a38a41173bf01bed8f89157768c1573f53e474a6", - "sha256:51e64ef2ebfb29cae1faa133b3710143496eca21c530f3f71424d77687764274", - "sha256:7a4bd47eaf6596e1295ecb11361139febe29b084a87bf005bf899f9a42edc3c6" - ], - "version": "==0.14" - }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "imagesize": { - "hashes": [ - "sha256:3f349de3eb99145973fefb7dbe38554414e5c30abd0c8e4b970a7c9d09f3a1d8", - "sha256:f3832918bc3c66617f92e35f5d70729187676313caa60c187eb0f28b8fe5e3b5" - ], - "version": "==1.1.0" - }, - "jinja2": { - "hashes": [ - "sha256:065c4f02ebe7f7cf559e49ee5a95fb800a9e4528727aec6f24402a5374c65013", - "sha256:14dd6caf1527abb21f08f86c784eac40853ba93edb79552aa1e4b8aef1b61c7b" - ], - "version": "==2.10.1" - }, - "markupsafe": { - "hashes": [ - "sha256:00bc623926325b26bb9605ae9eae8a215691f33cae5df11ca5424f06f2d1f473", - "sha256:09027a7803a62ca78792ad89403b1b7a73a01c8cb65909cd876f7fcebd79b161", - "sha256:09c4b7f37d6c648cb13f9230d847adf22f8171b1ccc4d5682398e77f40309235", - "sha256:1027c282dad077d0bae18be6794e6b6b8c91d58ed8a8d89a89d59693b9131db5", - "sha256:24982cc2533820871eba85ba648cd53d8623687ff11cbb805be4ff7b4c971aff", - "sha256:29872e92839765e546828bb7754a68c418d927cd064fd4708fab9fe9c8bb116b", - "sha256:43a55c2930bbc139570ac2452adf3d70cdbb3cfe5912c71cdce1c2c6bbd9c5d1", - "sha256:46c99d2de99945ec5cb54f23c8cd5689f6d7177305ebff350a58ce5f8de1669e", - "sha256:500d4957e52ddc3351cabf489e79c91c17f6e0899158447047588650b5e69183", - "sha256:535f6fc4d397c1563d08b88e485c3496cf5784e927af890fb3c3aac7f933ec66", - "sha256:62fe6c95e3ec8a7fad637b7f3d372c15ec1caa01ab47926cfdf7a75b40e0eac1", - "sha256:6dd73240d2af64df90aa7c4e7481e23825ea70af4b4922f8ede5b9e35f78a3b1", - "sha256:717ba8fe3ae9cc0006d7c451f0bb265ee07739daf76355d06366154ee68d221e", - "sha256:79855e1c5b8da654cf486b830bd42c06e8780cea587384cf6545b7d9ac013a0b", - "sha256:7c1699dfe0cf8ff607dbdcc1e9b9af1755371f92a68f706051cc8c37d447c905", - "sha256:88e5fcfb52ee7b911e8bb6d6aa2fd21fbecc674eadd44118a9cc3863f938e735", - "sha256:8defac2f2ccd6805ebf65f5eeb132adcf2ab57aa11fdf4c0dd5169a004710e7d", - "sha256:98c7086708b163d425c67c7a91bad6e466bb99d797aa64f965e9d25c12111a5e", - "sha256:9add70b36c5666a2ed02b43b335fe19002ee5235efd4b8a89bfcf9005bebac0d", - "sha256:9bf40443012702a1d2070043cb6291650a0841ece432556f784f004937f0f32c", - "sha256:ade5e387d2ad0d7ebf59146cc00c8044acbd863725f887353a10df825fc8ae21", - "sha256:b00c1de48212e4cc9603895652c5c410df699856a2853135b3967591e4beebc2", - "sha256:b1282f8c00509d99fef04d8ba936b156d419be841854fe901d8ae224c59f0be5", - "sha256:b2051432115498d3562c084a49bba65d97cf251f5a331c64a12ee7e04dacc51b", - "sha256:ba59edeaa2fc6114428f1637ffff42da1e311e29382d81b339c1817d37ec93c6", - "sha256:c8716a48d94b06bb3b2524c2b77e055fb313aeb4ea620c8dd03a105574ba704f", - "sha256:cd5df75523866410809ca100dc9681e301e3c27567cf498077e8551b6d20e42f", - "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7" - ], - "version": "==1.1.1" - }, - "more-itertools": { - "hashes": [ - "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", - "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" - ], - "markers": "python_version > '2.7'", - "version": "==7.0.0" - }, - "packaging": { - "hashes": [ - "sha256:0c98a5d0be38ed775798ece1b9727178c4469d9c3b4ada66e8e6b7849f8732af", - "sha256:9e1cbf8c12b1f1ce0bb5344b8d7ecf66a6f8a6e91bcb0c84593ed6d3ab5c4ab3" - ], - "version": "==19.0" - }, - "pluggy": { - "hashes": [ - "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", - "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" - ], - "version": "==0.9.0" - }, - "pudb": { - "hashes": [ - "sha256:ac30cfc64580958ab7265decb4cabb9141f08781ff072e9a336d5a7942ce35a6" - ], - "index": "pypi", - "version": "==2019.1" - }, - "py": { - "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" - ], - "version": "==1.8.0" - }, - "pygments": { - "hashes": [ - "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", - "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" - ], - "version": "==2.3.1" - }, - "pyparsing": { - "hashes": [ - "sha256:1873c03321fc118f4e9746baf201ff990ceb915f433f23b395f5580d1840cb2a", - "sha256:9b6323ef4ab914af344ba97510e966d64ba91055d6b9afa6b30799340e89cc03" - ], - "version": "==2.4.0" - }, - "pytest": { - "hashes": [ - "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d", - "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5" - ], - "index": "pypi", - "version": "==4.4.1" - }, - "pytz": { - "hashes": [ - "sha256:303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", - "sha256:d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141" - ], - "version": "==2019.1" - }, - "requests": { - "hashes": [ - "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", - "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" - ], - "version": "==2.21.0" - }, - "six": { - "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" - ], - "version": "==1.12.0" - }, - "snowballstemmer": { - "hashes": [ - "sha256:919f26a68b2c17a7634da993d91339e288964f93c274f1343e3bbbe2096e1128", - "sha256:9f3bcd3c401c3e862ec0ebe6d2c069ebc012ce142cce209c098ccb5b09136e89" - ], - "version": "==1.2.1" - }, - "sphinx": { - "hashes": [ - "sha256:423280646fb37944dd3c85c58fb92a20d745793a9f6c511f59da82fa97cd404b", - "sha256:de930f42600a4fef993587633984cc5027dedba2464bcf00ddace26b40f8d9ce" - ], - "index": "pypi", - "version": "==2.0.1" - }, - "sphinxcontrib-applehelp": { - "hashes": [ - "sha256:edaa0ab2b2bc74403149cb0209d6775c96de797dfd5b5e2a71981309efab3897", - "sha256:fb8dee85af95e5c30c91f10e7eb3c8967308518e0f7488a2828ef7bc191d0d5d" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-devhelp": { - "hashes": [ - "sha256:6c64b077937330a9128a4da74586e8c2130262f014689b4b89e2d08ee7294a34", - "sha256:9512ecb00a2b0821a146736b39f7aeb90759834b07e81e8cc23a9c70bacb9981" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-htmlhelp": { - "hashes": [ - "sha256:4670f99f8951bd78cd4ad2ab962f798f5618b17675c35c5ac3b2132a14ea8422", - "sha256:d4fd39a65a625c9df86d7fa8a2d9f3cd8299a3a4b15db63b50aac9e161d8eff7" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-jsmath": { - "hashes": [ - "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", - "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" - ], - "version": "==1.0.1" - }, - "sphinxcontrib-qthelp": { - "hashes": [ - "sha256:513049b93031beb1f57d4daea74068a4feb77aa5630f856fcff2e50de14e9a20", - "sha256:79465ce11ae5694ff165becda529a600c754f4bc459778778c7017374d4d406f" - ], - "version": "==1.0.2" - }, - "sphinxcontrib-serializinghtml": { - "hashes": [ - "sha256:c0efb33f8052c04fd7a26c0a07f1678e8512e0faec19f4aa8f2473a8b81d5227", - "sha256:db6615af393650bf1151a6cd39120c29abaf93cc60db8c48eb2dddbfdc3a9768" - ], - "version": "==1.1.3" - }, - "urllib3": { - "hashes": [ - "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", - "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" - ], - "version": "==1.24.3" - }, - "urwid": { - "hashes": [ - "sha256:644d3e3900867161a2fc9287a9762753d66bd194754679adb26aede559bcccbc" - ], - "version": "==2.0.1" - } - } -} @@ -10,48 +10,11 @@ module. It provides the following features: For more information, consult `the official website`_. -Examples --------- - -Converting an RGB color to HSL: - -.. code-block:: python - - from thcolor import Color - - color = Color(Color.Type.RGB, 55, 23, 224) - print(color.hsl()) - -Converting a HSL color to RGB with an alpha value: - -.. code-block:: python - - from thcolor import Color, Angle - - alpha = 0.75 - color = Color(Color.Type.HSL, Angle(Angle.Type.DEG, 180), 0.5, 1.0, alpha) - print(color.rgba()) - -Converting a textual representation to the RGBA color components: - -.. code-block:: python - - from thcolor import Color - - color = Color.from_text("darker(10%, hsl(0, 1, 50.0%))") - print(color.rgba()) - -Getting the CSS color representations (with compatibility for earlier CSS -versions) from a textual representation: - -.. code-block:: python - - from thcolor import Color - - color = Color.from_text("gray(red( #123456 )/0.2/)") - for repres in color.css(): - print(f"color: {repres}") +For getting started with the module, an onboarding is available on +`the official documentation`_, as well as guides, discussion topics and +references. .. _Thomas Touhey: https://thomas.touhey.fr/ .. _textoutpc: https://textout.touhey.pro/ .. _the official website: https://thcolor.touhey.pro/ +.. _the official documentation: https://thcolor.touhey.pro/docs/ diff --git a/docs/Makefile b/docs/Makefile index 28d11c9..26375a4 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -2,12 +2,13 @@ # # You can set these variables from the command line. + SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXWATCH = sphinx-autobuild SOURCEDIR = . BUILDDIR = _build -WEBROOT = thcolor.touhey.pro:thcolor_doc +WEBROOT = hercule:thcolor/docs # Put it first so that "make" without argument is like "make help". help: @@ -22,7 +23,8 @@ help: # Livehtml build. livehtml: - $(SPHINXWATCH) -b html -z ../thcolor $(SPHINXOPTS) . $(BUILDDIR)/html + $(SPHINXWATCH) -b html --watch ../thcolor --ignore "**/.*.kate-swp" \ + $(SPHINXOPTS) . $(BUILDDIR)/html .PHONY: livehtml diff --git a/docs/angles.rst b/docs/angles.rst deleted file mode 100644 index 71d52a9..0000000 --- a/docs/angles.rst +++ /dev/null @@ -1,12 +0,0 @@ -Angles -====== - -Some color representations use angles as some of their properties. Angles can -have one of the following types: - -.. autoclass:: thcolor.Angle.Type - -Angles in ``thcolor`` are instances of the following class: - -.. autoclass:: thcolor.Angle - :members: type, degrees, gradiants, radiants, turns diff --git a/docs/api.rst b/docs/api.rst new file mode 100644 index 0000000..3b41a3b --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,13 @@ +API Reference +============= + +If you are looking for information on a specific function, class or method, +this part of the documentation is for you. + +.. toctree:: + + api/errors + api/angles + api/colors + api/decoders + api/builtin diff --git a/docs/api/angles.rst b/docs/api/angles.rst new file mode 100644 index 0000000..67a37a2 --- /dev/null +++ b/docs/api/angles.rst @@ -0,0 +1,24 @@ +Angles API +========== + +.. py:module:: thcolor.angles + +Some color representations use angles as some of their properties. The base +class for angles is the following: + +.. autoclass:: Angle + :members: asdegrees, asgradians, asradians, asturns, asprincipal, fromtext + +Subclasses are the following: + +.. autoclass:: DegreesAngle + :members: degrees + +.. autoclass:: GradiansAngle + :members: gradians + +.. autoclass:: RadiansAngle + :members: radians + +.. autoclass:: TurnsAngle + :members: turns diff --git a/docs/api/builtin.rst b/docs/api/builtin.rst new file mode 100644 index 0000000..68c92a5 --- /dev/null +++ b/docs/api/builtin.rst @@ -0,0 +1,16 @@ +Builtin API +=========== + +.. py:module:: thcolor.builtin + +The following color decoders are defined: + +.. autoclass:: CSS1ColorDecoder + +.. autoclass:: CSS2ColorDecoder + +.. autoclass:: CSS3ColorDecoder + +.. autoclass:: CSS4ColorDecoder + +.. autoclass:: DefaultColorDecoder diff --git a/docs/api/colors.rst b/docs/api/colors.rst new file mode 100644 index 0000000..ac6e814 --- /dev/null +++ b/docs/api/colors.rst @@ -0,0 +1,44 @@ +Colors API +========== + +.. py:module:: thcolor.colors + +The base class for colors is the following: + +.. autoclass:: Color + :members: alpha, assrgb, ashsl, ashsv, ashwb, ascmyk, aslab, aslch, asxyz, + asyiq, asyuv, fromtext, replace, darker, lighter, desaturate, + saturate, css + +Subclasses are the following: + +.. autoclass:: CMYKColor + :members: cyan, magenta, yellow, black, alpha + +.. autoclass:: HSLColor + :members: hue, saturation, lightness, alpha + +.. autoclass:: HSVColor + :members: hue, saturation, value, alpha + +.. autoclass:: HWBColor + :members: hue, whiteness, blackness, alpha + +.. autoclass:: LABColor + :members: lightness, a, b, alpha + +.. autoclass:: LCHColor + :members: lightness, chroma, hue, alpha + +.. autoclass:: SRGBColor + :members: red, green, blue, alpha, frombytes, + fromnetscapecolorname, asbytes + +.. autoclass:: XYZColor + :members: x, y, z, alpha + +.. autoclass:: YIQColor + :members: y, i, q, alpha + +.. autoclass:: YUVColor + :members: y, u, v, alpha diff --git a/docs/api/decoders.rst b/docs/api/decoders.rst new file mode 100644 index 0000000..abab8e7 --- /dev/null +++ b/docs/api/decoders.rst @@ -0,0 +1,21 @@ +Decoders API +============ + +.. py:module:: thcolor.decoders + +The ``thcolor.decoders`` module defines base utilities for decoding color +expressions. + +.. autoclass:: ColorDecoder + :members: decode + +For convenience, the following class is defined to define color decoders: + +.. autoclass:: MetaColorDecoder + +.. autoclass:: alias + +.. autofunction:: fallback + +See :ref:`defining-decoders` for more information about how to use these +classes. diff --git a/docs/api/errors.rst b/docs/api/errors.rst new file mode 100644 index 0000000..11a8cdc --- /dev/null +++ b/docs/api/errors.rst @@ -0,0 +1,10 @@ +Errors API +========== + +.. py:module:: thcolor.errors + +In this section, are defined all non-standard errors that are raised by +all submodules. + +.. autoclass:: ColorExpressionSyntaxError + :members: text, column, func diff --git a/docs/colors.rst b/docs/colors.rst deleted file mode 100644 index 7bff8d1..0000000 --- a/docs/colors.rst +++ /dev/null @@ -1,16 +0,0 @@ -Colors -====== - -Colors can have one of the following types: - -.. autoclass:: thcolor.Color.Type - -RGB colors can have one of the following profiles: - -.. autoclass:: thcolor.Color.Profile - -Colors are represented in ``thcolor`` as instances of the following class: - -.. autoclass:: thcolor.Color - :members: type, rgb, rgba, hls, hlsa, hwb, hwba, cmyk, cmyka, lab, laba, - lch, lcha, xyz, xyza, css diff --git a/docs/conf.py b/docs/conf.py index 0627b7e..9b6148e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,11 +10,13 @@ # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. + def _add_paths(): - import os - import sys + import os + import sys + + sys.path.insert(0, os.path.abspath('..')) - sys.path.insert(0, os.path.abspath('..')) _add_paths() @@ -26,13 +28,12 @@ author = 'Thomas Touhey' # The full version, including alpha/beta/rc tags + def _get_release(): - from os.path import dirname, join - from pkg_resources import find_distributions as find_dist + from thcolor.version import version + + return version - module_path = join(dirname(__file__), '..') - dist = next(find_dist(module_path, True)) - return dist.version release = _get_release() @@ -42,16 +43,22 @@ release = _get_release() # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc' + 'sphinx.ext.autodoc', + 'sphinx.ext.todo', + 'sphinx_autodoc_typehints', ] +todo_include_todos = True + # Add any paths that contain templates here, relative to this directory. templates_path = [] # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.goutput*'] +exclude_patterns = [ + '_build', 'Thumbs.db', '.DS_Store', '.goutput*', 'requirements.txt', +] # -- Options for HTML output ------------------------------------------------- @@ -59,7 +66,8 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', '.goutput*'] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # -html_theme = 'sphinx_rtd_theme' +html_theme = 'bizstyle' +html_favicon = 'favicon.png' # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/discuss.rst b/docs/discuss.rst new file mode 100644 index 0000000..4e3a254 --- /dev/null +++ b/docs/discuss.rst @@ -0,0 +1,11 @@ +Discussion topics +================= + +thcolor has a number of concepts necessary for a full understanding of its +conception and an efficient use of it. You can find these concepts and +discussion topics in the following sections. + +.. toctree:: + + discuss/colors + discuss/color-expressions diff --git a/docs/discuss/color-expressions.rst b/docs/discuss/color-expressions.rst new file mode 100644 index 0000000..f29578e --- /dev/null +++ b/docs/discuss/color-expressions.rst @@ -0,0 +1,89 @@ +.. _expr: + +Color expressions +================= + +One of the aims of thcolor was to decode text-based expressions +representing colors with possibilities challenging and even outdo +CSS color expression possibilities. In thcolor, these expressions are +evaluated using :py:meth:`thcolor.decoders.ColorDecoder.decode`, +:py:meth:`thcolor.colors.Color.fromtext` or +:py:meth:`thcolor.angles.Angle.fromtext`. + +Concepts +-------- + +The goal of these expressions was to embrace and extend CSS syntax, so they +are basically either basic expressions or function calls, with the following +argument types: + + * Numbers. + * Percentages. + * Angles. + * Colors. + +These elements are separated by separators (either commas, slashes, or +spaces) and can be passed to functions, and the calls themselves can be passed +to other functions. A function call is made in the following fashion: + +:: + + <function name>([<number | percentage | angle | color> [<separator> …]]) + +If at least one separator (even spaces) are required between arguments, +extraneous separators between and after the arguments are ignored. Other than +if spaces are used as separators, spaces around the parenthesis or the +separators (and "between" the separators as spaces are recognized as +separators) are ignored. + +Here are some example calls: + +:: + + rgb(1, 2, 3) + rgb ( 1 22 //// 242 , 50.0% ,/,) + hsl (0 1 50 % / 22) + gray ( red( #123456 )/0.2/) + +.. _explain-decoders: + +Decoders +-------- + +Decoders are the tools within thcolor that handle the actual evaluation of +expressions. They have two important components aside from the decoding +function: + +* Options defining the behaviour of base parsing and evaluating. +* A reference of functions, colors and other data indexed by name. + +The options currently available are the following: + +* Whether natural colors are enabled or not; see :ref:`natural-colors`. +* Whether extended hexadecimal colors, i.e. 4 or 8 digit hexadecimal digits + preceded with a '#' sign, which includes transparency, are enabled or not. +* Whether names that are not found should be resolved using Netscape-like + color parsing or not, what is also known as "quirks mode". + +The reference contains three kinds of elements, all indexed by name: + +* Constants, always to be used as values, e.g. the ``navy`` color. +* Functions, always to be used by calling them, e.g. the ``rgb`` function. +* Functions with fallbacks, which have a behaviour when called and a fallback + value when used as a value, e.g. the ``red`` function in the extended + color decoder which extracts the red channel value as a function and acts + as the color ``red`` if used as a value. + +Decoders can be defined using two base classes which act in a very different +way: + +* **The manual way.** + You can define a decoder manually by making it inherit from + :py:class:`thcolor.decoders.ColorDecoder` and overriding + ``__mapping__`` to define the reference, and other double-underscore + properties for options. +* **The user-friendly way.** + You can define a decoder in an easier way by making it inherit from + :py:class:`thcolor.decoders.MetaColorDecoder` and defining options, + functions and data by defining them directly in the class body; + see :ref:`defining-decoders` for more information. diff --git a/docs/discuss/colors.rst b/docs/discuss/colors.rst new file mode 100644 index 0000000..fb5c20d --- /dev/null +++ b/docs/discuss/colors.rst @@ -0,0 +1,138 @@ +Color representations +===================== + +thcolor allows you to represent colors in different colorspaces and using +different kind of coordinates. This page regroups the information about these +representations. + +Note that all color representations have an ``alpha`` component. + +sRGB colors +----------- + +sRGB colors are colors represented using the red, green and blue channel +intensities in the `standard RGB color space`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.SRGBColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.assrgb` +("as sRGB") method. + +CMYK colors +----------- + +CMYK colors are colors represented using the cyan, magenta, yellow and +black values in the `CMYK color model`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.CMYKColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.ascmyk` method. + +HSL colors +---------- + +HSL colors are colors represented using the hue, saturation and lightness +as polar coordinates in the sRGB color space. See `HSL and HSV`_ for more +information. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.HSLColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.ashsl` method. + +HSV colors +---------- + +HSV colors are colors represented using the hue, saturation and value +as polar coordinates in the sRGB color space. See `HSL and HSV`_ for +more information. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.HSVColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.ashsv` method. + +.. _hwb-colors: + +HWB colors +---------- + +HWB colors are colors represented using the hue, whiteness and blackness +in the `HWB color model`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.HWBColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.ashwb` method. + +LAB colors +---------- + +LAB colors are colors represented using the lightness and A and B coordinates +in the `CIELAB color space`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.LABColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.aslab` method. + +LCH colors +---------- + +LCH colors are colors represented using the lightness, chroma and hue +in the `CIELAB color space`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.LCHColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.aslch` method. + +XYZ colors +---------- + +XYZ colors are colors represented using its x, y and z coordinates +in the `CIE 1931 XYZ color space`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.XYZColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.asxyz` +method. + +YIQ colors +---------- + +YIQ colors are colors represented using its coordinates in the +`YIQ color space`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.YIQColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.asyiq` method. + +YUV colors +---------- + +YUV colors are colors represented using its coordinates in the +`YUV color space`_. + +In thcolor, such a color is represented by the +:py:class:`thcolor.colors.YUVColor` class, and other representations can be +converted to this one using the :py:meth:`thcolor.colors.Color.asyuv` +method. + +.. _natural-colors: + +Natural colors +-------------- + +Natural colors (NCol) are an initiative from W3Schools to make a color +that is easily identifiable from reading its definition. + +In thcolor, no dedicated class exists for this representation since it is +a light derivative from :ref:`HWB colors <hwb-colors>` with the angle +expressed using a given format. It is, however, optionally supported +in expressions behind the ``__ncol_support__`` option. + +.. _standard RGB color space: https://en.wikipedia.org/wiki/SRGB +.. _CMYK color model: https://en.wikipedia.org/wiki/CMYK_color_model +.. _HSL and HSV: https://en.wikipedia.org/wiki/HSL_and_HSV +.. _HWB color model: https://en.wikipedia.org/wiki/HWB_color_model +.. _CIELAB color space: https://en.wikipedia.org/wiki/CIELAB_color_space +.. _CIE 1931 XYZ color space: https://en.wikipedia.org/wiki/CIE_1931_color_space +.. _YIQ color space: https://en.wikipedia.org/wiki/YIQ +.. _YUV color space: https://en.wikipedia.org/wiki/YUV +.. _`natural colors`: https://www.w3schools.com/colors/colors_ncol.asp diff --git a/docs/expressions.rst b/docs/expressions.rst deleted file mode 100644 index 6f9e645..0000000 --- a/docs/expressions.rst +++ /dev/null @@ -1,89 +0,0 @@ -.. _expr: - -Expressions -=========== - -One of the aims of the ``thcolor`` module was to decode text-based expressions -representing colors with possibilities challenging and even outdo -CSS color expression possibilities. This is what the following static method -is for: - -.. automethod:: thcolor.Color.from_text - -Expression concepts -------------------- - -The goal of these expressions was to embrace and extend CSS syntax, so they -are basically either basic expressions or function calls, with the following -argument types: - -.. autoclass:: thcolor.Reference.number - :members: - -.. autoclass:: thcolor.Reference.percentage - :members: - -.. autoclass:: thcolor.Reference.angle - :members: - -.. autoclass:: thcolor.Reference.color - :members: - -These elements are separated by separators (either commas, slashes, or simple -spaces) and can be passed to functions, and the calls themselves can be passed -to other functions. A function call is made in the following fashion: - -:: - - <function name>(<number | percentage | angle | color> [<separator> …]) - -If at least one separator (even simple spaces) are required between arguments, -extraneous separators between and after the arguments are ignored. Other than -if spaces are used as separators, spaces around the parenthesis or the -separators (and "between" the separators as spaces are recognized as -separators) are ignored. - -Here are some example calls: - -:: - - rgb(1, 2, 3) - rgb ( 1 22 //// 242 , 50.0% ,/,) - hsl (0 1 50 % / 22) - gray ( red( #123456 )/0.2/) - -In case of incorrectly formatted string, the following exception is returned: - -.. autoexception:: thcolor.ColorExpressionDecodingError - :members: - -Defining a reference --------------------- - -Functions and color names are defined in a reference: - -- color names are defined behind an overload of - :meth:`thcolor.Reference._color`. -- functions are defined as reference class methods and use `type hints - <https://www.python.org/dev/peps/pep-0484/>`_ to describe the types - they are expecting. - -The reference must be a derivative of the following class: - -.. autoclass:: thcolor.Reference - :members: _color, functions, colors, default - -Builtin references ------------------- - -The following references are defined: - -.. autoclass:: thcolor.CSS1Reference - -.. autoclass:: thcolor.CSS2Reference - -.. autoclass:: thcolor.CSS3Reference - -.. autoclass:: thcolor.CSS4Reference - -.. autoclass:: thcolor.DefaultReference diff --git a/docs/favicon.ico b/docs/favicon.ico Binary files differnew file mode 100644 index 0000000..2946f11 --- /dev/null +++ b/docs/favicon.ico diff --git a/docs/favicon.png b/docs/favicon.png Binary files differnew file mode 100644 index 0000000..4ebff7a --- /dev/null +++ b/docs/favicon.png diff --git a/docs/guides.rst b/docs/guides.rst new file mode 100644 index 0000000..8f5a931 --- /dev/null +++ b/docs/guides.rst @@ -0,0 +1,8 @@ +Guides +====== + +This section consists of multiple guides for solving specific problems. + +.. toctree:: + + guides/defining-decoders diff --git a/docs/guides/defining-decoders.rst b/docs/guides/defining-decoders.rst new file mode 100644 index 0000000..c9c1da5 --- /dev/null +++ b/docs/guides/defining-decoders.rst @@ -0,0 +1,216 @@ +.. _defining-decoders: + +Defining a decoder +================== + +The goal of this guide is to bootstrap you into making your own color decoder +using tools from thcolor. For clarity, you should first read :ref:`expr`, +especially the :ref:`explain-decoders` section. + +For this guide, we will want to do the following: + +* Create our ``TutorialColorDecoder`` with extended hexadecimal color + support. +* Create a ``sum`` function which can take 2 to 5 numbers and sum them. +* Create a ``diff`` function which takes 2 numbers and return the first one + minus the second one. +* Create a ``reverse_diff`` which takes 2 numbers and return the second one + minus the first one. +* Create an ``transparent`` function which returns the alpha value + as a byte between 0 and 255, and returns the transparent color when + used as a value. +* Create a ``awesome-color`` constant which evaluates as blue. + +Setting up the framework +------------------------ + +First of, the elements you will want to import are the following: + +* :py:class:`thcolor.decoders.MetaColorDecoder`, which will be the base + class your decoder will inherit from. +* :py:class:`thcolor.decoders.alias`, which will be useful when you want to + define an alias for an existing function without doing it manually. +* :py:class:`thcolor.decoders.fallback`, which will be useful when you want + to define a fallback value for a given function. +* Colors we might want to use from :py:mod:`thcolor.colors`. +* Angles we might want to use from :py:mod:`thcolor.angles`. + +For our exercise, we are going to use :py:class:`thcolor.colors.SRGBColor` +and no angles. + +Our base code is the following: + +.. code-block:: python + + from thcolor.decoders import MetaColorDecoder, alias, fallback + from thcolor.colors import Color, SRGBColor + + __all__ = ['TutorialColorDecoder'] + + class TutorialColorDecoder(MetaColorDecoder): + pass + +As we want extended hexadecimal colors to be read as well, we need to define +the corresponding option. As described in +:py:class:`thcolor.decoders.ColorDecoder`, the option for doing this is +``__extended_hex_support__``, which gives the following class definition: + +.. code-block:: + + class TutorialColorDecoder(MetaColorDecoder): + __extended_hex_support__ = True + +Defining the ``sum`` function +----------------------------- + +As described in the constraints, the ``sum`` function will sum 2 to 5 +numbers; the type of these numbers is not given, so we have three options: + +* If the hint on these numbers is ``int``, only integers will be accepted + by the function, and floats with decimal parts will throw an error. +* If the hint on these numbers is ``float``, integers will be converted + to ``float`` before being passed on to the function. +* If the hint on these numbers is ``typing.Union[int, float]``, the + function will receive both depending on what the syntax was in the + original expression. + +Here, we choose to use ``float``. The resulting code is the following: + +.. code-block:: + + class TutorialColorDecoder(MetaColorDecoder): + # ... + + def sum( + a: float, b: float, c: float = 0, + d: float = 0, e: float = 0, + ) -> float: + return a + b + c + d + e + +Defining the ``diff`` and ``reverse_diff`` functions +---------------------------------------------------- + +As described in the constraints, the ``diff`` function will compute the +difference between two numbers. Based on what we've learned in the previous +function, we can do the following: + +.. code-block:: + + class TutorialColorDecoder(MetaColorDecoder): + # ... + + def diff(a: float, b: float) -> float: + return a - b + +Now for the ``reverse_diff`` function, we could define it independently, +but in order to save time, we will use :py:class:`thcolor.decoders.alias`. +This function takes the following arguments: + +* The name of the function to alias. +* The new order of the arguments, by name. + +Based on this information, the :py:class:`thcolor.decoders.MetaColorDecoder` +will create the function out of the previous function (by calling it) and +take all related annotations and default values (if possible and available). + +For our current case, we can define our function the following way: + +.. code-block:: + + class TutorialColorDecoder(MetaColorDecoder): + # ... + + reverse_diff = alias('diff', args=('b', 'a')) + +Note that aliases can be defined anywhere within the class, even before +the aliased function. + +Defining the ``transparent`` function with a fallback value +----------------------------------------------------------- + +For the ``transparent`` function, we will get the alpha value, multiply it +by 255 and round it to the nearest integer. However, how do we add a fallback +value for the function? :py:class:`thcolor.decoders.fallback` comes to +our rescue! + +Once our function is defined, we use :py:class:`thcolor.decoders.fallback` +as a decorator by giving it the value the function should fall back on when +used as a constant. + +The resulting code is the following: + +.. code-block:: + + class TutorialColorDecoder(MetaColorDecoder): + # ... + + @fallback(SRGBColor(0, 0, 0, 0.0)) + def transparent(color: Color) -> int: + return int(round(color.alpha * 255)) + +Defining the ``awesome-color`` constant +--------------------------------------- + +For defining a constant, we can use affectations, as for aliases. +But how do we define names with carets when they are illegal in Python names? +We can replace it with ``_``; the color decoders in thcolor are not only +case-insensitive, they also treat carets and underscores the same. + +We can thus do the following: + +.. code-block:: + + class TutorialColorDecoder(MetaColorDecoder): + # ... + + awesome_color = SRGBColor.frombytes(0, 0, 255) + +Testing the resulting class +--------------------------- + +Putting all of our efforts together, we should have the following code: + +.. code-block:: python + + from thcolor.decoders import MetaColorDecoder, alias, fallback + from thcolor.colors import Color, SRGBColor + + __all__ = ['TutorialColorDecoder'] + + class TutorialColorDecoder(MetaColorDecoder): + __extended_hex_support__ = True + + def sum( + a: float, b: float, c: float = 0, + d: float = 0, e: float = 0, + ) -> float: + return a + b + c + d + e + + def diff(a: float, b: float) -> float: + return a - b + + reverse_diff = alias('diff', args=('b', 'a')) + + @fallback(SRGBColor(0, 0, 0, 0.0)) + def transparent(color: Color) -> int: + return int(round(color.alpha * 255)) + + awesome_color = SRGBColor.frombytes(0, 0, 255) + +Now that our class is defined, we can instanciate and test our decoder: + +.. code-block:: python + + decoder = TutorialColorDecoder() + results = decoder.decode( + 'awesome-color ' + 'sum(sum(1, 2, 3, 4), reverse_diff(transparent(transparent), 4))', + ) + + print(results) + +And the result we obtain are the following: + +:: + + (SRGBColor(red=0.0, green=0.0, blue=1.0, alpha=1.0), 14.0) diff --git a/docs/index.rst b/docs/index.rst index 33e175d..a7089eb 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,31 +1,38 @@ Welcome to thcolor's documentation! =================================== -This module is a color management module made by `Thomas Touhey`_ (``th`` -is for ``touhey``) for the `textoutpc`_ project, a BBCode to HTML translation -module. It provides the following features: +thcolor is a color management module made by `Thomas Touhey`_ +(``th`` is for ``touhey``). -- color management and conversions between formats (RGB, HSL, HWB, NCol, …). -- text-to-color using close-to-CSS format. +It was originally made for the `textoutpc`_ project, a BBCode to HTML +translation module. -To install the module, use pip: +thcolor allows you to: -.. code-block:: bash +* Represent colors using various colorspaces and coordinates. +* Convert between these representations. +* Parse colors and related elements using expressions inspired by (and + compatible with given the right options) CSS standards by the W3C. +* Make CSS expressions out of given color representations. - $ pip install thcolor +For more information, you can consult: -For more information and links, consult `the official website`_. +* The project homepage at `thcolor.touhey.pro`_. +* The PyPI project page at `pypi.org`_. +* The source at `forge.touhey.org`_. -Table of contents ------------------ +The documentation contents is the following: .. toctree:: :maxdepth: 2 - angles - colors - expressions + onboarding + guides + discuss + api .. _Thomas Touhey: https://thomas.touhey.fr/ .. _textoutpc: https://textout.touhey.pro/ -.. _the official website: https://thcolor.touhey.pro/ +.. _thcolor.touhey.pro: https://thcolor.touhey.pro/ +.. _pypi.org: https://pypi.org/project/thcolor/ +.. _forge.touhey.org: https://forge.touhey.org/thcolor.git/ diff --git a/docs/onboarding.rst b/docs/onboarding.rst new file mode 100644 index 0000000..fb28fe0 --- /dev/null +++ b/docs/onboarding.rst @@ -0,0 +1,12 @@ +Onboarding +========== + +You're a new user trying to figure out what you can and cannot do with +thcolor, and you're willing to experiment? You're at the right place! +In this section, you will be able to install, run and start tweaking +thcolor to better suit your needs. + +.. toctree:: + + onboarding/installing + onboarding/trying diff --git a/docs/onboarding/installing.rst b/docs/onboarding/installing.rst new file mode 100644 index 0000000..3677dcf --- /dev/null +++ b/docs/onboarding/installing.rst @@ -0,0 +1,36 @@ +Installing thcolor +================== + +In order to run and tweak thcolor, you must first install it; this section +will cover the need. + +Installing thcolor using pip +---------------------------- + +To install thcolor, you can use pip with the following command: + +.. code-block:: sh + + python -m pip install thcolor + +Some notes on this command: + + * On most Linux distributions, you can directly call ``pip`` (or ``pip3`` + on those where Python 2.x is still the default); I personnally prefer + to call it through Python as a module. + * On Linux and other UNIX-like distributions where Python 2.x is still the + default, when Python 3.x is installed, you must usually call it using + ``python3`` instead of ``python``. + * On Microsoft Windows, the Python executable, when added to the PATH, + goes by the name ``py`` instead of ``python``. + +Installing thcolor from source +------------------------------ + +To install thcolor from source, you can use the following commands: + +.. code-block:: sh + + python ./setup.py install --system # or --user + +.. _regex: https://pypi.org/project/regex/ diff --git a/docs/onboarding/trying.rst b/docs/onboarding/trying.rst new file mode 100644 index 0000000..85ac502 --- /dev/null +++ b/docs/onboarding/trying.rst @@ -0,0 +1,47 @@ +Trying out thcolor +================== + +Once thcolor is installed, it is time to put it to the test! +Here are a few use cases for the library. + +Converting an RGB color to HSL: + +.. code-block:: python + + from thcolor.colors import SRGBColor + + color = SRGBColor.frombytes(55, 23, 224) + print(color.ashsl()) + +Converting a HSL color to RGB with an alpha value: + +.. code-block:: python + + from thcolor.colors import HSLColor + from thcolor.angles import DegreesAngle + + color = HSLColor(DegreesAngle(180), 0.5, 1.0, 0.75) + print(color.assrgb()) + +Converting a textual representation to the RGBA color components: + +.. code-block:: python + + from thcolor.colors import Color + + color = Color.fromtext('darker(10%, hsl(0, 1, 50.0%))') + print(color.assrgb()) + +Getting the CSS color representations (with compatibility for earlier CSS +versions) from a textual representation: + +.. code-block:: python + + from thcolor.colors import Color + + color = Color.fromtext('gray(red( #123456 )/0.2/)') + for repres in color.css(): + print(f'color: {repres}') + +For more information, please consult the guides, discussion topics and +API reference on the current documentation. diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..bf6bf6c --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +sphinx +sphinx-autobuild +sphinx_autodoc_typehints +sphinx_rtd_theme diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/requirements.txt @@ -1,9 +1,9 @@ [metadata] name = thcolor -version = attr: thcolor.version +version = attr: thcolor.version.version url = https://thcolor.touhey.pro/ project_urls = - Documentation = https://thcolor.touhey.pro/docs/ + Documentation = https://thcolor.touhey.pro/docs/ author = Thomas Touhey author_email = thomas@touhey.fr description = color management module @@ -11,20 +11,19 @@ long_description = file: README.rst keywords = textout, color, parser, css license = MIT classifiers = - Development Status :: 4 - Beta - Natural Language :: English - License :: OSI Approved :: MIT License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Intended Audience :: Developers - Topic :: Text Processing :: Markup :: HTML + Development Status :: 4 - Beta + Natural Language :: English + License :: OSI Approved :: MIT License + Operating System :: OS Independent + Programming Language :: Python :: 3 + Intended Audience :: Developers + Topic :: Text Processing :: Markup :: HTML [options] zip_safe = False include_package_data = True -packages = thcolor, thcolor.builtin -install_requires = - regex +packages = thcolor +python_requires = >= 3.6 [options.package_data] * = *.txt, *.rst @@ -36,9 +35,16 @@ source-dir = docs universal = True [flake8] -ignore = F401, F403, E128, E131, E241, E261, E265, E271, W191 -exclude = .git, __pycache__, build, dist, docs/conf.py, test.py, test - -[tool:pytest] -python_files = tests.py test_*.py *_tests.py -testpaths = tests +ignore = D105,D107,D202,W503,T499 +exclude = + docs/conf.py + test.py +per-file-ignores = + tests/*:F403,F405,S101,D102,D103,T484 +rst-roles = + py:class + py:attr + py:data + py:meth +rst-directives = + py:data @@ -1,22 +1,21 @@ #!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor Python 3.x module, which is MIT-licensed. -#****************************************************************************** -""" Setup script for the thcolor Python package and script. """ +# ***************************************************************************** +# Copyright (C) 2019-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor Python module, which is MIT-licensed. +# ***************************************************************************** +"""Setup script for the thcolor Python package and script.""" from setuptools import setup as _setup kwargs = {} try: - from sphinx.setup_command import BuildDoc as _BuildDoc - kwargs['cmdclass'] = {'build_sphinx': _BuildDoc} -except: - pass + from sphinx.setup_command import BuildDoc as _BuildDoc + kwargs['cmdclass'] = {'build_sphinx': _BuildDoc} +except ImportError: + pass # Actually, most of the project's data is read from the `setup.cfg` file. - _setup(**kwargs) # End of file. diff --git a/tests/__init__.py b/tests/__init__.py index 47268e6..9e1e6db 100755 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# ***************************************************************************** +# Copyright (C) 2019-2021 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> # This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Unit tests for the `thcolor` Python module. """ +# ***************************************************************************** +"""Unit tests for the `thcolor` Python module.""" # This file is only there to indicate that the folder is a module. # It doesn't actually contain code. diff --git a/tests/test_angles.py b/tests/test_angles.py new file mode 100755 index 0000000..9438924 --- /dev/null +++ b/tests/test_angles.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2021 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Unit tests for the thcolor color decoding and management module.""" + +from math import pi + +import pytest +from thcolor.angles import * # NOQA + + +class TestAngles: + """Test angle definitions and conversions.""" + + @pytest.mark.parametrize('fst,snd', ( + (GradiansAngle(.1), GradiansAngle(.2)), + )) + def test_not_equal(self, fst, snd): + assert fst != snd + + @pytest.mark.parametrize('angle,expected', ( + (DegreesAngle(400), DegreesAngle(40)), + (DegreesAngle(-10), DegreesAngle(350)), + (TurnsAngle(-1.5), TurnsAngle(1)), + (RadiansAngle(6 * pi), RadiansAngle(0)), + (RadiansAngle(7.5 * pi), RadiansAngle(1.5 * pi)), + (GradiansAngle(-975), GradiansAngle(225)), + )) + def test_principal_angles(self, angle, expected): + return angle.asprincipal() == expected + + @pytest.mark.parametrize('test_input,expected', ( + ('120deg', DegreesAngle(120)), + ('5rad', RadiansAngle(5)), + ('3grad', GradiansAngle(3)), + ('6.turns', TurnsAngle(6)), + ('355', DegreesAngle(355)), + )) + def test_expr(self, test_input, expected): + angle = Angle.fromtext(test_input) + assert isinstance(angle, type(expected)) + assert angle == expected + + @pytest.mark.parametrize('rad,deg', ( + (0, 0), + (-pi / 2, 270), + (pi / 2, 90), + (3 * pi / 4, 135), + (-3 * pi / 4, 225), + )) + def test_radians_to_degrees(self, rad, deg): + return RadiansAngle(rad).asprincipal().asdegrees().degrees == deg + + +# End of file. diff --git a/tests/test_builtin.py b/tests/test_builtin.py new file mode 100755 index 0000000..a43e724 --- /dev/null +++ b/tests/test_builtin.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2021 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Unit tests for the thcolor color decoding and management module.""" + +import pytest +from thcolor.angles import * # NOQA +from thcolor.colors import * # NOQA +from thcolor.decoders import * # NOQA +from thcolor.builtin import * # NOQA +from thcolor.errors import * # NOQA + + +class TestDefaultDecoder: + """Test the default decoder with all features.""" + + @pytest.fixture + def decoder(self): + return DefaultColorDecoder() + + @pytest.mark.parametrize('test_input,expected', ( + ('blue', SRGBColor.frombytes(0, 0, 255, 1.00)), + ('rgb(110%, 0%, 0%)', SRGBColor(1.0, 0, 0, 1.00)), + ('rgb(1, 22,242)', SRGBColor.frombytes(1, 22, 242, 1.00)), + (' rgb (1,22, 242 , 50.0% )', SRGBColor.frombytes(1, 22, 242, 0.50)), + (' rgb (1 22/ 242,50.0%,/)', SRGBColor.frombytes(1, 22, 242, 0.50)), + ('rgba(1,22,242,0.500)', SRGBColor.frombytes(1, 22, 242, 0.50)), + ('rbga(5, 7)', SRGBColor.frombytes(5, 0, 7, 1.00)), + ('hsl(0, 1,50.0%)', HSLColor(DegreesAngle(0.0), 1.0, 0.5, 1.00)), + ( + 'hls(0 / 1 0.5 , 0.2)', + HSLColor(DegreesAngle(0.0), 0.5, 1.0, 0.20), + ), + ('hwb(0 0% 0)', HWBColor(DegreesAngle(0), 0.0, 0.0, 1.00)), + ('hbw(127 .5)', HWBColor(DegreesAngle(127), 0.0, 0.5)), + ('gray(100)', SRGBColor.frombytes(100, 100, 100, 1.00)), + ('gray(100 / 55 %)', SRGBColor.frombytes(100, 100, 100, 0.55)), + ('gray(red( #123456 )/0.2/)', SRGBColor.frombytes(18, 18, 18, 0.20)), + ('B20 50% 32%', HWBColor(DegreesAngle(252), .5, .32, 1.00)), + ('ncol(B20 / 50% 32%)', HWBColor(DegreesAngle(252), .5, .32, 1.00)), + ('cmyk(0% 37% 0.13 .78)', CMYKColor(0, .37, .13, .78, 1.00)), + ( + 'darker(10%, hsl(0, 1, 50.0%))', + HSLColor(DegreesAngle(0), 1.00, 0.40, 1.00), + ), + ( + 'lighter(50%, hsl(0, 1, 60.0%))', + HSLColor(DegreesAngle(0), 1.00, 1.00, 1.00), + ), + ( + 'saturate(10%, hls(0, 1, 85.0%))', + HSLColor(DegreesAngle(0), 0.95, 1.00, 1.00), + ), + ( + 'desaturate(10%, hls(0turn, 1, 5%, 0.2))', + HSLColor(DegreesAngle(0), 0.00, 1.00, 0.20), + ), + ( + 'rgba(255, 0, 0, 20 %)', + HSLColor(DegreesAngle(0), 1.00, 0.50, 0.20), + ), + ( + 'Y40, 33%, 55%', + HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00), + ), + ('cmyk(0% 37% 0.13 .78)', CMYKColor(0.00, 0.37, 0.13, 0.78, 1.00)), + )) + def test_decoding_colors(self, decoder, test_input, expected): + result = decoder.decode(test_input, prefer_colors=True) + assert result[0] == expected + +# End of file. diff --git a/tests/test_colors.py b/tests/test_colors.py new file mode 100755 index 0000000..7d0043d --- /dev/null +++ b/tests/test_colors.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2021 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Unit tests for the thcolor color decoding and management module.""" + +import pytest +from thcolor.angles import * # NOQA +from thcolor.colors import * # NOQA +from thcolor.utils import round_half_up + + +class TestSRGBColors: + """Test the sRGB color conversions.""" + + @pytest.mark.parametrize('args', ( + (None, 0, 0), + (0, None, 0), + (0, 0, None), + )) + def test_invalid_values(self, args): + """Try to instanciate the class using invalid values.""" + + with pytest.raises(ValueError): + SRGBColor(*args) + + @pytest.mark.parametrize('args,bytes_', ( + ((0, 0, 0), (0, 0, 0)), + ((.0019, .002, .005), (0, 1, 1)), + ((.1, .2, .3), (26, 51, 77)), + )) + def test_to_bytes(self, args, bytes_): + """Try converting to bytes and test the rounding up.""" + + assert SRGBColor(*args).asbytes() == bytes_ + + @pytest.mark.parametrize('args,bytes_', ( + ((0, .003921, .003925), (0, 1, 1)), + )) + def test_from_bytes(self, args, bytes_): + """Try converting from bytes and test the rounding up.""" + + assert SRGBColor(*args) == SRGBColor.frombytes(*bytes_) + + @pytest.mark.parametrize('name,bytes_', ( + # https://stackoverflow.com/q/8318911 + ('chucknorris', (192, 0, 0)), + ('ninjaturtle', (0, 160, 0)), + ('crap', (192, 160, 0)), + ('grass', (0, 160, 0)), + + # https://bugzilla.mozilla.org/show_bug.cgi?id=121738 + ('navyblue', (160, 176, 224)), + + # http://web.archive.org/web/20050403162633/http://www.jgc.org:80/tsc/index.htm + # See the "Flex Hex" technique. + ('FxFxFx', (240, 240, 240)), + ('#FxFxFx', (240, 240, 240)), + ('FqFeFm', (240, 254, 240)), + + # http://scrappy-do.blogspot.com/2004/08/little-rant-about-microsoft-internet.html + ('zqbttv', (0, 176, 0)), + ('6db6ec49efd278cd0bc92d1e5e072d680', (110, 205, 224)), + )) + def test_netscape_decoding(self, name, bytes_): + """Try decoding colors using Netscape color parsing.""" + + assert SRGBColor.fromnetscapecolorname(name).asbytes() == bytes_ + + @pytest.mark.parametrize('args,expected', ( + ((0, 0, 255), ('#0000FF',)), + ((1, 22, 242, .5), ( + '#0116F2', + 'rgba(1, 22, 242, 50%)', + )), + )) + def test_css(self, args, expected): + assert tuple(SRGBColor.frombytes(*args).css()) == expected + + @pytest.mark.parametrize('args,hsl', ( + ((0, 0, 0), (DegreesAngle(0), 0, 0)), + ((1, 2, 3), (DegreesAngle(210), .5, .01)), + ((50, 100, 150), (DegreesAngle(210), .5, .39)), + ((255, 255, 255), (DegreesAngle(0), 0, 1)), + ((82, 122, 122), (DegreesAngle(180), .2, .4)), + )) + def test_hsl(self, args, hsl): + hue, sat, lgt, *_ = SRGBColor.frombytes(*args).ashsl() + assert (hue, round(sat, 2), round(lgt, 2)) == hsl + + @pytest.mark.parametrize('args,hsv', ( + ((0, 0, 0), (DegreesAngle(0), 0, 0)), + ((255, 255, 255), (DegreesAngle(0), 0, 1)), + ((0, 255, 0), (DegreesAngle(120), 1, 1)), + ((0, 120, 0), (DegreesAngle(120), 1, .47)), + ((73, 33, 86), (DegreesAngle(285), .62, .34)), + ((10, 20, 30), (DegreesAngle(210), .67, .12)), + )) + def test_hsv(self, args, hsv): + hue, sat, val, *_ = SRGBColor.frombytes(*args).ashsv() + hue = DegreesAngle(int(hue.asdegrees().degrees)) + assert (hue, round(sat, 2), round(val, 2)) == hsv + + @pytest.mark.parametrize('args,hwb', ( + ((82, 122, 122), (DegreesAngle(180), .32, .52)), + ((13, 157, 230), (DegreesAngle(200), .05, .1)), + ((212, 212, 212), (DegreesAngle(0), .83, .17)), + )) + def test_hwb(self, args, hwb): + hue, wht, blk, *_ = SRGBColor.frombytes(*args).ashwb() + hue = DegreesAngle(int(hue.asdegrees().degrees)) + assert (hue, round(wht, 2), round(blk, 2)) == hwb + + @pytest.mark.parametrize('args,yiq', ( + ((0, 0, 0), (0, 0, 0)), + ((255, 255, 255), (1, 0, 0)), + ((0, 255, 0), (.587, -.275, -.523)), + ((0, 120, 0), (.276, -.129, -.246)), + ((73, 33, 86), (.2, .0271, .0983)), + ((10, 20, 30), (.07098, -.03611, .00389)), + )) + def test_yiq(self, args, yiq): + y, i, q, *_ = SRGBColor.frombytes(*args).asyiq() + print((y, i, q), yiq) + + yiq2 = tuple(map(lambda x: round_half_up(x, 3), (y, i, q))) + yiq = tuple(map(lambda x: round_half_up(x, 3), yiq)) + assert yiq == yiq2 + + @pytest.mark.parametrize('args,cmyk', ( + ((0, 0, 0), (0, 0, 0, 1)), + ((255, 255, 255), (0, 0, 0, 0)), + ((0, 255, 0), (1, 0, 1, 0)), + ((0, 120, 0), (1, 0, 1, .53)), + ((73, 33, 86), (.15, .62, 0, .66)), + ((10, 20, 30), (.67, .33, 0, .88)), + )) + def test_cmyk(self, args, cmyk): + c, m, y, k, *_ = SRGBColor.frombytes(*args).ascmyk() + assert ( + round_half_up(c, 2), + round_half_up(m, 2), + round_half_up(y, 2), + round_half_up(k, 2), + ) == cmyk + + +class TestHSLColors: + """Test the HSL color conversions.""" + + @pytest.mark.parametrize('args', ( + (0, 0, 0), + ('hello', 0, 0), + (DegreesAngle(0), -.5, 0), + (DegreesAngle(0), 1.5, 0), + (DegreesAngle(0), 0, -.5), + (DegreesAngle(0), 0, 1.5), + )) + def test_invalid_values(self, args): + """Try to instanciate the class using invalid values.""" + + with pytest.raises(ValueError): + HSLColor(*args) + + @pytest.mark.parametrize('args,rgb', ( + ((DegreesAngle(195), 1, .5), (0, 191, 255)), + ((DegreesAngle(240), 1, .25), (0, 0, 128)), + ((DegreesAngle(0), 1, .5), (255, 0, 0)), + ((DegreesAngle(0), 0, 0), (0, 0, 0)), + ((DegreesAngle(0), 0, 1), (255, 255, 255)), + ((DegreesAngle(0), 0, .01), (3, 3, 3)), + ((DegreesAngle(145), .30, .60), (122, 184, 148)), + )) + def test_srgb(self, args, rgb): + r, g, b = HSLColor(*args).assrgb().asbytes() + assert (r, g, b) == rgb + + @pytest.mark.parametrize('args,expected', ( + ((DegreesAngle(0), 1, .4), ( + '#CC0000', + 'hsl(0deg, 100%, 40%)', + )), + ((DegreesAngle(0), .5, 1, .2), ( + '#FFFFFF', + 'rgba(255, 255, 255, 20%)', + 'hsla(0deg, 50%, 100%, 20%)', + )), + )) + def test_css(self, args, expected): + assert tuple(HSLColor(*args).css()) == expected + + +class TestHSVColor: + """Test the HSV color conversions.""" + + @pytest.mark.parametrize('args,rgb', ( + ((DegreesAngle(120), 0, 0), (0, 0, 0)), + ((DegreesAngle(120), .5, .5), (64, 128, 64)), + ((DegreesAngle(120), 1, .5), (0, 128, 0)), + ((DegreesAngle(120), .5, 1), (128, 255, 128)), + ((DegreesAngle(355), .2, .8), (204, 163, 167)), + )) + def test_srgb(self, args, rgb): + assert HSVColor(*args).assrgb().asbytes() == rgb + + +class TestHWBColors: + """Test the HWB color conversions.""" + + @pytest.mark.parametrize('args,rgb', ( + ((DegreesAngle(145), .48, .28), (122, 184, 148)), + )) + def test_srgb(self, args, rgb): + r, g, b = HWBColor(*args).assrgb().asbytes() + assert (r, g, b) == rgb + + @pytest.mark.parametrize('args,expected', ( + ((DegreesAngle(127), 0, .5), ( + '#00800F', + 'hwb(127deg, 0%, 50%)', + )), + )) + def test_css(self, args, expected): + assert tuple(HWBColor(*args).css()) == expected + + +class TestCMYKColors: + """Test the CMYK color conversions.""" + + @pytest.mark.parametrize('args,rgb', ( + ((0, 0, 0, 0), (255, 255, 255)), + ((0, 0, 0, 1), (0, 0, 0)), + ((0, 0, 0, .45), (140, 140, 140)), + ((0, .25, 0, .45), (140, 105, 140)), + ((0, .25, .77, .45), (140, 105, 32)), + ((.02, .04, .06, .08), (230, 225, 221)), + )) + def test_srgb(self, args, rgb): + assert CMYKColor(*args).assrgb().asbytes() == rgb + + +class TestLABColors: + """Test the LAB color conversions.""" + + @pytest.mark.parametrize('args,lch', ( + # Examples created using the following online converter: + # http://www.easyrgb.com/en/convert.php + + ((0, 0, 0), (0, 0, DegreesAngle(0))), + ((.5, -128, 128), (.5, 181.019, DegreesAngle(135))), + )) + def test_lch(self, args, lch): + l, c, h, *_ = LABColor(*args).aslch() + assert ( + round_half_up(l, 3), + round_half_up(c, 3), + h.asdegrees(), + ) == lch + + +class TestLCHColors: + """Test the LCH color conversions.""" + + @pytest.mark.parametrize('args,lab', ( + # Examples created using the following online converter: + # http://www.easyrgb.com/en/convert.php + + ((0, 0, DegreesAngle(0)), (0, 0, 0)), + ((0, 50, DegreesAngle(0)), (0, 50, 0)), + ((.5, 50, DegreesAngle(0)), (.5, 50, 0)), + ((.5, 50, DegreesAngle(235)), (.5, -28.679, -40.958)), + ((.6, 200, DegreesAngle(146)), (.6, -165.808, 111.839)), + ((1, 200, DegreesAngle(0)), (1, 200, 0)), + )) + def test_lab(self, args, lab): + l, a, b, *_ = LCHColor(*args).aslab() + l2, a2, b2 = lab + assert ( + round_half_up(l, 3), + round_half_up(a, 3), + round_half_up(b, 3), + ) == ( + round_half_up(l2, 3), + round_half_up(a2, 3), + round_half_up(b2, 3), + ) + + +class TestXYZColors: + """Test the XYZ color conversions.""" + + @pytest.mark.parametrize('args', ( + (-.5, 0, 0), + (1.5, 0, 0), + (0, -.5, 0), + (0, 1.5, 0), + (0, 0, -.5), + (0, 0, 1.5), + (None, 0, 0), + (0, None, 0), + (0, 0, None), + )) + def test_invalid_values(self, args): + """Try to instanciate the class using invalid values.""" + + with pytest.raises(ValueError): + XYZColor(*args) + + @pytest.mark.parametrize('args,rgb', ( + ((0, 0, 0), (0, 0, 0)), + ((1, 1, 1), (277, 249, 244)), + ((.1, .2, .3), (-438, 147, 145)), + ((1, .5, 0), (378, -103, -153)), + )) + def test_rgb(self, args, rgb): + assert XYZColor(*args).assrgb().asbytes() == rgb + + @pytest.mark.parametrize('args,lab', ( + ((1, .5, 0), (.76069, 111.688, 131.154)), + ((0, .5, 1), (.76069, -327.885, -35.666)), + ((.2, .3, .4), (.61654, -37.321, -9.353)), + )) + def test_lab(self, args, lab): + l, a, b, *_ = XYZColor(*args).aslab() + l2, a2, b2 = lab + + l, a, b = ( + round_half_up(l, 3), + round_half_up(a, 3), + round_half_up(b, 3), + ) + l2, a2, b2 = ( + round_half_up(l2, 3), + round_half_up(a2, 3), + round_half_up(b2, 3), + ) + + assert (l, a, b) == (l2, a2, b2) + + +# TODO: add tests for invalid values in constructors. +# TODO: test YIQ colors. +# TODO: test YUV colors. + +# End of file. diff --git a/tests/test_decoders.py b/tests/test_decoders.py new file mode 100755 index 0000000..e3423aa --- /dev/null +++ b/tests/test_decoders.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2021 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Unit tests for the thcolor color decoding and management module.""" + +from typing import Any + +import pytest + +from thcolor.angles import * +from thcolor.colors import * +from thcolor.decoders import * +from thcolor.errors import * + + +class TestInvalidDecoders: + """Test the exceptions raised when an invalid decoder is created.""" + + def test_unknown_data_type(self): + with pytest.raises(TypeError, match=r'neither an alias'): + class BogusDecoder(MetaColorDecoder): + hello = 'hello' + + def test_circular_aliases(self): + with pytest.raises(ValueError, match=r'cyclic dependency'): + class BogusDecoder(MetaColorDecoder): + a = alias('b') + b = alias('a') + + def test_alias_unknown_argument(self): + with pytest.raises(ValueError, match=r'not an argument'): + class BogusDecoder(MetaColorDecoder): + def f(a: int) -> int: + return a + + g = alias('f', args=('b',)) + + def test_alias_non_default_argument(self): + with pytest.raises(ValueError, match=r'left without value'): + class BogusDecoder(MetaColorDecoder): + def f(a: int, b: int = 0) -> int: + return a + b + + g = alias('f', args=('b',)) + + def test_varargs(self): + with pytest.raises(ValueError, match=r'variable arg'): + class BogusDecoder(MetaColorDecoder): + def f(a: int, b: int = 0, *args) -> int: + return a + b + sum(args) + + +class TestBaseDecoder: + """Test the base decoder with no options enabled.""" + + @pytest.fixture + def decoder(self): + class StrictDecoder(MetaColorDecoder): + B20 = 12.34 + heLLo = 56.78 + + return StrictDecoder() + + @pytest.mark.parametrize('test_input,expected', ( + ('1 / 2 , 3', ( + 1, 2, 3.0, + )), + ('20deg 4turn 3,, 3. 3.0 .2 50%', ( + DegreesAngle(20), TurnsAngle(4), 3, None, 3.0, 3.0, 0.2, 0.5, + )), + ('#12345F', ( + SRGBColor.frombytes(18, 52, 95, 1.00), + )), + )) + def test_syntax(self, decoder, test_input, expected): + """Test basic syntax. + + Tests syntaxes that are always available, with separators. + """ + + result = decoder.decode(test_input) + assert result == expected + + @pytest.mark.parametrize('test_input,expected', ( + ('20.5, 20 / 19.5deg', ( + DegreesAngle(20.5), DegreesAngle(20), DegreesAngle(19.5), + )), + )) + def test_coersce_into_angles(self, decoder, test_input, expected): + """Test the decoding while indicating that we want angles.""" + + result = decoder.decode(test_input, prefer_angles=True) + assert result == expected + + def test_existing_variable(self, decoder): + """Test getting an existing variable.""" + + result = decoder.decode('hello') + assert result == (56.78,) + + def test_non_existing_variable(self, decoder): + """Test getting a non-existing variable.""" + + with pytest.raises(ColorExpressionSyntaxError, match=r'unknown value'): + decoder.decode('nonexisting') + + def test_existing_variable_ncol_format(self, decoder): + """Test decoding an expression using an NCol-like variable. + + We want to avoid the NCol subsytem from shadowing + existing variables when NCol support is disabled. + """ + + result = decoder.decode('B20') + assert result == (12.34,) + + def test_non_existing_variable_ncol_format(self, decoder): + """Test decoding an expression using an NCol-like variable. + + We want to avoid the NCol subsystem intercepting errors + in case of non-existing variables. + """ + + with pytest.raises(ColorExpressionSyntaxError, match=r'unknown value'): + decoder.decode('Y40') + + def test_disabled_extended_hex(self, decoder): + """Test decoding an expression using an extended hex color. + + This should be disabled in this state. + """ + + with pytest.raises(ColorExpressionSyntaxError, match=r'extended hex'): + decoder.decode('#1234') + + +class TestExtendedHexDecoder: + """Test base decoder with extended hex support.""" + + @pytest.fixture + def decoder(self): + class Decoder(MetaColorDecoder): + __extended_hex_support__ = True + + return Decoder() + + @pytest.mark.parametrize('test_input,expected', ( + ('#0003', SRGBColor(0, 0, 0, alpha=.2)), + ('#000A', SRGBColor(0, 0, 0, alpha=.666667)), + )) + def test_extended_hex(self, decoder, test_input, expected): + assert decoder.decode(test_input) == (expected,) + + +class TestNColDecoder: + """Test base decoder with ncol support.""" + + @pytest.fixture + def decoder(self): + class Decoder(MetaColorDecoder): + __ncol_support__ = True + + def sum2(a: int, b: int) -> int: + return a + b + + def sum5(a: int, b: int, c: int, d: int, e: int) -> int: + return a + b + c + d + e + + def col(a) -> Any: + return a + + return Decoder() + + @pytest.mark.parametrize('test_input,expected', ( + ( + 'B20 / 50% 32%', + HWBColor(DegreesAngle(252), .5, .32, 1.00), + ), + ( + 'B20 / / 16%', + HWBColor(DegreesAngle(252), 0, .16, 1.00), + ), + ( + 'Y40, 33%, 55%', + HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00), + ), + ( + 'Y40, sum2(35, -2), 55%', + HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00), + ), + ( + 'Y40, sum5(1, 1, 1, 1, 29), 55%', + HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00), + ), + ( + 'Y40', + HWBColor(DegreesAngle(84), 0, 0), + ), + ( + 'col(Y40)', + HWBColor(DegreesAngle(84), 0, 0), + ), + )) + def test_ncol(self, decoder, test_input, expected): + """Test natural colors.""" + + result = decoder.decode(test_input) + assert result == (expected,) + + def test_invalid_ncol(self, decoder): + with pytest.raises(ColorExpressionSyntaxError, match=r'unknown value'): + assert decoder.decode('Y105, 50%, 50%') + + +class TestNetscapeDecoder: + """Test base decoder with netscape color support.""" + + @pytest.fixture + def decoder(self): + class Decoder(MetaColorDecoder): + __ncol_support__ = False + __defaults_to_netscape_color__ = True + + return Decoder() + + @pytest.mark.parametrize('test_input,expected', ( + ('chucknorris', SRGBColor.frombytes(192, 0, 0, 1.00)), + ( + '6db6ec49efd278cd0bc92d1e5e072d680', + SRGBColor.frombytes(110, 205, 224), + ), + )) + def test_netscape_colors(self, decoder, test_input, expected): + """Test decoding using Netscape colors.""" + + result = decoder.decode(test_input, prefer_colors=True) + assert result == (expected,) + + @pytest.mark.parametrize('test_input,expected', ( + ('#123', SRGBColor.frombytes(17, 34, 51, 1.00)), + ('123', SRGBColor.frombytes(1, 2, 3, 1.00)), + ('123.0', SRGBColor.frombytes(18, 48, 0, 1.00)), + )) + def test_coersce_into_colors(self, decoder, test_input, expected): + """Test the decoding while indicating that we want colors. + + It is expected from the decoder to use Netscape color name + style decoding in this scenario. + """ + + result = decoder.decode(test_input, prefer_colors=True) + assert result == (expected,) + + +# End of file. diff --git a/tests/test_text.py b/tests/test_text.py deleted file mode 100755 index 069ef9c..0000000 --- a/tests/test_text.py +++ /dev/null @@ -1,73 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Unit tests for the thcolor color decoding and management module. """ - -import pytest -from thcolor import Color, Angle - -def _deg(value): - return Angle(Angle.Type.DEG, value) - -@pytest.mark.parametrize('test_input,expected', ( - ('blue', ( 0, 0, 255, 1.00)), - ('#12345F', ( 18, 52, 95, 1.00)), - ('#123', ( 17, 34, 51, 1.00)), - ('123', ( 1, 2, 3, 1.00)), - ('123.0', ( 18, 48, 0, 1.00)), - ('chucknorris', (192, 0, 0, 1.00)), - ('rgb(1, 22,242)', ( 1, 22, 242, 1.00)), - (' rgb (1,22, 242 , 50.0% )', ( 1, 22, 242, 0.50)), - (' rgb (1 22/ /242,50.0%,/)', ( 1, 22, 242, 0.50)), - ('rgba(1,22,242,0.500)', ( 1, 22, 242, 0.50)), - ('rbga(5, 7)', ( 5, 0, 7, 1.00)), - ('hsl(0, 1,50.0%)', (255, 0, 0, 1.00)), - ('hls(0 / 1 0.5 , 0.2)', (255, 255, 255, 0.20)), - ('hwb(0 0% 0)', (255, 0, 0, 1.00)), - ('hbw(127 .5)', ( 0, 128, 15, 1.00)), - ('gray(100)', (100, 100, 100, 1.00)), - ('gray(100 / 55 %)', (100, 100, 100, 0.55)), - ('gray(red( #123456 )/0.2/)', ( 18, 18, 18, 0.20)), - ('B20 50% 32%', (137, 128, 173, 1.00)), - ('ncol(B20 / 50% 32%)', (137, 128, 173, 1.00)), - ('cmyk(0% 37% 0.13 .78)', ( 56, 35, 49, 1.00)), - ('lab(50 50 0)', (193, 78, 121, 1.00)), -)) -def test_rgba(test_input, expected): - assert Color.from_text(test_input).rgba() == expected - -@pytest.mark.parametrize('test_input,expected', ( - ('darker(10%, hsl(0, 1, 50.0%))', (_deg( 0 ), 1.00, 0.40, 1.00)), - ('lighter(50%, hsl(0, 1, 60.0%))', (_deg( 0 ), 1.00, 1.00, 1.00)), - ('saturate(10%, hls(0, 1, 85.0%))', (_deg( 0 ), 0.95, 1.00, 1.00)), - ('desaturate(10%, hls(0, 1, 5%, 0.2))', (_deg( 0 ), 0.00, 1.00, 0.20)), - ('rgba(255, 0, 0, 20 %)', (_deg( 0 ), 1.00, 0.50, 0.20)), - ('Y40, 33%, 55%', (_deg(83.23), 0.16, 0.39, 1.00)), -)) -def test_hsla(test_input, expected): - assert Color.from_text(test_input).hsla() == expected - -@pytest.mark.parametrize('test_input,expected', ( - ('cmyk(0% 37% 0.13 .78)', (0.00, 0.37, 0.13, 0.78, 1.00)), -)) -def test_cmyka(test_input, expected): - assert Color.from_text(test_input).cmyka() == expected - -@pytest.mark.parametrize('test_input,expected', ( - ('blue', - ('#0000FF',)), - (' rgb (1,22, 242 , 50.0% )', - ('#0116F2', 'rgba(1, 22, 242, 50%)')), - ('darker(10%, hsl(0, 1, 50.0%))', - ('#CC0000', 'hsl(0deg, 100%, 40%)')), - ('hls(0 / 1 0.5 , 0.2)', - ('#FFFFFF', 'rgba(255, 255, 255, 20%)', 'hsla(0deg, 50%, 100%, 20%)')), - ('hbw(127 .5)', - ('#00800F', 'hwb(127deg, 0%, 50%)')), -)) -def test_css(test_input, expected): - assert Color.from_text(test_input).css() == expected - -# End of file. diff --git a/thcolor/__init__.py b/thcolor/__init__.py index 22c8a7e..4ee1693 100755 --- a/thcolor/__init__.py +++ b/thcolor/__init__.py @@ -1,24 +1,14 @@ #!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# ***************************************************************************** +# Copyright (C) 2018-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> # This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" HTML/CSS-like color parsing, mainly for the `[color]` tag. - Defines the `get_color()` function which returns an rgba value. +# ***************************************************************************** +"""HTML/CSS-like color parsing, mainly for the `[color]` tag. - The functions in this module do not aim at being totally compliant with - the W3C standards, although it is inspired from it. -""" - -from ._color import Color -from ._ref import Reference -from ._angle import Angle - -from ._exc import ColorExpressionDecodingError +Defines the `get_color()` function which returns an rgba value. -__all__ = ["version", "Color", "Reference", "Angle", - "ColorExpressionDecodingError"] - -version = "0.3.1" +The functions in this module do not aim at being totally compliant with +the W3C standards, although it is inspired from it. +""" # End of file. diff --git a/thcolor/_angle.py b/thcolor/_angle.py deleted file mode 100755 index ac3198d..0000000 --- a/thcolor/_angle.py +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Angle management. """ - -from math import pi as _pi -from enum import Enum as _Enum - -__all__ = ["Angle"] - -def _deg(name, value): - value = float(value) - return value # % 360.0 - -def _grad(name, value): - value = float(value) - return value # % 400.0 - -def _rad(name, value): - value = float(value) - return value # % (2 * _pi) - -def _turn(name, value): - value = float(value) - return value # % 1 - -# --- -# Angle definition. -# --- - -class Angle: - """ Class representing an angle within thcolor, used for some color - representations (most notably hue). Its constructor depends on the - first given argument, which represents the angle type as one of the - :class:`Angle.Type` constants. - - .. function:: Angle(Angle.Type.DEG, degrees) - - Create an angle with a value in degrees (canonical values are - between 0 and 360 excluded). For example, to create a 270° angle: - - .. code-block:: python - - angle = Angle(Angle.Type.DEG, 270) - - .. function:: Angle(Angle.Type.GRAD, gradiants) - - Create an angle with a value in gradiants (canonical values are - between 0 and 400 excluded). For example, to create a 565.5 - gradiants angle: - - .. code-block:: python - - angle = Angle(Angle.Type.GRAD, 565.5) - - .. function:: Angle(Angle.Type.RAD, radiants) - - Create an angle with a value in radiants (canonical values are - between 0 and 2π excluded). For example, to create a π - radiants angle: - - .. code-block:: python - - from math import pi - angle = Angle(Angle.Type.RAD, pi) - - .. function:: Angle(Angle.Type.TURN, turns) - - Create an angle with a value in turns (canonical values are - between 0 and 1 excluded). For example, to create a 3.5 turns - angle: - - .. code-block:: python - - angle = Angle(Angle.Type.TURN, 3.5) """ - - # Properties to work with: - # - # `_type`: the type as one of the `Angle.Type` constants. - # `_value`: the angle value. - - class Type(_Enum): - """ Class representing the type of an angle, its unit really. - The following types are available: - - .. data:: INVALID - - An invalid angle, for internal processing. - - .. data:: DEG - - An angle in degrees. A full circle is represented by - 360 degrees. - - .. data:: GRAD - - An angle in gradiants. A full circle is represented by - 400 gradiants. - - .. data:: RAD - - An angle in radiants. A full circle is represented by - 2π radiants. - - .. data:: TURN - - An angle in turns. A full circle is represented by 1 turn. """ - - INVALID = 0 - DEG = 1 - GRAD = 2 - RAD = 3 - TURN = 4 - - def __init__(self, *args, **kwargs): - self._type = Angle.Type.INVALID - self.set(*args, **kwargs) - - def __eq__(self, other): - if not isinstance(other, Angle): - return super().__eq__(other) - - if self._type == Angle.Type.INVALID: - return other.type == Angle.Type.INVALID - elif self._type == Angle.Type.DEG: - return other.degrees == self._value - elif self._type == Angle.Type.GRAD: - return other.gradiants == self._value - elif self._type == Angle.Type.RAD: - return other.radiants == self._value - elif self._type == Angle.Type.TURN: - return other.turns == self._value - - return False - - def __repr__(self): - args = (('type', f'{self.__class__.__name__}.{str(self._type)}'),) - if self._type in (Angle.Type.DEG, Angle.Type.GRAD, Angle.Type.RAD, - Angle.Type.TURN): - args += (('value', repr(self._value)),) - - argtext = ', '.join(f'{key} = {value}' for key, value in args) - return f"{self.__class__.__name__}({argtext})" - - # --- - # Management methods. - # --- - - def set(self, *args, **kwargs): - """ Set the angle. """ - - args = list(args) - - def _decode_varargs(*keys): - # Check for each key. - - results = () - - for names, convert_func, *value in keys: - for name in names: - if name in kwargs: - if args: - raise TypeError(f"{self.__class__.__name__}() " \ - f"got multiple values for argument {name}") - - raw_result = kwargs.pop(name) - break - else: - name = names[0] - if args: - raw_result = args.pop(0) - elif value: - raw_result = value[0] if len(value) == 1 else value - else: - raise TypeError(f"{self.__class__.__name__}() " \ - "missing a required positional argument: " \ - f"{name}") - - result = convert_func(name, raw_result) - results += (result,) - - # Check for keyword arguments for which keys are not in the set. - - if kwargs: - raise TypeError(f"{next(iter(kwargs.keys()))} is an invalid " \ - f"keyword argument for type {type}") - - return results - - # --- - # Main function body. - # --- - - # Check for the type. - - if args: - try: - type = kwargs.pop('type') - except: - type = args.pop(0) - else: - if isinstance(args[0], Color.Type): - raise TypeError(f"{self.__class__.__name__}() got " \ - "multiple values for argument 'type'") - else: - try: - type = kwargs.pop('type') - except: - type = self._type - if type == Angle.Type.INVALID: - raise TypeError(f"{self.__class__.__name__}() missing " \ - "required argument: 'type'") - - try: - type = Angle.Type(type) - except: - type = Angle.Type.DEG - - # Initialize the properties. - - if type == Angle.Type.DEG: - self._value, = _decode_varargs(\ - (('value', 'angle', 'degrees'), _deg)) - elif type == Angle.Type.GRAD: - self._value, = _decode_varargs(\ - (('value', 'angle', 'gradiants'), _grad)) - elif type == Angle.Type.RAD: - self._value, = _decode_varargs(\ - (('value', 'angle', 'radiants'), _rad)) - elif type == Angle.Type.TURN: - self._value, = _decode_varargs(\ - (('value', 'angle', 'turns'), _turn)) - else: - raise ValueError(f"invalid color type: {type}") - - # Once the arguments have been tested to be valid, we can set the - # type. - - self._type = type - - # --- - # Properties. - # --- - - @property - def type(self): - """ The read-only angle type as one of the :class:`Angle.Type` - constants. """ - - return self._type - - @property - def degrees(self): - """ The read-only angle value in degrees. If the angle isn't in degrees - already, it will be converted automatically. """ - - if self._type == Angle.Type.DEG: - return self._value - return self.turns * 360 - - @property - def gradiants(self): - """ The read-only angle value in gradiants. If the angle isn't in - gradiants already, it will be converted automatically. """ - - if self._type == Angle.Type.GRAD: - return self._value - return self.turns * 400 - - @property - def radiants(self): - """ The read-only angle value in radiants. If the angle isn't in - radiants already, it will be converted automatically. """ - - if self._type == Angle.Type.RAD: - return self._value - return self.turns * (2 * _pi) - - @property - def turns(self): - """ The read-only angle value in turns. If the angle isn't in - turns already, it will be converted automatically. """ - - if self._type == Angle.Type.DEG: - return self._value / 360 - elif self._type == Angle.Type.GRAD: - return self._value / 400 - elif self._type == Angle.Type.RAD: - return self._value / (2 * _pi) - elif self._type == Angle.Type.TURN: - return self._value - -# End of file. diff --git a/thcolor/_color.py b/thcolor/_color.py deleted file mode 100755 index 1bc8f6e..0000000 --- a/thcolor/_color.py +++ /dev/null @@ -1,1131 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" HTML/CSS like color parsing, mainly for the `[color]` tag. - Defines the `get_color()` function which returns an rgba value. """ - -from enum import Enum as _Enum -from warnings import warn as _warn - -_gg_no_re = False - -try: - import regex as _re -except ImportError: - _warn("could not import regex, text parsing is disabled.", - RuntimeWarning) - _gg_no_re = True - -from ._ref import Reference as _Reference -from ._angle import Angle as _Angle -from ._sys import (hls_to_rgb as _hls_to_rgb, rgb_to_hls as _rgb_to_hls, - rgb_to_hwb as _rgb_to_hwb, hwb_to_rgb as _hwb_to_rgb, - rgb_to_cmyk as _rgb_to_cmyk, cmyk_to_rgb as _cmyk_to_rgb, - lab_to_rgb as _lab_to_rgb, rgb_to_lab as _rgb_to_lab, - lab_to_lch as _lab_to_lch, lch_to_lab as _lch_to_lab, - netscape_color as _netscape_color) -from ._exc import (\ - ColorExpressionDecodingError as _ColorExpressionDecodingError, - NotEnoughArgumentsError as _NotEnoughArgumentsError, - TooManyArgumentsError as _TooManyArgumentsError, - InvalidArgumentTypeError as _InvalidArgumentTypeError, - InvalidArgumentValueError as _InvalidArgumentValueError) - -__all__ = ["Color"] - -# --- -# Decoding utilities. -# --- - -_color_pattern = None - -def _get_color_pattern(): - global _color_pattern - - if _color_pattern is None: - if _gg_no_re: - raise ImportError("text parsing is disabled until you install " \ - "the 'regex' module, e.g. via `pip install regex`.") - - _color_pattern = _re.compile(r""" - ( - ((?P<agl_val>-? ([0-9]+\.?|[0-9]*\.[0-9]+)) \s* - (?P<agl_typ>deg|grad|rad|turn)) - | ((?P<per>[0-9]+(\.[0-9]*)? | \.[0-9]+) \s* \%) - | (?P<num>[0-9]+(\.[0-9]*)? | \.[0-9]+) - | (?P<ncol>[RYGCBM] [0-9]{0,2} (\.[0-9]*)?) - | (\# (?P<hex>[0-9a-f]{3} | [0-9a-f]{4} | [0-9a-f]{6} | [0-9a-f]{8})) - | ((?P<name>[a-z]([a-z0-9\s_-]*[a-z0-9_-])?) - ( \s* \( \s* (?P<arg> (?0)? ) \s* \) )?) - ) - - \s* ((?P<sep>[,/\s])+ \s* (?P<nextargs> (?0))?)? - """, _re.VERBOSE | _re.I | _re.M) - - return _color_pattern - -# --- -# Color initialization varargs utilities. -# --- - -def _color_profile(name, value): - try: - value = Color.Profile.from_value(value) - except (TypeError, ValueError): - raise ValueError(f"{name} is not a valid color profile " \ - f"(got {repr(value)}).") - - return value - -def _byte(name, value): - try: - assert value == int(value) - assert 0 <= value < 256 - except (AssertionError, TypeError, ValueError): - raise ValueError(f"{name} should be a byte between 0 and 255") \ - from None - - return value - -def _signed(name, value): - try: - value = float(value) - except (AssertionError, TypeError, ValueError): - raise ValueError(f"{name} should be a signed number") - - return round(value, 4) - -def _unsigned(name, value): - try: - value = float(value) - assert value >= 0 - except (AssertionError, TypeError, ValueError): - raise ValueError(f"{name} should be a positive number") - - return round(value, 4) - -def _unrestricted_percentage(name, value): - try: - value = float(value) - assert 0.0 <= value - except (AssertionError, TypeError, ValueError): - raise ValueError(f"{name} should be a proportion starting from 0") \ - from None - - return round(value, 4) - -def _percentage(name, value): - try: - value = float(value) - assert 0.0 <= value <= 1.0 - except (AssertionError, TypeError, ValueError): - raise ValueError(f"{name} should be a proportion between 0 " \ - "and 1.0") from None - - return round(value, 4) - -def _hue(name, value): - if isinstance(value, _Angle): - pass - else: - try: - value = _Angle(value) - except: - raise ValueError(f"{name} should be an Angle instance") - - return value - -# --- -# Color class definition. -# --- - -class Color: - """ Class representing a color within thcolor. Its constructor depends - on the first given argument, which represents the color type as one - of the :class:`Color.Type` constants. - - .. function:: Color(Color.Type.RGB, red, green, blue, alpha = 1.0) - - Create a color using its red, green and blue components. Each is - expressed as a byte value, from 0 (dark) to 255 (light). - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. - - .. function:: Color(Color.Type.RGB, profile, red, green, blue, """ \ - """alpha = 1.0) - - Create a color using its profile, red, green and blue component. - Each is expressed as a byte value, from 0 (dark) to 255 (light). - - The profile is one of the :class:`thcolor.Color.Profile` - constants, and represents the RGB profile. - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. - - .. function:: Color(Color.Type.HSL, hue, saturation, lightness, """ \ - """alpha = 1.0) - - Create a color using its hue, saturation and lightness components. - The hue is represented by an :class:`Angle` object, and the - saturation and lightness are values going from 0.0 to 1.0. - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. - - .. function:: Color(Color.Type.HWB, hue, whiteness, blackness, """ \ - """alpha = 1.0) - - Create a color using its hue, whiteness and blackness components. - The hue is represented by an :class:`Angle` object, and the - whiteness and lightness are values going from 0.0 to 1.0. - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. - - .. function:: Color(Color.Type.CMYK, cyan, magenta, yellow, """ \ - """black, alpha = 1.0) - - Create a color using its cyan, magenta, yellow and blackness - components, which are all values going from 0.0 to 1.0. - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. - - .. function:: Color(Color.Type.LAB, lightness, a, b, alpha = 1.0) - - Create a color using its CIE Lightness (similar to the lightness - in the HSL representation) and the A and B axises in the Lab - colorspace, represented by signed numbers. - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. - - .. function:: Color(Color.Type.LCH, lightness, chroma, hue, """ \ - """alpha = 1.0) - - Create a color using its CIE Lightness (similar to the lightness - in the HSL representation), its chroma (as a positive number - theoretically unbounded) and its hue. - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. - - .. function:: Color(Color.Type.XYZ, x, y, z, alpha = 1.0) - - Create a color using its CIE XYZ components (as numbers between - 0 and 1). - - An alpha value going from 0.0 (invisible) to 1.0 (opaque) can be - appended to the base components. """ - - class Type(_Enum): - """ Class representing the type of a color, or how it is expressed. - The following types are available: - - .. data:: INVALID - - An invalid color, for internal processing. - - .. data:: RGB - - A color expressed through its sRGB components: red, green - and blue. - - .. data:: HSL - - A color expressed through its HSL components: hue, saturation - and lightness. - - .. data:: HWB - - A color expressed through its HWB components: hue, whiteness - and blackness. - - .. data:: CMYK - - A color expressed through its CMYK components: cyan, magenta, - yellow and black. - - .. data:: LAB - - A color expressed through its lightness and Lab colorspace - coordinates (A and B). - - .. data:: LCH - - A color expressed through its lightness, chroma and hue. - - .. data:: XYZ - - A color expressed through its CIE XYZ coordinates. - - An alpha component can be added to every single one of these - types, so it is not included in the type names. """ - - # Values start at 65600 in order not to infer with "normal" values - # going up to 65535, just in case. - - INVALID = 65600 - - RGB = 65601 - HSL = 65602 - HWB = 65603 - CMYK = 65604 - LAB = 65605 - LCH = 65606 - XYZ = 65607 - - class Profile(_Enum): - """ Class representing the profile of a color, or how it is expressed. - The following profiles are available: - - .. data:: SRGB - - A basic sRGB profile. - - .. data:: IMAGE_P3 - - See `the description in CSS Module Level 4 - <https://drafts.csswg.org/css-color/#valdef-color-image-p3>`_. - - .. data:: A98RGB - - The Adobe® RGB (1998) color profile. See `the description - in CSS Module Level 4 - <https://drafts.csswg.org/css-color/#valdef-color-a98rgb>`_. - - .. data:: PROPHOTORGB - - The ProPHOTO RGB color profile. See `the description in - CSS Module Level 4 - <https://drafts.csswg.org/css-color/#valdef-color-prophotorgb>`_. - - .. data:: REC2020 - - The REC.2020 colorspace. See `the description in CSS Module - Level 4 - <https://drafts.csswg.org/css-color/#valdef-color-rec2020>`_. """ - - SRGB = 65700 - IMAGE_P3 = 65701 - A98RGB = 65702 - PROPHOTORGB = 65703 - REC2020 = 65704 - - def from_value(value): - _profiles = { - 'srgb': 'SRGB', - 'imagep3': 'IMAGE_P3', - 'a98rgb': 'A98RGB', - 'prophotorgb': 'PROPHOTORGB', - 'rec2020': 'REC2020'} - - if type(value) == str: - newval = ''.join(c for c in value.casefold() if c in \ - '0123456789abcdefghijklmnopqrstuvwxyz') - try: - value = _profiles[newval] - except: - pass - - return getattr(Color.Profile, value) - - return Color.Profile(value) - - # Properties to work with: - # - # `_type`: the type as one of the `Color.Type` constants. - # `_alpha`: alpha value. - # - # RGB colors: - # `_r`, `_g`, `_b`: rgb components, as bytes. - # `_profile`: the color profile. - # - # HSL colors: - # `_hue`: hue. - # `_sat`, `_lgt`: saturation and light for HSL. - # - # HWB colors: - # `_hue`: hue. - # `_wht`, `_blk`: whiteness and blackness for HWB. - # - # CMYK colors: - # `_cy`, `_ma`, `_ye`, `_bl`: CMYK components. - # - # LAB colors: - # `_lgt`: lightness. - # `_a`, `_b`: coordinates in the Lab colorspace. - # - # LCH colors: - # `_lgt`: lightness. - # `_hue`: the hue. - # `_chr`: the chroma. - # - # XYZ colors: - # `_x`, `_y`, `_z`: XYZ components. - - def __init__(self, *args, **kwargs): - self._type = Color.Type.INVALID - self.set(*args, **kwargs) - - def __repr__(self): - args = (('type', f'{self.__class__.__name__}.{str(self._type)}'),) - if self._type == Color.Type.RGB: - args += (('profile', - f'{self.__class__.__name__}.{str(self._profile)}'), - ('red', repr(self._r)), ('green', repr(self._g)), - ('blue', repr(self._b))) - elif self._type == Color.Type.HSL: - args += (('hue', repr(self._hue)), ('saturation', repr(self._sat)), - ('lightness', repr(self._lgt))) - elif self._type == Color.Type.HWB: - args += (('hue', repr(self._hue)), ('whiteness', repr(self._wht)), - ('blackness', repr(self._blk))) - elif self._type == Color.Type.CMYK: - args += (('cyan', repr(self._cy)), ('magenta', repr(self._ma)), - ('yellow', repr(self._ye)), ('black', repr(self._bl))) - elif self._type == Color.Type.LAB: - args += (('lightness', repr(self._lgt)), ('a', repr(self._a)), - ('b', repr(self._b))) - elif self._type == Color.Type.LCH: - args += (('lightness', repr(self._lgt)), - ('chroma', repr(self._chr)), ('hue', repr(self._hue))) - elif self._type == Color.Type.XYZ: - args += (('x', repr(self._x)), ('y', repr(self._y)), - ('z', repr(self._z))) - - args += (('alpha', self._alpha),) - - argtext = ', '.join(f'{key} = {value}' for key, value in args) - return f"{self.__class__.__name__}({argtext})" - - def __eq__(self, other): - if not isinstance(other, Color): - return super().__eq__(other) - - if other.type == Color.Type.INVALID: - return self._type == Color.Type.INVALID - elif other.type == Color.Type.HSL: - return self.hsla() == other.hsla() - elif other.type == Color.Type.HWB: - return self.hwba() == other.hwba() - elif other.type == Color.Type.CMYK: - return self.cmyka() == other.cmyka() - elif other.type == Color.Type.LAB: - return self.laba() == other.laba() - elif other.type == Color.Type.LCH: - return self.lcha() == other.lcha() - elif other.type == Color.Type.XYZ: - return self.xyza() == other.xyza() - - return self.rgba() == other.rgba() - - # --- - # Management methods. - # --- - - def set(self, *args, **kwargs): - """ Set the color using its constructor arguments and keyword - arguments. """ - - args = list(args) - - def _decode_varargs(*keys): - class _UNDEFINED_CLASS: - pass - _UNDEFINED = _UNDEFINED_CLASS() - - def _get_value(value_array): - if not value_array: - value = _UNDEFINED - else: - value = value_array[0] if len(value_array) == 1 \ - else value_array - - return value - - # Check for each key. - - results = () - - left_args = len(keys) - - for names, convert_func, *value in keys: - value = _get_value(value) - - for name in names: - if name in kwargs: - if value is _UNDEFINED and args: - raise TypeError(f"{self.__class__.__name__}() " \ - f"got multiple values for argument {name}") - - raw_result = kwargs.pop(name) - break - else: - name = names[0] - if value is not _UNDEFINED and len(args) < left_args: - raw_result = value - elif args: - raw_result = args.pop(0) - else: - raise TypeError(f"{self.__class__.__name__}() " \ - "missing a required positional argument: " \ - f"{name}") - - result = convert_func(name, raw_result) - results += (result,) - - left_args -= 1 - - # Check for keyword arguments for which keys are not in the set. - - if kwargs: - raise TypeError(f"{next(iter(kwargs.keys()))} is an invalid " \ - f"keyword argument for type {type}") - - return results - - # --- - # Main function body. - # --- - - # Check for the type. - - if args: - try: - type = kwargs.pop('type') - except: - type = args.pop(0) - else: - if isinstance(args[0], Color.Type): - raise TypeError(f"{self.__class__.__name__}() got " \ - "multiple values for argument 'type'") - else: - try: - type = kwargs.pop('type') - except: - type = self._type - if type == Color.Type.INVALID: - raise TypeError(f"{self.__class__.__name__}() missing " \ - "required argument: 'type'") - - try: - type = Color.Type(type) - except: - type = Color.Type.RGB - - # Initialize the properties. - - if type == Color.Type.RGB: - self._profile, self._r, self._g, self._b, self._alpha = \ - _decode_varargs(\ - (('profile', 'p'), _color_profile, 'srgb'), - (('red', 'r'), _byte), - (('green', 'g'), _byte), - (('blue', 'b'), _byte), - (('alpha', 'a'), _percentage, 1.0)) - - if self._profile != Color.Profile.SRGB: - raise NotImplementedError("rgb profile " \ - f"{repr(self._profile)} isn't managed yet.") - elif type == Color.Type.HSL: - self._hue, self._sat, self._lgt, self._alpha = _decode_varargs(\ - (('hue', 'h'), _hue), - (('saturation', 'sat', 's'), _percentage), - (('lightness', 'light', 'lig', 'l'), _percentage), - (('alpha', 'a'), _percentage, 1.0)) - elif type == Color.Type.HWB: - self._hue, self._wht, self._blk, self._alpha = _decode_varargs(\ - (('hue', 'h'), _hue), - (('whiteness', 'white', 'w'), _percentage), - (('blackness', 'black', 'b'), _percentage), - (('alpha', 'a'), _percentage, 1.0)) - elif type == Color.Type.CMYK: - self._cy, self._ma, self._ye, self._bl, self._alpha = \ - _decode_varargs(\ - (('cyan', 'c'), _percentage), - (('magenta', 'm'), _percentage), - (('yellow', 'y'), _percentage), - (('black', 'b'), _percentage), - (('alpha', 'a'), _percentage, 1.0)) - elif type == Color.Type.LAB: - self._lgt, self._a, self._b, self._alpha = _decode_varargs(\ - (('lightness', 'light', 'lig', 'l'), _unrestricted_percentage), - (('a',), _signed), - (('b',), _signed), - (('alpha', 'a'), _percentage, 1.0)) - elif type == Color.Type.LCH: - self._lgt, self._chr, self._hue, self._alpha = _decode_varargs(\ - (('lightness', 'light', 'lig', 'l'), _percentage), - (('chroma', 'chr', 'c'), _unsigned), - (('hue', 'h'), _hue), - (('alpha', 'a'), _percentage, 1.0)) - elif type == Color.Type.XYZ: - self._x, self._y, self._z, self._alpha = _decode_varargs(\ - (('x',), _percentage), - (('y',), _percentage), - (('z',), _percentage), - (('alpha', 'a'), _percentage, 1.0)) - else: - raise ValueError(f"invalid color type: {type}") - - # Once the arguments have been tested to be valid, we can set the - # type. - - self._type = type - - # --- - # Properties. - # --- - - @property - def type(self): - """ The read-only angle type as one of the :class:`Color.Type` - constants. """ - - return self._type - - # --- - # Conversion methods. - # --- - - def rgb(self): - """ Get the sRGB (red, green, blue) components of the color. - For example: - - >>> Color.from_text("#876543").rgb() - ... (135, 101, 67) - - If the color is not represented as sRGB internally, it will be - converted. """ - - if self._type == Color.Type.RGB: - return (self._r, self._g, self._b) - elif self._type == Color.Type.HSL: - return _hls_to_rgb(self._hue, self._lgt, self._sat) - elif self._type == Color.Type.HWB: - return _hwb_to_rgb(self._hue, self._wht, self._blk) - elif self._type == Color.Type.CMYK: - return _cmyk_to_rgb(self._cy, self._ma, self._ye, self._bl) - elif self._type == Color.Type.LAB: - return _lab_to_rgb(self._lgt, self._a, self._b) - elif self._type == Color.Type.LCH: - return _lch_to_rgb(self._lgt, self._chr, self._hue) - - raise ValueError(f"color type {self._type} doesn't translate to rgb") - - def hsl(self): - """ Get the HSL (hue, saturation, lightness) components of the color. - For example: - - >>> Color.from_text("hsl(90turn 0% 5%)").hls() - ... (Angle(type = Angle.Type.TURN, value = 90.0), 0.05, 0.0) - - If the color is not represented as HSL internally, it will be - converted. """ - - if self._type == Color.Type.HSL: - return (self._hue, self._sat, self._lgt) - - try: - rgb = self.rgb() - except ValueError: - raise ValueError(f"color type {self._type} doesn't translate " \ - "to hsl") from None - - return _rgb_to_hls(*rgb) - - def hwb(self): - """ Get the HWB (hue, whiteness, blackness) components of the color. - For example: - - >>> Color.from_text("hwb(.7 turn / 5% 10%)").hwb() - ... (Angle(type = Angle.Type.TURN, value = 0.7), 0.05, 0.1) - - If the color is not represented as HSL internally, it will be - converted. """ - - if self._type == Color.Type.HWB: - return (self._hue, self._wht, self._blk) - - try: - rgb = self.rgb() - except ValueError: - raise ValueError(f"color type {self._type} doesn't translate " \ - "to hwb") from None - - return _rgb_to_hwb(*rgb) - - def cmyk(self): - """ Get the CMYK (cyan, magenta, yellow, black) components of the - color. For example: - - >>> Color.from_text("cmyk(.1 .2 .3 .4)").cmyk() - ... (0.1, 0.2, 0.3, 0.4) - - If the color is not represented as CMYK internally, it will be - converted naively. """ - - if self._type == Color.Type.CMYK: - return (self._cy, self._ma, self._ye, self._bl) - - try: - rgb = self.rgb() - except ValueError: - raise ValueError(f"color type {self._type} doesn't translate " \ - "to cmyk") from None - - return _rgb_to_cmyk(*rgb) - - def lab(self): - """ Get the LAB (lightness, Lab colorspace coordinates) components - of the color. For example: - - >>> Color.from_text("lab(50 50 0)").lab() - ... (0.5, 50, 0) - - If the color is not represented as LAB internally, it will be - converted. """ - - if self._type == Color.Type.LAB: - return (self._lgt, self._a, self._b) - elif self._type == Color.Type.LCH: - return _lch_to_lab(self._lgt, self._chr, self._hue) - - try: - rgb = self.rgb() - except ValueError: - raise ValueError(f"color type {self._type} doesn't translate " \ - "to lab") from None - - return _rgb_to_lab(*rgb) - - def lch(self): - """ Get the LCH (lightness, chroma, hue) components of the color. - For example: - - >>> Color.from_text("lch(50 230 0deg)").lch() - ... (0.5, 230, Angle(Angle.Type.DEG, 0)) - - If the color is not represented as LCH internally, it will be - converted. """ - - if self._type == Color.Type.LCH: - return (self._lgt, self._chr, self._hue) - - try: - lab = self.lab() - except ValueError: - raise ValueError(f"color type {self._type} doesn't translate " \ - "to lch") from None - - return _lab_to_lch(*lab) - - def xyz(self): - """ Get the XYZ components of the color. - For example: - - >>> Color.from_text("xyz(0.2, 0.4, 0.5)") - ... (0.2, 0.4, 0.5) - - If the color is not represented as XYZ internally, it will be - converted. """ - - if self._type == Color.Type.XYZ: - return (self._x, self._y, self._z) - - raise NotImplementedError # TODO - - def rgba(self): - """ Get the sRGB (red, green, blue) and alpha components of the color. - For example: - - >>> Color.from_text("#87654321").rgb() - ... (135, 101, 67, 0.1294) - - If the color is not represented as sRGB internally, it will be - converted. """ - - r, g, b = self.rgb() - alpha = self._alpha - - return (r, g, b, alpha) - - def hsla(self): - """ Get the HSL (hue, saturation, lightness) and alpha components of - the color. For example: - - >>> Color.from_text("hsl(90turn 0% 5% .8)").hlsa() - ... (Angle(type = Angle.Type.TURN, value = 90.0), 0.05, 0.0, 0.8) - - If the color is not represented as HSL internally, it will be - converted. """ - - h, s, l = self.hsl() - alpha = self._alpha - - return (h, s, l, alpha) - - def hls(self): - """ Alias for :meth:`hsl` but reverses the lightness and - saturation for commodity. """ - - h, s, l = self.hsl() - return (h, l, s) - - def hlsa(self): - """ Alias for :meth:`hsla` but reverses the lightness and - saturation for commodity. """ - - h, s, l, a = self.hsla() - return (h, l, s, a) - - def hwba(self): - """ Get the HWB (hue, whiteness, blackness) and alpha components of - the color. For example: - - >>> Color.from_text("hwb(.7 turn / 5% 10% .2)").hwba() - ... (Angle(type = Angle.Type.TURN, value = 0.7), 0.05, 0.1, 0.2) - - If the color is not represented as HSL internally, it will be - converted. """ - - h, w, b = self.hwb() - a = self._alpha - - return (h, w, b, a) - - def cmyka(self): - """ Get the CMYK (cyan, magenta, yellow, black) and alpha components - of the color. For example: - - >>> Color.from_text("cmyk(.1 .2 .3 .4 / 10%)").cmyka() - ... (0.1, 0.2, 0.3, 0.4, 0.1) - - If the color is not represented as CMYK internally, it will be - converted naively. """ - - c, m, y, k = self.cmyk() - a = self._alpha - - return (c, m, y, k, a) - - def laba(self): - """ Get the LAB (lightness, Lab colorspace coordinates) and alpha - components of the color. For example: - - >>> Color.from_text("lab(50 50 0 / 0.75)").laba() - ... (0.5, 50, 0, 0.75) - - If the color is not represented as LAB internally, it will be - converted naively. """ - - l, a, b = self.lab() - alpha = self._alpha - - return (l, a, b, alpha) - - def lcha(self): - """ Get the LCH (lightness, chroma, hue) and alpha components - of the color. For example: - - >>> Color.from_text("lch(50 230 0deg)").lcha() - ... (0.5, 230, Angle(Angle.Type.DEG, 0), 1.0) - - If the color is not represented as LCH internally, it will be - converted. """ - - l, c, h = self.lch() - alpha = self._alpha - - return (l, c, h, alpha) - - def xyza(self): - """ Get the XYZ and alpha components of the color. - For example: - - >>> Color.from_text("xyz(0.2, 0.4, 0.5 / 65%)") - ... (0.2, 0.4, 0.5, 0.65) - - If the color is not represented as XYZ internally, it will be - converted. """ - - x, y, z = self.xyz() - alpha = self._alpha - - return (x, y, z) - - def css(self): - """ Get the CSS color descriptions, with older CSS specifications - compatibility, as a list of strings. - - For example: - - >>> Color(Color.Type.RGB, 18, 52, 86, 0.82).css() - ... ["#123456", "rgba(18, 52, 86, 82%)"] """ - - def _percent(prop): - per = round(prop, 4) * 100 - if per == int(per): - per = int(per) - return per - - def _deg(agl): - agl = round(agl.degrees, 2) - if agl == int(agl): - agl = int(agl) - return agl - - def statements(): - # Start by yelling a #RRGGBB color, compatible with most - # web browsers around the world, followed by the rgba() - # notation if the alpha value isn't 1.0. - - r, g, b, a = self.rgba() - a = round(a, 3) - yield f'#{r:02X}{g:02X}{b:02X}' - - if a < 1.0: - yield f'rgba({r}, {g}, {b}, {_percent(a)}%)' - - # Then yield more specific CSS declarations in case - # they're supported (which would be neat!). - - if self._type == Color.Type.HSL: - args = f'{_deg(self._hue)}deg, ' \ - f'{_percent(self._sat)}%, {_percent(self._lgt)}%' - - if a < 1.0: - yield f'hsla({args}, {_percent(a)}%)' - else: - yield f'hsl({args})' - elif self._type == Color.Type.HWB: - args = f'{_deg(self._hue)}deg, ' \ - f'{_percent(self._wht)}%, {_percent(self._blk)}%' - - if a < 1.0: - yield f'hwba({args}, {_percent(a)}%)' - else: - yield f'hwb({args})' - - return tuple(statements()) - - # --- - # Static methods for decoding. - # --- - - def from_str(*args, **kwargs): - """ Alias for :meth:`from_text()`. """ - - return Color.from_text(value) - - def from_string(*args, **kwargs): - """ Alias for :meth:`from_text()`. """ - - return Color.from_text(value) - - def from_text(expr, ref = None): - """ Create a color from a string using a :class:`Reference` object. - If the ``ref`` argument is ``None``, then the default reference - is loaded. - - An example: - - >>> Color.from_text("#123456") - ... Color(type = Color.Type.RGB, red = 18, green = 52, """ \ - """ blue = 86, alpha = 1.0) """ - - if ref is None: - ref = _Reference.default() - if not isinstance(ref, _Reference): - raise ValueError("ref is expected to be a subclass of Reference") - - class argument: - def __init__(self, column, value): - self._column = column - self._value = value - - def __repr__(self): - return f"{self.__class__.__name__}(column = {self._column}, " \ - f"value = {repr(self._value)})" - - @property - def column(self): - return self._column - - @property - def value(self): - return self._value - - def recurse(column, match): - if not match: - return () - - if match['agl_val'] is not None: - # The matched value is an angle. - - agl_typ = { - 'deg': _Angle.Type.DEG, - 'grad': _Angle.Type.GRAD, - 'rad': _Angle.Type.RAD, - 'turn': _Angle.Type.TURN}[match['agl_typ']] - - value = _Reference.angle(_Angle(agl_typ, - float(match['agl_val']))) - elif match['per'] is not None: - # The matched value is a percentage. - - value = float(match['per']) - value = _Reference.percentage(value) - elif match['num'] is not None: - # The matched value is a number. - - value = _Reference.number(match['num']) - elif match['hex'] is not None: - # The matched value is a hex color. - - name = match['hex'] - - if len(name) <= 4: - name = ''.join(map(lambda x: x + x, name)) - - r = int(name[0:2], 16) - g = int(name[2:4], 16) - b = int(name[4:6], 16) - a = int(name[6:8], 16) / 255.0 if len(name) == 8 else 1.0 - - value = _Reference.color(Color(Color.Type.RGB, r, g, b, a)) - elif match['arg'] is not None: - # The matched value is a function. - - name = match['name'] - - # Get the arguments. - - args = recurse(column + match.start('arg'), - _get_color_pattern().fullmatch(match['arg'])) - - # Get the function and call it with the arguments. - - try: - func = ref.functions[name] - except KeyError: - raise _ColorExpressionDecodingError("no such function " \ - f"{repr(name)}", column = column) - - try: - value = func(*map(lambda x: x.value, args)) - except _NotEnoughArgumentsError as e: - raise _ColorExpressionDecodingError("not enough " \ - f"arguments (expected at least {e.count} arguments)", - column = column, func = name) - except _TooManyArgumentsError as e: - raise _ColorExpressionDecodingError("extraneous " \ - f"argument (expected {e.count} arguments at most)", - column = args[e.count].column, func = name) - except _InvalidArgumentTypeError as e: - raise _ColorExpressionDecodingError("type mismatch for " \ - f"argument {e.index + 1}: expected {e.expected}, " \ - f"got {e.got}", column = args[e.index].column, - func = name) - except _InvalidArgumentValueError as e: - raise _ColorExpressionDecodingError("erroneous value " \ - f"for argument {e.index + 1}: {e.text}", - column = args[e.index].column, func = name) - except NotImplementedError: - raise _ColorExpressionDecodingError("not implemented", - column = column, func = name) - else: - if match['ncol']: - # The match is probably a natural color (ncol), we ought - # to parse it and get the following arguments or, if - # anything is invalid, to treat it as a color name. - - name = match['ncol'] - - # First, get the letter and proportion. - - letter = name[0] - number = float(name[1:]) - - if number >= 0 and number < 100: - # Get the following arguments and check. - - args = recurse(column + match.start('nextargs'), - _get_color_pattern().fullmatch(match['nextargs'] \ - or "")) - - try: - assert len(args) >= 2 - w = args[0].value.to_factor() - b = args[1].value.to_factor() - except: - w = 0 - b = 0 - else: - args = args[2:] - - # Calculate the color and return the args. - - color = Color(Color.Type.HWB, - _Angle(_Angle.Type.DEG, 'RYGCBM'.find(letter) \ - * 60 + number / 100 * 60), w, b) - - # And finally, return the args. - - return (argument(column, _Reference.color(color)),) \ - + args - - # The matched value is a named color. - - name = match['name'] - - try: - # Get the named color (e.g. 'blue'). - - value = ref.colors[name] - assert value != None - except: - r, g, b = _netscape_color(name) - value = Color(Color.Type.RGB, r, g, b, 1.0) - - value = _Reference.color(value) - - return (argument(column, value),) \ - + recurse(column + match.start('nextargs'), - _get_color_pattern().fullmatch(match['nextargs'] or "")) - - # Strip the expression. - - lexpr = expr.strip() - column = (len(expr) - len(lexpr)) - expr = lexpr - del lexpr - - # Match the expression (and check it as a whole directly). - - match = _get_color_pattern().fullmatch(expr) - if match is None: - raise _ColorExpressionDecodingError("expression parsing failed") - - # Get the result and check its type. - - results = recurse(column, match) - if len(results) > 1: - raise _ColorExpressionDecodingError("extraneous value", - column = results[1].column) - - result = results[0].value - try: - result = ref.colors[result] - except AttributeError: - raise _ColorExpressionDecodingError("expected a color", - column = column) - - return result - -# End of file. diff --git a/thcolor/_exc.py b/thcolor/_exc.py deleted file mode 100755 index f4954d8..0000000 --- a/thcolor/_exc.py +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Exception definitions, with internal and external exceptions defined. """ - -__all__ = ["ColorExpressionDecodingError", - "NotEnoughArgumentsError", "TooManyArgumentsError", - "InvalidArgumentTypeError", "InvalidArgumentValueError"] - -# --- -# External exceptions. -# --- - -class ColorExpressionDecodingError(Exception): - """ A color decoding error has occurred on the text. """ - - def __init__(self, text, column = None, func = None): - try: - self._column = column - assert self._column >= 0 - except: - self._column = None - - self._func = str(func) - self._text = str(text) - - def __str__(self): - msg = "" - - if self._column is not None: - msg += f"at column {self._column}" - if self._func is not None: - msg += ", " - if self._func is not None: - msg += f"for function {repr(self._func)}" - if msg: - msg += ": " - - return msg + self._text - - @property - def text(self): - """ Exception message, usually linked to the context. """ - - return self._text - - @property - def column(self): - """ Column of the expression at which the exception has occurred, - ``None`` if the error has occurred on an unknown column or on the - whole exception. """ - - return self._column - - @property - def func(self): - """ Function name we were calling when the error has occurred, - either on arguments decoding or erroneous argument type or - value, ``None`` if the context is unknown or the error hasn't - occurred while calling a function or decoding its arguments. """ - - return self._func - -# --- -# Internal exceptions. -# --- - -class NotEnoughArgumentsError(Exception): - """ Not enough arguments. """ - - def __init__(self, count, name = None): - self._name = name - self._count = count - - def __str__(self): - msg = "not enough arguments" - if self._name is not None: - msg += f" for function {repr(self._name)}" - msg += f", expected {self._count} arguments at least" - - return msg - - @property - def count(self): - return self._count - -class TooManyArgumentsError(Exception): - """ Too many arguments. """ - - def __init__(self, count, name = None): - self._name = name - self._count = count - - def __str__(self): - msg = "too many arguments" - if self._name is not None: - msg += f" for function {repr(self._name)}" - msg += f", expected {self._count} arguments at most" - - return msg - - @property - def count(self): - return self._count - -class InvalidArgumentTypeError(Exception): - """ Invalid argument type. """ - - def __init__(self, index, expected, got, name = None): - self._name = name - self._index = index - self._expected = expected - self._got = got - - def __str__(self): - msg = f"wrong type for argument {self._index + 1}" - if self._name: - msg += f" of function {repr(self._name)}" - msg += f": expected {self._expected}, got {self._got}" - - return msg - - @property - def index(self): - return self._index - - @property - def expected(self): - return self._expected - - @property - def got(self): - return self._got - -class InvalidArgumentValueError(Exception): - """ Invalid argument value. """ - - def __init__(self, index, text, name = None): - self._name = name - self._index = index - self._text = text - - def __str__(self): - msg = f"erroneous value for argument {self._index + 1}" - if self._name: - msg += f" of function {repr(self._name)}" - msg += f": {self._text}" - - return msg - - @property - def index(self): - return self._index - - @property - def text(self): - return self._text - -# End of file. diff --git a/thcolor/_ref.py b/thcolor/_ref.py deleted file mode 100755 index 8a39cbb..0000000 --- a/thcolor/_ref.py +++ /dev/null @@ -1,494 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Named color reference, parent class. """ - -from inspect import (getfullargspec as _getfullargspec, - getmembers as _getmembers, ismethod as _ismethod) -from itertools import count as _count -from warnings import warn as _warn - -from ._angle import Angle as _Angle -from ._sys import netscape_color as _netscape_color -from ._exc import (NotEnoughArgumentsError as _NotEnoughArgumentsError, - TooManyArgumentsError as _TooManyArgumentsError, - InvalidArgumentTypeError as _InvalidArgumentTypeError) - -__all__ = ["Reference"] - -_default_reference = None -_color_cls = None - -def _get_color_class(): - global _color_cls - - if _color_cls is not None: - return _color_cls - - from ._color import Color - _color_cls = Color - return _color_cls - -class _type_or: - """ A type or another. """ - - def __init__(self, type1, type2): - self._type1 = type1 - self._type2 = type2 - - @property - def type1(self): - return self._type1 - - @property - def type2(self): - return self._type2 - - def __repr__(self): - return f"{repr(self._type1)} | {repr(self._type2)}" - - def __str__(self): - return f"{self._type1} or {self._type2}" - - def __or__(self, other): - return type_or(self, other) - - def __contains__(self, other): - return other in self._type1 or other in self._type2 - -# --- -# Main reference definition. -# --- - -class Reference: - """ Function reference for color parsing and operations. """ - - def __init__(self): - pass - - # --- - # Base type and function definitions for parsing. - # --- - - class base_type(type): - """ The metaclass for all types used below. """ - - def __new__(mcls, name, bases, attrs): - return super().__new__(mcls, name, bases, attrs) - - def __init__(self, name, bases, attrs): - self.__name = name - - def __contains__(self, other): - return self if other == self else None - - def __or__(self, other): - return _type_or(self, other) - - def __repr__(self): - return f"<class {repr(self.__name)}>" - - def __str__(self): - return self.__name - - class number(metaclass = base_type): - """ Syntaxical element for expression decoding, representing - a number, integer or decimal, positive or negative. - - This element is usually used to represent a byte value, - a factor (usually from 0.0 to 1.0), a color or an angle - in degrees. """ - - def __init__(self, value): - if type(value) == str: - self._strvalue = value - self._value = float(value) - else: - self._value = float(value) - if self._value == int(self._value): - self._strvalue = str(int(self._value)) - else: - self._strvalue = str(value) - - def __str__(self): - return self._strvalue - - @property - def value(self): - return self._value - - def to_byte(self): - """ Make a byte (from 0 to 255) out of the number. """ - - try: - value = int(self._value) - - assert value == self._value - assert 0 <= value < 256 - except: - raise ValueError("unsuitable value for byte conversion: " \ - f"{repr(self._value)}") - - return value - - def to_factor(self, min = 0.0, max = 1.0): - """ Make a factor (usually from 0.0 to 1.0) out of the number. """ - - if (min is not None and self._value < min) \ - or (max is not None and self._value > max): - if max is None: - msg = f"above {min}" - elif min is None: - msg = f"under {max}" - else: - msg = f"between {min} and {max}" - - raise ValueError(f"expected a value {msg}, got {self._value}") - - try: - assert 0.0 <= self._value <= 1.0 - except: - raise ValueError("expected a value between 0.0 and 1.0, got " \ - f"{self._value}") - - return self._value - - def to_hue(self): - """ Make an angle in degrees out of the number. """ - - return _Angle(_Angle.Type.DEG, self._value) - - class percentage(metaclass = base_type): - """ Syntaxical element for expression decoding, representing - a percentage (number followed by a '%' sign). - - This element is usually used to represent a factor (usually - from 0% to 100%) or anything that can be factored such as a - byte value (where 0% represents 0 and 100% represents 255). """ - - def __init__(self, value): - self._value = value - - if value < 0: - value = 0 - - # value can actually be more than 100. - - def __repr__(self): - return f"{self._value} %" - - def to_factor(self, min = 0.0, max = 1.0): - """ Make a factor (usually from 0.0 to 1.0) out of the number. """ - - value = self._value / 100 - if (min is not None and value < min) \ - or (max is not None and value > max): - if max is None: - msg = f"above {min * 100}%" - elif min is None: - msg = f"under {max * 100}%" - else: - msg = f"between {min * 100}% and {max * 100}%" - - raise ValueError(f"expected a percentage {msg}, " \ - f"got {self._value}%") - - return value - - def to_byte(self): - """ Make a byte (from 0 to 255) out of the number. """ - - if self._value < 0: - self._value = 0 - if self._value > 100: - self._value = 100 - - return int(min(self._value / 100, 1.0) * 255) - - class angle(metaclass = base_type): - """ Syntaxical element for expression decoding, representing - an angle (a number followed by an angle unit: degrees, gradiants, - radiants or turns). """ - - def __init__(self, value): - if not isinstance(value, _Angle): - raise TypeError("expected an Angle instance") - - self._value = value - - def __repr__(self): - return repr(self._value) - - def to_hue(self): - """ Get the :class:`thcolor.Angle` object. """ - - return self._value - - class color(metaclass = base_type): - """ Syntaxical element for expression decoding, representing - a color using several methods: - - - a hexadecimal code preceeded by a '#', e.g. "#123ABC". - - a natural color (see - `NCol <https://www.w3schools.com/colors/colors_ncol.asp>`_). - - a color name as defined by a :class:`Reference`. - - a legacy color name using the legacy color algorithm (or - 'Netscape' color algorithm). """ - - def __init__(self, value): - if not isinstance(value, _get_color_class()): - raise ValueError("expected a Color instance") - - self._value = value - - def __repr__(self): - return repr(self._value) - - def to_color(self): - """ Get the :class:`thcolor.Color` object. """ - - return self._value - - # --- - # Function helper. - # --- - - def alias(*names): - """ Decorator for aliasing with another name. Defines a lot, you - know. """ - - def _decorator(func): - if not hasattr(func, '_ref_aliases'): - func._ref_aliases = () - - func._ref_aliases += names - return func - - return _decorator - - # --- - # Function and named color getters. - # --- - - def _get_functions(self): - """ Function getter, which can be used as ``.functions[name](args…)``. - - Provides a wrapper to the method which checks the argument - types and perform the necessary conversions before passing - them to the functions. - - For example, with a classical CSS reference named ``ref``: - - >>> ref.functions['rgb'](ref.number(18), ref.number(52), """ \ - """ref.number(86)) - ... Color(type = Color.Type.RGB, red = 18, green = 52, """ \ - """blue = 86, alpha = 1.0) """ - - class _FunctionGetter: - def __init__(self, ref): - self._fref = ref - - def __getitem__(self, name): - fref = self._fref - found = False - - # First, check if the function name is valid and if the - # method exists. - - members = dict(_getmembers(fref, predicate = _ismethod)) - validname = lambda n: type(n) == str and n[0:1] != '_' \ - and n not in ('functions', 'named', 'default', 'alias') - - if validname(name): - try: - method = members[name] - found = True - except (KeyError, AssertionError): - pass - - # Then, if we haven't found the method, check the aliases. - - if not found: - for member in members.values(): - try: - aliases = member._ref_aliases - except (AttributeError, AssertionError): - continue - - if name not in aliases: - continue - method = member - found = True - break - - # If we still haven't found the method, well… time to raise - # the not found exception. - - if not found: - raise KeyError(repr(name)) - - # Make a function separated from the class, copy the - # annotations and add the type check on each argument. - - class _MethodCaller: - def __init__(self, fref, name, func): - self._fref = fref - self._name = name - self._func = func - - self.__annotations__ = func.__annotations__ - try: - del self.__annotations__['self'] - except: - pass - - spec = _getfullargspec(func) - - def annotate(arg_name): - try: - return spec.annotations[arg_name] - except: - return None - - self._args = list(map(annotate, spec.args[1:])) - self._optargs = [] - if spec.defaults: - self._optargs = self._args[-len(spec.defaults):] - self._args = self._args[:-len(self._optargs)] - - def __call__(self, *args): - if len(args) < len(self._args): - raise _NotEnoughArgumentsError(len(self._args), - self._name) - if len(args) > len(self._args) + len(self._optargs): - raise _TooManyArgumentsError(len(self._args), - self._name) - - arg_types = self._args \ - + self._optargs[:len(args) - len(self._args)] - arg_source = args - args = [] - - lit = zip(_count(), arg_source, arg_types) - for index, arg, exp in lit: - args.append(arg) - if exp is None: - continue - if type(arg) in exp: - continue - - # If we haven't got a color but a color is one - # of the accepted types, try to transform into - # a color to manage number colors using the - # Netscape transformation such as '123'. - - if Reference.color in exp: - try: - args[-1] = self._fref.colors[arg] - except: - pass - else: - continue - - raise _InvalidArgumentTypeError(index, - exp, type(arg), self._name) - - return self._func(*args) - - return _MethodCaller(self, name, method) - - return _FunctionGetter(self) - - def _get_colors(self): - """ Colors getter, which can be used as ``.colors[value]``. - For example, with a classical CSS reference named ``ref``: - - >>> ref.colors['blue'] - ... Color(type = Color.Type.RGB, red = 0, green = 0, """ \ - """blue = 255, alpha = 1.0) - >>> ref.colors[thcolor.Reference.color(""" \ - """thcolor.Color.from_text('#123456'))] - ... Color(type = Color.Type.RGB, red = 18, green = 52, """ \ - """blue = 86, alpha = 1.0) """ - - class _ColorGetter: - def __init__(self, ref): - self._cref = ref - - def __getitem__(self, key): - try: - return key.to_color() - except AttributeError: - pass - - try: - name = str(key) - except: - raise KeyError(repr(key)) - - try: - value = self._cref._color(name) - except KeyError: - pass - except Exception as e: - _warn(RuntimeWarning, f"{self.__class__.__name__} " \ - f"returned exception {e.__class__.__name__} instead " \ - f"of KeyError for color name {repr(name)}.") - pass - else: - try: - assert isinstance(value, _get_color_class()) - except AssertionError: - _warn(RuntimeWarning, f"{self.__class__.__name__} " \ - f"returned non-Color value {repr(value)} for " \ - f"color name {repr(name)}, ignoring.") - else: - return value - - try: - r, g, b = _netscape_color(name) - except: - pass - else: - Color = _get_color_class() - return Color(Color.Type.RGB, r, g, b) - - raise KeyError(repr(key)) - - return _ColorGetter(self) - - functions = property(_get_functions) - colors = property(_get_colors) - - # --- - # Default methods. - # --- - - def _color(self, name): - """ Name color getter used behind the - :attr:`thcolor.Reference.colors` getter, ought to be overriden - by superseeding classes. - - These classes should return ``super()._color(name)`` in case - they have no match for the given name. """ - - raise KeyError(f'{name}: no such color') - - def default(): - """ Static method for gathering the default reference, by - default :class:`thcolor.DefaultReference`. Is only used on - the base :class:`thcolor.Reference` type and shall not be - overriden. """ - - global _default_reference - - if _default_reference is not None: - return _default_reference - - from .builtin import DefaultReference - _default_reference = DefaultReference() - return _default_reference - -# End of file. diff --git a/thcolor/_sys.py b/thcolor/_sys.py deleted file mode 100755 index 014270a..0000000 --- a/thcolor/_sys.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2018 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Conversions between color systems. """ - -from math import (ceil as _ceil, atan2 as _atan2, sqrt as _sqrt, cos as _cos, - sin as _sin, pow as _pow) - -from ._angle import Angle as _Angle - -__all__ = ["hls_to_rgb", "rgb_to_hls", "rgb_to_hwb", "hwb_to_rgb", - "cmyk_to_rgb", "rgb_to_cmyk", "lab_to_rgb", "rgb_to_lab", - "lch_to_lab", "lab_to_lch", "netscape_color"] - -# --- -# Color systems conversion utilities. -# --- - -def _rgb(r, g, b): - return tuple(map(lambda x: int(round(x * 255, 0)), (r, g, b))) -def _hls(hue, s, l): - return _Angle(_Angle.Type.DEG, round(hue, 2)), round(l, 2), round(s, 2) - -def _rgb_to_lrgb(r, g, b): - def _linearize(val): - # Undo gamma encoding. - - val /= 255 - if val < 0.04045: - return val / 12.92 - - return _pow((val + 0.055) / 1.055, 2.4) - - return tuple(map(_linearize, (r, g, b))) - -def hls_to_rgb(hue, l, s): - """ Convert HLS to RGB. """ - - if s == 0: - # Achromatic color. - - return l, l, l - - def _hue_to_rgb(t1, t2, hue): - hue %= 6 - - if hue < 1: - return t1 + (t2 - t1) * hue - elif hue < 3: - return t2 - elif hue < 4: - return t1 + (t2 - t1) * (4 - hue) - return t1 - - hue = (hue.degrees % 360) / 60 - if l <= 0.5: - t2 = l * (s + 1) - else: - t2 = l + s - (l * s) - - t1 = l * 2 - t2 - - return _rgb(\ - _hue_to_rgb(t1, t2, hue + 2), - _hue_to_rgb(t1, t2, hue), - _hue_to_rgb(t1, t2, hue - 2)) - -def hwb_to_rgb(hue, w, bl): - """ Convert HWB to RGB color. - https://drafts.csswg.org/css-color/#hwb-to-rgb """ - - r, g, b = map(lambda x: x / 255, hls_to_rgb(hue, 0.5, 1.0)) - if w + bl > 1: - w, bl = map(lambda x: x / (w + bl), (w, bl)) - return _rgb(*map(lambda x: x * (1 - w - bl) + w, (r, g, b))) - -def cmyk_to_rgb(c, m, y, k): - """ Convert CMYK to RGB. """ - - r = 1 - min(1, c * (1 - k) + k) - g = 1 - min(1, m * (1 - k) + k) - b = 1 - min(1, y * (1 - k) + k) - - return _rgb(r, g, b) - -def lab_to_rgb(l, a, b): - """ Convert LAB to RGB. """ - - # TODO - raise NotImplementedError - -def rgb_to_lab(r, g, b): - """ Convert RGB to LAB. """ - - # TODO - raise NotImplementedError - -def lab_to_lch(l, a, b): - """ Convert RGB to LAB. """ - - h = _Angle(_Angle.Type.RAD, _atan2(b, a)) - c = _sqrt(a * a + b * b) - - return (l, c, h) - -def lch_to_lab(l, c, h): - """ Convert LCH to LAB. """ - - a = c * _cos(h.radians) - b = c * _sin(h.radians) - - return (l, a, b) - -def rgb_to_hls(r, g, b): - """ Convert RGB to HLS. """ - - r, g, b = map(lambda x: x / 255, (r, g, b)) - - min_value = min((r, g, b)) - max_value = max((r, g, b)) - chroma = max_value - min_value - - if chroma == 0: - hue = 0 - elif r == max_value: - hue = (g - b) / chroma - elif g == max_value: - hue = (b - r) / chroma + 2 - else: - hue = (r - g) / chroma + 4 - - hue = hue * 60 + (hue < 0) * 360 - l = (min_value + max_value) / 2 - if min_value == max_value: - s = 0 - else: - s = max_value - min_value - if l < 0.5: - s /= max_value + min_value - else: - s /= 2 - max_value - min_value - - return _hls(hue, l, s) - -def rgb_to_hwb(r, g, b): - """ Convert RGB to HWB. """ - - r, g, b = map(lambda x: x / 255, (r, g, b)) - - max_value = max((r, g, b)) - min_value = min((r, g, b)) - chroma = max_value - min_value - - if chroma == 0: - hue = 0 - elif r == max_value: - hue = (g - b) / chroma - elif g == max_value: - hue = (b - r) / chroma + 2 - elif g == max_value: - hue = (r - g) / chroma + 4 - - hue = (hue % 6) * 360 - w = min_value - b = max_value - - return _Angle(_Angle.Type.DEG, hue), w, b - -def rgb_to_cmyk(r, g, b): - """ Convert RGB to CMYK. """ - - r, g, b = map(lambda x: x / 255, (r, g, b)) - - k = 1 - max((r, g, b)) - if k == 1: - c, m, y = 0, 0, 0 - else: - c = (1 - r - k) / (1 - k) - m = (1 - g - k) / (1 - k) - y = (1 - b - k) / (1 - k) - - return (c, m, y, k) - -# --- -# Other utilities. -# --- - -def netscape_color(name): - """ Produce a color from a name (which can be all-text, all-digits or - both), using the Netscape behaviour. """ - - # Find more about this here: https://stackoverflow.com/a/8333464 - # - # First of all: - # - we sanitize our input by replacing invalid characters - # by '0' characters (the 0xFFFF limit is due to how - # UTF-16 was managed at the time). - # - we truncate our input to 128 characters. - - name = name.lower() - name = ''.join(c if c in '0123456789abcdef' \ - else ('0', '00')[ord(c) > 0xFFFF] \ - for c in name[:128])[:128] - - # Then we calculate some values we're going to need. - # `iv` is the size of the zone for a member. - # `sz` is the size of the digits slice to take in that zone - # (max. 8). - # `of` is the offset in the zone of the slice to take. - - iv = _ceil(len(name) / 3) - of = iv - 8 if iv > 8 else 0 - sz = iv - of - - # Then we isolate the slices using the values calculated - # above. `gr` will be an array of 3 or 4 digit strings - # (depending on the number of members). - - gr = list(map(lambda i: name[i * iv + of:i * iv + iv] \ - .ljust(sz, '0'), range(3))) - - # Check how many digits we can skip at the beginning of - # each slice. - - pre = min(map(lambda x: len(x) - len(x.lstrip('0')), gr)) - pre = min(pre, sz - 2) - - # Then we extract the values. - - it = map(lambda x: int('0' + x[pre:pre + 2], 16), gr) - r, g, b = it - - return (r, g, b) - -# End of file. diff --git a/thcolor/angles.py b/thcolor/angles.py new file mode 100644 index 0000000..23a0c2e --- /dev/null +++ b/thcolor/angles.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Angle representation and conversions.""" + +from math import pi as _pi +from typing import Any as _Any, Optional as _Optional + +from .utils import round_half_up as _round_half_up + +__all__ = [ + 'Angle', 'DegreesAngle', 'GradiansAngle', 'RadiansAngle', 'TurnsAngle', +] + + +class Angle: + """Abstract class representing an angle within thcolor. + + Used for some color representations (most notably hue). + """ + + __slots__ = () + + _value: float + _bottom: float = 0 + _top: float = 1 + + def __init__(self): + pass + + def __repr__(self): + params = ( + (key, getattr(self, key)) for key in dir(self) + if not key.startswith('_') and not callable(getattr(self, key)) + ) + + return ( + f'{self.__class__.__name__}(' + f"{', '.join(f'{key}={val!r}' for key, val in params)})" + ) + + def __eq__(self, other): + if not isinstance(other, Angle): + return False + + return ( + round(self.asturns().turns, 6) == round(other.asturns().turns, 6) + ) + + def asdegrees(self) -> 'DegreesAngle': + """Get the current angle as a degrees angle.""" + + try: + value = self._value + ob = self._bottom + ot = self._top + except AttributeError: + raise NotImplementedError from None + + nb = DegreesAngle._bottom + nt = DegreesAngle._top + + return DegreesAngle((value - ob) / (ot - ob) * (nt - nb) + nb) + + def asgradians(self) -> 'GradiansAngle': + """Get the current angle as a gradians angle.""" + + try: + value = self._value + ob = self._bottom + ot = self._top + except AttributeError: + raise NotImplementedError from None + + nb = GradiansAngle._bottom + nt = GradiansAngle._top + + return GradiansAngle((value - ob) / (ot - ob) * (nt - nb) + nb) + + def asradians(self) -> 'RadiansAngle': + """Get the current angle as a radians angle.""" + + try: + value = self._value + ob = self._bottom + ot = self._top + except AttributeError: + raise NotImplementedError from None + + nb = RadiansAngle._bottom + nt = RadiansAngle._top + + return RadiansAngle((value - ob) / (ot - ob) * (nt - nb) + nb) + + def asturns(self) -> 'TurnsAngle': + """Get the current angle as a turns angle.""" + + try: + value = self._value + ob = self._bottom + ot = self._top + except AttributeError: + raise NotImplementedError from None + + nb = TurnsAngle._bottom + nt = TurnsAngle._top + + return TurnsAngle((value - ob) / (ot - ob) * (nt - nb) + nb) + + def asprincipal(self): + """Get the principal angle.""" + + cls = self.__class__ + value = self._value + bottom, top = cls._bottom, cls._top + + return cls((value - bottom) % (top - bottom) + bottom) + + @classmethod + def fromtext( + cls, + expr: str, + decoder: _Optional[_Any] = None, + ) -> 'Angle': + """Create a color from a string. + + :param expr: The expression to decode. + """ + + if decoder is None: + from .builtin import DefaultColorDecoder + + decoder = DefaultColorDecoder() + + results = decoder.decode(expr, prefer_angles=True) + + if len(results) != 1 or not isinstance(results[0], cls): + raise ValueError( + f'result of expression was not an instance of {cls.__name__}: ' + f'single color: {results!r}', + ) + + return results[0] + + +class DegreesAngle(Angle): + """An angle expressed in degrees. + + A 270° angle can be created the following way: + + .. code-block:: python + + angle = DegreesAngle(270) + + :param degrees: Degrees, as canonical values are between 0 and 360 + excluded. + """ + + __slots__ = ('_value') + + _bottom = 0 + _top = 360.0 + + def __init__(self, degrees: float): + self._value = float(degrees) # % 360.0 + + def __str__(self): + x = self._value + return f'{_round_half_up(x, 4)}deg' + + @property + def degrees(self) -> float: + """Get the degrees.""" + + return self._value + + +class GradiansAngle(Angle): + """An angle expressed in gradians. + + A 565.5 gradians angle can be created the following way: + + .. code-block:: python + + angle = GradiansAngle(565.5) + + :param gradians: Gradians, as canonical values are between + 0 and 400.0 excluded. + """ + + __slots__ = ('_value') + + _bottom = 0 + _top = 400.0 + + def __init__(self, gradians: float): + self._value = float(gradians) # % 400.0 + + def __str__(self): + x = self._value + return f'{_round_half_up(x, 4)}grad' + + @property + def gradians(self) -> float: + """Get the gradians.""" + + return self._value + + +class RadiansAngle(Angle): + """An angle expressed in radians. + + A π radians angle can be created the following way: + + .. code-block:: python + + from math import pi + angle = RadiansAngle(pi) + + :param radians: Radians, as canonical are between 0 and 2π + excluded. + """ + + __slots__ = ('_value') + + _bottom = 0 + _top = 2 * _pi + + def __init__(self, radians: float): + self._value = float(radians) # % (2 * _pi) + + def __str__(self): + x = self._value + return f'{int(x) if x == int(x) else x}rad' + + def __repr__(self): + r = _round_half_up(self.radians / _pi, 4) + return f"{self.__class__.__name__}(radians={f'{r}π' if r else '0'})" + + @property + def radians(self) -> float: + """Get the radians.""" + + return self._value + + +class TurnsAngle(Angle): + """An angle expressed in turns. + + A 3.5 turns angle can be created the following way: + + .. code-block:: python + + angle = TurnsAngle(3.5) + + :param turns: Turns, as canonical values are between 0 and 1 + excluded. + """ + + __slots__ = ('_value') + + _bottom = 0 + _top = 1 + + def __init__(self, turns: float): + self._value = float(turns) # % 1.0 + + def __str__(self): + x = self._value + return f'{_round_half_up(x, 4)}turn' + + @property + def turns(self) -> float: + """Get the turns.""" + + return self._value + +# End of file. diff --git a/thcolor/builtin.py b/thcolor/builtin.py new file mode 100644 index 0000000..0179768 --- /dev/null +++ b/thcolor/builtin.py @@ -0,0 +1,528 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Builtin decoders using the base elements.""" + +from typing import Union as _Union + +from .angles import Angle as _Angle +from .colors import ( + CMYKColor as _CMYKColor, Color as _Color, HSLColor as _HSLColor, + HSVColor as _HSVColor, HWBColor as _HWBColor, LABColor as _LABColor, + LCHColor as _LCHColor, SRGBColor as _SRGBColor, XYZColor as _XYZColor, + YIQColor as _YIQColor, YUVColor as _YUVColor, +) +from .decoders import ( + MetaColorDecoder as _MetaColorDecoder, + alias as _alias, fallback as _fallback, +) +from .utils import factor as _factor + +__all__ = [ + 'CSS1ColorDecoder', 'CSS2ColorDecoder', 'CSS3ColorDecoder', + 'CSS4ColorDecoder', 'DefaultColorDecoder', +] + +_number = _Union[int, float] + + +def _rgb(x): + """Return an RGB color out of the given 6-digit hexadecimal code.""" + + from thcolor.colors import SRGBColor as _SRGBColor + + return _SRGBColor( + int(x[1:3], 16) / 255, + int(x[3:5], 16) / 255, + int(x[5:7], 16) / 255, + ) + + +# --- +# Main colors. +# --- + + +class CSS1ColorDecoder(_MetaColorDecoder): + """Named colors from CSS Level 1. + + See `<https://www.w3.org/TR/CSS1/>`_ for more information. + """ + + __defaults_to_netscape_color__ = True + + black = _rgb('#000000') + silver = _rgb('#c0c0c0') + gray = _rgb('#808080') + white = _rgb('#ffffff') + + maroon = _rgb('#800000') + red = _rgb('#ff0000') + purple = _rgb('#800080') + fuchsia = _rgb('#ff00ff') + green = _rgb('#008000') + lime = _rgb('#00ff00') + olive = _rgb('#808000') + yellow = _rgb('#ffff00') + navy = _rgb('#000080') + blue = _rgb('#0000ff') + teal = _rgb('#008080') + aqua = _rgb('#00ffff') + + transparent = _SRGBColor(0, 0, 0, 0) + + @staticmethod + def rgb(red: int = 0, green: int = 0, blue: int = 0) -> _Color: + """Make an RGB color out of the given components.""" + + return _SRGBColor( + red=_factor(red, max_=255, clip=True), + green=_factor(green, max_=255, clip=True), + blue=_factor(blue, max_=255, clip=True), + alpha=1.0, + ) + + +class CSS2ColorDecoder(CSS1ColorDecoder): + """Named colors from CSS Level 2 (Revision 1). + + See `<https://www.w3.org/TR/CSS2/>`_ for more information. + """ + + orange = _rgb('#ffa500') + + +class CSS3ColorDecoder(CSS2ColorDecoder): + """Named colors and functions from CSS Color Module Level 3. + + See `<https://drafts.csswg.org/css-color-3/>`_ for more information. + """ + + darkblue = _rgb('#00008B') + mediumblue = _rgb('#0000CD') + darkgreen = _rgb('#006400') + darkcyan = _rgb('#008B8B') + deepskyblue = _rgb('#00BFFF') + darkturquoise = _rgb('#00CED1') + mediumspringgreen = _rgb('#00FA9A') + springgreen = _rgb('#00FF7F') + cyan = _rgb('#00FFFF') + midnightblue = _rgb('#191970') + dodgerblue = _rgb('#1E90FF') + lightseagreen = _rgb('#20B2AA') + forestgreen = _rgb('#228B22') + seagreen = _rgb('#2E8B57') + darkslategray = _rgb('#2F4F4F') + darkslategrey = _rgb('#2F4F4F') + limegreen = _rgb('#32CD32') + mediumseagreen = _rgb('#3CB371') + turquoise = _rgb('#40E0D0') + royalblue = _rgb('#4169E1') + steelblue = _rgb('#4682B4') + darkslateblue = _rgb('#483D8B') + mediumturquoise = _rgb('#48D1CC') + indigo = _rgb('#4B0082') + darkolivegreen = _rgb('#556B2F') + cadetblue = _rgb('#5F9EA0') + cornflowerblue = _rgb('#6495ED') + mediumaquamarine = _rgb('#66CDAA') + dimgray = _rgb('#696969') + dimgrey = _rgb('#696969') + slateblue = _rgb('#6A5ACD') + olivedrab = _rgb('#6B8E23') + slategray = _rgb('#708090') + slategrey = _rgb('#708090') + lightslategray = _rgb('#778899') + lightslategrey = _rgb('#778899') + mediumslateblue = _rgb('#7B68EE') + lawngreen = _rgb('#7CFC00') + chartreuse = _rgb('#7FFF00') + aquamarine = _rgb('#7FFFD4') + grey = _rgb('#808080') + skyblue = _rgb('#87CEEB') + lightskyblue = _rgb('#87CEFA') + blueviolet = _rgb('#8A2BE2') + darkred = _rgb('#8B0000') + darkmagenta = _rgb('#8B008B') + saddlebrown = _rgb('#8B4513') + darkseagreen = _rgb('#8FBC8F') + lightgreen = _rgb('#90EE90') + mediumpurple = _rgb('#9370DB') + darkviolet = _rgb('#9400D3') + palegreen = _rgb('#98FB98') + darkorchid = _rgb('#9932CC') + yellowgreen = _rgb('#9ACD32') + sienna = _rgb('#A0522D') + brown = _rgb('#A52A2A') + darkgray = _rgb('#A9A9A9') + darkgrey = _rgb('#A9A9A9') + lightblue = _rgb('#ADD8E6') + greenyellow = _rgb('#ADFF2F') + paleturquoise = _rgb('#AFEEEE') + lightsteelblue = _rgb('#B0C4DE') + powderblue = _rgb('#B0E0E6') + firebrick = _rgb('#B22222') + darkgoldenrod = _rgb('#B8860B') + mediumorchid = _rgb('#BA55D3') + rosybrown = _rgb('#BC8F8F') + darkkhaki = _rgb('#BDB76B') + mediumvioletred = _rgb('#C71585') + indianred = _rgb('#CD5C5C') + peru = _rgb('#CD853F') + chocolate = _rgb('#D2691E') + tan = _rgb('#D2B48C') + lightgray = _rgb('#D3D3D3') + lightgrey = _rgb('#D3D3D3') + thistle = _rgb('#D8BFD8') + orchid = _rgb('#DA70D6') + goldenrod = _rgb('#DAA520') + palevioletred = _rgb('#DB7093') + crimson = _rgb('#DC143C') + gainsboro = _rgb('#DCDCDC') + plum = _rgb('#DDA0DD') + burlywood = _rgb('#DEB887') + lightcyan = _rgb('#E0FFFF') + lavender = _rgb('#E6E6FA') + darksalmon = _rgb('#E9967A') + violet = _rgb('#EE82EE') + palegoldenrod = _rgb('#EEE8AA') + lightcoral = _rgb('#F08080') + khaki = _rgb('#F0E68C') + aliceblue = _rgb('#F0F8FF') + honeydew = _rgb('#F0FFF0') + azure = _rgb('#F0FFFF') + sandybrown = _rgb('#F4A460') + wheat = _rgb('#F5DEB3') + beige = _rgb('#F5F5DC') + whitesmoke = _rgb('#F5F5F5') + mintcream = _rgb('#F5FFFA') + ghostwhite = _rgb('#F8F8FF') + salmon = _rgb('#FA8072') + antiquewhite = _rgb('#FAEBD7') + linen = _rgb('#FAF0E6') + lightgoldenrodyellow = _rgb('#FAFAD2') + oldlace = _rgb('#FDF5E6') + magenta = _rgb('#FF00FF') + deeppink = _rgb('#FF1493') + orangered = _rgb('#FF4500') + tomato = _rgb('#FF6347') + hotpink = _rgb('#FF69B4') + coral = _rgb('#FF7F50') + darkorange = _rgb('#FF8C00') + lightsalmon = _rgb('#FFA07A') + lightpink = _rgb('#FFB6C1') + pink = _rgb('#FFC0CB') + gold = _rgb('#FFD700') + peachpuff = _rgb('#FFDAB9') + navajowhite = _rgb('#FFDEAD') + moccasin = _rgb('#FFE4B5') + bisque = _rgb('#FFE4C4') + mistyrose = _rgb('#FFE4E1') + blanchedalmond = _rgb('#FFEBCD') + papayawhip = _rgb('#FFEFD5') + lavenderblush = _rgb('#FFF0F5') + seashell = _rgb('#FFF5EE') + cornsilk = _rgb('#FFF8DC') + lemonchiffon = _rgb('#FFFACD') + floralwhite = _rgb('#FFFAF0') + snow = _rgb('#FFFAFA') + lightyellow = _rgb('#FFFFE0') + ivory = _rgb('#FFFFF0') + + @staticmethod + def rgb( + red: _number = 0, + green: _number = 0, + blue: _number = 0, + alpha: _number = 1.0, + ) -> _Color: + """Make an RGB color out of the given components.""" + + return _SRGBColor( + red=_factor(red, max_=255, clip=True), + green=_factor(green, max_=255, clip=True), + blue=_factor(blue, max_=255, clip=True), + alpha=_factor(alpha, clip=True), + ) + + @staticmethod + def hsl( + hue: _Angle, + saturation: _number, + lightness: _number, + alpha: _number = 1.0, + ) -> _Color: + """Make an HSL color out of the given components.""" + + return _HSLColor( + hue=hue, + saturation=_factor(saturation), + lightness=_factor(lightness), + alpha=_factor(alpha), + ) + + rgba = _alias('rgb') + hsla = _alias('hsl') + + +class CSS4ColorDecoder(CSS3ColorDecoder): + """Named colors and functions from CSS Color Module Level 4. + + See `<https://drafts.csswg.org/css-color/>`_ for more information.. + """ + + __extended_hex_support__ = True + + rebeccapurple = _rgb('#663399') + + @staticmethod + def hwb( + hue: _Angle, + whiteness: _number = 0.0, + blackness: _number = 0.0, + alpha: _number = 1.0, + ) -> _Color: + """Make an HWB color out of the given components.""" + + return _HWBColor( + hue=hue, + whiteness=_factor(whiteness), + blackness=_factor(blackness), + alpha=_factor(alpha), + ) + + @staticmethod + def gray(gray: _number, alpha: _number = 1.0) -> _Color: + """Make a gray-scale color out of the given components.""" + + gray = _factor(gray, max_=255) + return _SRGBColor(gray, gray, gray, _factor(alpha)) + + @staticmethod + def lab( + light: _number, + a: _number, + b: _number, + alpha: _number = 1.0, + ) -> _Color: + """Make an LAB color out of the given components.""" + + return _LABColor( + lightness=max(_factor(light), 0.0), + a=a, b=b, + alpha=_factor(alpha), + ) + + @staticmethod + def lch( + light: _number, + chroma: _number, + hue: _Angle, + alpha: _number = 1.0, + ) -> _Color: + """Make an LCH color out of the given components.""" + + return _LCHColor( + lightness=max(_factor(light), 0.0), + chroma=max(chroma, 0.0), + hue=hue, + alpha=_factor(alpha), + ) + + hwba = _alias('hwb') + + +class DefaultColorDecoder(CSS4ColorDecoder): + """Functions extending the CSS Color Module Level 4 reference.""" + + __ncol_support__ = True + + rbg = _alias('rgb', args=('red', 'blue', 'green', 'alpha')) + rbga = _alias('rgb', args=('red', 'blue', 'green', 'alpha')) + brg = _alias('rgb', args=('blue', 'red', 'green', 'alpha')) + brga = _alias('rgb', args=('blue', 'red', 'green', 'alpha')) + bgr = _alias('rgb', args=('blue', 'green', 'red', 'alpha')) + bgra = _alias('rgb', args=('blue', 'green', 'red', 'alpha')) + gbr = _alias('rgb', args=('green', 'blue', 'red', 'alpha')) + gbra = _alias('rgb', args=('green', 'blue', 'red', 'alpha')) + grb = _alias('rgb', args=('green', 'red', 'blue', 'alpha')) + grba = _alias('rgb', args=('green', 'red', 'blue', 'alpha')) + hls = _alias('hsl', args=('hue', 'lightness', 'saturation', 'alpha')) + hlsa = _alias('hsl', args=('hue', 'lightness', 'saturation', 'alpha')) + hbw = _alias('hwb', args=('hue', 'blackness', 'whiteness', 'alpha')) + hbwa = _alias('hwb', args=('hue', 'blackness', 'whiteness', 'alpha')) + device_cmyk = _alias('cmyk') + + @staticmethod + def cmyk( + cyan: _number, + magenta: _number = 0.0, + yellow: _number = 0.0, + black: _number = 0.0, + alpha: _number = 1.0, + ) -> _Color: + """Make a CMYK color out of the given components.""" + + return _CMYKColor( + cyan=_factor(cyan), + magenta=_factor(magenta), + yellow=_factor(yellow), + black=_factor(black), + alpha=_factor(alpha), + ) + + @staticmethod + def hsv( + hue: _Angle, + saturation: _number, + value: _number, + alpha: _number = 1.0, + ) -> _Color: + """Make an HSV color out of the given components.""" + + return _HSVColor( + hue=hue, + saturation=_factor(saturation), + value=_factor(value), + alpha=_factor(alpha), + ) + + @staticmethod + def xyz( + x: _number, + y: _number, + z: _number, + alpha: _number = 1.0, + ) -> _Color: + """Make an XYZ color out of the given components.""" + + return _XYZColor( + x=_factor(x), + y=_factor(y), + z=_factor(z), + alpha=_factor(alpha), + ) + + @staticmethod + def yiq( + y: _number, + i: _number, + q: _number, + alpha: _number = 1.0, + ) -> _Color: + """Make a YIQ color out of the given components.""" + + return _YIQColor( + y=_factor(y), + i=_factor(i), + q=_factor(q), + alpha=_factor(alpha), + ) + + @staticmethod + def yuv( + y: _number, + u: _number, + v: _number, + alpha: _number = 1.0, + ) -> _Color: + """Make a YUV color out of the given components.""" + + return _YUVColor( + y=_factor(y), + u=_factor(u), + v=_factor(v), + alpha=_factor(alpha), + ) + + # --- + # Get the RGB components of a color. + # --- + + @_fallback(_rgb('#ff0000')) + @staticmethod + def red(color: _Color) -> int: + """Get the red channel value from an RGB color.""" + + r, g, b, _ = color.assrgb() + return r + + @_fallback(_rgb('#00ff00')) + @staticmethod + def green(color: _Color) -> int: + """Get the green channel value from an RGB color.""" + + r, g, b, _ = color.assrgb() + return g + + @_fallback(_rgb('#0000ff')) + @staticmethod + def blue(color: _Color) -> int: + """Get the blue channel value from an RGB color.""" + + r, g, b, _ = color.assrgb() + return b + + # --- + # Manage the lightness and saturation for HSL colors. + # --- + + @staticmethod + def darker(by: float, color: _Color) -> _Color: + """Make the color darker by a given factor. + + This is accomplished by calling + :py:meth:`thcolor.colors.Color.darker`. + """ + + return color.darker(by) + + @staticmethod + def lighter(by: float, color: _Color) -> _Color: + """Make the color lighter by a given factor. + + This is accomplished by calling + :py:meth:`thcolor.colors.Color.lighter`. + """ + + return color.lighter(by) + + @staticmethod + def desaturate(by: float, color: _Color) -> _Color: + """Desaturate the color by a given factor. + + This is accomplished by calling + :py:meth:`thcolor.colors.Color.desaturate`. + """ + + return color.desaturate(by) + + @staticmethod + def saturate(by: float, color: _Color) -> _Color: + """Saturate the color by a given factor. + + This is accomplished by calling + :py:meth:`thcolor.colors.Color.saturate`. + """ + + return color.saturate(by) + + # --- + # Others. + # --- + + @staticmethod + def ncol(color: _Color) -> _Color: + """Return a natural color (NCol). + + This method is actually compatibility with w3color.js. + NCols are managed directly without the function, so + the function only needs to return the color. + """ + + return color + + +# End of file. diff --git a/thcolor/builtin/__init__.py b/thcolor/builtin/__init__.py deleted file mode 100755 index c9bc0d2..0000000 --- a/thcolor/builtin/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Named colors references, using various sources. """ - -from ._css import (CSS1Reference, CSS2Reference, CSS3Reference, - CSS4Reference) -from ._default import DefaultReference - -__all__ = ["CSS1Reference", "CSS2Reference", "CSS3Reference", - "CSS4Reference", "DefaultReference"] - -# End of file. diff --git a/thcolor/builtin/_css.py b/thcolor/builtin/_css.py deleted file mode 100755 index ac9e434..0000000 --- a/thcolor/builtin/_css.py +++ /dev/null @@ -1,450 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Named colors and function definitions. Color names are case-insensitive. - Taken from: https://www.w3schools.com/cssref/css_colors.asp """ - -from .._color import Color as _Color -from .._ref import Reference as _Reference -from .._exc import InvalidArgumentValueError as _InvalidArgumentValueError - -__all__ = ["CSS1Reference", "CSS2Reference", "CSS3Reference", - "CSS4Reference"] - -def _rgb(raw): - r = int(raw[1:3], 16) - g = int(raw[3:5], 16) - b = int(raw[5:7], 16) - - return _Color(_Color.Type.RGB, r, g, b) - -class CSS1Reference(_Reference): - """ Named colors from `CSS Level 1 <https://www.w3.org/TR/CSS1/>`_. """ - - number = _Reference.number - percentage = _Reference.percentage - color = _Reference.color - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # --- - # Named colors. - # --- - - __colors = { - 'black': _rgb('#000000'), - 'silver': _rgb('#c0c0c0'), - 'gray': _rgb('#808080'), - 'white': _rgb('#ffffff'), - - 'maroon': _rgb('#800000'), - 'red': _rgb('#ff0000'), - 'purple': _rgb('#800080'), - 'fuchsia': _rgb('#ff00ff'), - 'green': _rgb('#008000'), - 'lime': _rgb('#00ff00'), - 'olive': _rgb('#808000'), - 'yellow': _rgb('#ffff00'), - 'navy': _rgb('#000080'), - 'blue': _rgb('#0000ff'), - 'teal': _rgb('#008080'), - 'aqua': _rgb('#00ffff')} - - def _color(self, name): - if name == 'transparent': - return _Color(_Color.Type.RGB, 0, 0, 0, 0) - - try: - return self.__colors[name] - except: - return super()._color(name) - - # --- - # Utilities. - # --- - - def _rgb(self, rgba, rgb_indexes): - r, g, b, alpha = rgba - ri, gi, bi = rgb_indexes - - try: - r = r.to_byte() - except ValueError as e: - raise _InvalidArgumentValueError(ri, str(e)) - - try: - g = g.to_byte() - except ValueError as e: - raise _InvalidArgumentValueError(gi, str(e)) - - try: - b = b.to_byte() - except ValueError as e: - raise _InvalidArgumentValueError(bi, str(e)) - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(3, str(e)) - - return _Reference.color(_Color(_Color.Type.RGB, r, g, b, alpha)) - - # --- - # Functions. - # --- - - def rgb(self, r: number | percentage, - g: number | percentage = number(0), - b: number | percentage = number(0)): - return self._rgb((r, g, b, 1.0), (0, 1, 2)) - -class CSS2Reference(CSS1Reference): - """ Named colors from `CSS Level 2 (Revision 1) - <https://www.w3.org/TR/CSS2/>`_. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # --- - # Named colors. - # --- - - __colors = { - 'orange': _rgb('#ffa500')} - - def _color(self, name): - try: - return self.__colors[name] - except: - return super()._color(name) - -class CSS3Reference(CSS2Reference): - """ Named colors and functions from `CSS Color Module Level 3 - <https://drafts.csswg.org/css-color-3/>`_. """ - - number = _Reference.number - percentage = _Reference.percentage - angle = _Reference.angle - color = _Reference.color - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # --- - # Named colors. - # --- - - __colors = { - 'darkblue': _rgb('#00008B'), - 'mediumblue': _rgb('#0000CD'), - 'darkgreen': _rgb('#006400'), - 'darkcyan': _rgb('#008B8B'), - 'deepskyblue': _rgb('#00BFFF'), - 'darkturquoise': _rgb('#00CED1'), - 'mediumspringgreen': _rgb('#00FA9A'), - 'springgreen': _rgb('#00FF7F'), - 'cyan': _rgb('#00FFFF'), - 'midnightblue': _rgb('#191970'), - 'dodgerblue': _rgb('#1E90FF'), - 'lightseagreen': _rgb('#20B2AA'), - 'forestgreen': _rgb('#228B22'), - 'seagreen': _rgb('#2E8B57'), - 'darkslategray': _rgb('#2F4F4F'), - 'darkslategrey': _rgb('#2F4F4F'), - 'limegreen': _rgb('#32CD32'), - 'mediumseagreen': _rgb('#3CB371'), - 'turquoise': _rgb('#40E0D0'), - 'royalblue': _rgb('#4169E1'), - 'steelblue': _rgb('#4682B4'), - 'darkslateblue': _rgb('#483D8B'), - 'mediumturquoise': _rgb('#48D1CC'), - 'indigo': _rgb('#4B0082'), - 'darkolivegreen': _rgb('#556B2F'), - 'cadetblue': _rgb('#5F9EA0'), - 'cornflowerblue': _rgb('#6495ED'), - 'mediumaquamarine': _rgb('#66CDAA'), - 'dimgray': _rgb('#696969'), - 'dimgrey': _rgb('#696969'), - 'slateblue': _rgb('#6A5ACD'), - 'olivedrab': _rgb('#6B8E23'), - 'slategray': _rgb('#708090'), - 'slategrey': _rgb('#708090'), - 'lightslategray': _rgb('#778899'), - 'lightslategrey': _rgb('#778899'), - 'mediumslateblue': _rgb('#7B68EE'), - 'lawngreen': _rgb('#7CFC00'), - 'chartreuse': _rgb('#7FFF00'), - 'aquamarine': _rgb('#7FFFD4'), - 'grey': _rgb('#808080'), - 'skyblue': _rgb('#87CEEB'), - 'lightskyblue': _rgb('#87CEFA'), - 'blueviolet': _rgb('#8A2BE2'), - 'darkred': _rgb('#8B0000'), - 'darkmagenta': _rgb('#8B008B'), - 'saddlebrown': _rgb('#8B4513'), - 'darkseagreen': _rgb('#8FBC8F'), - 'lightgreen': _rgb('#90EE90'), - 'mediumpurple': _rgb('#9370DB'), - 'darkviolet': _rgb('#9400D3'), - 'palegreen': _rgb('#98FB98'), - 'darkorchid': _rgb('#9932CC'), - 'yellowgreen': _rgb('#9ACD32'), - 'sienna': _rgb('#A0522D'), - 'brown': _rgb('#A52A2A'), - 'darkgray': _rgb('#A9A9A9'), - 'darkgrey': _rgb('#A9A9A9'), - 'lightblue': _rgb('#ADD8E6'), - 'greenyellow': _rgb('#ADFF2F'), - 'paleturquoise': _rgb('#AFEEEE'), - 'lightsteelblue': _rgb('#B0C4DE'), - 'powderblue': _rgb('#B0E0E6'), - 'firebrick': _rgb('#B22222'), - 'darkgoldenrod': _rgb('#B8860B'), - 'mediumorchid': _rgb('#BA55D3'), - 'rosybrown': _rgb('#BC8F8F'), - 'darkkhaki': _rgb('#BDB76B'), - 'mediumvioletred': _rgb('#C71585'), - 'indianred': _rgb('#CD5C5C'), - 'peru': _rgb('#CD853F'), - 'chocolate': _rgb('#D2691E'), - 'tan': _rgb('#D2B48C'), - 'lightgray': _rgb('#D3D3D3'), - 'lightgrey': _rgb('#D3D3D3'), - 'thistle': _rgb('#D8BFD8'), - 'orchid': _rgb('#DA70D6'), - 'goldenrod': _rgb('#DAA520'), - 'palevioletred': _rgb('#DB7093'), - 'crimson': _rgb('#DC143C'), - 'gainsboro': _rgb('#DCDCDC'), - 'plum': _rgb('#DDA0DD'), - 'burlywood': _rgb('#DEB887'), - 'lightcyan': _rgb('#E0FFFF'), - 'lavender': _rgb('#E6E6FA'), - 'darksalmon': _rgb('#E9967A'), - 'violet': _rgb('#EE82EE'), - 'palegoldenrod': _rgb('#EEE8AA'), - 'lightcoral': _rgb('#F08080'), - 'khaki': _rgb('#F0E68C'), - 'aliceblue': _rgb('#F0F8FF'), - 'honeydew': _rgb('#F0FFF0'), - 'azure': _rgb('#F0FFFF'), - 'sandybrown': _rgb('#F4A460'), - 'wheat': _rgb('#F5DEB3'), - 'beige': _rgb('#F5F5DC'), - 'whitesmoke': _rgb('#F5F5F5'), - 'mintcream': _rgb('#F5FFFA'), - 'ghostwhite': _rgb('#F8F8FF'), - 'salmon': _rgb('#FA8072'), - 'antiquewhite': _rgb('#FAEBD7'), - 'linen': _rgb('#FAF0E6'), - 'lightgoldenrodyellow': _rgb('#FAFAD2'), - 'oldlace': _rgb('#FDF5E6'), - 'magenta': _rgb('#FF00FF'), - 'deeppink': _rgb('#FF1493'), - 'orangered': _rgb('#FF4500'), - 'tomato': _rgb('#FF6347'), - 'hotpink': _rgb('#FF69B4'), - 'coral': _rgb('#FF7F50'), - 'darkorange': _rgb('#FF8C00'), - 'lightsalmon': _rgb('#FFA07A'), - 'lightpink': _rgb('#FFB6C1'), - 'pink': _rgb('#FFC0CB'), - 'gold': _rgb('#FFD700'), - 'peachpuff': _rgb('#FFDAB9'), - 'navajowhite': _rgb('#FFDEAD'), - 'moccasin': _rgb('#FFE4B5'), - 'bisque': _rgb('#FFE4C4'), - 'mistyrose': _rgb('#FFE4E1'), - 'blanchedalmond': _rgb('#FFEBCD'), - 'papayawhip': _rgb('#FFEFD5'), - 'lavenderblush': _rgb('#FFF0F5'), - 'seashell': _rgb('#FFF5EE'), - 'cornsilk': _rgb('#FFF8DC'), - 'lemonchiffon': _rgb('#FFFACD'), - 'floralwhite': _rgb('#FFFAF0'), - 'snow': _rgb('#FFFAFA'), - 'lightyellow': _rgb('#FFFFE0'), - 'ivory': _rgb('#FFFFF0')} - - def _color(self, name): - try: - return self.__colors[name] - except: - return super()._color(name) - - # --- - # Utilities. - # --- - - def _hsl(self, hsla, hsl_indexes): - h, s, l, alpha = hsla - hi, si, li = hsl_indexes - - try: - h = h.to_hue() - except ValueError as e: - raise _InvalidArgumentValueError(hi, str(e)) - - try: - s = s.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(si, str(e)) - - try: - l = l.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(li, str(e)) - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(3, str(e)) - - return _Reference.color(_Color(_Color.Type.HSL, h, s, l, alpha)) - - # --- - # Functions. - # --- - - @_Reference.alias('rgba') - def rgb(self, r: number | percentage, - g: number | percentage = number(0), b: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._rgb((r, g, b, alpha), (0, 1, 2)) - - @_Reference.alias('hsla') - def hsl(self, h: number | angle, s: number | percentage, - l: number | percentage, alpha: number | percentage = number(1.0)): - return self._hsl((h, s, l, alpha), (0, 1, 2)) - -class CSS4Reference(CSS3Reference): - """ Named colors and functions from `CSS Color Module Level 4 - <https://drafts.csswg.org/css-color/>`_. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - number = _Reference.number - percentage = _Reference.percentage - angle = _Reference.angle - color = _Reference.color - - # --- - # Named colors. - # --- - - __colors = { - 'rebeccapurple': _rgb('#663399')} - - def _color(self, name): - try: - return self.__colors[name] - except: - return super()._color(name) - - # --- - # Utilities. - # --- - - def _hwb(self, hwba, hwb_indexes): - h, w, b, alpha = hwba - hi, wi, bi = hwb_indexes - - try: - h = h.to_hue() - except ValueError as e: - raise _InvalidArgumentValueError(hi, str(e)) - - try: - w = w.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(wi, str(e)) - - try: - b = b.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(bi, str(e)) - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(3, str(e)) - - return _Reference.color(_Color(_Color.Type.HWB, h, w, b, alpha)) - - # --- - # Functions. - # --- - - @_Reference.alias('hwba') - def hwb(self, h: number | angle, w: number | percentage = number(0), - b: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._hwb((h, w, b, alpha), (0, 1, 2)) - - def gray(self, g: number | percentage, - alpha: number | percentage = number(1.0)): - try: - g = g.to_byte() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(1, str(e)) - - return _Reference.color(_Color(_Color.Type.RGB, g, g, g, alpha)) - - def lab(self, l: number, a: number, b: number, - alpha: number | percentage = number(1.0)): - - try: - l = l.value - if l < 0: - l = 0 - l /= 100 - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - a = a.value - b = b.value - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(3, str(e)) - - return _Reference.color(_Color(_Color.Type.LAB, l, a, b, alpha)) - - def lch(self, l: number, c: number, h: number | angle, - alpha: number | percentage = number(1.0)): - - try: - l = l.value - if l < 0: - l = 0 - l /= 100 - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - c = c.value - if c < 0: - c = 0 - - try: - h = h.to_hue() - except ValueError as e: - raise _InvalidArgumentValueError(2, str(e)) - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(3, str(e)) - - return _Reference.color(_Color(_Color.Type.LCH, l, c, h, alpha)) - -# End of file. diff --git a/thcolor/builtin/_default.py b/thcolor/builtin/_default.py deleted file mode 100755 index dd54a2b..0000000 --- a/thcolor/builtin/_default.py +++ /dev/null @@ -1,232 +0,0 @@ -#!/usr/bin/env python3 -#****************************************************************************** -# Copyright (C) 2019 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> -# This file is part of the thcolor project, which is MIT-licensed. -#****************************************************************************** -""" Named colors and function definitions. Color names are case-insensitive. - Extends the CSS references. """ - -from .._color import Color as _Color -from .._ref import Reference as _Reference -from ._css import CSS4Reference as _CSS4Reference - -__all__ = ["DefaultReference"] - -class DefaultReference(_CSS4Reference): - """ Functions extending the CSS Color Module Level 4 reference. """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - number = _Reference.number - percentage = _Reference.percentage - angle = _Reference.angle - color = _Reference.color - - # --- - # RGB functions. - # --- - - @_Reference.alias('rbga') - def rbg(self, r: number | percentage, - b: number | percentage = number(0), g: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._rgb((r, g, b, alpha), (0, 2, 1)) - - @_Reference.alias('brga') - def brg(self, b: number | percentage, - r: number | percentage = number(0), g: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._rgb((r, g, b, alpha), (1, 2, 0)) - - @_Reference.alias('bgra') - def bgr(self, b: number | percentage, - g: number | percentage = number(0), r: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._rgb((r, g, b, alpha), (2, 1, 0)) - - @_Reference.alias('gbra') - def gbr(self, g: number | percentage, - b: number | percentage = number(0), r: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._rgb((r, g, b, alpha), (2, 0, 1)) - - @_Reference.alias('grba') - def grb(self, g: number | percentage, - r: number | percentage = number(0), b: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._rgb((r, g, b, alpha), (1, 0, 2)) - - # --- - # HLS and HWB aliases. - # --- - - @_Reference.alias('hlsa') - def hls(self, h: number | angle, l: number | percentage, - s: number | percentage, alpha: number | percentage = number(1.0)): - return self._hsl((h, s, l, alpha), (0, 2, 1)) - - @_Reference.alias('hbwa') - def hbw(self, h: number | angle, b: number | percentage = number(0), - w: number | percentage = number(0), - alpha: number | percentage = number(1.0)): - return self._hwb((h, w, b, alpha), (0, 2, 1)) - - # --- - # CMYK utilities and extensions. - # --- - - def cmyk(self, c: number | percentage, - m: number | percentage = percentage(0), - y: number | percentage = percentage(0), - k = number | percentage(0), - alpha: number | percentage = number(1.0)): - ci, mi, yi, ki = 0, 1, 2, 3 - - try: - c = c.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(ci, str(e)) - - try: - m = m.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(mi, str(e)) - - try: - y = y.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(yi, str(e)) - - try: - k = k.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(ki, str(e)) - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(4, str(e)) - - return _Reference.color(_Color(_Color.Type.CMYK, c, m, y, k, alpha)) - - def xyz(self, x: number | percentage, y: number | percentage, - z: number | percentage, alpha: number | percentage = number(1.0)): - - try: - x = x.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - try: - y = y.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(1, str(e)) - - try: - z = z.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(2, str(e)) - - try: - alpha = alpha.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(3, str(e)) - - return _Reference.color(_Color(_Color.Type.XYZ, x, y, z, alpha)) - - # --- - # Get the RGB components of a color. - # --- - - def red(self, col: color) -> number: - r, g, b = col.to_color().rgb() - return _Reference.number(r) - - def green(self, col: color) -> number: - r, g, b = col.to_color().rgb() - return _Reference.number(g) - - def blue(self, col: color) -> number: - r, g, b = col.to_color().rgb() - return _Reference.number(b) - - # --- - # Manage the lightness and saturation for HSL colors. - # --- - - def darker(self, by: number | percentage, col: color) -> color: - try: - by = by.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - try: - col = col.to_color() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - h, l, s, a = col.hlsa() - l = max(l - by, 0.0) - - return _Reference.color(_Color(_Color.Type.HSL, h, s, l, a)) - - def lighter(self, by: number | percentage, col: color) -> color: - try: - by = by.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - try: - col = col.to_color() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - h, l, s, a = col.hlsa() - l = min(l + by, 1.0) - - return _Reference.color(_Color(_Color.Type.HSL, h, s, l, a)) - - def desaturate(self, by: number | percentage, col: color) -> color: - try: - by = by.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - try: - col = col.to_color() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - h, l, s, a = col.hlsa() - s = max(s - by, 0.0) - - return _Reference.color(_Color(_Color.Type.HSL, h, s, l, a)) - - def saturate(self, by: number | percentage, col: color) -> color: - try: - by = by.to_factor() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - try: - col = col.to_color() - except ValueError as e: - raise _InvalidArgumentValueError(0, str(e)) - - h, l, s, a = col.hlsa() - s = min(s + by, 1.0) - - return _Reference.color(_Color(_Color.Type.HSL, h, s, l, a)) - - # --- - # Others. - # --- - - def ncol(self, col: color) -> color: - # Compatibility with w3color.js! NCols are managed directly without - # the function, so the function doesn't do anything. - - return col - -# End of file. diff --git a/thcolor/colors.py b/thcolor/colors.py new file mode 100644 index 0000000..cfc9240 --- /dev/null +++ b/thcolor/colors.py @@ -0,0 +1,1424 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Color representations and conversions.""" + +from math import ( + atan2 as _atan2, ceil as _ceil, cos as _cos, sin as _sin, sqrt as _sqrt, +) +from typing import ( + Any as _Any, Optional as _Optional, Sequence as _Sequence, Tuple as _Tuple, +) + +from .angles import ( + Angle as _Angle, DegreesAngle as _DegreesAngle, + RadiansAngle as _RadiansAngle, TurnsAngle as _TurnsAngle, +) +from .utils import round_half_up as _round_half_up + +__all__ = [ + 'CMYKColor', 'Color', 'HSLColor', 'HSVColor', 'HWBColor', + 'LABColor', 'LCHColor', 'SRGBColor', 'XYZColor', + 'YIQColor', 'YUVColor', +] + + +class Color: + """Class representing a color within thcolor. + + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_alpha') + _params: _Sequence[str] = () + + def __init__(self, alpha: float = 1.0): + super().__init__() + + try: + alpha = float(alpha) + except (TypeError, ValueError): + raise ValueError(f'alpha should be a float, is {alpha!r}') + else: + if alpha < 0 or alpha > 1: + raise ValueError('alpha should be between 0.0 and 1.0') + + self._alpha = alpha + + def __repr__(self): + params = ( + (key, getattr(self, key)) + for key in self._params + ('alpha',) + ) + return ( + f'{self.__class__.__name__}(' + f'{", ".join(f"{key}={val!r}" for key, val in params)})' + ) + + def __eq__(self, other: _Any) -> bool: + if not isinstance(other, Color): + return False + + srgb = tuple(map(lambda x: round(x, 3), self.assrgb())) + orgb = tuple(map(lambda x: round(x, 3), other.assrgb())) + + return srgb == orgb + + @property + def alpha(self) -> float: + """Get the alpha component value. + + Represented as a float varying between 0.0 (invisible) + and 1.0 (opaque). + """ + + return self._alpha + + @classmethod + def fromtext( + cls, + expr: str, + decoder: _Optional[_Any] = None, + ) -> 'Color': + """Create a color from a string. + + :param expr: The expression to decode. + """ + + if decoder is None: + from .builtin import DefaultColorDecoder + + decoder = DefaultColorDecoder() + + results = decoder.decode(expr, prefer_colors=True) + + if len(results) != 1 or not isinstance(results[0], cls): + raise ValueError( + f'result of expression was not an instance of {cls.__name__}: ' + f'single color: {results!r}', + ) + + return results[0] + + # --- + # Conversions. + # --- + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + raise NotImplementedError + + def ashsl(self) -> 'HSLColor': + """Get an HSLColor out of the current object.""" + + return self.assrgb().ashsl() + + def ashsv(self) -> 'HSVColor': + """Get an HSVColor out of the current object.""" + + return self.assrgb().ashsv() + + def ashwb(self) -> 'HWBColor': + """Get an HWBColor out of the current object.""" + + return self.assrgb().ashwb() + + def ascmyk(self) -> 'CMYKColor': + """Get a CMYKColor out of the current object.""" + + return self.assrgb().ascmyk() + + def aslab(self) -> 'LABColor': + """Get a LABColor out of the current object.""" + + raise NotImplementedError + + def aslch(self) -> 'LCHColor': + """Get a LCHColor out of the current object.""" + + raise NotImplementedError + + def asxyz(self) -> 'XYZColor': + """Get an XYZColor out of the current object.""" + + raise NotImplementedError + + def asyiq(self) -> 'YIQColor': + """Get an YIQColor out of the current object.""" + + return self.assrgb().asyiq() + + def asyuv(self) -> 'YUVColor': + """Get an YUVColor out of the current object.""" + + return self.assrgb().asyuv() + + # --- + # Operations on colors. + # --- + + def replace(self, **properties) -> 'Color': + """Get the color with the given properties replaced. + + For changing the alpha on an RGB color: + + .. code-block:: python + + >>> SRGBColor(.1, .2, .3).replace(alpha=.5) + ... SRGBColor(red=0.1, green=0.2, blue=0.3, alpha=0.5) + + For changing the lightness on an HSL color: + + .. code-block:: pycon + + >>> HSLColor(DegreesAngle(270), .5, 1).replace(lightness=.2) + ... HSLColor(hue=DegreesAngle(degrees=270.0), saturation=0.5, + ... lightness=0.2, alpha=1.0) + + :param properties: Properties to change from the original color. + """ + + params = { + key: getattr(self, key) + for key in (*self._params, 'alpha') + } + + for key, value in properties.items(): + if key not in params: + raise KeyError( + f'no such argument {key!r} in ' + f'{self.__class__.__name__} parameters', + ) + + params[key] = value + + return type(self)(**params) + + def darker(self, by: float = 0.1) -> 'Color': + """Get a darker version of the given color. + + :param by: Percentage by which the color should be darker. + """ + + color = self.ashsl() + return color.replace(lightness=max(color.lightness - by, 0.0)) + + def lighter(self, by: float = 0.1) -> 'Color': + """Get a lighter version of the given color. + + :param by: Percentage by which the color should be lighter. + """ + + color = self.ashsl() + return color.replace(lightness=min(color.lightness + by, 1.0)) + + def desaturate(self, by: float = 0.1) -> 'Color': + """Get a less saturated version of the given color. + + :param by: Percentage by which the color should be + desaturated. + """ + + color = self.ashsl() + return color.replace(saturation=max(color.saturation - by, 0.0)) + + def saturate(self, by: float = 0.1) -> 'Color': + """Get a more saturated version of the given color. + + :param by: Percentage by which the color should be + saturated. + """ + + color = self.ashsl() + return color.replace(saturation=min(color.saturation + by, 1.0)) + + def css(self) -> _Sequence[str]: + """Get the CSS color descriptions. + + Includes older CSS specifications compatibility, + as a sequence of strings. + + For example: + + >>> SRGBColor.frombytes(18, 52, 86, 0.82).css() + ... ("#123456", "rgba(18, 52, 86, 82%)") + """ + + def _percent(prop): + per = _round_half_up(prop, 4) * 100 + if per == int(per): + per = int(per) + return per + + def _deg(agl): + return int(agl.asdegrees().degrees) + + def statements(): + # Start by yelling a #RRGGBB color, compatible with most + # web browsers around the world, followed by the rgba() + # notation if the alpha value isn't 1.0. + a = _round_half_up(self.alpha, 3) + + try: + rgb = self.assrgb() + except NotImplementedError: + pass + else: + r, g, b = rgb.asbytes() + + yield f'#{r:02X}{g:02X}{b:02X}' + + if a < 1.0: + yield f'rgba({r}, {g}, {b}, {_percent(a)}%)' + + # Then yield more specific CSS declarations in case + # they're supported (which would be neat!). + if isinstance(self, HSLColor): + hue, sat, lgt = ( + self.hue, self.saturation, self.lightness, + ) + args = ( + f'{_deg(hue)}deg, {_percent(sat)}%, {_percent(lgt)}%' + ) + + if a < 1.0: + yield f'hsla({args}, {_percent(a)}%)' + else: + yield f'hsl({args})' + elif isinstance(self, HWBColor): + hue, wht, blk = ( + self.hue, self.whiteness, self.blackness) + + args = f'{_deg(hue)}deg, ' \ + f'{_percent(wht)}%, {_percent(blk)}%' + + if a < 1.0: + yield f'hwba({args}, {_percent(a)}%)' + else: + yield f'hwb({args})' + + return tuple(statements()) + +# --- +# Color implementations. +# --- + + +class SRGBColor(Color): + """A color expressed using its channel intensities in the sRGB profile. + + :param red: Value for :py:attr:`red`. + :param green: Value for :py:attr:`green`. + :param blue: Value for :py:attr:`blue`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_red', '_green', '_blue') + _params = ('red', 'green', 'blue') + + def __init__( + self, + red: float, + green: float, + blue: float, + alpha: float = 1.0, + ): + super().__init__(alpha) + + try: + red = float(red) + except (TypeError, ValueError): + raise ValueError(f'red should be a float, is {red!r}') + + try: + green = float(green) + except (TypeError, ValueError): + raise ValueError(f'green should be a float, is {green!r}') + + try: + blue = float(blue) + except (TypeError, ValueError): + raise ValueError(f'blue should be a float, is {blue!r}') + + self._red = red + self._green = green + self._blue = blue + + def __iter__(self): + return iter(( + self.red, + self.green, + self.blue, + self.alpha, + )) + + @property + def red(self) -> float: + """Get the intensity of the red channel. + + Represented as a float between 0.0 (dark) and 1.0 (light). + """ + + return self._red + + @property + def green(self) -> float: + """Get the intensity of the green channel. + + Represented as a float between 0.0 (dark) and 1.0 (light). + """ + + return self._green + + @property + def blue(self) -> float: + """Get the intensity of the blue channel. + + Represented as a float between 0.0 (dark) and 1.0 (light). + """ + + return self._blue + + @classmethod + def frombytes( + cls, + red: int, + green: int, + blue: int, + alpha: float = 1.0, + ) -> 'SRGBColor': + """Get an sRGB color from colors using values between 0 and 255.""" + + return cls( + red=red / 255, + green=green / 255, + blue=blue / 255, + alpha=alpha, + ) + + @classmethod + def fromnetscapecolorname(cls, name: str) -> 'SRGBColor': + """Get an sRGB color from a Netscape color name.""" + + name = str(name) + if name[0] == '#': + name = name[1:] + + # Find more about this here: https://stackoverflow.com/a/8333464 + # + # First of all: + # - we sanitize our input by replacing invalid characters + # by '0' characters (the 0xFFFF limit is due to how + # UTF-16 was managed at the time). + # - we truncate our input to 128 characters. + name = name.lower() + name = ''.join( + c if c in '0123456789abcdef' else '00'[:1 + (ord(c) > 0xFFFF)] + for c in name[:128] + )[:128] + + # Then we calculate some values we're going to need. + # `iv` is the size of the zone for a member. + # `sz` is the size of the digits slice to take in that zone + # (max. 8). + # `of` is the offset in the zone of the slice to take. + iv = _ceil(len(name) / 3) + of = iv - 8 if iv > 8 else 0 + sz = iv - of + + # Then we isolate the slices using the values calculated + # above. `gr` will be an array of 3 or 4 digit strings + # (depending on the number of members). + gr = list(map( + lambda i: name[i * iv + of:i * iv + iv].ljust(sz, '0'), + range(3), + )) + + # Check how many digits we can skip at the beginning of + # each slice. + pre = min(map(lambda x: len(x) - len(x.lstrip('0')), gr)) + pre = min(pre, sz - 2) + + # Then we extract the values. + r, g, b = map(lambda x: int('0' + x[pre:pre + 2], 16), gr) + + return cls.frombytes(r, g, b) + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + return SRGBColor( + red=self.red, + green=self.green, + blue=self.blue, + alpha=self.alpha, + ) + + def ashsl(self) -> 'HSLColor': + """Get an HSLColor out of the current object.""" + + r, g, b = self.red, self.green, self.blue + + min_value = min((r, g, b)) + max_value = max((r, g, b)) + chroma = max_value - min_value + + if chroma == 0: + hue = 0. + elif r == max_value: + hue = (g - b) / chroma + elif g == max_value: + hue = (b - r) / chroma + 2 + else: + hue = (r - g) / chroma + 4 + + hue = hue * 60 + (hue < 0) * 360 + lgt = (min_value + max_value) / 2 + if min_value == max_value: + s = 0. + else: + s = max_value - min_value + if lgt < 0.5: + s /= max_value + min_value + else: + s /= 2 - max_value - min_value + + return HSLColor( + hue=_DegreesAngle(_round_half_up(hue, 2)), + saturation=_round_half_up(s, 2), + lightness=_round_half_up(lgt, 2), + alpha=self.alpha, + ) + + def ashsv(self) -> 'HSVColor': + """Get an HSVColor out of the current object.""" + + r, g, b = self.red, self.green, self.blue + maxc = max(r, g, b) + minc = min(r, g, b) + + value = maxc + if minc == maxc: + turns, saturation = 0., 0. + else: + saturation = (maxc - minc) / maxc + rc, gc, bc = map(lambda x: (maxc - x) / (maxc - minc), (r, g, b)) + + if r == maxc: + turns = bc - gc + elif g == maxc: + turns = 2 + rc - bc + else: + turns = 4 + gc - rc + + turns = (turns / 6) % 1 + + return HSVColor( + hue=_TurnsAngle(turns), + saturation=saturation, + value=value, + alpha=self.alpha, + ) + + def ashwb(self) -> 'HWBColor': + """Get an HWBColor out of the current object.""" + + r, g, b = self.red, self.green, self.blue + + max_ = max((r, g, b)) + min_ = min((r, g, b)) + chroma = max_ - min_ + + if chroma == 0: + hue = 0. + elif r == max_: + hue = (g - b) / chroma + elif g == max_: + hue = (b - r) / chroma + 2 + elif b == max_: + hue = (r - g) / chroma + 4 + + hue /= 6 + w = min_ + b = 1 - max_ + + return HWBColor( + hue=_TurnsAngle(hue), + whiteness=w, + blackness=b, + alpha=self.alpha, + ) + + def ascmyk(self) -> 'CMYKColor': + """Get a CMYKColor out of the current object.""" + + r, g, b, _ = self + + k = 1 - max((r, g, b)) + if k == 1: + c, m, y = 0, 0, 0 + else: + c = (1 - r - k) / (1 - k) + m = (1 - g - k) / (1 - k) + y = (1 - b - k) / (1 - k) + + return CMYKColor( + cyan=c, + magenta=m, + yellow=y, + black=k, + alpha=self.alpha, + ) + + def asyiq(self) -> 'YIQColor': + """Get an YIQColor out of the current object.""" + + r, g, b = self.red, self.green, self.blue + + return YIQColor( + y=.587 * g + .114 * b + .299 * r, + i=-.275 * g - .321 * b + .596 * r, + q=-.523 * g + .311 * b + .212 * r, + alpha=self.alpha, + ) + + def asbytes(self) -> _Tuple[int, int, int]: + """Get the red, blue and green bytes.""" + + return ( + int(_round_half_up(self.red * 255)), + int(_round_half_up(self.green * 255)), + int(_round_half_up(self.blue * 255)), + ) + + +class HSLColor(Color): + """A color expressed using its hue, saturation and lightness components. + + :param hue: Value for :py:attr:`hue`. + :param saturation: Value for :py:attr:`saturation`. + :param lightness: Value for :py:attr:`lightness`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_hue', '_saturation', '_lightness') + _params = ('hue', 'saturation', 'lightness') + + def __init__( + self, + hue: _Angle, + saturation: float, + lightness: float, + alpha: float = 1.0, + ): + super().__init__(alpha) + + if not isinstance(hue, _Angle): + raise ValueError('hue should be an angle') + + try: + saturation = float(saturation) + except (TypeError, ValueError): + raise ValueError( + f'saturation should be a float, is {saturation!r}', + ) + else: + if saturation < 0 or saturation > 1: + raise ValueError('saturation should be between 0.0 and 1.0') + + try: + lightness = float(lightness) + except (TypeError, ValueError): + raise ValueError(f'lightness should be a float, is {lightness!r}') + else: + if lightness < 0 or lightness > 1: + raise ValueError('lightness should be between 0.0 and 1.0') + + self._hue = hue + self._saturation = saturation + self._lightness = lightness + + def __iter__(self): + return iter((self.hue, self.saturation, self.lightness, self.alpha)) + + @property + def hue(self) -> _Angle: + """Get the hue, as an angle.""" + + return self._hue + + @property + def saturation(self) -> float: + """Get the saturation, between 0.0 and 1.0.""" + + return self._saturation + + @property + def lightness(self) -> float: + """Get the lightness, between 0.0 and 1.0.""" + + return self._lightness + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + hue_obj, s, lgt = self.hue.asdegrees(), self.saturation, self.lightness + + if s == 0: + # Achromatic color. + + return SRGBColor( + red=lgt, + green=lgt, + blue=lgt, + alpha=self.alpha, + ) + + def _hue_to_rgb(t1, t2, hue): + hue %= 6 + + if hue < 1: + return t1 + (t2 - t1) * hue + elif hue < 3: + return t2 + elif hue < 4: + return t1 + (t2 - t1) * (4 - hue) + return t1 + + hue = (hue_obj.degrees % 360) / 60 + if lgt <= 0.5: + t2 = lgt * (s + 1) + else: + t2 = lgt + s - (lgt * s) + + t1 = lgt * 2 - t2 + + return SRGBColor( + red=_hue_to_rgb(t1, t2, hue + 2), + green=_hue_to_rgb(t1, t2, hue), + blue=_hue_to_rgb(t1, t2, hue - 2), + alpha=self.alpha, + ) + + def ashsl(self) -> 'HSLColor': + """Get an HSLColor out of the current object.""" + + return HSLColor( + hue=self.hue, + saturation=self.saturation, + lightness=self.lightness, + alpha=self.alpha, + ) + + +class HSVColor(Color): + """A color expressed using its hue, saturation and value components. + + :param hue: Value for :py:attr:`hue`. + :param saturation: Value for :py:attr:`saturation`. + :param value: Value for :py:attr:`value`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_hue', '_saturation', '_value') + _params = ('hue', 'saturation', 'value') + + def __init__( + self, + hue: _Angle, + saturation: float, + value: float, + alpha: float = 1.0, + ): + super().__init__(alpha) + + self._hue = hue + self._saturation = saturation + self._value = value + + def __iter__(self): + return iter((self.hue, self.saturation, self.value, self.alpha)) + + @property + def hue(self) -> _Angle: + """Get the hue, as an angle.""" + + return self._hue + + @property + def saturation(self) -> float: + """Get the saturation, between 0.0 and 1.0.""" + + return self._saturation + + @property + def value(self) -> float: + """Get the value, between 0.0 and 1.0.""" + + return self._value + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + hue, saturation, value = ( + self.hue.asturns(), + self.saturation, + self.value, + ) + + if saturation == 0: + r, g, b = value, value, value + else: + f = hue.turns * 6.0 + f, i = f - int(f), int(f) % 6 + + p = value * (1.0 - saturation) + q = value * (1.0 - saturation * f) + t = value * (1.0 - saturation * (1.0 - f)) + + if i == 0: + r, g, b = value, t, p + elif i == 1: + r, g, b = q, value, p + elif i == 2: + r, g, b = p, value, t + elif i == 3: + r, g, b = p, q, value + elif i == 4: + r, g, b = t, p, value + elif i == 5: + r, g, b = value, p, q + + return SRGBColor( + red=r, + green=g, + blue=b, + alpha=self.alpha, + ) + + def ashsv(self) -> 'HSVColor': + """Get an HSVColor out of the current object.""" + + return HSVColor( + hue=self.hue, + saturation=self.saturation, + value=self.value, + alpha=self.alpha, + ) + + +class HWBColor(Color): + """A color expressed using its hue, whiteness and blackness components. + + :param hue: Value for :py:attr:`hue`. + :param whiteness: Value for :py:attr:`whiteness`. + :param blackness: Value for :py:attr:`blackness`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_hue', '_whiteness', '_blackness') + _params = ('hue', 'whiteness', 'blackness') + + def __init__( + self, + hue: _Angle, + whiteness: float = 0.0, + blackness: float = 0.0, + alpha: float = 1.0, + ): + super().__init__(alpha) + + self._hue = hue + self._whiteness = whiteness + self._blackness = blackness + + def __iter__(self): + return iter((self.hue, self.whiteness, self.blackness, self.alpha)) + + @property + def hue(self) -> _Angle: + """Get the hue, as an angle.""" + + return self._hue + + @property + def whiteness(self) -> float: + """Get the whiteness, as a value between 0.0 and 1.0.""" + + return self._whiteness + + @property + def blackness(self) -> float: + """Get the blackness, as a value between 0.0 and 1.0.""" + + return self._blackness + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + hue, w, bl = self.hue, self.whiteness, self.blackness + + color = HSLColor(hue, 1.0, .5).assrgb() + r, g, b = color.red, color.green, color.blue + + if w + bl > 1: + w, bl = map(lambda x: x / (w + bl), (w, bl)) + + r, g, b = map(lambda x: x * (1 - w - bl) + w, (r, g, b)) + + return SRGBColor( + red=r, + green=g, + blue=b, + alpha=self.alpha, + ) + + def ashwb(self) -> 'HWBColor': + """Get an HWBColor out of the current object.""" + + return HWBColor( + hue=self.hue, + whiteness=self.whiteness, + blackness=self.blackness, + alpha=self.alpha, + ) + + +class CMYKColor(Color): + """A color expressed using its CMYK channels' intensities. + + :param cyan: Value for :py:attr:`cyan`. + :param magenta: Value for :py:attr:`magenta`. + :param yellow: Value for :py:attr:`yellow`. + :param black: Value for :py:attr:`black`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_cyan', '_magenta', '_yellow', '_black') + _params = ('cyan', 'magenta', 'yellow', 'black') + + def __init__( + self, + cyan: float, + magenta: float, + yellow: float, + black: float, + alpha: float = 1.0, + ): + super().__init__(alpha) + + self._cyan = cyan + self._magenta = magenta + self._yellow = yellow + self._black = black + + def __iter__(self): + return iter(( + self.cyan, + self.magenta, + self.yellow, + self.black, + self.alpha, + )) + + @property + def cyan(self): + """Get the cyan channel intensity between 0.0 and 1.0.""" + + return self._cyan + + @property + def magenta(self): + """Get the magenta channel intensity between 0.0 and 1.0.""" + + return self._magenta + + @property + def yellow(self): + """Get the yellow channel intensity between 0.0 and 1.0.""" + + return self._yellow + + @property + def black(self): + """Get the black channel intensity between 0.0 and 1.0.""" + + return self._black + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + c, m, y, k = self.cyan, self.magenta, self.yellow, self.black + + r = 1 - min(1, c * (1 - k) + k) + g = 1 - min(1, m * (1 - k) + k) + b = 1 - min(1, y * (1 - k) + k) + + return SRGBColor( + red=r, + green=g, + blue=b, + alpha=self.alpha, + ) + + def ascmyk(self) -> 'CMYKColor': + """Get a CMYKColor out of the current object.""" + + return CMYKColor( + cyan=self.cyan, + magenta=self.magenta, + yellow=self.yellow, + black=self.black, + alpha=self.alpha, + ) + + +class LABColor(Color): + """A color expressed using its CIELAB color space cartesian coordinates. + + :param lightness: Value for :py:attr:`lightness`. + :param a: Value for :py:attr:`a`. + :param b: Value for :py:attr:`b`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_lightness', '_a', '_b') + _params = ('lightness', 'a', 'b') + + def __init__( + self, + lightness: float, + a: float, + b: float, + alpha: float = 1.0, + ): + super().__init__(alpha) + + self._lightness = lightness + self._a = a + self._b = b + + def __iter__(self): + return iter((self.lightness, self.a, self.b, self.alpha)) + + @property + def lightness(self) -> float: + """Get the CIE lightness. + + Similar to the lightness in the HSL representation. + Represented as a float between 0.0 and 1.0. + """ + + return self._lightness + + @property + def a(self) -> float: + """Get the A axis value in the Lab colorspace.""" + + return self._a + + @property + def b(self) -> float: + """Get the B axis value in the Lab colorspace.""" + + return self._b + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + return self.asxyz().assrgb() + + def aslab(self) -> 'LABColor': + """Get a LABColor out of the current object.""" + + return LABColor( + lightness=self.lightness, + a=self.a, + b=self.b, + alpha=self.alpha, + ) + + def aslch(self) -> 'LCHColor': + """Get a LCHColor out of the current object.""" + + l, a, b = self.lightness, self.a, self.b + + return LCHColor( + lightness=l, + chroma=_sqrt(a ** 2 + b ** 2), + hue=_RadiansAngle(_atan2(b, a)).asprincipal(), + alpha=self.alpha, + ) + + +class LCHColor(Color): + """A color expressed using its CIELAB color space polar coordinates. + + :param lightness: Value for :py:attr:`lightness`. + :param chroma: Value for :py:attr:`chroma`. + :param hue: Value for :py:attr:`hue`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_lightness', '_chroma', '_hue', '_alpha') + _params = ('lightness', 'chroma', 'hue') + + def __init__( + self, + lightness: float, + chroma: float, + hue: _Angle, + alpha: float = 1.0, + ): + super().__init__(alpha) + + try: + lightness = float(lightness) + except (TypeError, ValueError): + raise ValueError(f'lightness should be a float, is {lightness!r}') + else: + if lightness < 0 or lightness > 1: + raise ValueError('lightness should be between 0.0 and 1.0') + + try: + chroma = float(chroma) + except (TypeError, ValueError): + raise ValueError(f'chroma should be a float, is {chroma!r}') + else: + if chroma < 0: + chroma = 0.0 + + if not isinstance(hue, _Angle): + raise TypeError(f'hue should be an Angle, is {hue!r}') + + self._lightness = lightness + self._chroma = chroma + self._hue = hue + + def __iter__(self): + return iter((self.lightness, self.chroma, self.hue, self.alpha)) + + @property + def lightness(self) -> float: + """Get the CIE lightness. + + Similar to the lightness in the HSL representation. + Represented as a float between 0.0 and 1.0. + """ + + return self._lightness + + @property + def chroma(self) -> float: + """Get the chroma. + + Represented as a positive number theoretically unbounded. + """ + + return self._chroma + + @property + def hue(self) -> _Angle: + """Get the hue, as an angle.""" + + return self._hue + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + return self.aslab().asxyz().assrgb() + + def aslab(self) -> 'LABColor': + """Get a LABColor out of the current object.""" + + l, c, h = self.lightness, self.chroma, self.hue.asradians() + + return LABColor( + lightness=l, + a=c * _cos(h.radians), + b=c * _sin(h.radians), + alpha=self.alpha, + ) + + def aslch(self) -> 'LCHColor': + """Get a LCHColor out of the current object.""" + + return LCHColor( + lightness=self.lightness, + chroma=self.chroma, + hue=self.hue, + alpha=self.alpha, + ) + + +class XYZColor(Color): + """A color expressed using its CIEXYZ color space coordinates. + + :param x: Value for :py:attr:`x`. + :param y: Value for :py:attr:`y`. + :param z: Value for :py:attr:`z`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_x', '_y', '_z') + _params = ('x', 'y', 'z') + + def __init__(self, x: float, y: float, z: float, alpha: float = 1.0): + super().__init__(alpha) + + try: + x = float(x) + except (TypeError, ValueError): + raise ValueError(f'x should be a float, is {x!r}') + else: + if x < 0 or x > 1: + raise ValueError('x should be between 0.0 and 1.0') + + try: + y = float(y) + except (TypeError, ValueError): + raise ValueError(f'y should be a float, is {y!r}') + else: + if y < 0 or y > 1: + raise ValueError('y should be between 0.0 and 1.0') + + try: + z = float(z) + except (TypeError, ValueError): + raise ValueError(f'z should be a float, is {z!r}') + else: + if z < 0 or z > 1: + raise ValueError('z should be between 0.0 and 1.0') + + self._x = x + self._y = y + self._z = z + + def __iter__(self): + return iter((self.x, self.y, self.z, self.alpha)) + + @property + def x(self) -> float: + """Get the CIE X component, between 0.0 and 1.0.""" + + return self._x + + @property + def y(self) -> float: + """Get the CIE Y component, between 0.0 and 1.0.""" + + return self._y + + @property + def z(self) -> float: + """Get the CIE Z component, between 0.0 and 1.0.""" + + return self._z + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + # For more information about this algorithm, see these links: + # + # * http://www.easyrgb.com/en/math.php#text9 + # (insufficient precision but you get the gist of the algorithm). + # * https://stackoverflow.com/a/45238704 + + x, y, z = self.x, self.y, self.z + + r = x * 3.2404542 + y * -1.5371385 + z * -.4985314 + g = x * -.9692660 + y * 1.8760108 + z * .0415560 + b = x * .0556434 + y * -.2040259 + z * 1.0572252 + + r, g, b = map( + lambda x: ( + 1.055 * (x ** (1 / 2.4)) - .055 + if x > .0031308 + else 12.92 * x + ), + (r, g, b), + ) + + return SRGBColor( + red=r, + green=g, + blue=b, + alpha=self.alpha, + ) + + def aslab(self) -> 'LABColor': + """Get a LABColor out of the current object.""" + + x, y, z = self.x, self.y, self.z + + # Uses the current industry standard formula, delta E 2000, + # on D65/2°. + # For more information, see the following links: + # + # * http://www.easyrgb.com/en/math.php#text9 + # * https://github.com/cangoektas/xyz-to-lab/blob/master/src/index.js + + D65 = (95.047, 100, 108.883) + + def calculate(data): + value, ref = data + value = value * 100 / ref + if value > .008856: + return value ** (1 / 3) + return value * 7.878 + 16 / 116 + + x, y, z = map(calculate, zip((x, y, z), D65)) + + return LABColor( + lightness=(116 * y - 16) / 100, + a=500 * (x - y), + b=200 * (y - z), + alpha=self.alpha, + ) + + def asxyz(self) -> 'XYZColor': + """Get an XYZColor out of the current object.""" + + return XYZColor( + x=self.x, + y=self.y, + z=self.z, + alpha=self.alpha, + ) + + +class YIQColor(Color): + """A color expressed using its YIQ components. + + :param y: Value for :py:attr:`y`. + :param i: Value for :py:attr:`i`. + :param q: Value for :py:attr:`q`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_y', '_i', '_q') + _params = ('y', 'i', 'q') + + def __init__( + self, + y: float, + i: float, + q: float, + alpha: float = 1.0, + ): + super().__init__(alpha) + + self._y = y + self._i = i + self._q = q + + def __iter__(self): + return iter((self.y, self.i, self.q, self.alpha)) + + @property + def y(self) -> float: + """Get the luma.""" + + return self._y + + @property + def i(self) -> float: + """Get the orange-blue range value.""" + + return self._i + + @property + def q(self) -> float: + """Get the purple-green range value.""" + + return self._q + + def assrgb(self) -> 'SRGBColor': + """Get an SRGBColor out of the current object.""" + + y, i, q = self.y, self.i, self.q + + return SRGBColor( + red=max(0.0, min(1.0, ( + y + .9468822170900693 * i + .6235565819861433 * q + ))), + green=max(0.0, min(1.0, ( + y - .27478764629897834 * i - .6356910791873801 * q + ))), + blue=max(0.0, min(1.0, ( + y - 1.1085450346420322 * i + 1.7090069284064666 * q + ))), + alpha=self.alpha, + ) + + def asyiq(self) -> 'YIQColor': + """Get an YIQColor out of the current object.""" + + return YIQColor( + y=self.y, + i=self.i, + q=self.q, + alpha=self.alpha, + ) + + +class YUVColor(Color): + """A color expressed using its YUV components. + + :param y: Value for :py:attr:`y`. + :param u: Value for :py:attr:`u`. + :param v: Value for :py:attr:`v`. + :param alpha: Value for :py:attr:`alpha`. + """ + + __slots__ = ('_y', '_u', '_v') + _params = ('y', 'u', 'v') + + def __init__( + self, + y: float, + u: float, + v: float, + alpha: float = 1.0, + ): + super().__init__(alpha) + + self._y = y + self._u = u + self._v = v + + def __iter__(self): + return iter((self.y, self.u, self.v, self.alpha)) + + @property + def y(self) -> float: + """Get the luma.""" + + return self._y + + @property + def u(self) -> float: + """Get the U chrominance.""" + + return self._u + + @property + def v(self) -> float: + """Get the V chrominance.""" + + return self._v + + def asyuv(self) -> 'YUVColor': + """Get an YUVColor out of the current object.""" + + return YUVColor( + y=self.y, + u=self.u, + v=self.v, + alpha=self.alpha, + ) + +# End of file. diff --git a/thcolor/decoders.py b/thcolor/decoders.py new file mode 100644 index 0000000..b29f149 --- /dev/null +++ b/thcolor/decoders.py @@ -0,0 +1,1240 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Function and data reference.""" + +import re as _re +from abc import ABCMeta as _ABCMeta +from collections.abc import Mapping as _Mapping +from enum import Enum as _Enum, auto as _auto +from inspect import getfullargspec as _getfullargspec +from itertools import zip_longest as _zip_longest +from typing import ( + Any as _Any, List as _List, Optional as _Optional, Sequence as _Sequence, + Tuple as _Tuple, Union as _Union, +) + +from .angles import ( + Angle as _Angle, DegreesAngle as _DegreesAngle, + GradiansAngle as _GradiansAngle, RadiansAngle as _RadiansAngle, + TurnsAngle as _TurnsAngle, +) +from .colors import ( + Color as _Color, HWBColor as _HWBColor, SRGBColor as _SRGBColor, +) +from .errors import ColorExpressionSyntaxError as _ColorExpressionSyntaxError +from .utils import factor as _factor + +__all__ = [ + 'ColorDecoder', 'MetaColorDecoder', + 'alias', 'fallback', +] + +_NO_DEFAULT_VALUE = type('_NO_DEFAULT_VALUE_TYPE', (), {})() + + +def _canonicalkey(x): + """Get a canonical key for the decoder mapping.""" + + return str(x).replace('-', '_').casefold() + + +def _issubclass(cls, types): + """Check if ``cls`` is a subclass of ``types``. + + Until Python 3.10, this function is not aware of the special + types available in the standard ``typing`` module; this function + aims at fixing this for Python <= 3.9. + """ + + # Check if is a special case from typing that we know of. + try: + origin = types.__origin__ + except AttributeError: + if types is _Any: + return True + else: + if origin is _Union or origin is _Optional: + return any(_issubclass(cls, type_) for type_ in types.__args__) + + # If ``types`` is any iterable type (tuple, list, you name it), + # then we check if the class is a subclass of at least one element + # in the iterable. + try: + types_iter = iter(types) + except TypeError: + pass + else: + return any(_issubclass(cls, type_) for type_ in types_iter) + + # We default on the base ``issubclass`` from here. + try: + return issubclass(cls, types) + except TypeError: + return False + + +def _isinstance(value, types): + """Check if ``cls`` is a subclass of ``types``. + + Until Python 3.10, this function is not aware of the special + types available in the standard ``typing`` module; this function + aims at fixing this for Python <= 3.9. + """ + + return _issubclass(type(value), types) + + +def _get_args(func) -> _Sequence[_Tuple[str, _Any, _Any]]: + """Get the arguments definition from a function. + + Each argument is returned as a tuple containing: + + * The name of the argument. + * The type of the argument. + * The default value of the argument; + ``_NO_DEFAULT_VALUE`` if the argument has no default value. + + In case an argument is keyword-only with no default + value, the function will raise an exception. + """ + + if isinstance(func, staticmethod): + func = func.__func__ + + argspec = _getfullargspec(func) + try: + kwarg = next( + arg + for arg in argspec.kwonlyargs + if arg not in (argspec.kwonlydefaults or ()) + ) + except StopIteration: + pass + else: + raise ValueError( + f'keyword-only argument {kwarg} has no default value', + ) + + if argspec.varargs is not None: + raise ValueError( + 'function has variable arguments, which is unsupported', + ) + + annotations = getattr(func, '__annotations__', {}) + + argnames = argspec.args + argtypes = [annotations.get(name) for name in argnames] + argdefaultvalues = list(argspec.defaults or ()) + argdefaultvalues = ( + [_NO_DEFAULT_VALUE] * ( + len(argspec.args or ()) - len(argdefaultvalues) + ) + + argdefaultvalues + ) + + return list(zip(argnames, argtypes, argdefaultvalues)) + + +def _make_function(func, name: str, args: _Optional[_Sequence[_Any]]): + """Create a function calling another. + + This function will rearrange the given positional + arguments with the given argument order ``args``. + + It will also set the default value and annotations of + the previous function, rearranged using the given + function. + """ + + # Here, we get the variables for the base function call. + # The resulting variables are the following: + # + # * ``proxy_args``: the proxy arguments as (name, type, defvalue) tuples. + # * ``args_index``: a args index -> proxy index correspondance. + args_spec = _get_args(func) + args_index: _List[_Optional[int]] + + if not args: + args_index = list(range(len(args_spec))) + proxy_args = args_spec + else: + args_names = [name for (name, _, _) in args_spec] + + args_index = [None] * len(args_spec) + proxy_args = [] + + for proxy_index_i, arg in enumerate(args): + try: + index = args_names.index(arg) + except ValueError: + raise ValueError( + f'{arg!r} is not an argument of the aliased function', + ) + + args_index[index] = proxy_index_i + proxy_args.append(args_spec[index]) + + # We want to check that no value without a default value was + # left unspecified. + for (aname, _, adefvalue), proxy_index in zip(args_spec, args_index): + if proxy_index is None and adefvalue is _NO_DEFAULT_VALUE: + raise ValueError( + f'{aname!r} is left without value in the aliased function', + ) + + # Produce the function code. + locals_ = {'__func': func} + + def _iter_proxy_args(d, args): + # First, obtain the index of the last optional argument from the + # end, because even arguments with default values before + # mandatory arguments cannot have a default value exposed in Python. + # + # Then, yield the arguments. + + try: + mandatory_until = max( + index for index, (_, _, adefvalue) in enumerate(args) + if adefvalue is _NO_DEFAULT_VALUE + ) + except ValueError: + mandatory_until = -1 + + for index, (aname, atype, adefvalue) in enumerate(args): + atypename = None + adefvaluename = None + + if atype is not None: + atypename = f'__type{index}' + d[atypename] = atype + + if index > mandatory_until and adefvalue is not _NO_DEFAULT_VALUE: + adefvaluename = f'__value{index}' + d[adefvaluename] = adefvalue + + yield f'{aname}' + ( + ( + f': {atypename} = {adefvaluename}', + f': {atypename}', + )[adefvaluename is None], + (f'={adefvaluename}', '')[adefvaluename is None], + )[atypename is None] + + def _iter_final_args(d, args_spec, arg_indexes): + for index, ((aname, _, adefvalue), p_index) in enumerate( + zip(args_spec, arg_indexes), + ): + if p_index is None: + defvaluename = f'__defvalue{index}' + d[defvaluename] = adefvalue + yield defvaluename + else: + yield aname + + proxy_args_s = ', '.join(_iter_proxy_args(locals_, proxy_args)) + final_args_s = ', '.join(_iter_final_args(locals_, args_spec, args_index)) + keys = ', '.join(locals_.keys()) + + code = ( + f'def __define_alias({keys}):\n' + f' def {name}({proxy_args_s}):\n' + f' return __func({final_args_s})\n' + f' return {name}\n' + '\n' + f'__func = __define_alias({keys})\n' + ) + + exec(code, {}, locals_) # noqa: S102 + func = locals_['__func'] + + return func + + +def _ncol_func( + angle: _Angle, + whiteness: _Union[int, float] = 0, + blackness: _Union[int, float] = 0, +): + return _HWBColor( + hue=angle, + whiteness=_factor(whiteness), + blackness=_factor(blackness), + ) + + +# --- +# Lexer. +# --- + + +class _ColorExpressionTokenType(_Enum): + """Token type.""" + + NAME = _auto() + ANGLE = _auto() + PERCENTAGE = _auto() + INTEGER = _auto() + FLOAT = _auto() + NCOL = _auto() + HEX = _auto() + EMPTY = _auto() + CALL_START = _auto() + CALL_END = _auto() + + # Not generated by the lexer. + CALL = _auto() + + +class _ColorExpressionToken: + """A token as expressed by the color expression lexer.""" + + __slots__ = ('_type', '_name', '_value', '_column', '_rawtext') + + TYPE_NAME = _ColorExpressionTokenType.NAME + TYPE_ANGLE = _ColorExpressionTokenType.ANGLE + TYPE_PERCENTAGE = _ColorExpressionTokenType.PERCENTAGE + TYPE_INTEGER = _ColorExpressionTokenType.INTEGER + TYPE_FLOAT = _ColorExpressionTokenType.FLOAT + TYPE_NCOL = _ColorExpressionTokenType.NCOL + TYPE_HEX = _ColorExpressionTokenType.HEX + TYPE_EMPTY = _ColorExpressionTokenType.EMPTY + TYPE_CALL_START = _ColorExpressionTokenType.CALL_START + TYPE_CALL_END = _ColorExpressionTokenType.CALL_END + + # Not generated by the lexer. + TYPE_CALL = _ColorExpressionTokenType.CALL + + def __init__( + self, + type_: _ColorExpressionTokenType, + name=None, + value=None, + column=None, + rawtext=None, + ): + self._type = type_ + self._name = name + self._value = value + self._column = column + self._rawtext = rawtext + + def __repr__(self): + args = [f'type_=TYPE_{self._type.name}'] + if self._name is not None: + args.append(f'name={self._name!r}') + if self._value is not None: + args.append(f'value={self._value!r}') + if self._column is not None: + args.append(f'column={self._column!r}') + if self._rawtext is not None: + args.append(f'rawtext={self._rawtext!r}') + + return f'{self.__class__.__name__}({", ".join(args)})' + + @property + def type_(self): + """Token type.""" + + return self._type + + @property + def name(self): + """Token name.""" + + return self._name + + @property + def value(self): + """Token value.""" + + return self._value + + @property + def column(self): + """Column at which the token is located.""" + + return self._column + + @property + def rawtext(self): + """Raw text for decoding.""" + + return self._rawtext + + +_colorexpressionpattern = _re.compile( + r""" + \s* + (?P<arg> + ( + (?P<agl> + (?P<agl_sign> [+-]?) + (?P<agl_val> ([0-9]+(\.[0-9]*)?|[0-9]*\.[0-9]+)) \s* + (?P<agl_typ>deg|grad|rad|turns?) + ) + | (?P<per> + (?P<per_val>[+-]? [0-9]+(\.[0-9]*)? | [+-]? \.[0-9]+) + \s* \% + ) + | (?P<int_val>[+-]? [0-9]+) + | (?P<flt_val>[+-]? [0-9]+\.[0-9]* | [+-]? \.[0-9]+) + | (?P<ncol>[RYGCBM] [0-9]{0,2} (\.[0-9]*)?) + | ( + \# (?P<hex> + [0-9a-f]{3} | [0-9a-f]{4} + | [0-9a-f]{6} | [0-9a-f]{8} + ) + ) + | (?P<name> [a-z0-9_-]+) + | + ) \s* (?P<sep>,|/|\s|\(|\)|$) + ) + \s* + """, + _re.VERBOSE | _re.I | _re.M, +) + + +def _get_color_tokens(string: str, extended_hex: bool = False): + """Get color tokens. + + :param string: The string to get the color tokens from. + :param extended_hex: Whether 4 or 8-digit hex colors are + allowed (``True``) or not (``False``). + """ + + start: int = 0 + was_call_end: bool = False + + while string: + match = _colorexpressionpattern.match(string) + if match is None: + s = f'{string[:17]}...' if len(string) > 20 else string + raise _ColorExpressionSyntaxError( + f'syntax error near {s!r}', + column=start, + ) + + result = match.groupdict() + column = start + match.start('arg') + + is_call_end = False + + if result['name']: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_NAME, + value=result['name'], + column=column, + ) + elif result['agl'] is not None: + value = float(result['agl_sign'] + result['agl_val']) + typ = result['agl_typ'] + + ang: _Angle + if typ == 'deg': + ang = _DegreesAngle(value) + elif typ == 'rad': + ang = _RadiansAngle(value) + elif typ == 'grad': + ang = _GradiansAngle(value) + elif typ in ('turn', 'turns'): + ang = _TurnsAngle(value) + else: + raise NotImplementedError + + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_ANGLE, + value=ang, + column=column, + rawtext=result['agl'], + ) + elif result['per'] is not None: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_PERCENTAGE, + value=float(result['per_val']) / 100, + column=column, + rawtext=result['per'], + ) + elif result['int_val'] is not None: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_INTEGER, + value=int(result['int_val']), + column=column, + rawtext=result['int_val'], + ) + elif result['flt_val'] is not None: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_FLOAT, + value=float(result['flt_val']), + column=column, + rawtext=result['flt_val'], + ) + elif result['ncol'] is not None: + letter = result['ncol'][0] + number = float(result['ncol'][1:]) + + if number < 0 or number >= 100: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_NAME, + value=result['ncol'], + column=column, + ) + else: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_NCOL, + value=_DegreesAngle( + 'RYGCBM'.find(letter) * 60 + number / 100 * 60, + ), + column=column, + rawtext=result['ncol'], + ) + elif result['hex'] is not None: + value_s = result['hex'] + if len(value_s) in (4, 8) and not extended_hex: + raise _ColorExpressionSyntaxError( + 'extended hex values are forbidden: ' + f'{"#" + value_s!r}', + column=start, + ) + + if len(value_s) <= 4: + value_s = ''.join(map(lambda x: x + x, value_s)) + + r = int(value_s[0:2], 16) + g = int(value_s[2:4], 16) + b = int(value_s[4:6], 16) + a = int(value_s[6:8], 16) / 255.0 if len(value_s) == 8 else 1.0 + + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_HEX, + value=_SRGBColor.frombytes(r, g, b, a), + column=column, + rawtext='#' + result['hex'], + ) + else: + # ``was_call_end`` hack: take ``func(a, b) / c`` for example. + # By default, it is tokenized as the following: + # + # * name 'func' + # * call start + # * name 'a' + # * name 'b' + # * call end + # * empty arg [1] + # * name 'c' + # + # The empty arg at [1] is tokenized since an argument could + # directly be after the right parenthesis. However, we do not + # want this to be considered an empty arg, so we ignore empty + # args right after call ends. + # + # Note that ``func(a, b) / / c`` will still contain an empty + # argument before name 'c'. + if not was_call_end: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_EMPTY, + column=column, + rawtext='', + ) + + sep = result['sep'] + column = start + match.start('sep') + + if sep == '(': + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_CALL_START, + column=column, + rawtext=sep, + ) + elif sep == ')': + is_call_end = True + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_CALL_END, + column=column, + rawtext=sep, + ) + + start += match.end() + string = string[match.end():] + was_call_end = is_call_end + + +# --- +# Base decoder classes. +# --- + +def fallback(value: _Union[_Color, _Angle, int, float]): + """Set a fallback value on a function, if used as a variable. + + When a function is used as a symbol instead of a function, + by default, it yields that it is callable and should be + accompanied with arguments. + + Using this decorator on a function makes it to be evaluated + as a color in this case. + """ + + if not isinstance(value, (_Color, _Angle, int, float)): + raise ValueError( + 'fallback value should be a color, an angle or a number, ' + f'is {value!r}', + ) + + def decorator(func): + func.__fallback_value__ = value + return func + + return decorator + + +class alias: + """Define an alias for a function.""" + + __slots__ = ('_name', '_args') + + def __init__(self, name: str, args: _Sequence[str] = ()): + self._name = name + self._args = tuple(args) + + if len(self._args) != len(set(self._args)): + raise ValueError('arguments should be unique') + if any(not x or not isinstance(x, str) for x in self._args): + raise ValueError('all arguments should be non-empty strings') + + @property + def name(self): + """Get the name of the function the current alias targets.""" + + return self._name + + @property + def args(self) -> _Sequence[str]: + """Get the arguments' order of the alias.""" + + return self._args + + +class ColorDecoder(_Mapping): + """Base color decoder. + + This color decoder behaves as a mapping returning syntax elements, + with the additional properties controlling its behaviour. + + The properties defined at class definition time are the following: + + ``__mapping__`` + Defines the base mapping that is copied at the + instanciation of each class. + + ``__ncol_support__`` + Defines whether natural colors (NCol) are + supported while decoding or not. + + ``__extended_hex_support__`` + Defines whether 4 or 8-digit + hexadecimal colors (starting with a '#') are allowed or not. + + ``__defaults_to_netscape_color`` + Defines whether color decoding + defaults to Netscape color parsing or not. + + These properties cannot be changed at runtime, although they might + be in a future version of thcolor. + """ + + __slots__ = ('_mapping',) + __mapping__: _Mapping = {} + __ncol_support__: bool = False + __extended_hex_support__: bool = False + __defaults_to_netscape_color__: bool = False + + def __init__(self): + cls = self.__class__ + + mapping = {} + for key, value in cls.__mapping__.items(): + mapping[_canonicalkey(key)] = value + + self._mapping = mapping + self._ncol_support = bool(cls.__ncol_support__) + self._extended_hex_support = bool(cls.__extended_hex_support__) + self._defaults_to_netscape_color = bool( + cls.__defaults_to_netscape_color__, + ) + + def __getattr__(self, key): + try: + return self[key] + except KeyError: + raise AttributeError + + def __getitem__(self, key): + return self._mapping[_canonicalkey(key)] + + def __iter__(self): + return iter(self._mapping) + + def __len__(self): + return len(self._mapping) + + def __repr__(self): + return f'{self.__class__.__name__}()' + + def decode( + self, + expr: str, + prefer_colors: bool = False, + prefer_angles: bool = False, + ) -> _Sequence[_Optional[_Union[_Color, _Angle, int, float]]]: + """Decode a color expression. + + When top-level result(s) are not colors and colors are + actually expected if possible to obtain, the caller should + set ``prefer_colors`` to ``True`` in order for top-level + conversions to take place. + + Otherwise, when top-level result(s) are not angles and angles + are actually expected if possible to obtain, the caller should + set ``prefer_angles`` to ``True`` in order for top-level + conversions to take place. + """ + + global _color_pattern + + ncol_support = self._ncol_support + extended_hex_support = self._extended_hex_support + defaults_to_netscape = self._defaults_to_netscape_color + + # Parsing stage; the results will be in ``current``. + # + # * ``stack``: the stack of current values. + # * ``func_stack``: the function name or special type, as tokens. + # Accepted token types here are ``NAME`` and ``NCOL``. + # * ``implicit_stack``: when the current function is an implicit + # one, this stack contains the arguments left to read in this + # implicit function. + # * ``current``: the current list of elements, which is put on + # top of the stack when a call is started and is popped out of + # the top of the stack when a call is ended. + stack: _List[_List[_ColorExpressionToken]] = [] + func_stack: _List[_ColorExpressionToken] = [] + implicit_stack: _List[int] = [] + current: _List[_ColorExpressionToken] = [] + + def _get_parent_func(): + """Get the parent function name for exceptions.""" + + if not func_stack: + return None + if func_stack[0].type_ == _ColorExpressionToken.TYPE_NCOL: + return '<implicit ncol function>' + return func_stack[0].value + + token_iter = _get_color_tokens( + expr, + extended_hex=extended_hex_support, + ) + for token in token_iter: + if ( + token.type_ == _ColorExpressionToken.TYPE_NCOL + and ncol_support + ): + func_stack.insert(0, token) + implicit_stack.insert(0, 3) + stack.insert(0, current) + current = [] + elif token.type_ == _ColorExpressionToken.TYPE_NCOL: + current.append(_ColorExpressionToken( + _ColorExpressionToken.TYPE_NAME, + value=token.rawtext, + rawtext=token.rawtext, + column=token.column, + )) + elif token.type_ in ( + _ColorExpressionToken.TYPE_NAME, + _ColorExpressionToken.TYPE_ANGLE, + _ColorExpressionToken.TYPE_PERCENTAGE, + _ColorExpressionToken.TYPE_INTEGER, + _ColorExpressionToken.TYPE_FLOAT, + _ColorExpressionToken.TYPE_HEX, + _ColorExpressionToken.TYPE_EMPTY, + ): + # Current token is a value, we add it. + current.append(token) + elif token.type_ == _ColorExpressionToken.TYPE_CALL_START: + if func_stack and ( + func_stack[0].type_ == _ColorExpressionToken.TYPE_NCOL + ): + implicit_stack[0] += 1 + + name_token = current.pop(-1) + if name_token.type_ != _ColorExpressionToken.TYPE_NAME: + raise _ColorExpressionSyntaxError( + 'expected the name of the function to call, ' + f'got a {name_token.type_.name}', + column=name_token.column, + func=_get_parent_func(), + ) + + func_stack.insert(0, name_token) + stack.insert(0, current) + current = [] + elif token.type_ == _ColorExpressionToken.TYPE_CALL_END: + # First, pop out all of the implicit functions. + + while func_stack: + try: + name_token = func_stack.pop(0) + except IndexError: + raise _ColorExpressionSyntaxError( + 'extraneous closing parenthesis', + column=token.column, + func=_get_parent_func(), + ) from None + + if name_token.type_ == _ColorExpressionToken.TYPE_NAME: + break + + # We pop the function out of the stack with the + # arguments we have. + implicit_stack.pop(0) + old_current = stack.pop(0) + old_current.append(_ColorExpressionToken( + type_=_ColorExpressionToken.TYPE_CALL, + column=name_token.column, + name=_ncol_func, + value=(name_token, *current), + )) + current = old_current + + # We have a function name and it is not implicit. + old_current = stack.pop(0) + old_current.append(_ColorExpressionToken( + _ColorExpressionTokenType.CALL, + name=name_token.value, + value=current, + column=name_token.column, + )) + current = old_current + else: + raise NotImplementedError( + f'unknown token type: {token.type_!r}', + ) + + while func_stack and ( + func_stack[0].type_ == _ColorExpressionToken.TYPE_NCOL + ): + implicit_stack[0] -= 1 + if implicit_stack[0] > 0: + break + + ncol = func_stack.pop(0) + implicit_stack.pop(0) + + # We have the required number of tokens for this + # to work. + old_current = stack.pop(0) + old_current.append(_ColorExpressionToken( + type_=_ColorExpressionToken.TYPE_CALL, + column=ncol.column, + name=_ncol_func, + value=(ncol, *current), + )) + current = old_current + + # We want to pop out all implicit functions we have. + # If we still have an explicit function in the function stack, + # that means a parenthesis was omitted. + while func_stack: + try: + name_token = func_stack.pop(0) + except IndexError: + break + + if name_token.type_ == _ColorExpressionToken.TYPE_NAME: + raise _ColorExpressionSyntaxError( + 'missing closing parenthesis', + column=len(expr), + func=_get_parent_func(), + ) + + # We pop the function out of the stack with the + # arguments we have. + implicit_stack.pop(0) + old_current = stack.pop(0) + old_current.append(_ColorExpressionToken( + type_=_ColorExpressionToken.TYPE_CALL, + column=name_token.column, + name=_ncol_func, + value=(name_token, *current), + )) + current = old_current + + # Evaluating stage. + def evaluate(element, parent_func=None): + """Evaluate the element in the current context. + + Always returns a tuple where: + + * The first element is the real answer. + * The second element is the string which can be used + in the case of a color fallback (when Netscape color + defaulting is on). + """ + + if element.type_ == _ColorExpressionTokenType.NAME: + try: + data = self[element.value] + except KeyError: + if defaults_to_netscape: + return ( + _SRGBColor.fromnetscapecolorname(element.value), + element.value, + ) + + raise _ColorExpressionSyntaxError( + f'unknown value {element.value!r}', + column=element.column, + func=parent_func, + ) + else: + if not isinstance(data, (_Color, _Angle, int, float)): + try: + fallback_value = data.__fallback_value__ + except AttributeError: + raise _ColorExpressionSyntaxError( + f'{element.value!r} is not a value and ' + f'has no fallback value (is {data!r})', + column=element.column, + func=parent_func, + ) + + if not isinstance(fallback_value, ( + _Color, _Angle, int, float, + )): + raise _ColorExpressionSyntaxError( + f'{element.value!r} fallback value ' + f'{fallback_value!r} is not a color, ' + 'an angle or a number.', + column=element.column, + func=parent_func, + ) + + data = fallback_value + + return (data, element.value) + elif element.type_ != _ColorExpressionTokenType.CALL: + return (element.value, element.rawtext) + + func_name = element.name + if callable(func_name): + func = func_name + func_name = '<implicit ncol function>' + else: + try: + func = self[func_name] + except KeyError: + raise _ColorExpressionSyntaxError( + f'function {func_name!r} not found', + column=element.column, + func=parent_func, + ) + + # Check the function. + try: + args_spec = _get_args(func) + except ValueError as exc: + raise _ColorExpressionSyntaxError( + f'function {func_name!r} is unsuitable for ' + f'calling: {str(exc)}', + column=element, + func=parent_func, + ) + + # Check the argument count. + # + # We remove empty arguments at the end of calls so that + # even calls such as ``func(a, b, , , , , ,)`` will only + # have arguments 'a' and 'b'. + unevaluated_args = element.value + while ( + unevaluated_args and unevaluated_args[-1].type_ + == _ColorExpressionToken.TYPE_EMPTY + ): + unevaluated_args.pop(-1) + + if len(unevaluated_args) > len(args_spec): + raise _ColorExpressionSyntaxError( + f'too many arguments for {func_name!r}: ' + f'expected {len(args_spec)}, got {len(unevaluated_args)}', + column=element.column, + func=parent_func, + ) + + # Now we should evaluate subtokens and check the types of + # the function. + new_args = [] + + for token, (argname, argtype, argdefvalue) in _zip_longest( + unevaluated_args, args_spec, fillvalue=None, + ): + arg, fallback_string, column = None, None, element.column + if token is not None: + column = token.column + arg, *_, fallback_string = evaluate(token) + + arg = arg if arg is not None else argdefvalue + if arg is _NO_DEFAULT_VALUE: + raise _ColorExpressionSyntaxError( + f'expected a value for {argname!r}', + column=column, + func=func_name, + ) + + if argtype is None or _isinstance(arg, argtype): + pass + elif ( + _issubclass(_Color, argtype) and defaults_to_netscape + and fallback_string is not None + ): + arg = _SRGBColor.fromnetscapecolorname(fallback_string) + elif ( + _issubclass(_Angle, argtype) + and isinstance(arg, (int, float)) + ): + arg = _DegreesAngle(arg) + elif ( + _issubclass(float, argtype) + and isinstance(arg, int) + ): + arg = float(arg) + elif ( + _issubclass(int, argtype) + and isinstance(arg, float) + and arg == int(arg) + ): + arg = int(arg) + else: + raise _ColorExpressionSyntaxError( + f'{arg!r} did not match expected type {argtype!r}' + f' for argument {argname!r}', + column=column, + func=func_name, + ) + + new_args.append(arg) + + # Get the result. + result = func(*new_args) + if result is None: + raise _ColorExpressionSyntaxError( + f'function {func_name!r} returned an empty ' + 'result for the following arguments: ' + f'{", ".join(map(repr, new_args))}.', + column=token.column, + func=parent_func, + ) + + return result, None + + return tuple( + _SRGBColor.fromnetscapecolorname(fallback_string) + if ( + not isinstance(result, _Color) + and prefer_colors + and defaults_to_netscape + and fallback_string is not None + ) + else _DegreesAngle(result) + if ( + isinstance(result, (int, float)) + and prefer_angles + ) + else result + for result, *_, fallback_string in map(evaluate, current) + ) + + +# --- +# Meta base type. +# --- + + +class _MetaColorDecoderType(_ABCMeta): + """The decoder type.""" + + def __new__(mcls, clsname, superclasses, attributedict): + elements = {} + options = { + '__ncol_support__': False, + '__extended_hex_support__': False, + '__defaults_to_netscape_color__': False, + } + + # Explore the parents. + for supercls in reversed(superclasses): + if not issubclass(supercls, ColorDecoder): + continue + + for option in options: + if getattr(supercls, option, False): + options[option] = True + + elements.update(supercls.__mapping__) + + for option, value in options.items(): + options[option] = attributedict.get(option, value) + + # Instanciate the class. + clsattrs = { + '__doc__': attributedict.get('__doc__', None), + '__mapping__': elements, + } + clsattrs.update(options) + + cls = super().__new__(mcls, clsname, superclasses, clsattrs) + del clsattrs + + # Get the elements and aliases from the current attribute dictionary. + aliases = {} + childelements = {} + for key, value in attributedict.items(): + if key.startswith('_') or key.endswith('_'): + continue + + key = _canonicalkey(key) + + if isinstance(value, alias): + aliases[key] = value + continue + elif callable(value): + pass + elif isinstance(value, (int, float, _Color, _Angle)): + value = value + else: + raise TypeError( + f'{clsname} property {key!r} is neither an alias, ' + 'a function, a number, a color or an angle', + ) + + childelements[key] = value + + # Resolve the aliases. + # + # Because of dependencies, we do the basic solution for that: + # We try to resolve aliases as we can, ignoring the ones for which + # the aliases are still awaiting. If we could not resolve any + # of the aliases in one round and there still are some left, + # there must be a cyclic dependency somewhere. + # + # This is not optimized. However, given the cases we have, + # it'll be enough. + aliaselements = {} + while aliases: + resolved = len(aliases) + nextaliases = {} + + for key, alias_value in aliases.items(): + funcname = _canonicalkey(alias_value.name) + + # Check if the current alias still depends on an alias + # that hasn't been resolved yet; in that case, ignore + # it for now. + if funcname in aliases: + resolved -= 1 + nextaliases[key] = alias_value + continue + + # Check the function name and arguments. + try: + func = childelements[funcname] + except KeyError: + try: + func = elements[funcname] + except KeyError: + raise ValueError( + f'{clsname} property {key!r} references ' + f'undefined function {funcname!r}', + ) from None + + if not callable(func): + raise ValueError( + f'{clsname} property {key!r} ' + f'references non-function {funcname!r}', + ) + + aliaselements[_canonicalkey(key)] = _make_function( + func, name=key.replace('-', '_'), args=alias_value.args, + ) + + aliases = nextaliases + if aliases and not resolved: + raise ValueError( + f'could not resolve left aliases in class {clsname}, ' + 'there might be a cyclic dependency between these ' + 'aliases: ' + ', '.join(aliases.keys()) + '.', + ) + + # Add all of the elements in order of importance. + elements.update(aliaselements) + elements.update(childelements) + + # Check the types of the annotations, just in case. + for key, element in elements.items(): + try: + args_spec = _get_args(element) + except TypeError: + continue + + for argname, argtype, argdefvalue in args_spec: + if argtype is None: + continue + + if all( + not _issubclass(type_, argtype) + for type_ in (int, float, _Color, _Angle, type(None)) + ): + raise TypeError( + f'function {key!r}, argument {argname!r}: ' + 'none of the types handled by the decoder are ' + f'valid subclasses of type hint {argtype}', + ) + + if argdefvalue is _NO_DEFAULT_VALUE: + continue + + if ( + not _isinstance(argdefvalue, argtype) + and not ( + isinstance(argdefvalue, int) + and _issubclass(float, argtype) + ) + and not ( + isinstance(argdefvalue, float) + and int(argdefvalue) == argdefvalue + and _issubclass(int, argtype) + ) + and not ( + isinstance(argdefvalue, (int, float)) + and _issubclass(_Angle, argtype) + ) + ): + raise TypeError( + f'function {key!r}, argument {argname!r}: ' + f'default value {argdefvalue!r} is not a valid ' + f'instance of type hint {argtype!r}', + ) + + return cls + + +class MetaColorDecoder(ColorDecoder, metaclass=_MetaColorDecoderType): + """Base meta color decoder, which gets the function and things.""" + + pass + + +# End of file. diff --git a/thcolor/errors.py b/thcolor/errors.py new file mode 100755 index 0000000..5c6e0f4 --- /dev/null +++ b/thcolor/errors.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2019-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Exception definitions.""" + +__all__ = ['ColorExpressionSyntaxError'] + + +class ColorExpressionSyntaxError(Exception): + """An error has occurred while decoding a color expression. + + Such an error can happen during parsing or evaluating. + """ + + def __init__(self, text, column=None, func=None): + self._column = column if column is not None and column >= 0 else None + self._func = str(func) if func else None + self._text = str(text) if text else '' + + def __str__(self): + msg = '' + + if self._column is not None: + msg += f'at column {self._column}' + if self._func is not None: + msg += ', ' + if self._func is not None: + msg += f'for function {self._func!r}' + if msg: + msg += ': ' + + return msg + self._text + + @property + def text(self): + """Exception message, usually linked to the context.""" + + return self._text + + @property + def column(self): + """Column of the expression at which the exception has occurred. + + ``None`` if the error has occurred on an unknown column or on + the whole exception. + """ + + return self._column + + @property + def func(self): + """Name of the function we were calling when the error occurred. + + Either on arguments decoding or erroneous argument type or value. + Is ``None`` if the context is unknown or the error hasn't + occurred while calling a function or decoding its + arguments. + """ + + return self._func + + +# End of file. diff --git a/thcolor/utils.py b/thcolor/utils.py new file mode 100644 index 0000000..337c794 --- /dev/null +++ b/thcolor/utils.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Utilities for the thcolor module.""" + +from typing import Optional as _Optional + +__all__ = ['factor', 'round_half_up'] + + +def factor(x, max_: int = 100, clip: bool = False): + """Return a factor based on if something is a float or an int.""" + + if isinstance(x, float): + pass + elif x in (0, 1) and max_ == 100: + x = float(x) + else: + x /= max_ + + if clip: + x = max(0, min(1, x)) + + return x + + +def round_half_up(number: float, ndigits: _Optional[int] = None) -> float: + """Round a number to the nearest integer. + + This function exists because Python's built-in ``round`` function + uses half-to-even rounding, also called "Banker's rounding". + This means that 1.5 is rounded to 2 and 2.5 is also rounded to 2. + + What we want is a half-to-up rounding, so we have this function. + """ + + if ndigits is None: + ndigits = 0 + + base = 10 ** -ndigits + + result = round( + (number // base) * base + ( + base if (number % base) >= (base / 2) else 0 + ), + ndigits=ndigits, + ) + + if result == int(result): + result = int(result) + return result + + +# End of file. diff --git a/thcolor/version.py b/thcolor/version.py new file mode 100755 index 0000000..886c512 --- /dev/null +++ b/thcolor/version.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python3 +# ***************************************************************************** +# Copyright (C) 2021-2022 Thomas "Cakeisalie5" Touhey <thomas@touhey.fr> +# This file is part of the thcolor project, which is MIT-licensed. +# ***************************************************************************** +"""Version definition for the thcolor module.""" + +__all__ = ['version'] + +version = '0.4' + +# End of file. |