diff options
author | Thomas Touhey <thomas@touhey.fr> | 2021-09-11 21:09:06 +0200 |
---|---|---|
committer | Thomas Touhey <thomas@touhey.fr> | 2021-12-31 21:01:37 +0100 |
commit | 542a4bcb3ca5fc0c867c07c399dd16e8642f68d9 (patch) | |
tree | d6fd255efc366ed7843bf37fba17f8246a52482d | |
parent | b3edd31df8c96035d70c0216e444d6dd9951699f (diff) |
Reworking thcolor.
42 files changed, 3736 insertions, 3595 deletions
diff --git a/.python-version b/.python-version index 0b2eb36..f870be2 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.7.2 +3.10.1 @@ -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 -q 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" - } - } -} diff --git a/docs/Makefile b/docs/Makefile index 28d11c9..cd5fe80 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -2,12 +2,14 @@ # # You can set these variables from the command line. + +PE = pipenv run SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXWATCH = sphinx-autobuild +SPHINXBUILD = $(PE) sphinx-build +SPHINXWATCH = $(PE) 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 +24,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..2ad7a43 --- /dev/null +++ b/docs/api.rst @@ -0,0 +1,10 @@ +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/angles + api/colors diff --git a/docs/api/angles.rst b/docs/api/angles.rst new file mode 100644 index 0000000..609bb33 --- /dev/null +++ b/docs/api/angles.rst @@ -0,0 +1,24 @@ +Angles +====== + +.. 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 + +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/colors.rst b/docs/api/colors.rst new file mode 100644 index 0000000..937cd61 --- /dev/null +++ b/docs/api/colors.rst @@ -0,0 +1,34 @@ +Colors +====== + +.. py:module:: thcolor.colors + +The base class for colors is the following: + +.. autoclass:: Color + :members: alpha, assrgb, ashsl, ashwb, ascmyk, aslab, aslch, asxyz, + replace, darker, lighter, desaturate, saturate, + css, from_text + +Subclasses are the following: + +.. autoclass:: SRGBColor + :members: red, green, blue, alpha, frombytes, fromnetscapecolorname + +.. autoclass:: HSLColor + :members: hue, saturation, lightness, alpha + +.. autoclass:: HWBColor + :members: hue, whiteness, blackness, alpha + +.. autoclass:: CMYKColor + :members: cyan, magenta, yellow, black, alpha + +.. autoclass:: LABColor + :members: lightness, a, b, alpha + +.. autoclass:: LCHColor + :members: lightness, chroma, hue, alpha + +.. autoclass:: XYZColor + :members: x, y, z, alpha 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..22cfb36 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,9 +43,13 @@ 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 = [] diff --git a/docs/discuss.rst b/docs/discuss.rst new file mode 100644 index 0000000..d7e2055 --- /dev/null +++ b/docs/discuss.rst @@ -0,0 +1,10 @@ +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/color-expressions diff --git a/docs/discuss/color-expressions.rst b/docs/discuss/color-expressions.rst new file mode 100644 index 0000000..0109057 --- /dev/null +++ b/docs/discuss/color-expressions.rst @@ -0,0 +1,53 @@ +.. _expr: + +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. This is what the following static method +is for: + +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: + + * Numbers. + * Percentages. + * Angles. + * Colors. + +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/) + +Defining a reference +-------------------- + +Functions and color names are defined in a reference: + +.. todo:: + + More about this. 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/index.rst b/docs/index.rst index 33e175d..50b5c57 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,26 +5,17 @@ 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: -- color management and conversions between formats (RGB, HSL, HWB, NCol, …). -- text-to-color using close-to-CSS format. - -To install the module, use pip: - -.. code-block:: bash - - $ pip install thcolor + * Color management and conversions between formats (RGB, HSL, HWB, NCol, …). + * Text-to-color using a format close to CSS. For more information and links, consult `the official website`_. -Table of contents ------------------ - .. toctree:: :maxdepth: 2 - angles - colors - expressions + onboarding + discuss + api .. _Thomas Touhey: https://thomas.touhey.fr/ .. _textoutpc: https://textout.touhey.pro/ diff --git a/docs/onboarding.rst b/docs/onboarding.rst new file mode 100644 index 0000000..412df44 --- /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/tweaking diff --git a/docs/onboarding/installing.rst b/docs/onboarding/installing.rst new file mode 100644 index 0000000..4fee3ca --- /dev/null +++ b/docs/onboarding/installing.rst @@ -0,0 +1,35 @@ +Installing thcolor +================== + +In order to run and tweak thcolor, you must first install it; this section +will cover the need. + +Dependencies +------------ + +thcolor dependencies are pure Python dependencies, automatically installed +when using a package manager such as pip: + + * regex_, used for parsing color expressions. + +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``. + +.. _regex: https://pypi.org/project/regex/ diff --git a/docs/onboarding/tweaking.rst b/docs/onboarding/tweaking.rst new file mode 100644 index 0000000..9ad9ab2 --- /dev/null +++ b/docs/onboarding/tweaking.rst @@ -0,0 +1,16 @@ +Tweaking thcolor +================ + +In order to start tweaking thcolor using Python instead of the CLI, you +can import utilities from the module. The minimal code for running the +server is the following: + +.. code-block:: python + + from thcolor import Color + + color = Color.from_text('darker(#123456 10%)') + print(color) + +For more information, please consult the discussion topics and API reference +on the current documentation. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/requirements.txt @@ -1,6 +1,6 @@ [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/ @@ -36,9 +36,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,D208,D210,D401,W503 +exclude = + docs/conf.py + test.py +per-file-ignores = + tests/*:F405,S101,D102,D103 +rst-roles = + py:class + py:attr + py:data + py:meth +rst-directives = + py:data @@ -1,8 +1,8 @@ #!/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. -#****************************************************************************** +# ***************************************************************************** +# 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 @@ -10,10 +10,10 @@ 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. diff --git a/tests/__init__.py b/tests/__init__.py index 47268e6..f8d9bfb 100755 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,8 +1,8 @@ #!/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. """ # This file is only there to indicate that the folder is a module. diff --git a/tests/test_angles.py b/tests/test_angles.py new file mode 100755 index 0000000..d5358a4 --- /dev/null +++ b/tests/test_angles.py @@ -0,0 +1,27 @@ +#!/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 ( + Angle, DegreesAngle, GradiansAngle, RadiansAngle, TurnsAngle, +) + + +@pytest.mark.parametrize('test_input,expected', ( + ('120deg', DegreesAngle(120)), + ('5rad', RadiansAngle(5)), + ('3grad', GradiansAngle(3)), + ('6.turns', TurnsAngle(6)), + ('355', DegreesAngle(355)), +)) +def test_angles(test_input, expected): + angle = Angle.fromtext(test_input) + assert isinstance(angle, type(expected)) + assert angle == expected + + +# End of file. diff --git a/tests/test_colors.py b/tests/test_colors.py new file mode 100755 index 0000000..58988a5 --- /dev/null +++ b/tests/test_colors.py @@ -0,0 +1,38 @@ +#!/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 DegreesAngle +from thcolor.colors import SRGBColor, HSLColor, HWBColor + + +@pytest.mark.parametrize('color,expected', ( + (SRGBColor.frombytes(0, 0, 255), ( + '#0000FF', + )), + (SRGBColor.frombytes(1, 22, 242, alpha=.5), ( + '#0116F2', + 'rgba(1, 22, 242, 50%)', + )), + (HSLColor(DegreesAngle(0), 1, .4), ( + '#CC0000', + 'hsl(0deg, 100%, 40%)', + )), + (HSLColor(DegreesAngle(0), .5, 1, alpha=.2), ( + '#FFFFFF', + 'rgba(255, 255, 255, 20%)', + 'hsla(0deg, 50%, 100%, 20%)', + )), + (HWBColor(DegreesAngle(127), blackness=.5), ( + '#00800F', + 'hwb(127deg, 0%, 50%)', + )), +)) +def test_css(color, expected): + assert color.css() == expected + +# End of file. diff --git a/tests/test_decoders.py b/tests/test_decoders.py new file mode 100755 index 0000000..8dd91eb --- /dev/null +++ b/tests/test_decoders.py @@ -0,0 +1,209 @@ +#!/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.errors import * # NOQA + + +class TestBaseDecoder: + @pytest.fixture + def decoder(self): + class StrictDecoder(MetaColorDecoder): + __ncol_support__ = False + __defaults_to_netscape_color__ = False + + 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') + + +class TestNColDecoder: + """ Test base decoder with ncol support. """ + + @pytest.fixture + def decoder(self): + class Decoder(MetaColorDecoder): + __ncol_support__ = True + __defaults_to_netscape_color__ = False + + return Decoder() + + @pytest.mark.parametrize('test_input,expected', ( + ( + 'B20 / 50% 32%', + HWBColor(DegreesAngle(252), .5, .32, 1.00), + ), + ( + 'Y40, 33%, 55%', + HWBColor(DegreesAngle(84), 0.33, 0.55, 1.00), + ), + )) + def test_ncol(self, decoder, test_input, expected): + """ Test natural colors. """ + + result = decoder.decode(test_input) + assert result == (expected,) + + +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)), + )) + 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,) + + +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(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_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..8da42a9 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. - 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..6dd36a6 --- /dev/null +++ b/thcolor/angles.py @@ -0,0 +1,269 @@ +#!/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 Optional as _Optional + +__all__ = [ + 'Angle', 'DegreesAngle', 'GradiansAngle', 'RadiansAngle', 'TurnsAngle', +] + + +class Angle: + """ Abstract class representing an angle within thcolor. + + Used for some color representations (most notably hue). + """ + + __slots__ = () + + 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, 3) == round(other.asturns().turns, 3) + ) + + 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) + + @classmethod + def fromtext( + cls, + expr: str, + decoder: _Optional = None, + ) -> 'Angle': + """ Create a color from a string. + + :param expr: The expression to decode. + """ + + if decoder is None: + from .decoders.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; 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'{int(x) if x == int(x) else x}deg' + + @property + def degrees(self) -> float: + """ 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; 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'{int(x) if x == int(x) else x}grad' + + @property + def gradians(self) -> float: + """ 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; 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 = self.radians / _pi + ir = int(r) + if r == ir: + r = ir + + return f"{self.__class__.__name__}(radians = {f'{r}π' if r else '0'})" + + @property + def radians(self) -> float: + """ 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; 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'{int(x) if x == int(x) else x}turn' + + @property + def turns(self) -> float: + """ Turns. """ + + return self._value + +# 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..afbfd58 --- /dev/null +++ b/thcolor/colors.py @@ -0,0 +1,1267 @@ +#!/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 collections.abc import Mapping as _Mapping, Sequence as _Sequence +from math import ( + atan2 as _atan2, ceil as _ceil, cos as _cos, sin as _sin, sqrt as _sqrt, +) +from typing import Optional as _Optional, Tuple as _Tuple + +from .angles import ( + Angle as _Angle, DegreesAngle as _DegreesAngle, + RadiansAngle as _RadiansAngle, TurnsAngle as _TurnsAngle, +) + +__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 = () + + def __init__(self, alpha: float = 1.0): + super().__init__() + + 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: 'Color') -> bool: + if not isinstance(other, Color): + return False + + sc = self.assrgb() + oc = other.assrgb() + + srgb = sc.red, sc.green, sc.blue, sc.alpha + orgb = oc.red, oc.green, oc.blue, oc.alpha + + return srgb == orgb + + @property + def alpha(self) -> float: + """ 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 = None, + ) -> 'Color': + """ Create a color from a string. + + :param expr: The expression to decode. + """ + + if decoder is None: + from .decoders.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: _Mapping[str, object]) -> 'Color': + """ Replace components and obtain a copy of the color. + + Returns a copy of the color with the property values replaced. + + For changing the alpha on an RGB color: + + .. code-block:: python + + color = _RGBColor(1, 2, 3).replace(alpha=.5) + + For changing the lightness on an HSL color: + + .. code-block:: python + + angle = _DegreesAngle(270) + color = _HSLColor(angle, .5, 1).replace(lightness=.2) + + :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) -> 'HSLColor': + """ 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) -> 'HSLColor': + """ 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) -> 'HSLColor': + """ 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) -> 'HSLColor': + """ 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(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.asdegrees().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. + + a = round(self.alpha, 3) + + try: + rgb = self.assrgb() + except NotImplementedError: + pass + else: + r, g, b = rgb.tobytes() + + 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) + + 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: + """ 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: + """ 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: + """ 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) + + # 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(hue, 2)), + saturation=round(s, 2), + lightness=round(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 + + 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 HWBColor( + hue=_DegreesAngle(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. """ + + raise NotImplementedError # TODO + + def asyuv(self) -> 'YUVColor': + """ Get an YUVColor out of the current object. """ + + raise NotImplementedError # TODO + + def tobytes(self) -> _Tuple[int, int, int]: + """ Get the red, blue and green bytes. """ + + return ( + int(round(self.red * 255)), + int(round(self.green * 255)), + int(round(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) + + 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: + """ The hue, as an angle. """ + + return self._hue + + @property + def saturation(self) -> float: + """ The saturation, between 0.0 and 1.0. """ + + return self._saturation + + @property + def lightness(self) -> float: + """ The lightness, between 0.0 and 1.0. """ + + return self._lightness + + def assrgb(self) -> 'SRGBColor': + """ Get an SRGBColor out of the current object. """ + + hue, 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.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: + """ The hue, as an angle. """ + + return self._hue + + @property + def saturation(self) -> float: + """ The saturation, between 0.0 and 1.0. """ + + return self._saturation + + @property + def value(self) -> float: + """ 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: + """ The hue, as an angle. """ + + return self._hue + + @property + def whiteness(self) -> float: + """ The whiteness, as a value between 0.0 and 1.0. """ + + return self._whiteness + + @property + def blackness(self) -> float: + """ 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): + """ Cyan channel intensity between 0.0 and 1.0. """ + + return self._cyan + + @property + def magenta(self): + """ Magenta channel intensity between 0.0 and 1.0. """ + + return self._magenta + + @property + def yellow(self): + """ Yellow channel intensity between 0.0 and 1.0. """ + + return self._yellow + + @property + def black(self): + """ 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, a = self + + 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=a, + ) + + 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: + """ 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: + """ The A axis value in the Lab colorspace. """ + + return self._a + + @property + def b(self) -> float: + """ The B axis value in the Lab colorspace. """ + + return self._b + + def assrgb(self) -> 'SRGBColor': + """ Get an SRGBColor out of the current object. """ + + raise NotImplementedError # TODO + + 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 * a + b * b), + hue=_RadiansAngle(_atan2(b, a)), + 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) + + 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: + """ 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: + """ The chroma. + + Represented as a positive number theoretically unbounded. + """ + + return self._chroma + + @property + def hue(self) -> _Angle: + """ The hue, as an angle. """ + + return self._hue + + 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) + + 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: + """ The CIE X component, between 0.0 and 1.0. """ + + return self._x + + @property + def y(self) -> float: + """ The CIE Y component, between 0.0 and 1.0. """ + + return self._y + + @property + def z(self) -> float: + """ The CIE Z component, between 0.0 and 1.0. """ + + return self._z + + def asxyz(self) -> 'XYZColor': + """ Get an XYZColor out of the current object. """ + + return self + + +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: + """ The luma. """ + + return self._y + + @property + def i(self) -> float: + """ The orange-blue range value. """ + + return self._i + + @property + def q(self) -> float: + """ The purple-green range value. """ + + return self._q + + def assrgb(self) -> 'SRGBColor': + """ Get an SRGBColor out of the current object. """ + + raise NotImplementedError # TODO + + 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: + """ The luma. """ + + return self._y + + @property + def u(self) -> float: + """ The U chrominance. """ + + return self._u + + @property + def v(self) -> float: + """ The V chrominance. """ + + return self._v + + def assrgb(self) -> 'SRGBColor': + """ Get an SRGBColor out of the current object. """ + + raise NotImplementedError # TODO + + 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/__init__.py b/thcolor/decoders/__init__.py new file mode 100644 index 0000000..f2d1863 --- /dev/null +++ b/thcolor/decoders/__init__.py @@ -0,0 +1,20 @@ +#!/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. """ + +from .base import ColorDecoder, MetaColorDecoder, alias, fallback +from .builtin import ( + CSS1ColorDecoder, CSS2ColorDecoder, CSS3ColorDecoder, + CSS4ColorDecoder, DefaultColorDecoder +) + +__all__ = [ + 'ColorDecoder', 'CSS1ColorDecoder', 'CSS2ColorDecoder', + 'CSS3ColorDecoder', 'CSS4ColorDecoder', 'DefaultColorDecoder', + 'MetaColorDecoder', 'alias', 'fallback', +] + +# End of file. diff --git a/thcolor/decoders/base.py b/thcolor/decoders/base.py new file mode 100644 index 0000000..227b0a6 --- /dev/null +++ b/thcolor/decoders/base.py @@ -0,0 +1,1061 @@ +#!/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 auto as _auto, Enum as _Enum +from typing import ( + Any as _Any, Optional as _Optional, Union as _Union, + Sequence as _Sequence, Tuple as _Tuple, +) +from inspect import getfullargspec as _getfullargspec + +from ..angles import Angle as _Angle +from ..colors import ( + Color as _Color, HWBColor as _HWBColor, SRGBColor as _SRGBColor, +) +from ..angles import ( + DegreesAngle as _DegreesAngle, GradiansAngle as _GradiansAngle, + RadiansAngle as _RadiansAngle, TurnsAngle as _TurnsAngle, +) +from ..errors import ColorExpressionSyntaxError as _ColorExpressionSyntaxError + +__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 _factor(x, max_=100): + """ Return a factor based on if something is a float or an int. """ + + if isinstance(x, float): + return x + return x / max_ + + +def _get_args(func) -> _Sequence[_Tuple[str, _Any, _Any]]: + """ Get the arguments from a function in order to + call it using optional or make an alias. + + 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. + """ + + 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', + ) + + 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) + + 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, 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 + 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 = ', '.join(_iter_proxy_args(locals_, proxy_args)) + final_args = ', '.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}):\n' + f' return __func({final_args})\n' + f' return {name}\n' + '\n' + f'__func = __define_alias({keys})\n' + ) + + exec(code, {}, locals_) + func = locals_['__func'] + + return func + + +# --- +# 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-z_-]([a-z0-9_-]*[a-z0-9_-])?) + | + ) \s* (?P<sep>,|/|\s|\(|\)|$) + ) + \s* + """, + _re.VERBOSE | _re.I | _re.M, +) + + +def _get_color_tokens(string: str): + """ Get color tokens. """ + + start = 0 + was_call_end = 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'] + if typ == 'deg': + value = _DegreesAngle(value) + elif typ == 'rad': + value = _RadiansAngle(value) + elif typ == 'grad': + value = _GradiansAngle(value) + elif typ in ('turn', 'turns'): + value = _TurnsAngle(value) + else: + raise NotImplementedError + + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_ANGLE, + value=value, + 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: + yield _ColorExpressionToken( + _ColorExpressionToken.TYPE_NCOL, + value=result['ncol'], + column=column, + rawtext=result['ncol'], + ) + elif result['hex'] is not None: + value = result['hex'] + if len(value) <= 4: + value = ''.join(map(lambda x: x + x, value)) + + r = int(value[0:2], 16) + g = int(value[2:4], 16) + b = int(value[4:6], 16) + a = int(value[6:8], 16) / 255.0 if len(value) == 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]): + """ Decorator for setting a fallback value on a function. + + 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): + return self._name + + @property + def args(self) -> _Sequence[str]: + 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. + * ``__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 + __defaults_to_netscape_color__: bool = True + + def __init__(self): + cls = self.__class__ + + mapping = {} + for key, value in cls.__mapping__.items(): + mapping[_canonicalkey(key)] = value + + self._mapping = mapping + self._ncol_support = cls.__ncol_support__ + self._defaults_to_netscape_color = 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 = bool(self._ncol_support) + defaults_to_netscape = bool(self._defaults_to_netscape_color) + + # Parsing stage; the results will be in ``current``. + + stack = [] + func_stack = [_ColorExpressionToken( + _ColorExpressionToken.TYPE_NAME, + value=None, + )] + current = [] + + token_iter = _get_color_tokens(expr) + for token in token_iter: + if ( + token.type_ == _ColorExpressionToken.TYPE_NCOL + and ncol_support + ): + letter = token.value[0] + number = float(token.value[1:]) + + if number < 0 or number >= 100: + raise _ColorExpressionSyntaxError( + 'ncol number should be between 0 and 100 excluded, ' + f'is {number!r}', + column=token.column, + func=func_stack[0].value, + ) + + # Get the two next tokens, which should be numbers. + # Note that they cannot result from function calls, they + # should be defined in the syntax directly. + # + # TODO: make this an implicit function with two arguments, + # so that percentages can be determined dynamically. + # TODO: if the NColor is not valid, maybe it is a symbol + # name, in which case a default should be set. + + first_token, second_token = None, None + try: + first_token = next(token_iter) + second_token = next(token_iter) + except StopIteration: + break + + if first_token is None or second_token is None or any( + token.type_ not in ( + _ColorExpressionToken.TYPE_INTEGER, + _ColorExpressionToken.TYPE_FLOAT, + _ColorExpressionToken.TYPE_PERCENTAGE, + ) + for token in (first_token, second_token) + ): + raise _ColorExpressionSyntaxError( + 'ncol should be followed by the whiteness and ' + 'the blackness as two constant values', + column=token.column, + func=func_stack[0].value, + ) + + current.append(_ColorExpressionToken( + type_=_ColorExpressionToken.TYPE_HEX, + column=token.column, + value=_HWBColor( + hue=_DegreesAngle( + 'RYGCBM'.find(letter) * 60 + number / 100 * 60 + ), + whiteness=_factor(first_token.value), + blackness=_factor(second_token.value), + ), + )) + 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 simply add it. + + current.append(token) + elif token.type_ == _ColorExpressionToken.TYPE_CALL_START: + 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=func_stack[0].value, + ) + + func_stack.insert(0, name_token) + stack.insert(0, current) + current = [] + elif token.type_ == _ColorExpressionToken.TYPE_CALL_END: + try: + old_current = stack.pop(0) + except IndexError: + raise _ColorExpressionSyntaxError( + 'extraneous closing parenthesis', + column=token.column, + func=func_stack[0].value, + ) from None + + name_token = func_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}', + ) + + if stack: + raise _ColorExpressionSyntaxError( + 'missing closing parenthesis', + column=len(expr), + func=func_stack[0].value, + ) + + # 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 + + 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( + unevaluated_args, args_spec + ): + arg, *_, fallback_string = evaluate(token) + arg = arg if arg is not None else argdefvalue + + if arg is None: + raise _ColorExpressionSyntaxError( + f'expected a value for {argname!r}', + column=token.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) + else: + raise _ColorExpressionSyntaxError( + f'{arg!r} did not match expected type {argtype!r}' + f' for argument {argname!r}', + column=token.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, + '__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. + # + # TODO: check if the argument types are valid (we had ``_angle`` + # versus ``_Angle`` problems before, so we need to check). + + 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) + + return cls + + +class MetaColorDecoder(ColorDecoder, metaclass=_MetaColorDecoderType): + """ Base meta color decoder, which gets the function and things. """ + + __defaults_to_netscape_color__ = True + __ncol_support__ = False + + +# End of file. diff --git a/thcolor/decoders/builtin.py b/thcolor/decoders/builtin.py new file mode 100644 index 0000000..a512d2d --- /dev/null +++ b/thcolor/decoders/builtin.py @@ -0,0 +1,511 @@ +#!/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 .base import ( + MetaColorDecoder as _MetaColorDecoder, + alias as _alias, fallback as _fallback, +) +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, +) + +__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. """ + + return _SRGBColor( + int(x[1:3], 16) / 255, + int(x[3:5], 16) / 255, + int(x[5:7], 16) / 255, + ) + + +def _factor(x, max_: int = 100): + """ Return a factor based on if something is a float or an int. """ + + if isinstance(x, float): + return x + if x in (0, 1) and max_ == 100: + return float(x) + return x / max_ + + +# --- +# Main colors. +# --- + + +class CSS1ColorDecoder(_MetaColorDecoder): + """ Named colors from CSS Level 1. + + See <https://www.w3.org/TR/CSS1/>_ for more information. + """ + + 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) + + def rgb(red: int = 0, green: int = 0, blue: int = 0) -> _Color: + """ Make an RGB color out of the given components. """ + + return _SRGBColor( + red=red / 255, + green=green / 255, + blue=blue / 255, + 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') + + 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), + green=_factor(green, max_=255), + blue=_factor(blue, max_=255), + alpha=_factor(alpha), + ) + + 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.. + """ + + rebeccapurple = _rgb('#663399') + + 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), + ) + + 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)) + + 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), + ) + + def lch( + light: _number, + chroma: _number, + hue: _Angle, + alpha: _number = 1.0, + ) -> _Color: + """ Make an LCH color out of the given components. """ + + return _LCHColor( + light=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') + + 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), + ) + + 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), + ) + + 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), + ) + + 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), + ) + + 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')) + def red(color: _Color) -> int: + """ Get the red channel value from an RGB color. """ + + r, g, b, _ = color.assrgb() + return r + + @_fallback(_rgb('#00ff00')) + def green(color: _Color) -> int: + """ Get the green channel value from an RGB color. """ + + r, g, b, _ = color.assrgb() + return g + + @_fallback(_rgb('#0000ff')) + def blue(color: _Color) -> int: + """ Get the blue channel value from an RGB color. """ + + r, g, b, _ = color.ascolor().assrgb() + return b + + # --- + # Manage the lightness and saturation for HSL colors. + # --- + + 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) + + 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) + + 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) + + 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. + # --- + + 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 just needs to return the color. + """ + + return color + + +# End of file. diff --git a/thcolor/errors.py b/thcolor/errors.py new file mode 100755 index 0000000..3fa71c0 --- /dev/null +++ b/thcolor/errors.py @@ -0,0 +1,64 @@ +#!/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): + """ A color decoding error has occurred on the text. """ + + 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/version.py b/thcolor/version.py new file mode 100755 index 0000000..5a3a5fc --- /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. |