fix(pdf): shim image_to_url for drawable-canvas on modern Streamlit

User hit ``AttributeError: module 'streamlit.elements.image' has
no attribute 'image_to_url'`` on first PDF import. Root cause:
``streamlit-drawable-canvas`` 0.9.3 (last upstream release 2023)
calls a Streamlit internal that was relocated in Streamlit
~1.30+. The function moved from ``streamlit.elements.image`` to
``streamlit.elements.lib.image_utils`` AND its signature
changed — the second positional argument is now a
``LayoutConfig`` dataclass instead of a plain ``int`` width.

Three remedies considered:

1. Downgrade Streamlit. Reverses unrelated improvements +
   security fixes; not on the table.
2. Fork drawable-canvas. The maintenance hit isn't worth it for a
   one-line internal API change.
3. **Ship a compatibility shim.** Re-attach a wrapper at the old
   import path that adapts the old call shape to the new
   function. This is the standard workaround the wider Streamlit
   community has converged on for this exact regression.

``src/gui/_drawable_canvas_compat.py`` does (3). The ``install()``
helper is idempotent, opt-in (not auto-run at module import — a
grep for ``_install_canvas_compat`` shows every call site), and
no-ops if Streamlit hasn't moved the function OR if the new
function isn't where we expect (lets the canvas surface a real
error rather than papering over a different bug). The page calls
``_install_canvas_compat()`` once at module top before any
``st_canvas`` invocation; Streamlit's script-rerun model means
this fires every page load but the ``_PATCHED`` guard makes
re-runs free.

The shim wraps the old ``width=int`` arg into a default-constructed
``LayoutConfig()`` — the old ``width=-1`` sentinel meant "use
the image's natural width", which is also what an unconfigured
LayoutConfig produces. Confirmed by inspecting Streamlit 1.57.0's
``image_utils.py``.

4 new tests pin the shim contract:

- ``install()`` attaches ``image_to_url`` to the old path on modern
  Streamlit
- Idempotent — calling twice doesn't double-wrap
- Doesn't clobber a future Streamlit that restores the original
  at the old path
- Translates ``(image, -1, False, "RGB", "PNG", "id")`` into a
  proper call to the new function with a ``LayoutConfig`` instance

If a future Streamlit upgrade moves ``image_to_url`` AGAIN, the
shim's silent-no-op fallback means the canvas error surfaces
again and points at where to look. The shim doesn't paper over
mysteries; it only patches the one specific relocation we know
about.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-19 23:29:20 +00:00
parent e6ee2e3481
commit 10015c40e1
3 changed files with 209 additions and 0 deletions

View File

@@ -0,0 +1,86 @@
"""Compatibility shim for streamlit-drawable-canvas on modern Streamlit.
``streamlit-drawable-canvas`` 0.9.3 (last release 2023) calls
``streamlit.elements.image.image_to_url(image, width, clamp,
channels, output_format, image_id)``. Streamlit ~1.30+ moved this
helper out of ``streamlit.elements.image`` and changed its
signature so the second positional argument is now a
``LayoutConfig`` dataclass instead of a plain ``int`` width.
The canvas package hasn't been updated, so on modern Streamlit
its very first call fails with::
AttributeError: module 'streamlit.elements.image'
has no attribute 'image_to_url'
This module re-attaches a wrapper at the old import path that
adapts the old call shape to the new function. Import it once
before any ``st_canvas`` call; idempotent.
The shim is opt-in (not auto-installed at module import) so the
audit log of "I patched a third-party internal" is visible in
``grep`` rather than silently happening on every page load.
"""
from __future__ import annotations
_PATCHED = False
def install() -> None:
"""Install the ``image_to_url`` compatibility shim.
Idempotent — safe to call multiple times. Returns silently
if the canvas package or Streamlit can't be imported (lets
the caller handle the "PDF deps missing" path on its own).
"""
global _PATCHED
if _PATCHED:
return
try:
import streamlit.elements.image as _old_image_module
except ImportError:
return
# Already present (old Streamlit, or already shimmed) — bail.
if hasattr(_old_image_module, "image_to_url"):
_PATCHED = True
return
try:
from streamlit.elements.lib.image_utils import (
image_to_url as _new_image_to_url,
)
from streamlit.elements.lib.layout_utils import LayoutConfig
except ImportError:
# ``image_to_url`` is in some other location we don't know
# about yet — let the canvas surface its own error so we
# learn where to look. Don't fail silently.
return
def _shim(
image,
width,
clamp,
channels,
output_format,
image_id,
) -> str:
"""Old API → new API. The old ``width=-1`` sentinel meant
"use the image's natural width", which is also the new
function's default behavior when ``LayoutConfig`` is left
unconfigured."""
layout = LayoutConfig()
return _new_image_to_url(
image,
layout,
clamp,
channels,
output_format,
image_id,
)
_old_image_module.image_to_url = _shim
_PATCHED = True

View File

@@ -30,6 +30,7 @@ if str(_project_root) not in sys.path:
sys.path.insert(0, str(_project_root))
from src.audit import log_event, log_page_open
from src.gui._drawable_canvas_compat import install as _install_canvas_compat
from src.gui.components import hide_streamlit_chrome, render_sticky_footer
from src.pdf_extract import (
PdfDependencyMissing,
@@ -39,6 +40,12 @@ from src.pdf_extract import (
render_page_image,
)
# streamlit-drawable-canvas 0.9.3 calls a Streamlit internal
# (``image_to_url``) that was relocated in Streamlit ~1.30+. The
# shim re-attaches the old import path with a signature adapter.
# See ``src/gui/_drawable_canvas_compat.py`` for the why.
_install_canvas_compat()
def _pdf_deps_status() -> tuple[bool, list[str]]:
"""Probe each runtime PDF dep without forcing the user to hit the