diff --git a/src/imwald/core/author_html.py b/src/imwald/core/author_html.py index 780689a..5d40f35 100644 --- a/src/imwald/core/author_html.py +++ b/src/imwald/core/author_html.py @@ -25,10 +25,14 @@ def avatar_img_or_placeholder( pic = safe_http_url(parsed.get("picture")) r = max(6, size_px // 5) if pic: - return ( + img = ( f'' ) + return ( + f'{img}' + ) return ( f'' f"{av}" - f'
' + f'' f'
{disp}
' f'
{npub_e} · {pk_s}
' f"{nip_line_html}{about_line_html}" - f"
" - ) - return ( - f'{inner}' + f"" + f"" ) + return inner def thread_reply_author_row_html( @@ -91,16 +94,15 @@ def thread_reply_author_row_html( inner = ( '
' f"{av}" - '
' + f'' + '
' f'k{int(kind)}   ' f'{name}   ' f'{npub_e}' - "
" - ) - return ( - f'{inner}' + "
" ) + return inner def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, npub_tooltip: str, badge_style: str) -> str: @@ -111,8 +113,10 @@ def inline_profile_badge_html(parsed: dict[str, str | None], pubkey_hex: str, np img = "" if pic: img = ( + f'' f'' + "" ) inner = f"{img}@{name}" return f'{inner}' diff --git a/src/imwald/core/display_constants.py b/src/imwald/core/display_constants.py new file mode 100644 index 0000000..deb3726 --- /dev/null +++ b/src/imwald/core/display_constants.py @@ -0,0 +1,3 @@ +"""Shared display limits for HTML fragments and Qt rich text.""" + +IMAGE_DISPLAY_MAX_WIDTH_PX = 400 diff --git a/src/imwald/core/md_render.py b/src/imwald/core/md_render.py index 01b273e..ee3f017 100644 --- a/src/imwald/core/md_render.py +++ b/src/imwald/core/md_render.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING, cast import nh3 from imwald.core.database import Database +from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX from imwald.core.nip30_emoji import preprocess_nip30_emoji_markdown, nip30_emoji_urls_from_tags from imwald.core.nostr_entity_render import preprocess_nostr_entities @@ -62,6 +63,9 @@ _NH3_STYLE_FILTER = frozenset({ "gap", "object-fit", "vertical-align", + "cursor", + "text-decoration", + "box-sizing", }) @@ -78,6 +82,11 @@ def _nh3_attributes() -> dict[str, set[str]]: if img_a is not None: img_a.add("style") img_a.add("class") + a_a = raw.get("a") + if a_a is not None: + a_a.add("class") + a_a.add("title") + a_a.add("style") _nh3_attrs_merged = dict(raw) return _nh3_attrs_merged @@ -90,6 +99,65 @@ def _nh3_clean(html: str) -> str: ) +_IMG_TAG_RE = re.compile(r"]*?)(?:/>|>)", re.IGNORECASE) + + +def _parse_img_width_height(attr_blob: str) -> tuple[int | None, int | None]: + """Parse numeric ``width`` / ``height`` from an ```` attribute blob (best-effort).""" + w_m = re.search(r"""\bwidth\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))\b""", attr_blob, re.I) + h_m = re.search(r"""\bheight\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))\b""", attr_blob, re.I) + w = None + h = None + if w_m: + w = int(next(g for g in w_m.groups() if g is not None)) + if h_m: + h = int(next(g for g in h_m.groups() if g is not None)) + return w, h + + +def _parse_img_src(attr_blob: str) -> str | None: + m = re.search(r"""\bsrc\s*=\s*("([^"]*)"|'([^']*)')""", attr_blob, re.I) + if not m: + return None + return m.group(2) or m.group(3) or None + + +def _wrap_http_images_for_fullsize(fragment: str) -> str: + """ + Wrap ```` in ```` so QTextBrowser can open the image URL. + + Skips tiny square thumbnails (both ``width`` and ``height`` ≤ 64) to avoid double-linking + small UI avatars that already sit inside other anchors. + """ + out: list[str] = [] + last = 0 + for m in _IMG_TAG_RE.finditer(fragment): + start, end = m.span() + attr_blob = m.group(1) + full_tag = fragment[start:end] + out.append(fragment[last:start]) + last = end + src = _parse_img_src(attr_blob) + if not src or not src.startswith(("http://", "https://")): + out.append(full_tag) + continue + pre = fragment[max(0, start - 220) : start] + if re.search(r']*class="[^"\']*imwald-fullimg[^"\']*"[^>]*>\s*$', pre, re.I): + out.append(full_tag) + continue + w, h = _parse_img_width_height(attr_blob) + if w is not None and h is not None and w <= 64 and h <= 64: + out.append(full_tag) + continue + href = html.escape(src, quote=True) + out.append( + f'{full_tag}' + ) + out.append(fragment[last:]) + return "".join(out) + + def _marked_quickjs_ctx() -> Context | None: """Singleton QuickJS context with ``marked`` loaded, or None if unavailable.""" global _qjs_ctx, _marked_load_failed @@ -182,7 +250,8 @@ def markdown_html_fragment( raw = _render_marked_js(md) if raw is None: raw = _render_markdown_fallback(md) - return _nh3_clean(raw) + cleaned = _nh3_clean(raw) + return _nh3_clean(_wrap_http_images_for_fullsize(cleaned)) def markdown_plain_summary( @@ -231,15 +300,17 @@ def markdown_to_plain_text( return plain.strip() -_PREVIEW_CSS = """""" diff --git a/src/imwald/core/media_compress.py b/src/imwald/core/media_compress.py new file mode 100644 index 0000000..26dd143 --- /dev/null +++ b/src/imwald/core/media_compress.py @@ -0,0 +1,265 @@ +"""Resize + compress images and (optionally) audio/video before upload or local storage.""" + +from __future__ import annotations + +import io +import logging +import mimetypes +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +if TYPE_CHECKING: + from imwald.core.database import Database + +log = logging.getLogger(__name__) + +SETTING_MEDIA_COMPRESS_STRENGTH = "media_compress_strength" # "0" light, "1" balanced, "2" strong +SETTING_MEDIA_MAX_IMAGE_WIDTH = "media_max_image_width" # pixels, default 1000 + +MEDIA_STORE_MAX_WIDTH_DEFAULT = 1000 + +MediaStrength = Literal[0, 1, 2] + + +def read_compression_strength(db: Database | None) -> MediaStrength: + """0 = light (larger files), 1 = balanced, 2 = strong (smaller files).""" + if db is None: + return 1 + raw = (db.get_setting(SETTING_MEDIA_COMPRESS_STRENGTH, "1") or "1").strip() + try: + v = int(raw) + except ValueError: + v = 1 + v = max(0, min(2, v)) + return cast(MediaStrength, v) + + +def read_max_image_width(db: Database | None) -> int: + if db is None: + return MEDIA_STORE_MAX_WIDTH_DEFAULT + raw = (db.get_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, str(MEDIA_STORE_MAX_WIDTH_DEFAULT)) or "").strip() + try: + w = int(raw) + except ValueError: + return MEDIA_STORE_MAX_WIDTH_DEFAULT + return max(320, min(w, 8192)) + + +def _strength_params(level: MediaStrength) -> dict[str, int | str]: + if level == 0: + return {"jpeg": 90, "png": 6, "video_crf": 20, "audio_abr": "128k"} + if level == 1: + return {"jpeg": 78, "png": 9, "video_crf": 23, "audio_abr": "96k"} + return {"jpeg": 66, "png": 9, "video_crf": 28, "audio_abr": "64k"} + + +def _guess_kind(filename: str, data: bytes) -> Literal["image", "video", "audio", "unknown"]: + ext = Path(filename).suffix.lower() + audio_ext = frozenset({".mp3", ".m4a", ".aac", ".opus", ".ogg", ".oga", ".flac", ".wav"}) + video_ext = frozenset({".mp4", ".mkv", ".mov", ".avi", ".m4v"}) + if ext in audio_ext: + return "audio" + if ext == ".webm": + if data.startswith(b"\x1a\x45\xdf\xa3"): + return "video" + return "audio" + if ext in video_ext: + return "video" + if ext in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff", ".avif"}: + return "image" + if data[:8] == b"\x89PNG\r\n\x1a\n" or data[:2] == b"\xff\xd8" or data[:6] in (b"GIF87a", b"GIF89a"): + return "image" + guessed, _ = mimetypes.guess_type(filename) + if guessed: + if guessed.startswith("image/"): + return "image" + if guessed.startswith("video/"): + return "video" + if guessed.startswith("audio/"): + return "audio" + return "unknown" + + +def _ffmpeg_available() -> bool: + return shutil.which("ffmpeg") is not None + + +def compress_image_bytes( + data: bytes, + *, + max_width: int, + strength: MediaStrength, + original_name: str, +) -> tuple[bytes, str, str]: + """ + Downscale to ``max_width`` on the wide edge (if wider), then compress. + + Returns ``(bytes, mime_type, filename)``. + """ + from PIL import Image, ImageOps # type: ignore[import-not-found, import-untyped] + p = _strength_params(strength) + jpeg_q = int(p["jpeg"]) + png_level = int(p["png"]) + + bio = io.BytesIO(data) + im_t = Image.open(bio) + im_t = ImageOps.exif_transpose(im_t) + im_t.load() + w0, _h0 = im_t.size + if w0 > max_width: + nh = max(1, int(round(im_t.size[1] * (max_width / float(w0))))) + im_t = im_t.resize((max_width, nh), Image.Resampling.LANCZOS) # pyright: ignore[reportUnknownMemberType] + im = im_t + + has_alpha = im.mode in ("RGBA", "LA") or (im.mode == "P" and "transparency" in im.info) + stem = Path(original_name).stem or "image" + if has_alpha: + out = im.convert("RGBA") + buf = io.BytesIO() + out.save(buf, format="PNG", compress_level=png_level, optimize=True) + blob = buf.getvalue() + return blob, "image/png", f"{stem}.png" + + rgb = im.convert("RGB") + buf = io.BytesIO() + rgb.save(buf, format="JPEG", quality=jpeg_q, optimize=True, progressive=True) + blob = buf.getvalue() + return blob, "image/jpeg", f"{stem}.jpg" + + +def _compress_video_ffmpeg( + src: Path, + dst: Path, + *, + max_width: int, + strength: MediaStrength, +) -> bool: + p = _strength_params(strength) + crf = int(p["video_crf"]) + abr = str(p["audio_abr"]) + vf = f"scale=min({max_width}\\,iw):-2" + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(src), + "-vf", + vf, + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + str(crf), + "-c:a", + "aac", + "-b:a", + abr, + "-movflags", + "+faststart", + str(dst), + ] + try: + subprocess.run(cmd, check=True, capture_output=True, timeout=600) + except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as e: + log.warning("ffmpeg video compress failed: %s", e) + return False + return dst.is_file() and dst.stat().st_size > 0 + + +def _compress_audio_ffmpeg(src: Path, out_m4a: Path, *, strength: MediaStrength) -> bool: + p = _strength_params(strength) + abr = str(p["audio_abr"]) + cmd = [ + "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + str(src), + "-vn", + "-c:a", + "aac", + "-b:a", + abr, + str(out_m4a), + ] + try: + subprocess.run(cmd, check=True, capture_output=True, timeout=600) + except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as e: + log.warning("ffmpeg audio compress failed: %s", e) + return False + return out_m4a.is_file() and out_m4a.stat().st_size > 0 + + +def prepare_bytes_for_storage( + data: bytes, + filename: str, + *, + mime_hint: str | None, + db: Database | None, + strength: MediaStrength | None = None, +) -> tuple[bytes, str, str]: + """ + Apply user-configured compression before upload or writing ``media_cache``. + + Returns ``(blob, mime_type, filename)``. On failure, returns the original bytes. + """ + if not data: + return data, mime_hint or "application/octet-stream", filename + + lvl: MediaStrength = strength if strength is not None else read_compression_strength(db) + max_w = read_max_image_width(db) + kind = _guess_kind(filename, data) + mime = mime_hint or mimetypes.guess_type(filename)[0] or "application/octet-stream" + + if kind == "image": + try: + out_b, out_m, out_n = compress_image_bytes( + data, max_width=max_w, strength=lvl, original_name=filename + ) + return out_b, out_m, out_n + except Exception as e: # noqa: BLE001 + log.warning("image compress skipped: %s", e) + return data, mime, filename + + if kind in ("video", "audio") and not _ffmpeg_available(): + log.info("ffmpeg not found; skipping %s compression", kind) + return data, mime, filename + + if kind == "video" and _ffmpeg_available(): + suf = Path(filename).suffix.lower() or ".mp4" + with tempfile.TemporaryDirectory() as td: + td_path = Path(td) + src = td_path / f"in{suf}" + dst = td_path / "out.mp4" + src.write_bytes(data) + if _compress_video_ffmpeg(src, dst, max_width=max_w, strength=lvl): + out_b = dst.read_bytes() + stem = Path(filename).stem or "video" + if len(out_b) < len(data): + return out_b, "video/mp4", f"{stem}.mp4" + return data, mime, filename + + if kind == "audio" and _ffmpeg_available(): + suf = Path(filename).suffix.lower() or ".m4a" + with tempfile.TemporaryDirectory() as td: + td_path = Path(td) + src = td_path / f"in{suf}" + dst = td_path / "out.m4a" + src.write_bytes(data) + if _compress_audio_ffmpeg(src, dst, strength=lvl): + out_b = dst.read_bytes() + stem = Path(filename).stem or "audio" + if len(out_b) < len(data): + return out_b, "audio/mp4", f"{stem}.m4a" + return data, mime, filename + + return data, mime, filename diff --git a/src/imwald/core/nostr_engine.py b/src/imwald/core/nostr_engine.py index f3e2bc6..0a3f69b 100644 --- a/src/imwald/core/nostr_engine.py +++ b/src/imwald/core/nostr_engine.py @@ -282,10 +282,14 @@ class NostrEngine(QObject): tags0: list[list[str]] = [["client", "imwald"]] try: from imwald.core.forest_avatar import build_forest_avatar_png + from imwald.core.media_compress import prepare_bytes_for_storage from imwald.core.nostr_nip96_upload import nip94_tags_to_imeta, upload_image_nip96_nostr_build png = build_forest_avatar_png() - url, nip94_tags = upload_image_nip96_nostr_build(sec, png) + blob, mime, fname = prepare_bytes_for_storage( + png, "imwald-forest-avatar.png", mime_hint="image/png", db=self.db + ) + url, nip94_tags = upload_image_nip96_nostr_build(sec, blob, filename=fname, mime=mime) profile["picture"] = url tags0.append(nip94_tags_to_imeta(nip94_tags)) except Exception as e: # noqa: BLE001 diff --git a/src/imwald/ui/feed_page.py b/src/imwald/ui/feed_page.py index 3b20b95..b23dfe6 100644 --- a/src/imwald/ui/feed_page.py +++ b/src/imwald/ui/feed_page.py @@ -26,6 +26,7 @@ from PySide6.QtWidgets import ( from imwald.core.author_html import feed_op_author_block_html, thread_reply_author_row_html from imwald.core.database import Database +from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX from imwald.core.kind0_profile import parse_kind0_profile from imwald.core.md_render import markdown_html_fragment, markdown_to_plain_text from imwald.core.nip19 import encode_npub @@ -110,9 +111,12 @@ def _format_engagement_html( if u: esc_u = html.escape(u, quote=True) esc_alt = html.escape(raw_disp, quote=True) + mx = IMAGE_DISPLAY_MAX_WIDTH_PX e = ( + f'' f'{esc_alt}' + f'style="vertical-align:middle;max-height:1.35em;max-width:{mx}px;width:auto" />' + f"" ) else: e = html.escape(raw_disp, quote=False) @@ -161,6 +165,7 @@ class FeedPage(QWidget): eng_layout.setContentsMargins(0, 0, 0, 0) self._engagement_label = QLabel("") self._engagement_label.setTextFormat(Qt.TextFormat.RichText) + self._engagement_label.setOpenExternalLinks(True) self._engagement_label.setWordWrap(True) eng_layout.addWidget(self._engagement_label) self._engagement_label.installEventFilter(self) diff --git a/src/imwald/ui/main_window.py b/src/imwald/ui/main_window.py index d9b3000..614034b 100644 --- a/src/imwald/ui/main_window.py +++ b/src/imwald/ui/main_window.py @@ -9,6 +9,8 @@ from PySide6.QtGui import QAction, QCloseEvent, QColor, QIcon, QPainter, QPen, Q from PySide6.QtWidgets import ( QComboBox, QDialog, + QDialogButtonBox, + QFormLayout, QInputDialog, QLabel, QLineEdit, @@ -16,6 +18,7 @@ from PySide6.QtWidgets import ( QListWidgetItem, QMainWindow, QMessageBox, + QSpinBox, QSplitter, QStackedWidget, QTabWidget, @@ -29,6 +32,13 @@ from imwald.core.database import Database from imwald.core.kind0_profile import parse_kind0_profile from imwald.core.nostr_engine import AUTHOR_METADATA_KINDS, NostrEngine from imwald.core.md_render import markdown_plain_summary +from imwald.core.media_compress import ( + MEDIA_STORE_MAX_WIDTH_DEFAULT, + SETTING_MEDIA_COMPRESS_STRENGTH, + SETTING_MEDIA_MAX_IMAGE_WIDTH, + read_compression_strength, + read_max_image_width, +) from imwald.core.relay_list import resolve_for_account from imwald.core.relay_policy import augment_feed_with_trending from imwald.ui.composer_dialog import ComposerDialog @@ -329,6 +339,9 @@ class MainWindow(QMainWindow): a_db = QAction("&Local database…", self) a_db.triggered.connect(lambda: self._go_stack_page(3)) m_tools.addAction(a_db) + a_media = QAction("&Media compression…", self) + a_media.triggered.connect(self._open_media_compression_dialog) + m_tools.addAction(a_media) m_help = self.menuBar().addMenu("&Help") a_about = QAction("&About", self) @@ -341,6 +354,43 @@ class MainWindow(QMainWindow): ) m_help.addAction(a_about) + def _open_media_compression_dialog(self) -> None: + dlg = QDialog(self) + dlg.setWindowTitle("Media compression") + lay = QVBoxLayout(dlg) + form = QFormLayout() + combo = QComboBox() + combo.addItem("Light — larger files, higher quality", 0) + combo.addItem("Balanced — default", 1) + combo.addItem("Strong — smaller files, more loss", 2) + cur = read_compression_strength(self._db) + idx = combo.findData(int(cur)) + combo.setCurrentIndex(max(0, idx)) + spin = QSpinBox() + spin.setRange(320, 8192) + spin.setSingleStep(40) + spin.setValue(read_max_image_width(self._db)) + spin.setToolTip("Images are scaled down to this width (if wider) before JPEG/PNG compression.") + form.addRow("Strength (images + ffmpeg A/V)", combo) + form.addRow("Max image / video scale width (px)", spin) + lay.addLayout(form) + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel, + ) + buttons.accepted.connect(dlg.accept) + buttons.rejected.connect(dlg.reject) + lay.addWidget(buttons) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + self._db.set_setting(SETTING_MEDIA_COMPRESS_STRENGTH, str(int(combo.currentData()))) + self._db.set_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, str(int(spin.value()))) + QMessageBox.information( + self, + "Media compression", + "Settings saved. They apply to the next upload (e.g. new profile picture via onboarding). " + f"Default max width is {MEDIA_STORE_MAX_WIDTH_DEFAULT}px if you reset the database.", + ) + def _wire_engine(self) -> None: self._engine.event_ingested.connect(self._on_event_ingested) self._engine.relay_status.connect(self._relay_status_message) diff --git a/src/imwald/ui/profile_page.py b/src/imwald/ui/profile_page.py index 6fbbae7..a19b207 100644 --- a/src/imwald/ui/profile_page.py +++ b/src/imwald/ui/profile_page.py @@ -11,6 +11,7 @@ from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import QFrame, QScrollArea, QTabWidget, QVBoxLayout, QWidget from imwald.core.author_html import avatar_img_or_placeholder +from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX from imwald.core.database import Database from imwald.core.kind0_profile import display_name_from_profile_or_hex, parse_kind0_profile from imwald.core.profile_lnurl import build_merged_lnurl_pay_section, collect_unique_lnurlp_urls @@ -145,9 +146,13 @@ class ProfilePage(QWidget): banner_html = "" if banner and str(banner).strip().startswith("https://"): bu = html.escape(str(banner).strip(), quote=True) + bw = IMAGE_DISPLAY_MAX_WIDTH_PX banner_html = ( - f"
" - f'
' + f"
" + f'' + f'' + f"
" ) about_raw = (parsed.get("about") or "").strip() diff --git a/src/imwald/ui/relay_status_panel.py b/src/imwald/ui/relay_status_panel.py index 54fc203..b2c7c7d 100644 --- a/src/imwald/ui/relay_status_panel.py +++ b/src/imwald/ui/relay_status_panel.py @@ -173,7 +173,7 @@ class RelayStatusPanel(QWidget): relay_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) relay_scroll.setFrameShape(QFrame.Shape.NoFrame) relay_scroll.setStyleSheet(f"QScrollArea {{ background: transparent; border: none; }}") - relay_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred) + relay_scroll.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Expanding) self._log = QTextEdit() self._log.setReadOnly(True) @@ -192,9 +192,10 @@ class RelayStatusPanel(QWidget): split.setChildrenCollapsible(False) split.addWidget(relay_scroll) split.addWidget(self._log) - split.setStretchFactor(0, 0) + # Relays ~2/3 height, log ~1/3 when the panel is resized. + split.setStretchFactor(0, 2) split.setStretchFactor(1, 1) - split.setSizes([200, 360]) + split.setSizes([400, 200]) root.addWidget(split, stretch=1) self.setMaximumWidth(360) diff --git a/src/imwald/ui/theme.py b/src/imwald/ui/theme.py index d25b221..33fde54 100644 --- a/src/imwald/ui/theme.py +++ b/src/imwald/ui/theme.py @@ -4,6 +4,8 @@ from __future__ import annotations from PySide6.QtWidgets import QApplication +from imwald.core.display_constants import IMAGE_DISPLAY_MAX_WIDTH_PX + # Shared with HTML fragments (feed header, markdown preview). TEXT = "#dceee6" TEXT_MUTED = "#8fb0a3" @@ -17,6 +19,7 @@ BG_CARD = "#151f1a" BORDER = "#2a3d34" BG_CODE = "#0a100d" +_W = IMAGE_DISPLAY_MAX_WIDTH_PX FEED_DOC_CSS = f""" """ diff --git a/tests/test_md_render.py b/tests/test_md_render.py index 72f1e03..c226712 100644 --- a/tests/test_md_render.py +++ b/tests/test_md_render.py @@ -21,3 +21,10 @@ def test_preprocess_turns_bare_image_url_into_markdown_image() -> None: md = "hello\nhttps://example.com/x.png\nbye" out = preprocess_standalone_image_urls(md) assert "![](https://example.com/x.png)" in out + + +def test_markdown_image_wrapped_for_fullsize_link() -> None: + html = markdown_html_fragment("![](https://example.com/wide.png)") + assert "imwald-fullimg" in html + assert "https://example.com/wide.png" in html + assert " bytes: + im = Image.new("RGB", (1600, 400), color=(200, 30, 30)) + buf = io.BytesIO() + im.save(buf, format="PNG", compress_level=3) + return buf.getvalue() + + +def test_compress_image_scales_to_max_width() -> None: + raw = _rgb_wide_png() + out, mime, name = compress_image_bytes(raw, max_width=1000, strength=1, original_name="x.png") + assert mime == "image/jpeg" + assert name.endswith(".jpg") + im = Image.open(io.BytesIO(out)) + assert im.size[0] <= 1000 + + +def test_strength_stronger_yields_smaller_or_equal_jpeg() -> None: + raw = _rgb_wide_png() + light, _, _ = compress_image_bytes(raw, max_width=1000, strength=0, original_name="w.png") + strong, _, _ = compress_image_bytes(raw, max_width=1000, strength=2, original_name="w.png") + assert len(strong) <= len(light) + + +def test_read_settings_from_db() -> None: + with tempfile.TemporaryDirectory() as td: + db = Database(Path(td) / "m.sqlite") + db.connect() + assert read_compression_strength(db) == 1 + assert read_max_image_width(db) == 1000 + db.set_setting(SETTING_MEDIA_COMPRESS_STRENGTH, "2") + db.set_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, "1200") + assert read_compression_strength(db) == 2 + assert read_max_image_width(db) == 1200 + + +def test_prepare_bytes_image_round_trip() -> None: + raw = _rgb_wide_png() + with tempfile.TemporaryDirectory() as td: + db = Database(Path(td) / "m2.sqlite") + db.connect() + db.set_setting(SETTING_MEDIA_MAX_IMAGE_WIDTH, "800") + out, mime, _name = prepare_bytes_for_storage( + raw, "wide.png", mime_hint="image/png", db=db, strength=1 + ) + assert mime == "image/jpeg" + im = Image.open(io.BytesIO(out)) + assert im.size[0] <= 800