nace-haystack 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,171 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import os
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ from typing import Any
8
+ from urllib.parse import urlsplit
9
+
10
+ import httpx
11
+ import nace_sdk
12
+ from haystack import logging
13
+ from haystack.utils import Secret
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ try:
18
+ __version__ = version("nace-haystack")
19
+ except PackageNotFoundError: # a source checkout that isn't installed
20
+ __version__ = "0.0.0"
21
+
22
+ CLIENT_NAME = f"nace-haystack/{__version__}"
23
+
24
+ # The newest Drex model, used when a component is created without `model`. Bumped in a package release whenever
25
+ # Drex ships a new model (see RELEASING.md); every other model id is sent as given and Drex decides.
26
+ DEFAULT_MODEL = "drex-max-v1.1"
27
+
28
+ # Released, pinned versions never gain image input, so this list can't go stale.
29
+ TEXT_ONLY_MODELS = frozenset({"drex-v1.5", "drex-v2.0"})
30
+
31
+ # Drex may take up to 55 seconds to answer, so shorter timeouts can cut off a valid request.
32
+ MIN_TIMEOUT = 60.0
33
+
34
+ # The longest wait between retries, in seconds, even when the server asks for a longer one
35
+ MAX_RETRY_WAIT = 60.0
36
+
37
+
38
+ class CappedRetryPolicy(nace_sdk.RetryPolicy):
39
+ """
40
+ nace-sdk's retry policy, with every wait capped at `MAX_RETRY_WAIT`.
41
+
42
+ The SDK honours `retry-after-ms` however long it is, so a misbehaving server or gateway could stall a pipeline
43
+ for hours. This overrides the SDK's private `_wait` until the SDK caps waits itself.
44
+ """
45
+
46
+ def _wait(self, attempt: int, error: BaseException) -> float:
47
+ return min(super()._wait(attempt, error), MAX_RETRY_WAIT)
48
+
49
+
50
+ def is_text_only(model: str) -> bool:
51
+ """
52
+ Whether `model` is a released Drex version known to read text only.
53
+
54
+ :param model: A Drex model id, optionally with a provider prefix such as `nace-ai/` and an OpenRouter variant
55
+ suffix such as `:floor`.
56
+ :returns: `True` for `drex-v1.5` and `drex-v2.0`, `False` for every other id.
57
+ """
58
+ return model.rsplit("/", 1)[-1].lstrip("~").partition(":")[0] in TEXT_ONLY_MODELS
59
+
60
+
61
+ def is_openrouter(base_url: str | None) -> bool:
62
+ """
63
+ Whether `base_url` points to OpenRouter, which takes a different request format.
64
+
65
+ :param base_url: The API base URL, or `None` for the default.
66
+ :returns: `True` if the host is openrouter.ai.
67
+ """
68
+ return bool(base_url) and (urlsplit(str(base_url)).hostname or "").endswith("openrouter.ai")
69
+
70
+
71
+ def provider_name(base_url: str | None) -> str:
72
+ """
73
+ Names who answers requests sent to `base_url`, for error messages.
74
+
75
+ :param base_url: The API base URL, or `None` for the default.
76
+ :returns: `"OpenRouter"` or `"Drex"`.
77
+ """
78
+ return "OpenRouter" if is_openrouter(base_url) else "Drex"
79
+
80
+
81
+ def resolve_base_url(api_base_url: str | None) -> str | None:
82
+ """
83
+ Resolves the API base URL from the argument or the `NACE_BASE_URL` environment variable.
84
+
85
+ :param api_base_url: The base URL passed to the component, or `None`.
86
+ :returns: The base URL, or `None` for the SDK default. A blank value (common in `.env` files and CI) means the
87
+ default. OpenRouter's documented `.../api/v1` is trimmed to `.../api`, because the SDK appends `/v1/...`.
88
+ """
89
+ base_url = api_base_url if api_base_url is not None else os.environ.get("NACE_BASE_URL")
90
+ base_url = (base_url or "").strip() or None
91
+ if base_url and is_openrouter(base_url):
92
+ base_url = base_url.rstrip("/").removesuffix("/v1")
93
+ return base_url
94
+
95
+
96
+ def client_options(
97
+ api_key: Secret, api_base_url: str | None, timeout: float | None, max_retries: int | None
98
+ ) -> dict[str, Any]:
99
+ """
100
+ Builds the keyword arguments for `nace_sdk.NaceClient` and `nace_sdk.AsyncNaceClient`.
101
+
102
+ :param api_key: The Nace API key.
103
+ :param api_base_url: The base URL passed to the component, or `None`.
104
+ :param timeout: Timeout in seconds for each request attempt. `None` means 60 seconds.
105
+ :param max_retries: Maximum number of retries after a failed attempt. `None` means the SDK default of 2.
106
+ :returns: The client keyword arguments.
107
+ """
108
+ base_url = resolve_base_url(api_base_url)
109
+ if timeout is None:
110
+ timeout = MIN_TIMEOUT
111
+ elif timeout < MIN_TIMEOUT:
112
+ logger.warning(
113
+ "A timeout of {timeout} s can cut off Drex, which may take up to 55 s to answer. Use at least {minimum} s.",
114
+ timeout=timeout,
115
+ minimum=MIN_TIMEOUT,
116
+ )
117
+ headers = {}
118
+ if is_openrouter(base_url):
119
+ headers = {"HTTP-Referer": "https://nace.ai", "X-OpenRouter-Title": "Drex for Haystack"}
120
+ # The SDK's retry budget defaults to 30 seconds in total, which would skip retries after a long attempt.
121
+ # `timeout` already bounds each attempt, so the budget is disabled.
122
+ retry_kwargs: dict[str, Any] = {"timeout": None}
123
+ if max_retries is not None:
124
+ retry_kwargs["max_retries"] = max_retries
125
+ return {
126
+ "api_key": api_key.resolve_value(),
127
+ "base_url": base_url,
128
+ "timeout": timeout,
129
+ "retry": CappedRetryPolicy(**retry_kwargs),
130
+ "headers": headers,
131
+ "client_info": CLIENT_NAME,
132
+ }
133
+
134
+
135
+ def _set_user_agent(request: httpx.Request) -> None:
136
+ # The SDK sends its own User-Agent; ours names this integration so traffic can be measured per channel.
137
+ request.headers["User-Agent"] = CLIENT_NAME
138
+
139
+
140
+ async def _set_user_agent_async(request: httpx.Request) -> None:
141
+ _set_user_agent(request)
142
+
143
+
144
+ def make_client(options: dict[str, Any]) -> nace_sdk.NaceClient:
145
+ """
146
+ Creates a synchronous Nace client that identifies this integration on every request.
147
+
148
+ :param options: Keyword arguments from `client_options`, plus an optional `transport` for tests.
149
+ :returns: The client.
150
+ """
151
+ options = dict(options)
152
+ http_client = httpx.Client(
153
+ timeout=options["timeout"], transport=options.pop("transport", None), event_hooks={"request": [_set_user_agent]}
154
+ )
155
+ return nace_sdk.NaceClient(**options, http_client=http_client)
156
+
157
+
158
+ def make_async_client(options: dict[str, Any]) -> nace_sdk.AsyncNaceClient:
159
+ """
160
+ Creates an asynchronous Nace client that identifies this integration on every request.
161
+
162
+ :param options: Keyword arguments from `client_options`, plus an optional `transport` for tests.
163
+ :returns: The client.
164
+ """
165
+ options = dict(options)
166
+ http_client = httpx.AsyncClient(
167
+ timeout=options["timeout"],
168
+ transport=options.pop("transport", None),
169
+ event_hooks={"request": [_set_user_agent_async]},
170
+ )
171
+ return nace_sdk.AsyncNaceClient(**options, http_client=http_client)
@@ -0,0 +1,49 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from collections.abc import Callable, Iterator, Sequence
6
+ from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
7
+ from typing import TypeVar
8
+
9
+ T = TypeVar("T")
10
+ R = TypeVar("R")
11
+
12
+
13
+ def map_in_threads(func: Callable[[T], R], items: Sequence[T], max_workers: int) -> list[R]:
14
+ """
15
+ Applies `func` to every item on up to `max_workers` threads and returns the results in input order.
16
+
17
+ Unlike `Executor.map`, which notices a failure only once every earlier item is done, the first exception from
18
+ any item stops the run: no item starts after it, so no further requests are sent.
19
+
20
+ :param func: The function to apply.
21
+ :param items: The inputs.
22
+ :param max_workers: The most items processed at once.
23
+ :returns: `func(item)` for each item, in the order of `items`.
24
+ :raises Exception: The first exception `func` raised, after the items already running have finished.
25
+ """
26
+ results: dict[int, R] = {}
27
+ pending: dict[Future[R], int] = {}
28
+ queue: Iterator[tuple[int, T]] = iter(enumerate(items))
29
+ # Items are handed out one at a time as workers free up, rather than queued all at once, so a worker can never
30
+ # pick up another item between a failure and the moment it is noticed
31
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
32
+
33
+ def start_next() -> None:
34
+ for index, item in queue:
35
+ pending[executor.submit(func, item)] = index
36
+ return
37
+
38
+ for _ in range(max_workers):
39
+ start_next()
40
+ while pending:
41
+ done, _ = wait(pending, return_when=FIRST_COMPLETED)
42
+ for future in done:
43
+ index = pending.pop(future)
44
+ error = future.exception()
45
+ if error is not None:
46
+ raise error
47
+ results[index] = future.result()
48
+ start_next()
49
+ return [results[index] for index in range(len(items))]
@@ -0,0 +1,138 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import json
6
+ import re
7
+ from collections.abc import Sequence
8
+ from typing import Any
9
+
10
+ import nace_sdk
11
+
12
+ BILLING_URL = "https://console.nace.ai/billing"
13
+ OPENROUTER_CREDITS_URL = "https://openrouter.ai/settings/credits"
14
+
15
+ # Longer messages are cut, so an unexpected response body can't flood logs and tracebacks
16
+ MAX_MESSAGE_CHARS = 500
17
+
18
+ _IMAGE_PATH = re.compile(r"^images\.(\d+)")
19
+
20
+
21
+ def readable_error(error: nace_sdk.NaceError, provider: str, *, image_types: Sequence[str] = ()) -> nace_sdk.NaceError:
22
+ """
23
+ Rewrites an API error so its message says who refused the request and what to change.
24
+
25
+ The result is an instance of the same nace-sdk class, so `except nace_sdk.NaceError` (or a narrower class such
26
+ as `nace_sdk.NaceRateLimitError`) still catches it, and its `status`, `body`, `issues`, `request_id` and
27
+ `idempotency_key` are kept. A 404 or 405 names the URL, which is how a wrong base URL shows.
28
+
29
+ :param error: The error nace-sdk raised.
30
+ :param provider: Who answered: `"Drex"`, or `"OpenRouter"` on that backend.
31
+ :param image_types: The MIME type of each image sent, in order, so a refused PDF can be named.
32
+ :returns: The readable error, or `error` itself if it isn't an HTTP error response.
33
+ """
34
+ if not isinstance(error, nace_sdk.NaceAPIError) or isinstance(error, nace_sdk.NaceAPIResponseValidationError):
35
+ return error
36
+ message, found = _unwrap(error.message) if error.message else (None, [])
37
+ if message:
38
+ message = _shorten(message, provider, error.status)
39
+ issues = error.issues or found
40
+ # nace-sdk prints errors as "<status> <message> (request_id=...)"
41
+ readable = type(error)(
42
+ error.status,
43
+ error.body,
44
+ error.headers,
45
+ message=f"from {provider}: {_describe(error, provider, message, issues, image_types)}",
46
+ )
47
+ readable.idempotency_key = error.idempotency_key
48
+ return readable
49
+
50
+
51
+ def _unwrap(message: str) -> tuple[str, list[dict[str, str]]]:
52
+ # Gateways such as OpenRouter pass upstream errors through as text like `HTTP 422: {"detail": "..."}` or a JSON
53
+ # list of schema issues. Return a short message and any `{"path", "message"}` issues found in it.
54
+ text = message.strip()
55
+ wrapped = re.match(r"^HTTP \d{3}: (.*)$", text, re.DOTALL)
56
+ if wrapped:
57
+ text = wrapped.group(1).strip()
58
+ try:
59
+ data: Any = json.loads(text)
60
+ except ValueError:
61
+ return text, []
62
+ if isinstance(data, dict):
63
+ inner = data.get("detail") or data.get("message") or data.get("error")
64
+ if isinstance(inner, str):
65
+ return inner, []
66
+ if isinstance(inner, dict) and isinstance(inner.get("message"), str):
67
+ return inner["message"], []
68
+ data = inner
69
+ if isinstance(data, list):
70
+ issues = []
71
+ for item in data:
72
+ if isinstance(item, dict) and item.get("message"):
73
+ path = item.get("path", "")
74
+ if isinstance(path, list):
75
+ path = ".".join(str(part) for part in path)
76
+ issues.append({"path": str(path), "message": str(item["message"])})
77
+ if issues:
78
+ return "the request is invalid", issues
79
+ return text, []
80
+
81
+
82
+ def _shorten(message: str, provider: str, status: int) -> str:
83
+ # Gateways and proxies answer some failures with an HTML page, which says nothing useful in a Python error
84
+ if message.lstrip()[:15].lower().startswith(("<!doctype", "<html")):
85
+ return f"{provider} returned an HTML error page (HTTP {status})"
86
+ if len(message) > MAX_MESSAGE_CHARS:
87
+ return message[:MAX_MESSAGE_CHARS] + "…"
88
+ return message
89
+
90
+
91
+ def _describe(
92
+ error: nace_sdk.NaceAPIError,
93
+ provider: str,
94
+ message: str | None,
95
+ issues: list[dict[str, str]],
96
+ image_types: Sequence[str],
97
+ ) -> str:
98
+ if error.status == 413: # noqa: PLR2004
99
+ # Often an HTML page from the gateway rather than a Drex error
100
+ if not image_types:
101
+ return "the request is too large. Shorten the text."
102
+ text = (
103
+ "the request is too large. Send fewer or smaller images, or pass big images as files uploaded to Drex "
104
+ "with nace-sdk."
105
+ )
106
+ if "application/pdf" in image_types:
107
+ text += (
108
+ "\nA PDF can't be shrunk to fit: convert its pages to images first, for example with Haystack's "
109
+ "PDFToImageContent."
110
+ )
111
+ return text
112
+ text = message or "the request failed"
113
+ lines = [f"{issue.get('path') or '(request)'}: {issue['message']}" for issue in issues if issue.get("message")]
114
+ # Drex's top-level message often repeats a single issue; don't print it twice
115
+ repeated = len(issues) == 1 and (issues[0].get("message") == message or lines == [message])
116
+ if lines and not repeated:
117
+ text += "\n" + "\n".join(f" {line}" for line in lines)
118
+ for issue in issues:
119
+ refused = _IMAGE_PATH.match(issue.get("path") or "")
120
+ if (
121
+ refused
122
+ and int(refused.group(1)) < len(image_types)
123
+ and image_types[int(refused.group(1))] == "application/pdf"
124
+ ):
125
+ number = int(refused.group(1)) + 1
126
+ text += (
127
+ f"\nImage {number} is a PDF, and Drex reads images. Convert PDF pages to images first, for example "
128
+ "with Haystack's PDFToImageContent."
129
+ )
130
+ if error.status in (404, 405) and error.endpoint:
131
+ # Usually a wrong base URL (`api_base_url` or `NACE_BASE_URL`)
132
+ text += f"\n{error.endpoint} doesn't exist. Check the base URL."
133
+ if error.status == 402: # noqa: PLR2004
134
+ if provider == "OpenRouter":
135
+ text += f"\nAdd credits at {OPENROUTER_CREDITS_URL}"
136
+ else:
137
+ text += f"\nAdd funds at {BILLING_URL}"
138
+ return text
@@ -0,0 +1,204 @@
1
+ # SPDX-FileCopyrightText: 2026-present Nace AI
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import base64
6
+ import io
7
+ import math
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any, TypeAlias
10
+
11
+ import filetype
12
+ import nace_sdk
13
+ from haystack import logging
14
+ from haystack.dataclasses import ByteStream, ImageContent
15
+ from haystack.lazy_imports import LazyImport
16
+
17
+ with LazyImport("Run 'pip install pillow'") as pillow_import:
18
+ from PIL import Image, ImageOps
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ ImageInput: TypeAlias = (
23
+ ImageContent | ByteStream | nace_sdk.UploadedFile | nace_sdk.WorkspaceFileSource | Mapping[str, str]
24
+ )
25
+
26
+ # Drex's documented request size limit. It is used only to decide when inline images must be shrunk; Drex enforces
27
+ # the real limit and explains it when a request is too large.
28
+ MAX_REQUEST_BYTES = 4 * 1024 * 1024
29
+
30
+ _JPEG_QUALITY = 90
31
+ _MAX_SHRINK_ROUNDS = 6
32
+ # Below about 1 megapixel, small print on a scanned page stops being readable
33
+ _MIN_SHRUNK_PIXELS = 1_000_000
34
+
35
+
36
+ def _sniff(data: bytes) -> str:
37
+ guess = filetype.guess(data)
38
+ return guess.mime if guess else "application/octet-stream"
39
+
40
+
41
+ def _declared(mime_type: str | None) -> str | None:
42
+ # `application/octet-stream` says only "some bytes", which is what loaders such as a plain file read produce
43
+ return None if mime_type == "application/octet-stream" else mime_type
44
+
45
+
46
+ def _reference(image: Any) -> dict[str, str] | None:
47
+ if isinstance(image, (nace_sdk.UploadedFile, nace_sdk.WorkspaceFileSource)):
48
+ return {"type": "workspace_file", "workspace_id": str(image.workspace_id), "file_id": str(image.file_id)}
49
+ if isinstance(image, Mapping) and image.get("type") == "workspace_file":
50
+ return {"type": "workspace_file", "workspace_id": str(image["workspace_id"]), "file_id": str(image["file_id"])}
51
+ return None
52
+
53
+
54
+ def to_wire_image(image: ImageInput, *, openrouter: bool) -> str | dict[str, str]:
55
+ """
56
+ Converts an image to the form Drex reads.
57
+
58
+ Images are sent with their own MIME type; Drex decides which types it reads and explains a refusal.
59
+
60
+ :param image: An `ImageContent`, a `ByteStream`, or a reference to a file uploaded to Drex with nace-sdk.
61
+ :param openrouter: Whether the request goes to OpenRouter, which takes inline images only.
62
+ :returns: A `data:` URL, or a `workspace_file` reference.
63
+ :raises ValueError: If the image is a file reference on OpenRouter, or isn't a supported input.
64
+ """
65
+ if isinstance(image, ImageContent):
66
+ mime_type = _declared(image.mime_type) or _sniff(base64.b64decode(image.base64_image))
67
+ return f"data:{mime_type};base64,{image.base64_image}"
68
+ if isinstance(image, ByteStream):
69
+ mime_type = _declared(image.mime_type) or _sniff(image.data)
70
+ return f"data:{mime_type};base64,{base64.b64encode(image.data).decode()}"
71
+ reference = _reference(image)
72
+ if reference is None:
73
+ msg = (
74
+ f"Unsupported image {type(image).__name__}. Pass an ImageContent, a ByteStream, or a file uploaded to "
75
+ "Drex with nace-sdk (an UploadedFile, a WorkspaceFileSource, or a "
76
+ '{"type": "workspace_file", "workspace_id": ..., "file_id": ...} dict).'
77
+ )
78
+ raise ValueError(msg)
79
+ if openrouter:
80
+ msg = "OpenRouter accepts inline images only, not files uploaded to Drex. Pass the image itself instead."
81
+ raise ValueError(msg)
82
+ return reference
83
+
84
+
85
+ def image_types(images: Sequence[ImageInput]) -> list[str]:
86
+ """
87
+ Lists the MIME type of each image, so error messages can name the one Drex refused.
88
+
89
+ :param images: The images as the caller passed them.
90
+ :returns: One MIME type per image, or `"workspace_file"` for a reference to an uploaded file.
91
+ """
92
+ types = []
93
+ for image in images:
94
+ if isinstance(image, ImageContent):
95
+ types.append(_declared(image.mime_type) or _sniff(base64.b64decode(image.base64_image)))
96
+ elif isinstance(image, ByteStream):
97
+ types.append(_declared(image.mime_type) or _sniff(image.data))
98
+ else:
99
+ types.append("workspace_file")
100
+ return types
101
+
102
+
103
+ def fit_images(images: list[str | dict[str, str]], budget: int) -> list[str | dict[str, str]]:
104
+ """
105
+ Shrinks inline images that don't fit in one request, keeping everything else as it is.
106
+
107
+ Drex Max downsizes every image to about 1.6 megapixels itself, so a shrunk image that still fits keeps the
108
+ detail the model reads. Images that fit are sent untouched, so a model that reads more detail isn't held back.
109
+ An image is never shrunk below about 1 megapixel, where small print stops being readable: when the text leaves
110
+ too little room, a clear error says so instead.
111
+
112
+ :param images: `data:` URLs and `workspace_file` references, in request order.
113
+ :param budget: How many characters the inline images may take in the request in total.
114
+ :returns: The images in the same order. Data URLs over their share of `budget` are re-encoded as JPEG and scaled
115
+ down until they fit. References, and data Pillow can't open (such as a PDF), are returned unchanged.
116
+ :raises ValueError: If an image must be shrunk and Pillow isn't installed, or can't fit without going below
117
+ the quality floor.
118
+ """
119
+ inline = [index for index, image in enumerate(images) if isinstance(image, str)]
120
+ if not inline or sum(len(images[index]) for index in inline) <= budget:
121
+ return list(images)
122
+ share = budget // len(inline)
123
+ oversized = [index for index in inline if len(images[index]) > share]
124
+ left = budget - sum(len(images[index]) for index in inline if index not in oversized)
125
+ share = left // len(oversized)
126
+ fitted = list(images)
127
+ for index in oversized:
128
+ fitted[index] = _shrink(str(images[index]), share, number=index + 1)
129
+ return fitted
130
+
131
+
132
+ def _no_room(number: int) -> ValueError:
133
+ msg = (
134
+ f"Image {number} is too big to send with this much text: it would have to shrink below about 1 megapixel to "
135
+ "fit Drex's request limit. Shorten the text, send fewer images, or pass big images as files uploaded to Drex "
136
+ "with nace-sdk, which don't count toward the request size."
137
+ )
138
+ return ValueError(msg)
139
+
140
+
141
+ def _shrink(data_url: str, share: int, *, number: int) -> str:
142
+ try:
143
+ pillow_import.check()
144
+ except ImportError as error:
145
+ msg = (
146
+ "An image is too big to send to Drex in one request, and shrinking it needs Pillow. "
147
+ "Run 'pip install pillow', or downscale images before this component, for example with "
148
+ "ImageFileToImageContent(size=(2048, 2048)) or PDFToImageContent(size=(2048, 2048))."
149
+ )
150
+ raise ValueError(msg) from error
151
+
152
+ raw = base64.b64decode(data_url.partition(",")[2])
153
+ try:
154
+ with Image.open(io.BytesIO(raw)) as opened:
155
+ # Phone photos store their rotation in EXIF, which JPEG re-encoding drops: apply it to the pixels first
156
+ original = _flatten(ImageOps.exif_transpose(opened))
157
+ except OSError:
158
+ # Not an image Pillow can read, such as a PDF. Drex refuses it with a readable error.
159
+ return data_url
160
+ if share <= 0:
161
+ raise _no_room(number)
162
+
163
+ scale = 1.0
164
+ image = original
165
+ shrunk = _jpeg(image)
166
+ for _ in range(_MAX_SHRINK_ROUNDS):
167
+ if len(shrunk) <= share:
168
+ break
169
+ scale *= math.sqrt(share / len(shrunk)) * 0.95
170
+ size = (max(1, round(original.width * scale)), max(1, round(original.height * scale)))
171
+ if size[0] * size[1] < _MIN_SHRUNK_PIXELS:
172
+ raise _no_room(number)
173
+ image = original.resize(size, Image.Resampling.LANCZOS)
174
+ shrunk = _jpeg(image)
175
+ if len(shrunk) > share:
176
+ raise _no_room(number)
177
+ logger.warning(
178
+ "Image {original} ({before:.1f} MB) -> {size} ({after:.1f} MB), shrunk to fit Drex's request limit.",
179
+ original=f"{original.width}x{original.height}",
180
+ before=len(raw) / 1e6,
181
+ size=f"{image.width}x{image.height}",
182
+ after=len(shrunk) * 3 / 4 / 1e6,
183
+ )
184
+ return shrunk
185
+
186
+
187
+ def _flatten(image: "Image.Image") -> "Image.Image":
188
+ # JPEG has no transparency: paint transparent areas white rather than letting them turn black.
189
+ if image.mode in ("RGBA", "LA") or (image.mode == "P" and "transparency" in image.info):
190
+ rgba = image.convert("RGBA")
191
+ page = Image.new("RGB", rgba.size, (255, 255, 255))
192
+ page.paste(rgba, mask=rgba.getchannel("A"))
193
+ return page
194
+ # 16-bit and 32-bit grayscale (common in scanner output) would be clipped at 255, turning the page white.
195
+ # Scale it down to 8 bits instead.
196
+ if image.mode.startswith("I;16") or image.mode == "I":
197
+ image = image.convert("I").point(lambda value: value / 256).convert("L")
198
+ return image.convert("RGB")
199
+
200
+
201
+ def _jpeg(image: "Image.Image") -> str:
202
+ buffer = io.BytesIO()
203
+ image.save(buffer, "JPEG", quality=_JPEG_QUALITY)
204
+ return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode()