├── tests ├── __init__.py ├── test_image.py └── test_audio.py ├── src └── captcha │ ├── py.typed │ ├── data │ ├── 0 │ │ └── default.wav │ ├── 1 │ │ └── default.wav │ ├── 2 │ │ └── default.wav │ ├── 3 │ │ └── default.wav │ ├── 4 │ │ └── default.wav │ ├── 5 │ │ └── default.wav │ ├── 6 │ │ └── default.wav │ ├── 7 │ │ └── default.wav │ ├── 8 │ │ └── default.wav │ ├── 9 │ │ └── default.wav │ ├── beep.wav │ ├── A │ │ └── default.wav │ ├── B │ │ └── default.wav │ ├── C │ │ └── default.wav │ ├── D │ │ └── default.wav │ ├── E │ │ └── default.wav │ ├── F │ │ └── default.wav │ ├── G │ │ └── default.wav │ ├── H │ │ └── default.wav │ ├── I │ │ └── default.wav │ ├── J │ │ └── default.wav │ ├── K │ │ └── default.wav │ ├── L │ │ └── default.wav │ ├── M │ │ └── default.wav │ ├── N │ │ └── default.wav │ ├── O │ │ └── default.wav │ ├── P │ │ └── default.wav │ ├── Q │ │ └── default.wav │ ├── R │ │ └── default.wav │ ├── S │ │ └── default.wav │ ├── T │ │ └── default.wav │ ├── U │ │ └── default.wav │ ├── V │ │ └── default.wav │ ├── W │ │ └── default.wav │ ├── X │ │ └── default.wav │ ├── Y │ │ └── default.wav │ ├── Z │ │ └── default.wav │ └── DroidSansMono.ttf │ ├── __init__.py │ ├── audio.py │ └── image.py ├── setup.py ├── docs ├── requirements.txt ├── _static │ ├── icon.png │ ├── image-demo.png │ ├── icon.svg │ ├── dark-logo.svg │ └── light-logo.svg ├── api.rst ├── sponsors.rst ├── index.rst ├── changelog.rst ├── image.rst ├── contribute.rst ├── audio.rst └── conf.py ├── MANIFEST.in ├── .gitignore ├── requirements-dev.lock ├── README.rst ├── .github └── workflows │ ├── docs.yml │ ├── pypi.yml │ └── test.yml ├── LICENSE ├── README.md └── pyproject.toml /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/captcha/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx 2 | sphinx-sitemap 3 | shibuya 4 | -------------------------------------------------------------------------------- /docs/_static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/docs/_static/icon.png -------------------------------------------------------------------------------- /src/captcha/data/beep.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/beep.wav -------------------------------------------------------------------------------- /docs/_static/image-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/docs/_static/image-demo.png -------------------------------------------------------------------------------- /src/captcha/data/0/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/0/default.wav -------------------------------------------------------------------------------- /src/captcha/data/1/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/1/default.wav -------------------------------------------------------------------------------- /src/captcha/data/2/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/2/default.wav -------------------------------------------------------------------------------- /src/captcha/data/3/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/3/default.wav -------------------------------------------------------------------------------- /src/captcha/data/4/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/4/default.wav -------------------------------------------------------------------------------- /src/captcha/data/5/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/5/default.wav -------------------------------------------------------------------------------- /src/captcha/data/6/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/6/default.wav -------------------------------------------------------------------------------- /src/captcha/data/7/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/7/default.wav -------------------------------------------------------------------------------- /src/captcha/data/8/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/8/default.wav -------------------------------------------------------------------------------- /src/captcha/data/9/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/9/default.wav -------------------------------------------------------------------------------- /src/captcha/data/A/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/A/default.wav -------------------------------------------------------------------------------- /src/captcha/data/B/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/B/default.wav -------------------------------------------------------------------------------- /src/captcha/data/C/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/C/default.wav -------------------------------------------------------------------------------- /src/captcha/data/D/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/D/default.wav -------------------------------------------------------------------------------- /src/captcha/data/E/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/E/default.wav -------------------------------------------------------------------------------- /src/captcha/data/F/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/F/default.wav -------------------------------------------------------------------------------- /src/captcha/data/G/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/G/default.wav -------------------------------------------------------------------------------- /src/captcha/data/H/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/H/default.wav -------------------------------------------------------------------------------- /src/captcha/data/I/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/I/default.wav -------------------------------------------------------------------------------- /src/captcha/data/J/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/J/default.wav -------------------------------------------------------------------------------- /src/captcha/data/K/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/K/default.wav -------------------------------------------------------------------------------- /src/captcha/data/L/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/L/default.wav -------------------------------------------------------------------------------- /src/captcha/data/M/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/M/default.wav -------------------------------------------------------------------------------- /src/captcha/data/N/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/N/default.wav -------------------------------------------------------------------------------- /src/captcha/data/O/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/O/default.wav -------------------------------------------------------------------------------- /src/captcha/data/P/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/P/default.wav -------------------------------------------------------------------------------- /src/captcha/data/Q/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/Q/default.wav -------------------------------------------------------------------------------- /src/captcha/data/R/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/R/default.wav -------------------------------------------------------------------------------- /src/captcha/data/S/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/S/default.wav -------------------------------------------------------------------------------- /src/captcha/data/T/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/T/default.wav -------------------------------------------------------------------------------- /src/captcha/data/U/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/U/default.wav -------------------------------------------------------------------------------- /src/captcha/data/V/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/V/default.wav -------------------------------------------------------------------------------- /src/captcha/data/W/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/W/default.wav -------------------------------------------------------------------------------- /src/captcha/data/X/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/X/default.wav -------------------------------------------------------------------------------- /src/captcha/data/Y/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/Y/default.wav -------------------------------------------------------------------------------- /src/captcha/data/Z/default.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/Z/default.wav -------------------------------------------------------------------------------- /src/captcha/data/DroidSansMono.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lepture/captcha/HEAD/src/captcha/data/DroidSansMono.ttf -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | recursive-include src/captcha/data *.wav 4 | recursive-include src/captcha/data *.ttf 5 | 6 | graft docs 7 | prune docs/_build 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | *.egg-info 4 | __pycache__ 5 | bin 6 | build 7 | develop-eggs 8 | dist 9 | eggs 10 | parts 11 | .DS_Store 12 | .installed.cfg 13 | docs/_build 14 | cover/ 15 | .tox 16 | *.bak 17 | *.c 18 | *.so 19 | venv/ 20 | .venv/ 21 | demo.* 22 | .coverage 23 | coverage.xml 24 | tests/demo.* 25 | uv.lock 26 | -------------------------------------------------------------------------------- /src/captcha/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | """ 4 | Captcha 5 | ~~~~~~~ 6 | 7 | A captcha library that generates audio and image CAPTCHAs. 8 | 9 | :copyright: (c) 2014 by Hsiaoming Yang. 10 | :license: BSD, see LICENSE for more details. 11 | """ 12 | 13 | __version__ = '0.7.1' 14 | __author__ = 'Hsiaoming Yang ' 15 | __homepage__ = 'https://github.com/lepture/captcha' 16 | -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | :description: The API References for audio and image CAPTCHAs. 2 | 3 | API References 4 | ============== 5 | 6 | .. rst-class:: lead 7 | 8 | Here are the list of API reference; it might be helpful for developers. 9 | 10 | ---- 11 | 12 | Image 13 | ----- 14 | 15 | .. module:: captcha.image 16 | 17 | .. autoclass:: ImageCaptcha 18 | :members: 19 | 20 | 21 | Audio 22 | ----- 23 | 24 | .. module:: captcha.audio 25 | 26 | .. autoclass:: AudioCaptcha 27 | :members: 28 | -------------------------------------------------------------------------------- /tests/test_image.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import os 4 | from captcha.image import ImageCaptcha 5 | 6 | ROOT = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | def test_image_generate(): 10 | captcha = ImageCaptcha() 11 | data = captcha.generate('1234') 12 | assert hasattr(data, 'read') 13 | 14 | 15 | def test_save_image(): 16 | captcha = ImageCaptcha() 17 | filepath = os.path.join(ROOT, 'demo.png') 18 | captcha.write('1234', filepath) 19 | assert os.path.isfile(filepath) 20 | -------------------------------------------------------------------------------- /tests/test_audio.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import os 4 | from captcha.audio import AudioCaptcha 5 | 6 | ROOT = os.path.abspath(os.path.dirname(__file__)) 7 | 8 | 9 | def test_audio_generate(): 10 | captcha = AudioCaptcha() 11 | data = captcha.generate('1234') 12 | assert isinstance(data, bytearray) 13 | assert bytearray(b'RIFF') in data 14 | 15 | 16 | def test_audio_random(): 17 | captcha = AudioCaptcha() 18 | data = captcha.random(4) 19 | assert len(data) == 4 20 | 21 | 22 | def test_save_audio(): 23 | captcha = AudioCaptcha() 24 | filepath = os.path.join(ROOT, 'demo.wav') 25 | captcha.write('1234', filepath) 26 | assert os.path.isfile(filepath) 27 | -------------------------------------------------------------------------------- /docs/_static/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-dev.lock: -------------------------------------------------------------------------------- 1 | # This file was autogenerated by uv via the following command: 2 | # uv export --no-hashes -o requirements-dev.lock 3 | -e . 4 | colorama==0.4.6 ; sys_platform == 'win32' 5 | coverage==7.6.1 ; python_full_version < '3.9' 6 | coverage==7.6.12 ; python_full_version >= '3.9' 7 | exceptiongroup==1.2.2 ; python_full_version < '3.11' 8 | iniconfig==2.0.0 9 | mypy==1.14.1 ; python_full_version < '3.9' 10 | mypy==1.15.0 ; python_full_version >= '3.9' 11 | mypy-extensions==1.0.0 12 | packaging==24.2 13 | pillow==10.4.0 ; python_full_version < '3.9' 14 | pillow==11.1.0 ; python_full_version >= '3.9' 15 | pluggy==1.5.0 16 | pytest==8.3.4 17 | pytest-cov==5.0.0 ; python_full_version < '3.9' 18 | pytest-cov==6.0.0 ; python_full_version >= '3.9' 19 | ruff==0.9.6 20 | tomli==2.2.1 ; python_full_version <= '3.11' 21 | typing-extensions==4.12.2 22 | -------------------------------------------------------------------------------- /docs/sponsors.rst: -------------------------------------------------------------------------------- 1 | :orphan: 2 | :layout: simple 3 | 4 | Sponsored by 5 | ============ 6 | 7 | .. rst-class:: lead 8 | 9 | People and organizations supporting Hsiaoming Yang. 10 | 11 | ------ 12 | 13 | This library is open source, licensed under BSD-3-Clause, and completely free to use. 14 | 15 | If you'd like to support my work, consider becoming a `GitHub Sponsor`_. 16 | 17 | .. _`GitHub Sponsor`: https://github.com/sponsors/lepture 18 | 19 | .. sponsors:: Gold Sponsors 20 | :amount: 100 21 | :size: 2xl 22 | :show-name: 23 | 24 | .. sponsors:: Silver Sponsors 25 | :amount: 50, 100 26 | :size: xl 27 | 28 | .. sponsors:: Sponsors 29 | :amount: 25, 50 30 | :size: md 31 | 32 | .. sponsors:: Backers 33 | :amount: 10, 25 34 | :size: sm 35 | 36 | .. sponsors:: Supporter 37 | :amount: 1, 10 38 | :size: sm 39 | 40 | .. sponsors:: Past Sponsors 41 | :amount: 0 42 | :size: xs 43 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | :description: A Python captcha library that generates audio and image CAPTCHAs. 2 | 3 | Captcha 4 | ======== 5 | 6 | .. rst-class:: lead 7 | 8 | A Python captcha library that generates audio and image CAPTCHAs. 9 | 10 | ---- 11 | 12 | Installation 13 | ------------ 14 | 15 | Installing captcha is simple with ``pip``:: 16 | 17 | $ pip install captcha 18 | 19 | Simple Guide 20 | ------------ 21 | 22 | Here is a simple guide on how to use :class:`AudioCaptcha` and :class:`ImageCaptcha`: 23 | 24 | .. code-block:: python 25 | 26 | from captcha.audio import AudioCaptcha 27 | from captcha.image import ImageCaptcha 28 | 29 | audio = AudioCaptcha(voicedir='/path/to/voices') 30 | image = ImageCaptcha(fonts=['/path/A.ttf', '/path/B.ttf']) 31 | 32 | data = audio.generate('1234') 33 | audio.write('1234', 'out.wav') 34 | 35 | data = image.generate('1234') 36 | image.write('1234', 'out.png') 37 | 38 | 39 | Next Steps 40 | ---------- 41 | 42 | .. toctree:: 43 | :caption: Guide 44 | 45 | image 46 | audio 47 | api 48 | 49 | .. toctree:: 50 | :caption: Development 51 | 52 | contribute 53 | changelog 54 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Captcha 2 | ======= 3 | 4 | A captcha library that generates audio and image CAPTCHAs. 5 | 6 | Features 7 | -------- 8 | 9 | 1. Audio CAPTCHAs 10 | 2. Image CAPTCHAs 11 | 12 | 13 | Installation 14 | ------------ 15 | 16 | Install captcha with pip:: 17 | 18 | $ pip install captcha 19 | 20 | Usage 21 | ----- 22 | 23 | Audio and Image CAPTCHAs are in separated modules: 24 | 25 | .. code-block:: python 26 | 27 | from captcha.audio import AudioCaptcha 28 | from captcha.image import ImageCaptcha 29 | 30 | audio = AudioCaptcha(voicedir='/path/to/voices') 31 | image = ImageCaptcha(fonts=['/path/A.ttf', '/path/B.ttf']) 32 | 33 | data = audio.generate('1234') 34 | audio.write('1234', 'out.wav') 35 | 36 | data = image.generate('1234') 37 | image.write('1234', 'out.png') 38 | 39 | This is the APIs for your daily works. We do have built-in voice data and font 40 | data. But it is suggested that you use your own voice and font data. 41 | 42 | Useful Links 43 | ------------ 44 | 45 | 1. GitHub: https://github.com/lepture/captcha 46 | 2. Docs: https://captcha.lepture.com/ 47 | 48 | 49 | License 50 | ------- 51 | 52 | Licensed under BSD. Please see LICENSE for licensing details. 53 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs to Pages 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | workflow_dispatch: 9 | 10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | # Allow one concurrent deployment 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | build: 23 | name: build docs 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-python@v5 28 | with: 29 | python-version: 3.9 30 | 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v5 33 | 34 | - name: install package 35 | run: python -m pip install . 36 | 37 | - name: install deps 38 | run: python -m pip install -r docs/requirements.txt 39 | 40 | - name: build docs 41 | run: sphinx-build docs _site -b dirhtml 42 | 43 | - name: Upload artifact 44 | uses: actions/upload-pages-artifact@v3 45 | 46 | deploy: 47 | environment: 48 | name: github-pages 49 | url: ${{ steps.deployment.outputs.page_url }} 50 | 51 | runs-on: ubuntu-latest 52 | needs: build 53 | 54 | steps: 55 | - name: Deploy to GitHub Pages 56 | id: deployment 57 | uses: actions/deploy-pages@v4 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Hsiaoming Yang 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 8 | 9 | * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 10 | 11 | * Neither the name of the creator nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. 12 | 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 15 | -------------------------------------------------------------------------------- /.github/workflows/pypi.yml: -------------------------------------------------------------------------------- 1 | name: Release to PyPI 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | 11 | jobs: 12 | build: 13 | name: build dist files 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - uses: actions/setup-python@v5 20 | with: 21 | python-version: 3.9 22 | 23 | - name: install build 24 | run: python -m pip install --upgrade build 25 | 26 | - name: build dist 27 | run: python -m build 28 | 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: artifacts 32 | path: dist/* 33 | if-no-files-found: error 34 | 35 | publish: 36 | environment: 37 | name: pypi-release 38 | url: https://pypi.org/project/captcha/ 39 | permissions: 40 | id-token: write 41 | name: release to pypi 42 | needs: build 43 | runs-on: ubuntu-latest 44 | 45 | steps: 46 | - uses: actions/download-artifact@v4 47 | with: 48 | name: artifacts 49 | path: dist 50 | 51 | - name: Push build artifacts to PyPI 52 | uses: pypa/gh-action-pypi-publish@release/v1 53 | 54 | release: 55 | name: write release note 56 | runs-on: ubuntu-latest 57 | needs: publish 58 | 59 | steps: 60 | - uses: actions/checkout@v4 61 | with: 62 | fetch-depth: 0 63 | - uses: actions/setup-node@v4 64 | with: 65 | node-version: 20 66 | - run: npx changelogithub --no-group 67 | continue-on-error: true 68 | env: 69 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 70 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'wip-*' 7 | paths-ignore: 8 | - '.github/**' 9 | - 'docs/**' 10 | - '*.md' 11 | - '*.rst' 12 | pull_request: 13 | branches-ignore: 14 | - 'wip-*' 15 | paths-ignore: 16 | - 'docs/**' 17 | - '*.md' 18 | - '*.rst' 19 | 20 | 21 | jobs: 22 | lint: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: actions/setup-python@v5 27 | with: 28 | python-version: "3.9" 29 | 30 | - name: Install dependencies 31 | run: pip install -r requirements-dev.lock 32 | 33 | - name: ruff lint 34 | run: ruff check 35 | 36 | - name: mypy lint 37 | run: mypy 38 | 39 | test: 40 | needs: lint 41 | runs-on: ubuntu-latest 42 | 43 | strategy: 44 | fail-fast: false 45 | max-parallel: 3 46 | matrix: 47 | python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] 48 | 49 | steps: 50 | - uses: actions/checkout@v4 51 | - name: Set up Python ${{ matrix.python-version }} 52 | uses: actions/setup-python@v5 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | 56 | - name: Install dependencies 57 | run: pip install -r requirements-dev.lock 58 | 59 | - name: Report coverage 60 | run: pytest --cov=captcha --cov-report=xml 61 | 62 | - name: Upload coverage reports to Codecov 63 | uses: codecov/codecov-action@v4 64 | env: 65 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 66 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | .. rst-class:: lead 5 | 6 | Here lists the release notes of python captcha library. 7 | 8 | ---- 9 | 10 | v0.7.1 11 | ------ 12 | 13 | Released on Mar 1, 2025 14 | 15 | - Add python 3.12, 3.13 supports. 16 | - Replace secrets.randint with secure alternatives. 17 | 18 | v0.7.0 19 | ------ 20 | 21 | Released on Feb 20, 2025 22 | 23 | - Fix for ``Pillow >= 11``. 24 | - Add voice data A to Z, via :pull:`77`. 25 | 26 | v0.6.0 27 | ------ 28 | 29 | Released on Jul 18, 2024 30 | 31 | - Add character settings for ``ImageCaptcha``. 32 | 33 | v0.5.0 34 | ------ 35 | 36 | Released on Jul 29, 2023 37 | 38 | - Fix for ``Pillow >= 10``, via :issue:`63` 39 | - Drop support for Python bellow 3.8 40 | - Add type hints 41 | 42 | v0.4 43 | ---- 44 | 45 | Released on Mar 15, 2022 46 | 47 | - Fix "'float' object cannot be interpreted as an integer" in Python 3.10.0 48 | 49 | 50 | v0.3 51 | ---- 52 | 53 | Released on Nov 6, 2018 54 | 55 | - Support Python 3.5, 3.6, 3.7 56 | 57 | v0.2.4 58 | ------ 59 | 60 | Released on Jul 14, 2017 61 | 62 | - Fix compatibility with PIL, via :pull:`18` 63 | 64 | v0.2.3 65 | ------ 66 | 67 | Released on Jun 21, 2017 68 | 69 | - Fix image width bug 70 | - Fix non-integer error 71 | 72 | v0.2.2 73 | ------ 74 | 75 | Released on Mar 17, 2017 76 | 77 | - Fix memory leak 78 | 79 | 80 | v0.2.1 81 | ------ 82 | 83 | Released on Oct 4, 2015. 84 | 85 | - Fix AudioCaptcha in Python 3 86 | - Improve ImageCaptcha 87 | 88 | 89 | v0.2 90 | ---- 91 | 92 | Released on Aug 12, 2015. 93 | 94 | - File format of Image CAPTCHA can be specified 95 | 96 | 97 | v0.1.1 98 | ------ 99 | 100 | Released on Dec 2, 2014, this is a bugfix release. 101 | 102 | - Use cStringIO in Python 2 instead of BytesIO 103 | - Fix random.randint for Python 3 104 | - Add font_sizes parameters for ImageCaptcha when width is too small 105 | 106 | 107 | v0.1 108 | ---- 109 | 110 | Released on Nov 27, 2014, the very first release. 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Captcha 2 | 3 | A captcha library that generates audio and image CAPTCHAs. 4 | 5 | 6 | [![GitHub Sponsor](https://badgen.net/badge/support/captcha/blue?icon=github)](https://github.com/sponsors/lepture) 7 | [![Build Status](https://github.com/lepture/captcha/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/lepture/captcha/actions/workflows/test.yml) 8 | [![codecov](https://codecov.io/gh/lepture/captcha/branch/main/graph/badge.svg?token=xLjcXGMaeo)](https://codecov.io/gh/lepture/captcha) 9 | 10 | ## Install 11 | 12 | Install captcha with pip: 13 | 14 | ``` 15 | pip install captcha 16 | ``` 17 | 18 | ## Features 19 | 20 | 1. Audio CAPTCHAs 21 | 2. Image CAPTCHAs 22 | 23 | ## Usage 24 | 25 | Audio and Image CAPTCHAs are in separated modules: 26 | 27 | ```python 28 | from captcha.audio import AudioCaptcha 29 | from captcha.image import ImageCaptcha 30 | 31 | audio = AudioCaptcha(voicedir='/path/to/voices') 32 | image = ImageCaptcha(fonts=['/path/A.ttf', '/path/B.ttf']) 33 | 34 | data = audio.generate('1234') 35 | audio.write('1234', 'out.wav') 36 | 37 | data = image.generate('1234') 38 | image.write('1234', 'out.png') 39 | ``` 40 | 41 | This is the APIs for your daily works. We do have built-in voice data and font 42 | data. But it is suggested that you use your own voice and font data. 43 | 44 | ### Use Custom Colors 45 | 46 | In order to change colors you have to specify your desired color as a tuple of Red, Green and Blue value. 47 | Example:- `(255, 255, 0)` for yellow color, (255, 0, 0)` for red color. 48 | 49 | ```python 50 | from captcha.image import ImageCaptcha 51 | 52 | image = ImageCaptcha(fonts=['/path/A.ttf', '/path/B.ttf']) 53 | 54 | data = image.generate('1234') 55 | image.write('1234', 'out.png', bg_color=(255, 255, 0), fg_color=(255, 0, 0)) # red text in yellow background 56 | ``` 57 | 58 | 59 | ## Useful Links 60 | 61 | 1. GitHub: https://github.com/lepture/captcha 62 | 2. Docs: https://captcha.lepture.com/ 63 | 64 | ## Demo 65 | 66 | Here are some demo results: 67 | 68 | ![Image Captcha](https://github.com/lepture/captcha/releases/download/v0.5.0/demo.png) 69 | 70 | [Audio Captcha](https://github.com/lepture/captcha/releases/download/v0.5.0/demo.wav) 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "captcha" 3 | description = "A captcha library that generates audio and image CAPTCHAs." 4 | authors = [{name = "Hsiaoming Yang", email="me@lepture.com"}] 5 | dependencies = [ 6 | "Pillow", 7 | ] 8 | license = {text = "BSD-3-Clause"} 9 | requires-python = ">=3.9" 10 | dynamic = ["version"] 11 | readme = "README.rst" 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Environment :: Console", 15 | "Environment :: Web Environment", 16 | "Intended Audience :: Developers", 17 | "License :: OSI Approved :: BSD License", 18 | "Operating System :: OS Independent", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3", 21 | "Programming Language :: Python :: 3.9", 22 | "Programming Language :: Python :: 3.10", 23 | "Programming Language :: Python :: 3.11", 24 | "Programming Language :: Python :: 3.12", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.14", 27 | "Programming Language :: Python :: Implementation :: CPython", 28 | "Topic :: Security", 29 | ] 30 | 31 | [project.urls] 32 | Documentation = "https://captcha.lepture.com/" 33 | Source = "https://github.com/lepture/captcha" 34 | 35 | [build-system] 36 | requires = ["setuptools"] 37 | build-backend = "setuptools.build_meta" 38 | 39 | [dependency-groups] 40 | dev = [ 41 | "pytest", 42 | "pytest-cov", 43 | "mypy", 44 | "ruff", 45 | ] 46 | 47 | [tool.setuptools.dynamic] 48 | version = {attr = "captcha.__version__"} 49 | 50 | [tool.setuptools.packages.find] 51 | where = ["src"] 52 | 53 | [tool.setuptools.package-data] 54 | captcha = ["py.typed"] 55 | 56 | [tool.pytest.ini_options] 57 | pythonpath = ["src", "."] 58 | testpaths = ["tests"] 59 | filterwarnings = ["error"] 60 | 61 | [tool.coverage.run] 62 | branch = true 63 | source = ["captcha"] 64 | 65 | [tool.coverage.paths] 66 | source = ["src"] 67 | 68 | [tool.coverage.report] 69 | exclude_lines = [ 70 | "pragma: no cover", 71 | "raise NotImplementedError", 72 | "@(abc\\.)?abstractmethod", 73 | "@overload", 74 | ] 75 | 76 | [tool.mypy] 77 | strict = true 78 | python_version = "3.9" 79 | files = ["src/captcha"] 80 | show_error_codes = true 81 | pretty = true 82 | -------------------------------------------------------------------------------- /docs/image.rst: -------------------------------------------------------------------------------- 1 | :description: Learn how to generate image CAPTCHA in Python. 2 | 3 | Image Captcha 4 | ============= 5 | 6 | .. rst-class:: lead 7 | 8 | Unleash captivating security with image CAPTCHA mastery. 9 | 10 | ---- 11 | 12 | This module is compatibile with Pillow 9.4.0+. 13 | 14 | .. module:: captcha.image 15 | :noindex: 16 | 17 | Usage 18 | ----- 19 | 20 | Generating image CAPTCHA with the :class:`ImageCaptcha` class is incredibly straightforward. 21 | 22 | 23 | .. code-block:: python 24 | 25 | from io import BytesIO 26 | from captcha.image import ImageCaptcha 27 | 28 | captcha = ImageCaptcha() 29 | data: BytesIO = captcha.generate('ABCD') 30 | 31 | The result image would be something like: 32 | 33 | .. figure:: ./_static/image-demo.png 34 | 35 | This image CAPTCHA is generated by above code 36 | 37 | Fonts 38 | ----- 39 | 40 | The ``ImageCaptcha`` library comes with one built-in font named "DroidSansMono", 41 | which is licensed under the Apache License 2.0. However, it is recommended to use 42 | your own custom fonts for generating CAPTCHA images. 43 | 44 | .. code-block:: python 45 | 46 | custom_fonts = ['path/to/your/custom_font.ttf'] 47 | captcha = ImageCaptcha(fonts=[custom_fonts]) 48 | 49 | On the fly 50 | ---------- 51 | 52 | Let's explore how to use the CAPTCHA library to dynamically render image 53 | CAPTCHAs within a Flask application. This allows you to generate CAPTCHA 54 | images dynamically and serve them directly to users when they access a 55 | specific endpoint. 56 | 57 | .. code-block:: python 58 | 59 | from flask import Flask, Response 60 | from captcha.image import ImageCaptcha 61 | 62 | image = ImageCaptcha() 63 | app = Flask(__name__) 64 | 65 | @app.route("/captcha") 66 | def captcha_view(): 67 | # add your own logic to generate the code 68 | code = "ABCD" 69 | data = image.generate(code) 70 | return Response(data, mimetype="image/png") 71 | 72 | Character Settings 73 | ------------------ 74 | 75 | .. versionadded:: 0.6 76 | 77 | Update the default settings to change the character renderring. 78 | 79 | .. code-block:: python 80 | 81 | from captcha.image import ImageCaptcha 82 | 83 | captcha = ImageCaptcha() 84 | captcha.character_rotate = (-40, 40) 85 | captcha.generate("ABCD") 86 | 87 | Available options: 88 | 89 | .. code-block:: python 90 | 91 | character_offset_dx: tuple[int, int] = (0, 4) 92 | character_offset_dy: tuple[int, int] = (0, 6) 93 | character_rotate: tuple[int, int] = (-30, 30) 94 | character_warp_dx: tuple[float, float] = (0.1, 0.3) 95 | character_warp_dy: tuple[float, float] = (0.2, 0.3) 96 | word_space_probability: float = 0.5 97 | word_offset_dx: float = 0.25 98 | -------------------------------------------------------------------------------- /docs/contribute.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | .. rst-class:: lead 5 | 6 | Thank you for considering contributing to this little library! 7 | 8 | ---- 9 | 10 | Support questions 11 | ----------------- 12 | 13 | Please don't use the issue tracker for this. The issue tracker is a tool 14 | to address bugs and feature requests. Use the our `GitHub Discussions`_ 15 | for questions about captcha. 16 | 17 | .. _GitHub Discussions: https://github.com/lepture/captcha/discussions 18 | 19 | 20 | Reporting issues 21 | ---------------- 22 | 23 | Include the following information in your post: 24 | 25 | - Describe what you expected to happen. 26 | - If possible, include a `minimal reproducible example`_ to help us 27 | identify the issue. This also helps check that the issue is not with 28 | your own code. 29 | - Describe what actually happened. Include the full traceback if there 30 | was an exception. 31 | - List your Python and ``captcha`` versions. If possible, check if this 32 | issue is already fixed in the latest releases or the latest code in 33 | the repository. 34 | 35 | .. _minimal reproducible example: https://stackoverflow.com/help/minimal-reproducible-example 36 | 37 | 38 | Submitting patches 39 | ------------------ 40 | 41 | If you found something you can improve, a "Pull Request" is always 42 | welcoming. Here are something you need to notice before submitting 43 | the PR. 44 | 45 | If there is not an open issue for what you want to submit, prefer 46 | opening one for discussion before working on a PR. You can work on any 47 | issue that doesn't have an open PR linked to it or a maintainer assigned 48 | to it. These show up in the sidebar. No need to ask if you can work on 49 | an issue that interests you. 50 | 51 | Include the following in your patch: 52 | 53 | - Include tests if your patch adds or changes code. Make sure the test 54 | fails without your patch. 55 | - Update any relevant docs pages and docstrings. Docs pages and 56 | docstrings should be wrapped at 72 characters. 57 | - Add an entry in ``CHANGES.rst``. Use the same style as other 58 | entries. Also include ``.. versionchanged::`` inline changelogs in 59 | relevant docstrings. 60 | 61 | Running the tests 62 | ~~~~~~~~~~~~~~~~~ 63 | 64 | Run the basic test suite with pytest. 65 | 66 | .. code-block:: text 67 | 68 | $ pytest 69 | 70 | 71 | Updating the docs 72 | ~~~~~~~~~~~~~~~~~ 73 | 74 | When something has been changed, document them in the docs. You may need 75 | to add a change log in the ``changelog.rst`` file. 76 | 77 | Conventional Commits 78 | ~~~~~~~~~~~~~~~~~~~~ 79 | 80 | We are using `Conventional Commits `_. 81 | When you ``git commit`` some changes, please follow the "Conventional Commits" for the 82 | commit message. 83 | -------------------------------------------------------------------------------- /docs/audio.rst: -------------------------------------------------------------------------------- 1 | :description: Learn how to generate audio CAPTCHA in Python. 2 | 3 | Audio Captcha 4 | ============= 5 | 6 | .. rst-class:: lead 7 | 8 | Unlock security with Audio CAPTCHA aastery. 9 | 10 | ---- 11 | 12 | .. module:: captcha.audio 13 | :noindex: 14 | 15 | Usage 16 | ----- 17 | 18 | Generating audio CAPTCHA with the :class:`AudioCaptcha`` class is remarkably simple. 19 | 20 | .. code-block:: python 21 | 22 | from captcha.audio import AudioCaptcha 23 | 24 | captcha = AudioCaptcha() 25 | data: bytearray = captcha.generate('1234') 26 | 27 | Here is an example of an audio captcha: 28 | 29 | .. raw:: html 30 | 31 | 34 | 35 | Voice library 36 | ------------- 37 | 38 | The ``AudioCaptcha`` module comes with built-in voice files for 39 | numbers from 0 to 9. However, for enhanced security and customization, 40 | it is highly recommended to use your own voice library. This section 41 | will guide you on how to generate your own voice library using ``espeak`` 42 | and ``ffmpeg``. 43 | 44 | .. code-block:: bash 45 | 46 | # Set the language code 47 | export ESLANG=en 48 | 49 | # Create a directory for the specified language code 50 | mkdir "$ESLANG" 51 | 52 | # Loop through each character (a-z, A-Z, 0-9) and create a directory for each 53 | for i in {a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z,A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z,0,1,2,3,4,5,6,7,8,9}; do 54 | mkdir "$ESLANG/$i" 55 | espeak -a 150 -s 100 -p 15 -v "$ESLANG" "$i" -w "$ESLANG/$i/orig_default.wav" 56 | ffmpeg -i "$ESLANG/$i/orig_default.wav" -ar 8000 -ac 1 -acodec pcm_u8 "$ESLANG/$i/default.wav" 57 | rm "$ESLANG/$i/orig_default.wav" 58 | done 59 | 60 | Then use the voice library: 61 | 62 | .. code-block:: python 63 | 64 | from captcha.audio import AudioCaptcha 65 | 66 | voice_dir = "path/to/en" # we generated the wav files in "en" folder 67 | captcha = AudioCaptcha(voice_dir) 68 | 69 | Web server 70 | ---------- 71 | 72 | In addition to generating and saving the voice files in a directory or 73 | cloud storage like Amazon S3, you can also serve the Voice CAPTCHA audio 74 | files on-the-fly. 75 | 76 | Let's explore how to use the CAPTCHA library to dynamically serve audio 77 | CAPTCHAs within a Flask application. 78 | 79 | .. code-block:: python 80 | 81 | from io import BytesIO 82 | from flask import Flask, Response 83 | from captcha.audio import AudioCaptcha 84 | 85 | audio = AudioCaptcha() 86 | app = Flask(__name__) 87 | 88 | 89 | @app.route("/captcha") 90 | def captcha_view(): 91 | # add your own logic to generate the code 92 | code = "1234" 93 | data = audio.generate(code) 94 | return Response(BytesIO(data), mimetype="audio/wav") 95 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | import captcha 2 | 3 | project = 'Captcha' 4 | copyright = '2013, Hsiaoming Yang' 5 | author = 'Hsiaoming Yang' 6 | 7 | master_doc = 'index' 8 | 9 | # The full version, including alpha/beta/rc tags 10 | version = captcha.__version__ 11 | release = version 12 | 13 | 14 | # -- General configuration --------------------------------------------------- 15 | 16 | # Add any Sphinx extension module names here, as strings. They can be 17 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 18 | # ones. 19 | extensions = [ 20 | "sphinx.ext.autodoc", 21 | "sphinx.ext.intersphinx", 22 | "sphinx.ext.extlinks", 23 | "sphinx_sitemap", 24 | "shibuya.sponsors", 25 | ] 26 | 27 | extlinks = { 28 | 'pull': ('https://github.com/lepture/captcha/pull/%s', 'pull request #%s'), 29 | 'issue': ('https://github.com/lepture/captcha/issues/%s', 'issue #%s'), 30 | } 31 | 32 | intersphinx_mapping = { 33 | "python": ("https://docs.python.org/3", None), 34 | } 35 | 36 | html_baseurl = "https://captcha.lepture.com/" 37 | sitemap_url_scheme = "{link}" 38 | sponsors_json_url = "https://cdn.jsdelivr.net/gh/lepture/lepture/sponsors.json" 39 | 40 | # Add any paths that contain templates here, relative to this directory. 41 | templates_path = ['_templates'] 42 | 43 | # List of patterns, relative to source directory, that match files and 44 | # directories to ignore when looking for source files. 45 | # This pattern also affects html_static_path and html_extra_path. 46 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 47 | 48 | html_static_path = ["_static"] 49 | 50 | 51 | # -- Options for HTML output ------------------------------------------------- 52 | 53 | # The theme to use for HTML and HTML Help pages. See the documentation for 54 | # a list of builtin themes. 55 | # 56 | html_theme = 'shibuya' 57 | html_theme_options = { 58 | "accent_color": "green", 59 | "light_logo": "_static/light-logo.svg", 60 | "dark_logo": "_static/dark-logo.svg", 61 | "twitter_site": 'lepture', 62 | "twitter_creator": 'lepture', 63 | "twitter_url": 'https://twitter.com/lepture', 64 | "github_url": 'https://github.com/lepture/captcha', 65 | "carbon_ads_code": "CE7DKK3W", 66 | "carbon_ads_placement": "captchalepturecom", 67 | "og_image_url": "https://captcha.lepture.com/_static/icon.png", 68 | "nav_links": [ 69 | { 70 | "title": "Support me", 71 | "url": "/sponsors", 72 | }, 73 | ] 74 | } 75 | 76 | # Add any paths that contain custom static files (such as style sheets) here, 77 | # relative to this directory. They are copied after the builtin static files, 78 | # so a file named "default.css" will overwrite the builtin "default.css". 79 | # html_static_path = ['_static'] 80 | 81 | html_copy_source = False 82 | html_show_sourcelink = False 83 | 84 | html_favicon = "_static/icon.svg" 85 | 86 | html_context = { 87 | "source_type": "github", 88 | "source_user": "lepture", 89 | "source_repo": "captcha", 90 | } 91 | -------------------------------------------------------------------------------- /src/captcha/audio.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | captcha.audio 4 | ~~~~~~~~~~~~~ 5 | 6 | Generate Audio CAPTCHAs, with built-in digits CAPTCHA. 7 | 8 | This module is totally inspired by https://github.com/dchest/captcha 9 | """ 10 | 11 | import typing as t 12 | import os 13 | import copy 14 | import wave 15 | import struct 16 | import secrets 17 | import operator 18 | from functools import reduce 19 | 20 | 21 | __all__ = ['AudioCaptcha'] 22 | 23 | WAVE_SAMPLE_RATE = 8000 # HZ 24 | WAVE_HEADER = bytearray( 25 | b'RIFF\x00\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00' 26 | b'@\x1f\x00\x00@\x1f\x00\x00\x01\x00\x08\x00data' 27 | ) 28 | WAVE_HEADER_LENGTH = len(WAVE_HEADER) - 4 29 | DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data') 30 | 31 | 32 | def _read_wave_file(filepath: str) -> bytearray: 33 | w = wave.open(filepath) 34 | data = w.readframes(-1) 35 | w.close() 36 | return bytearray(data) 37 | 38 | 39 | def change_speed(body: bytearray, speed: float = 1) -> bytearray: 40 | """Change the voice speed of the wave body.""" 41 | if speed == 1: 42 | return body 43 | 44 | length = int(len(body) * speed) 45 | rv = bytearray(length) 46 | 47 | step: float = 0 48 | for v in body: 49 | i = int(step) 50 | while i < int(step + speed) and i < length: 51 | rv[i] = v 52 | i += 1 53 | step += speed 54 | return rv 55 | 56 | 57 | def patch_wave_header(body: bytearray) -> bytearray: 58 | """Patch header to the given wave body. 59 | 60 | :param body: the wave content body, it should be bytearray. 61 | """ 62 | length = len(body) 63 | 64 | padded = length + length % 2 65 | total = WAVE_HEADER_LENGTH + padded 66 | 67 | header = copy.copy(WAVE_HEADER) 68 | # fill the total length position 69 | header[4:8] = bytearray(struct.pack(' bytearray: 82 | """Create white noise for background""" 83 | noise = bytearray(length) 84 | adjust = 128 - int(level / 2) 85 | i = 0 86 | while i < length: 87 | v = secrets.randbelow(257) 88 | noise[i] = v % level + adjust 89 | i += 1 90 | return noise 91 | 92 | 93 | def create_silence(length: int) -> bytearray: 94 | """Create a piece of silence.""" 95 | data = bytearray(length) 96 | i = 0 97 | while i < length: 98 | data[i] = 128 99 | i += 1 100 | return data 101 | 102 | 103 | def change_sound(body: bytearray, level: float = 1) -> bytearray: 104 | if level == 1: 105 | return body 106 | 107 | body = copy.copy(body) 108 | for i, v in enumerate(body): 109 | if v > 128: 110 | v = int((v - 128) * level + 128) 111 | v = max(v, 128) 112 | v = min(v, 255) 113 | elif v < 128: 114 | v = int(128 - (128 - v) * level) 115 | v = min(v, 128) 116 | v = max(v, 0) 117 | body[i] = v 118 | return body 119 | 120 | 121 | def mix_wave(src: bytearray, dst: bytearray) -> bytearray: 122 | """Mix two wave body into one.""" 123 | if len(src) > len(dst): 124 | # output should be longer 125 | dst, src = src, dst 126 | 127 | for i, sv in enumerate(src): 128 | dv = dst[i] 129 | if sv < 128 and dv < 128: 130 | dst[i] = int(sv * dv / 128) 131 | else: 132 | dst[i] = int(2 * (sv + dv) - sv * dv / 128 - 256) 133 | return dst 134 | 135 | 136 | BEEP = _read_wave_file(os.path.join(DATA_DIR, 'beep.wav')) 137 | END_BEEP = change_speed(BEEP, 1.4) 138 | SILENCE = create_silence(int(WAVE_SAMPLE_RATE / 5)) 139 | 140 | 141 | class AudioCaptcha: 142 | """Create an audio CAPTCHA. 143 | 144 | Create an instance of AudioCaptcha is pretty simple:: 145 | 146 | captcha = AudioCaptcha() 147 | captcha.write('1234', 'out.wav') 148 | 149 | This module has a built-in digits CAPTCHA, but it is suggested that you 150 | create your own voice data library. A voice data library is a directory 151 | that contains lots of single charater named directories, for example:: 152 | 153 | voices/ 154 | 0/ 155 | 1/ 156 | 2/ 157 | 158 | The single charater named directories contain the wave files which pronunce 159 | the directory name. A charater directory can has many wave files, this 160 | AudioCaptcha will randomly choose one of them. 161 | 162 | You should always use your own voice library:: 163 | 164 | captcha = AudioCaptcha(voicedir='/path/to/voices') 165 | """ 166 | def __init__(self, voicedir: t.Optional[str] = None): 167 | if voicedir is None: 168 | voicedir = DATA_DIR 169 | 170 | self._voicedir = voicedir 171 | self._cache: t.Dict[str, t.List[bytearray]] = {} 172 | self._choices: t.List[str] = [] 173 | 174 | @property 175 | def choices(self) -> t.List[str]: 176 | """Available choices for characters to be generated.""" 177 | if self._choices: 178 | return self._choices 179 | for n in os.listdir(self._voicedir): 180 | if len(n) == 1 and os.path.isdir(os.path.join(self._voicedir, n)): 181 | self._choices.append(n) 182 | return self._choices 183 | 184 | def random(self, length: int = 6) -> t.List[str]: 185 | """Generate a random string with the given length. 186 | 187 | :param length: the return string length. 188 | """ 189 | return [secrets.choice(self.choices) for _ in range(length)] 190 | 191 | def load(self) -> None: 192 | """Load voice data into memory.""" 193 | for name in self.choices: 194 | self._load_data(name) 195 | 196 | def _load_data(self, name: str) -> None: 197 | dirname = os.path.join(self._voicedir, name) 198 | data: t.List[bytearray] = [] 199 | for f in os.listdir(dirname): 200 | filepath = os.path.join(dirname, f) 201 | if f.endswith('.wav') and os.path.isfile(filepath): 202 | data.append(_read_wave_file(filepath)) 203 | self._cache[name] = data 204 | 205 | def _twist_pick(self, key: str) -> bytearray: 206 | voice = secrets.choice(self._cache[key]) 207 | 208 | # random change speed 209 | speed = (secrets.randbelow(31) + 90) / 100.0 210 | voice = change_speed(voice, speed) 211 | 212 | # random change sound 213 | level = (secrets.randbelow(41) + 80) / 100.0 214 | voice = change_sound(voice, level) 215 | return voice 216 | 217 | def _noise_pick(self) -> bytearray: 218 | key = secrets.choice(self.choices) 219 | voice = secrets.choice(self._cache[key]) 220 | voice = copy.copy(voice) 221 | voice.reverse() 222 | 223 | speed = (secrets.randbelow(9) + 8) / 10.0 224 | voice = change_speed(voice, speed) 225 | 226 | level = (secrets.randbelow(5) + 2) / 10.0 227 | voice = change_sound(voice, level) 228 | return voice 229 | 230 | def create_background_noise(self, length: int, chars: str) -> bytearray: 231 | noise = create_noise(length, 4) 232 | pos = 0 233 | while pos < length: 234 | sound = self._noise_pick() 235 | end = pos + len(sound) + 1 236 | noise[pos:end] = mix_wave(sound, noise[pos:end]) 237 | pos = end + secrets.randbelow(int(WAVE_SAMPLE_RATE / 10) + 1) 238 | return noise 239 | 240 | def create_wave_body(self, chars: str) -> bytearray: 241 | voices: t.List[bytearray] = [] 242 | inters: t.List[int] = [] 243 | for c in chars: 244 | voices.append(self._twist_pick(c)) 245 | i = secrets.randbelow(WAVE_SAMPLE_RATE * 3 - WAVE_SAMPLE_RATE + 1) + WAVE_SAMPLE_RATE 246 | inters.append(i) 247 | 248 | durations = map(lambda a: len(a), voices) 249 | length = max(durations) * len(chars) + reduce(operator.add, inters) 250 | bg = self.create_background_noise(length, chars) 251 | 252 | # begin 253 | pos: int = inters[0] 254 | for i, v in enumerate(voices): 255 | end = pos + len(v) + 1 256 | bg[pos:end] = mix_wave(v, bg[pos:end]) 257 | pos = end + inters[i] 258 | 259 | return BEEP + SILENCE + BEEP + SILENCE + BEEP + bg + END_BEEP 260 | 261 | def generate(self, chars: str) -> bytearray: 262 | """Generate audio CAPTCHA data. The return data is a bytearray. 263 | 264 | :param chars: text to be generated. 265 | """ 266 | if not self._cache: 267 | self.load() 268 | body = self.create_wave_body(chars) 269 | return patch_wave_header(body) 270 | 271 | def write(self, chars: str, output: str) -> None: 272 | """Generate and write audio CAPTCHA data to the output. 273 | 274 | :param chars: text to be generated. 275 | :param output: output destionation. 276 | """ 277 | data = self.generate(chars) 278 | with open(output, 'wb') as f: 279 | f.write(data) 280 | -------------------------------------------------------------------------------- /src/captcha/image.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | captcha.image 4 | ~~~~~~~~~~~~~ 5 | 6 | Generate Image CAPTCHAs, just the normal image CAPTCHAs you are using. 7 | """ 8 | 9 | from __future__ import annotations 10 | import os 11 | import secrets 12 | import typing as t 13 | from PIL.Image import new as createImage, Image, Transform, Resampling 14 | from PIL.ImageDraw import Draw, ImageDraw 15 | from PIL.ImageFilter import SMOOTH 16 | from PIL.ImageFont import FreeTypeFont, truetype 17 | from io import BytesIO 18 | 19 | __all__ = ['ImageCaptcha'] 20 | 21 | 22 | ColorTuple = t.Union[t.Tuple[int, int, int], t.Tuple[int, int, int, int]] 23 | 24 | DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data') 25 | DEFAULT_FONTS = [os.path.join(DATA_DIR, 'DroidSansMono.ttf')] 26 | 27 | 28 | class ImageCaptcha: 29 | """Create an image CAPTCHA. 30 | 31 | Many of the codes are borrowed from wheezy.captcha, with a modification 32 | for memory and developer friendly. 33 | 34 | ImageCaptcha has one built-in font, DroidSansMono, which is licensed under 35 | Apache License 2. You should always use your own fonts:: 36 | 37 | captcha = ImageCaptcha(fonts=['/path/to/A.ttf', '/path/to/B.ttf']) 38 | 39 | You can put as many fonts as you like. But be aware of your memory, all of 40 | the fonts are loaded into your memory, so keep them a lot, but not too 41 | many. 42 | 43 | :param width: The width of the CAPTCHA image. 44 | :param height: The height of the CAPTCHA image. 45 | :param fonts: Fonts to be used to generate CAPTCHA images. 46 | :param font_sizes: Random choose a font size from this parameters. 47 | """ 48 | lookup_table: list[int] = [int(i * 1.97) for i in range(256)] 49 | character_offset_dx: tuple[int, int] = (0, 4) 50 | character_offset_dy: tuple[int, int] = (0, 6) 51 | character_rotate: tuple[int, int] = (-30, 30) 52 | character_warp_dx: tuple[float, float] = (0.1, 0.3) 53 | character_warp_dy: tuple[float, float] = (0.2, 0.3) 54 | word_space_probability: float = 0.5 55 | word_offset_dx: float = 0.25 56 | 57 | def __init__( 58 | self, 59 | width: int = 160, 60 | height: int = 60, 61 | fonts: list[str] | None = None, 62 | font_sizes: tuple[int, ...] | None = None): 63 | self._width = width 64 | self._height = height 65 | self._fonts = fonts or DEFAULT_FONTS 66 | self._font_sizes = font_sizes or (42, 50, 56) 67 | self._truefonts: list[FreeTypeFont] = [] 68 | 69 | @property 70 | def truefonts(self) -> list[FreeTypeFont]: 71 | if self._truefonts: 72 | return self._truefonts 73 | self._truefonts = [ 74 | truetype(n, s) 75 | for n in self._fonts 76 | for s in self._font_sizes 77 | ] 78 | return self._truefonts 79 | 80 | @staticmethod 81 | def create_noise_curve(image: Image, color: ColorTuple) -> Image: 82 | w, h = image.size 83 | x1 = secrets.randbelow(int(w / 5) + 1) 84 | x2 = secrets.randbelow(w - int(w / 5) + 1) + int(w / 5) 85 | y1 = secrets.randbelow(h - 2 * int(h / 5) + 1) + int(h / 5) 86 | y2 = secrets.randbelow(h - y1 - int(h / 5) + 1) + y1 87 | points = [x1, y1, x2, y2] 88 | end = secrets.randbelow(41) + 160 89 | start = secrets.randbelow(21) 90 | Draw(image).arc(points, start, end, fill=color) 91 | return image 92 | 93 | @staticmethod 94 | def create_noise_dots( 95 | image: Image, 96 | color: ColorTuple, 97 | width: int = 3, 98 | number: int = 30) -> Image: 99 | draw = Draw(image) 100 | w, h = image.size 101 | while number: 102 | x1 = secrets.randbelow(w + 1) 103 | y1 = secrets.randbelow(h + 1) 104 | draw.line(((x1, y1), (x1 - 1, y1 - 1)), fill=color, width=width) 105 | number -= 1 106 | return image 107 | 108 | def _draw_character( 109 | self, 110 | c: str, 111 | draw: ImageDraw, 112 | color: ColorTuple) -> Image: 113 | font = secrets.choice(self.truefonts) 114 | _, _, w, h = draw.multiline_textbbox((1, 1), c, font=font) 115 | 116 | dx1 = secrets.randbelow(self.character_offset_dx[1] - self.character_offset_dx[0] + 1) + self.character_offset_dx[0] 117 | dy1 = secrets.randbelow(self.character_offset_dy[1] - self.character_offset_dy[0] + 1) + self.character_offset_dy[0] 118 | im = createImage('RGBA', (int(w) + dx1, int(h) + dy1)) 119 | Draw(im).text((dx1, dy1), c, font=font, fill=color) 120 | 121 | # rotate 122 | im = im.crop(im.getbbox()) 123 | im = im.rotate( 124 | self.character_rotate[0] + (secrets.randbits(32) / (2**32)) * (self.character_rotate[1] - self.character_rotate[0]), 125 | Resampling.BILINEAR, 126 | expand=True, 127 | ) 128 | 129 | # warp 130 | dx2 = w * (secrets.randbits(32) / (2**32)) * (self.character_warp_dx[1] - self.character_warp_dx[0]) + self.character_warp_dx[0] 131 | dy2 = h * (secrets.randbits(32) / (2**32)) * (self.character_warp_dy[1] - self.character_warp_dy[0]) + self.character_warp_dy[0] 132 | x1 = int(secrets.randbits(32) / (2**32) * (dx2 - (-dx2)) + (-dx2)) 133 | y1 = int(secrets.randbits(32) / (2**32) * (dy2 - (-dy2)) + (-dy2)) 134 | x2 = int(secrets.randbits(32) / (2**32) * (dx2 - (-dx2)) + (-dx2)) 135 | y2 = int(secrets.randbits(32) / (2**32) * (dy2 - (-dy2)) + (-dy2)) 136 | w2 = w + abs(x1) + abs(x2) 137 | h2 = h + abs(y1) + abs(y2) 138 | data = ( 139 | x1, y1, 140 | -x1, h2 - y2, 141 | w2 + x2, h2 + y2, 142 | w2 - x2, -y1, 143 | ) 144 | im = im.resize((w2, h2)) 145 | im = im.transform((int(w), int(h)), Transform.QUAD, data) 146 | return im 147 | 148 | def create_captcha_image( 149 | self, 150 | chars: str, 151 | color: ColorTuple, 152 | background: ColorTuple) -> Image: 153 | """Create the CAPTCHA image itself. 154 | 155 | :param chars: text to be generated. 156 | :param color: color of the text. 157 | :param background: color of the background. 158 | 159 | The color should be a tuple of 3 numbers, such as (0, 255, 255). 160 | """ 161 | image = createImage('RGB', (self._width, self._height), background) 162 | draw = Draw(image) 163 | 164 | images: list[Image] = [] 165 | for c in chars: 166 | if secrets.randbits(32) / (2**32) > self.word_space_probability: 167 | images.append(self._draw_character(" ", draw, color)) 168 | images.append(self._draw_character(c, draw, color)) 169 | 170 | text_width = sum([im.size[0] for im in images]) 171 | 172 | width = max(text_width, self._width) 173 | image = image.resize((width, self._height)) 174 | 175 | average = int(text_width / len(chars)) 176 | rand = int(self.word_offset_dx * average) 177 | offset = int(average * 0.1) 178 | 179 | for im in images: 180 | w, h = im.size 181 | mask = im.convert('L').point(self.lookup_table) 182 | image.paste(im, (offset, int((self._height - h) / 2)), mask) 183 | offset = offset + w + (-secrets.randbelow(rand + 1)) 184 | 185 | if width > self._width: 186 | image = image.resize((self._width, self._height)) 187 | 188 | return image 189 | 190 | def generate_image(self, chars: str, 191 | bg_color: ColorTuple | None = None, 192 | fg_color: ColorTuple | None = None) -> Image: 193 | """Generate the image of the given characters. 194 | 195 | :param chars: text to be generated. 196 | :param bg_color: background color of the image in rgb format (r, g, b). 197 | :param fg_color: foreground color of the text in rgba format (r,g,b,a). 198 | """ 199 | background = bg_color if bg_color else random_color(238, 255) 200 | random_fg_color = random_color(10, 200, secrets.randbelow(36) + 220) 201 | color: ColorTuple = fg_color if fg_color else random_fg_color 202 | 203 | im = self.create_captcha_image(chars, color, background) 204 | self.create_noise_dots(im, color) 205 | self.create_noise_curve(im, color) 206 | im = im.filter(SMOOTH) 207 | return im 208 | 209 | def generate(self, chars: str, format: str = 'png', 210 | bg_color: ColorTuple | None = None, 211 | fg_color: ColorTuple | None = None) -> BytesIO: 212 | """Generate an Image Captcha of the given characters. 213 | 214 | :param chars: text to be generated. 215 | :param format: image file format 216 | :param bg_color: background color of the image in rgb format (r, g, b). 217 | :param fg_color: foreground color of the text in rgba format (r,g,b,a). 218 | """ 219 | im = self.generate_image(chars, bg_color=bg_color, fg_color=fg_color) 220 | out = BytesIO() 221 | im.save(out, format=format) 222 | out.seek(0) 223 | return out 224 | 225 | def write(self, chars: str, output: str, format: str = 'png', 226 | bg_color: ColorTuple | None = None, 227 | fg_color: ColorTuple | None = None) -> None: 228 | """Generate and write an image CAPTCHA data to the output. 229 | 230 | :param chars: text to be generated. 231 | :param output: output destination. 232 | :param format: image file format 233 | :param bg_color: background color of the image in rgb format (r, g, b). 234 | :param fg_color: foreground color of the text in rgba format (r,g,b,a). 235 | """ 236 | im = self.generate_image(chars, bg_color=bg_color, fg_color=fg_color) 237 | im.save(output, format=format) 238 | 239 | 240 | def random_color( 241 | start: int, 242 | end: int, 243 | opacity: int | None = None) -> ColorTuple: 244 | red = secrets.randbelow(end - start + 1) + start 245 | green = secrets.randbelow(end - start + 1) + start 246 | blue = secrets.randbelow(end - start + 1) + start 247 | if opacity is None: 248 | return red, green, blue 249 | return red, green, blue, opacity 250 | -------------------------------------------------------------------------------- /docs/_static/dark-logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/light-logo.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------