Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/actions/setup-python-uv/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ runs:
steps:

- name: install uv
uses: astral-sh/setup-uv@v8.3.2
uses: astral-sh/setup-uv@v10.3.0
with:
version: "0.11.27"
enable-cache: true

- uses: actions/setup-python@v6.3.0
- uses: actions/setup-python@v7.0.0
with:
python-version: "3.13"

Expand Down
8 changes: 6 additions & 2 deletions .github/workflows/action-updater.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1
with:
token: ${{ secrets.WORKFLOW_SECRET }}

- name: Run GitHub Actions Version Updater
uses: saadmk11/github-actions-version-updater@v0.9.0
uses: saadmk11/github-actions-version-updater@v1.0.1
with:
token: ${{ secrets.WORKFLOW_SECRET }}
# Local composite actions pin third-party actions too; they live
# outside .github/workflows, so they need an explicit location.
# Directories are scanned recursively.
extra_workflow_locations: ".github/actions"
38 changes: 19 additions & 19 deletions .github/workflows/cicd.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Infos on current github runners:
# https://docs.github.com/en/free-pro-team@latest/actions/reference/specifications-for-github-hosted-runners#about-github-hosted-runners
# https://docs.github.com/en/actions/reference/runners/github-hosted-runners#single-cpu-runners

name: CI/CD

Expand Down Expand Up @@ -29,9 +29,9 @@ jobs:
os: [macos-15, macos-26, windows-2022, windows-2025, ubuntu-22.04, ubuntu-24.04]
steps:
# Setup environment
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1
if: ${{ github.event_name != 'workflow_dispatch' }}
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1
if: ${{ github.event_name == 'workflow_dispatch' }}
with:
ref: ${{ inputs.refToBuild }}
Expand Down Expand Up @@ -106,7 +106,7 @@ jobs:

- name: Coveralls Parallel
if: github.repository_owner == 'dynobo'
uses: coverallsapp/github-action@v2.3.7
uses: coverallsapp/github-action@v2.3.8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: ${{ matrix.os }}
Expand All @@ -121,7 +121,7 @@ jobs:
matrix:
os: [macos-15, windows-2025, ubuntu-26.04]
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1

- uses: ./.github/actions/setup-python-uv

Expand All @@ -132,9 +132,9 @@ jobs:
name: Test flatpak build
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1

- uses: actions/setup-python@v6.3.0
- uses: actions/setup-python@v7.0.0
with:
python-version: "3.13"

Expand Down Expand Up @@ -165,7 +165,7 @@ jobs:
matrix:
os: [macos-15, windows-2025, ubuntu-26.04]
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1

- uses: ./.github/actions/setup-python-uv

Expand All @@ -188,7 +188,7 @@ jobs:
name: Test documentation build
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1

- uses: ./.github/actions/setup-python-uv

Expand All @@ -201,9 +201,9 @@ jobs:
if: github.repository_owner == 'dynobo'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1
- name: Coveralls Finished
uses: coverallsapp/github-action@v2.3.7
uses: coverallsapp/github-action@v2.3.8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
parallel-finished: true
Expand All @@ -221,16 +221,16 @@ jobs:
language: ["python"]
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.0
uses: actions/checkout@v7.0.1
- name: Initialize CodeQL
uses: github/codeql-action/init@v4.37.0
uses: github/codeql-action/init@v4.39.0
with:
languages: ${{ matrix.language }}
queries: security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@v4.37.0
uses: github/codeql-action/autobuild@v4.39.0
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4.37.0
uses: github/codeql-action/analyze@v4.39.0

deploy-briefcase:
name: Briefcase build & draft release
Expand All @@ -248,7 +248,7 @@ jobs:
matrix:
os: [macos-15, windows-2025, ubuntu-26.04]
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1

- uses: ./.github/actions/setup-python-uv

Expand Down Expand Up @@ -293,14 +293,14 @@ jobs:
# Used to sign the release's artifacts with sigstore-python.
id-token: write
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1

- uses: ./.github/actions/setup-python-uv

- name: Build Python package
run: uv build
- name: Publish to PyPi
uses: pypa/gh-action-pypi-publish@v1.14.0
uses: pypa/gh-action-pypi-publish@v1.14.2
with:
verbose: true
print-hash: true
Expand All @@ -315,7 +315,7 @@ jobs:
&& github.repository_owner == 'dynobo'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7.0.0
- uses: actions/checkout@v7.0.1

- uses: ./.github/actions/setup-python-uv

Expand Down
2 changes: 1 addition & 1 deletion CHANGELOG
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## v0.7.0 (upcoming)

**All systems:**
- 🗲 Breaking: Drop support for Python 3.9.
- 🗲 Breaking: Drop support for Python 3.10.
- Fix unresposive window when selecting larger screen regions.

**Windows**:
Expand Down
87 changes: 37 additions & 50 deletions normcap/detection/codes/detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,19 @@

logger = logging.getLogger(__name__)


def _image_to_memoryview(image: QtGui.QImage) -> memoryview:
"""Transform Qimage to a bytearray without padding.

Args:
image: Input image.

Returns:
Memoryview of the image data as 3D array: width x height x RGB.
"""
image = image.convertToFormat(QtGui.QImage.Format.Format_RGB888)
ptr = image.constBits()
bytes_per_line = image.bytesPerLine() # Includes padding
width = image.width()
height = image.height()
channels = 3 # RGB888 has 3 channels

# Create a new bytearray to exclude padding
raw_data = bytearray()
for y in range(height):
row_start = y * bytes_per_line
row_end = row_start + (width * channels)
raw_data.extend(ptr[row_start:row_end]) # Exclude padding

return memoryview(raw_data).cast(
"B", shape=(image.height(), image.width(), channels)
)
# QImage formats which zxingcpp can read directly. Everything else has to be converted
# beforehand, because zxingcpp's own fallback calls `convertToFormat(24)` with a plain
# int where PySide6 requires a `QImage.Format` enum, and therefore always raises.
_SUPPORTED_IMAGE_FORMATS = frozenset(
{
QtGui.QImage.Format.Format_ARGB32,
QtGui.QImage.Format.Format_BGR888,
QtGui.QImage.Format.Format_Grayscale8,
QtGui.QImage.Format.Format_RGBA8888,
QtGui.QImage.Format.Format_RGB32,
QtGui.QImage.Format.Format_RGB888,
}
)


def _get_text_type_and_transform(text: str) -> tuple[str, TextType]:
Expand Down Expand Up @@ -66,63 +52,64 @@ def _get_text_type_and_transform(text: str) -> tuple[str, TextType]:


def _detect_codes_via_zxing(
image: memoryview,
image: QtGui.QImage,
) -> Generator[tuple[str, TextType, CodeType], None, None]:
"""Decode QR and barcodes from image.

Args:
image: Input image.

Returns:
URL(s), separated bye newline
Yields:
Detected text, type of text and type of code.
"""
logger.info("Detect Barcodes and QR Codes")

if image.format() not in _SUPPORTED_IMAGE_FORMATS:
logger.debug("Convert %s to RGB888 for zxingcpp", image.format())
image = image.convertToFormat(QtGui.QImage.Format.Format_RGB888)

results = zxingcpp.read_barcodes(image)

if not results:
return None

codes = [result.text for result in results if result.text]
code_types = [r.format for r in results]
return

qr_formats = {
zxingcpp.BarcodeFormat.QRCode,
zxingcpp.BarcodeFormat.RMQRCode,
zxingcpp.BarcodeFormat.MicroQRCode,
}

logger.info("Found %s codes", len(codes))
logger.info("Found %s codes", len(results))

for result in results:
if not result.valid:
logger.warning("Invalid %s result: %s", result.format, result.error)
continue

if not result.text:
logger.debug("Skip %s result without text", result.format)
continue

for code, code_format in zip(codes, code_types, strict=True):
if code_format in qr_formats:
code_type = CodeType.QR
elif code_format not in (qr_formats):
code_type = CodeType.BARCODE
else:
raise ValueError()
code_type = CodeType.QR if result.format in qr_formats else CodeType.BARCODE

text = code.strip()
text, text_type = _get_text_type_and_transform(text)
text, text_type = _get_text_type_and_transform(result.text.strip())
yield text, text_type, code_type


def detect_codes(image: QtGui.QImage) -> list[DetectionResult]:
"""Decode & decode QR and barcodes from image.
"""Detect & decode QR and barcodes from image.

Args:
image: Input image with potentially one or more barcocdes / QR Codes.
image: Input image with potentially one or more barcodes / QR Codes.

Returns:
Result of the detection. If more than one code is detected, the detected values
are separated by newlines. If no code is detected, None is returned.
are separated by newlines. An empty list if no code is detected.
"""
logger.debug("Start QR/Barcode detection")
# ONHOLD: Switch to using QImage directly, when a new zxingcpp is released
image_buffer = _image_to_memoryview(image)

results = []
for text, text_type, code_type in _detect_codes_via_zxing(image=image_buffer):
for text, text_type, code_type in _detect_codes_via_zxing(image=image):
text_detector = TextDetector[code_type.value]
results.append(
DetectionResult(text=text, text_type=text_type, detector=text_detector)
Expand Down
6 changes: 1 addition & 5 deletions normcap/screenshot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,12 @@
capture_with_handler,
get_available_handlers,
)
from normcap.screenshot.permissions import (
has_screenshot_permission,
macos_reset_screenshot_permission,
)
from normcap.screenshot.permissions import has_screenshot_permission

__all__ = [
"Handler",
"capture",
"capture_with_handler",
"get_available_handlers",
"has_screenshot_permission",
"macos_reset_screenshot_permission",
]
5 changes: 3 additions & 2 deletions normcap/screenshot/handlers/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ def capture() -> list[QtGui.QImage]:

Works well on X11, fails on multi monitor macOS.

On macOS, Qt >= 6.12 captures via ScreenCaptureKit, which requires the
"Screen Recording" permission. Without it, empty images are returned.
On macOS, Qt >= 6.12 captures via ScreenCaptureKit, which can return empty
images, e.g. when the "Screen Recording" permission is missing or when the
capture times out.
"""
images = []
for screen in QtWidgets.QApplication.screens():
Expand Down
23 changes: 20 additions & 3 deletions normcap/screenshot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ def get_available_handlers() -> list[Handler]:


def _capture(handler: Handler) -> list[QtGui.QImage]:
"""Capture screen using a single handler.

Returns:
Single image for every screen, or empty list if the capture failed or only
empty images (e.g. Qt's ScreenCaptureKit grab on macOS) have been returned.
"""
capture_handler = _capture_handlers[handler]
if not capture_handler.is_compatible():
logger.warning("%s's capture() called on incompatible system!", handler.name)
Expand All @@ -72,9 +78,20 @@ def _capture(handler: Handler) -> list[QtGui.QImage]:
except Exception:
logger.exception("%s's capture() failed!", handler.name)
return []
else:
logger.info("Screen captured using %s", handler.name)
return images

empty_count = sum(1 for image in images if image.isNull())
if empty_count:
logger.warning(
"%s's capture() returned %d of %d empty image(s)!",
handler.name,
empty_count,
len(images),
)
if images and empty_count == len(images):
return []

logger.info("Screen captured using %s", handler.name)
return images


def capture_with_handler(handler_name: str) -> list[QtGui.QImage]:
Expand Down
Loading
Loading