From ccd4a88b59a595f263e3b8c33436da87d05ea42f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:11:44 +0000 Subject: [PATCH 1/6] Bump the pip-all group with 4 updates Bumps the pip-all group with 4 updates: [langchain-core](https://github.com/langchain-ai/langchain), [langchain-perplexity](https://github.com/langchain-ai/langchain), [python-multipart](https://github.com/Kludex/python-multipart) and [ruff](https://github.com/astral-sh/ruff). Updates `langchain-core` from 1.4.0 to 1.4.1 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-core==1.4.0...langchain-core==1.4.1) Updates `langchain-perplexity` from 1.3.1 to 1.3.2 - [Release notes](https://github.com/langchain-ai/langchain/releases) - [Commits](https://github.com/langchain-ai/langchain/compare/langchain-perplexity==1.3.1...langchain-perplexity==1.3.2) Updates `python-multipart` from 0.0.30 to 0.0.32 - [Release notes](https://github.com/Kludex/python-multipart/releases) - [Changelog](https://github.com/Kludex/python-multipart/blob/main/CHANGELOG.md) - [Commits](https://github.com/Kludex/python-multipart/compare/0.0.30...0.0.32) Updates `ruff` from 0.15.15 to 0.15.16 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.15.15...0.15.16) --- updated-dependencies: - dependency-name: langchain-core dependency-version: 1.4.1 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip-all - dependency-name: langchain-perplexity dependency-version: 1.3.2 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip-all - dependency-name: python-multipart dependency-version: 0.0.32 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: pip-all - dependency-name: ruff dependency-version: 0.15.16 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: pip-all ... Signed-off-by: dependabot[bot] --- Pipfile.lock | 56 ++++++++++++++++++++++++++-------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 460f1619..e5b8b255 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1111,12 +1111,12 @@ }, "langchain-core": { "hashes": [ - "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", - "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c" + "sha256:8234eb8cd3200f690e278159b7d7cee5976381ec90ece7b48db8d8e8850ab37d", + "sha256:e5dee06e70c123cb98cb0158e4416efac1e386ff47a484901ccf88555e28eec6" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.4.0" + "version": "==1.4.1" }, "langchain-google-genai": { "hashes": [ @@ -1138,12 +1138,12 @@ }, "langchain-perplexity": { "hashes": [ - "sha256:6bad128417c3841e5aa10458760247fa98f4f2bc98b1d247815aaf463ce9b53a", - "sha256:f9c0e2cf24a636d4bf90ac46e0c36cfe506b7f83f08d181c80a57194068f54e6" + "sha256:b806e62ccabc5dfc385c7afeee05a48b32c2553505227e92807053389e8f70c9", + "sha256:ba9424c0447906704a6d1a49003689e29480291c8716311934ab305990dd0fc3" ], "index": "pypi", "markers": "python_full_version >= '3.10.0' and python_full_version < '4.0.0'", - "version": "==1.3.1" + "version": "==1.3.2" }, "langchain-protocol": { "hashes": [ @@ -2415,12 +2415,12 @@ }, "python-multipart": { "hashes": [ - "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118", - "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b" + "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", + "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23" ], "index": "pypi", "markers": "python_version >= '3.10'", - "version": "==0.0.30" + "version": "==0.0.32" }, "pyuploadcare": { "hashes": [ @@ -3968,28 +3968,28 @@ }, "ruff": { "hashes": [ - "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", - "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", - "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", - "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", - "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", - "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", - "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", - "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", - "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", - "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", - "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", - "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", - "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", - "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", - "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", - "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", - "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", - "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd" + "sha256:197c207ed75ffba54a0dec23db4aa939a27a3053073e085e0042433cbdc58e4a", + "sha256:1e15bc8c94513dae2a40cc9ef07c94fdd4ecc9e29dabebeebe170f952322c9e3", + "sha256:3a39fec45ab316cc23e7558f23fea4a70403ddb5648ea9a4a3854a16973d0071", + "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", + "sha256:4e4215bc938bc3c8215c1472c1aa437e310fee20cd427335fec9d7e609563628", + "sha256:528c68f39a91498a8d50e91ff5985df3d105782bab49cc378e73ac26bff083e8", + "sha256:580378f7bd4aa25f72e74aa54948a9622f142b1e509521dd10902e886681cc1e", + "sha256:6ac3c0b3969cc6cf6b158c4e2f8f682acb58e7d700d8a44b65ecdc72d66ab0b2", + "sha256:7c8d26be963b090f10e29abc8b3e74a2a321f6fa34e02424e30b5af89350ecbb", + "sha256:7ed55c58950df60589a9a7a5d2f8fa5f54ebd287163be805adfe6ee95a9de123", + "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", + "sha256:a267c46ba1593fc26b8eecbea050b39d40c0b6bb7781ee11c90a02cd10032951", + "sha256:ba93191d79003116b95128c9d306e045200fdbd0bccb782b110f3cd1d4abc5cf", + "sha256:bb27515fa6240fb586ae82b901a59e67d24acff86f2190b433dc542fe0435aeb", + "sha256:c6ee4b90520630120ef032aa5cc10db483852dff950e78b1d717e2993a61ac8d", + "sha256:d05e78d38c78caf020b03789e25106c93017db5a0cb6e2819885018c61343b78", + "sha256:d482feaf51512b50f9790ceb417a56a61dd1e9d9bf967662b9ed27c01b34f53a", + "sha256:f198cf4123602a2280ed46c307bcbafe41758d6fee5b456b6b6058ca1514b3b4" ], "index": "pypi", "markers": "python_version >= '3.7'", - "version": "==0.15.15" + "version": "==0.15.16" }, "the-agent": { "editable": true, From 3d75886e73bac7d52b32293ac081aac5610dce35 Mon Sep 17 00:00:00 2001 From: the-agent-abot Date: Sat, 6 Jun 2026 08:13:06 +0000 Subject: [PATCH 2/6] Auto-lock: Update dependencies --- Pipfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index e5b8b255..4ac49d80 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -186,11 +186,11 @@ }, "anthropic": { "hashes": [ - "sha256:0e26b90841c2dced7cc6e98d21d5517d0be33f1876b8e779f478202e28bcaa07", - "sha256:e53ed5f6bf36fb1ecb9b25d8634cfd30e02fab9fb3374a0c2d5c585874757230" + "sha256:d0e4a7448e54c3942833cee5b3de5f1b31289fd49999bfbcc2ec0c0acaddf75f", + "sha256:f26e2645e31f66eff526b923f539b80b4b6eda1a918790cd77c0afe5e24a2203" ], "markers": "python_version >= '3.9'", - "version": "==0.105.2" + "version": "==0.106.0" }, "anyio": { "hashes": [ @@ -2958,11 +2958,11 @@ }, "tqdm": { "hashes": [ - "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", - "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf" + "sha256:fc163d96b287bd031e1aa24421ce4411b25559bd0a1be4fe649bdaa4d2c02bf5", + "sha256:fea4a90e4023f764914569f7802a297277c5ab1a66be5144143e142e1a4031d8" ], "markers": "python_version >= '3.7'", - "version": "==4.67.3" + "version": "==4.68.1" }, "typing-extensions": { "hashes": [ From e30a44826fa1274b3542a6edef00d7285cfe728b Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Sun, 7 Jun 2026 19:19:50 +0200 Subject: [PATCH 3/6] Enable link and reply previews on Twitter/X social screenshots --- src/di/di.py | 4 +- .../chat/llm_tools/llm_tool_library.py | 3 +- src/features/social_cards/card_layout.py | 8 +- src/features/social_cards/card_renderer.py | 4 + src/features/social_cards/card_template.py | 241 ++++------- src/features/social_cards/card_utils.py | 193 +++++++++ src/features/social_cards/embedded_post.py | 140 ++++++ src/features/social_cards/link_preview.py | 398 ++++++++++++++++++ .../social_cards/social_card_orchestrator.py | 62 ++- .../web_browsing/twitter_status_fetcher.py | 109 ++++- test/features/social_cards/test_card_utils.py | 205 +++++++++ .../test_social_card_orchestrator.py | 4 +- .../test_twitter_status_fetcher.py | 217 +++++++++- 13 files changed, 1411 insertions(+), 177 deletions(-) create mode 100644 src/features/social_cards/card_utils.py create mode 100644 src/features/social_cards/embedded_post.py create mode 100644 src/features/social_cards/link_preview.py create mode 100644 test/features/social_cards/test_card_utils.py diff --git a/src/di/di.py b/src/di/di.py index 27e9e0e8..65e28b38 100644 --- a/src/di/di.py +++ b/src/di/di.py @@ -841,9 +841,9 @@ def photo_downloader(self, bearer_token: str | None = None) -> "PhotoDownloader" return PhotoDownloader(bearer_token = bearer_token) # noinspection PyMethodMayBeStatic - def social_card_orchestrator(self, x_api_tool: ConfiguredTool) -> "SocialCardOrchestrator": + def social_card_orchestrator(self, x_api_tool: ConfiguredTool, vision_tool: ConfiguredTool) -> "SocialCardOrchestrator": from features.social_cards.social_card_orchestrator import SocialCardOrchestrator - return SocialCardOrchestrator(x_api_tool, self) + return SocialCardOrchestrator(x_api_tool, vision_tool, self) def url_shortener( self, diff --git a/src/features/chat/llm_tools/llm_tool_library.py b/src/features/chat/llm_tools/llm_tool_library.py index fadefe26..9c285a5e 100644 --- a/src/features/chat/llm_tools/llm_tool_library.py +++ b/src/features/chat/llm_tools/llm_tool_library.py @@ -496,7 +496,8 @@ def render_social_post(di: DI, url: str) -> str: """ try: x_api_tool = di.tool_choice_resolver.require_tool(SocialCardOrchestrator.TOOL_TYPE, default_tool_for(SocialCardOrchestrator.TOOL_TYPE)) - image_url = di.social_card_orchestrator(x_api_tool).execute(url) + vision_tool = di.tool_choice_resolver.require_tool(SocialCardOrchestrator.VISION_TOOL_TYPE, default_tool_for(SocialCardOrchestrator.VISION_TOOL_TYPE)) + image_url = di.social_card_orchestrator(x_api_tool, vision_tool).execute(url) invoker_chat = di.require_invoker_chat() di.platform_bot_sdk().smart_send_photo( media_mode = invoker_chat.media_mode, diff --git a/src/features/social_cards/card_layout.py b/src/features/social_cards/card_layout.py index 4fb34091..68faffae 100644 --- a/src/features/social_cards/card_layout.py +++ b/src/features/social_cards/card_layout.py @@ -12,10 +12,10 @@ HEADER_HEIGHT = AVATAR_SIZE + CARD_INNER_PAD DIVIDER_OPACITY = 0.2 FOOTER_OPACITY = 0.45 -FONT_SIZE_NAME = 20 -FONT_SIZE_DATE = 15 -FONT_SIZE_BODY = 22 -FONT_SIZE_FOOTER = 14 +FONT_SIZE_NAME = 22 +FONT_SIZE_DATE = 16 +FONT_SIZE_BODY = 26 +FONT_SIZE_FOOTER = 16 LINE_HEIGHT_BODY = 32 # pixels per line in body text DROP_SHADOW_BLUR = 10 DROP_SHADOW_DY = 6 diff --git a/src/features/social_cards/card_renderer.py b/src/features/social_cards/card_renderer.py index bff31dc7..b87af31b 100644 --- a/src/features/social_cards/card_renderer.py +++ b/src/features/social_cards/card_renderer.py @@ -17,6 +17,8 @@ def render( profile_bytes: bytes | None = None, media_bytes: list[bytes] | None = None, short_url: str | None = None, + link_preview_data: list[dict] | None = None, + quoted_tweet_data: dict | None = None, ) -> bytes: media = media_bytes or [] card_width = card_width_from_text(tweet.text) @@ -27,6 +29,8 @@ def render( profile_bytes = profile_bytes, media_bytes = media, short_url = short_url, + link_preview_data = link_preview_data or [], + quoted_tweet_data = quoted_tweet_data, ) font_files = [str(p) for p in _FONTS_DIR.glob("*.ttf") if p.is_file()] return resvg_py.svg_to_bytes( diff --git a/src/features/social_cards/card_template.py b/src/features/social_cards/card_template.py index 64d343a1..e7decef6 100644 --- a/src/features/social_cards/card_template.py +++ b/src/features/social_cards/card_template.py @@ -3,9 +3,8 @@ import re import urllib.request from datetime import datetime, timezone -from pathlib import Path -from PIL import Image, ImageFont +from PIL import Image from features.social_cards.card_layout import ( AVATAR_GAP, @@ -30,66 +29,36 @@ PHOTO_GAP, X_ICON_SIZE, ) +from features.social_cards.card_utils import ( + FONT_NAME, + FONT_PATH, + b64_image, + emoji_split, + escape_xml, + image_mime, + render_text_segments, + rounded_rect_path, + text_width, +) +from features.social_cards.embedded_post import render_embedded_post +from features.social_cards.link_preview import render_link_previews from features.social_cards.theme import ThemeColors from features.web_browsing.twitter_status_fetcher import TweetData from util.config import config -_FONT_PATH = Path(config.fonts_dir) / "Heebo-Variable.ttf" -_FONT_NAME = "Heebo" -_EMOJI_FONT_NAME = "Noto Color Emoji" - _FONT_B64: str | None = None _LOGO_CACHE: dict[str, bytes] = {} _SPECIAL_TOKEN_RE = re.compile(r"(https?://\S+|www\.\S+|@\w+|#\w+|\$[A-Za-z]+)") -_EMOJI_RE = re.compile( - "(?:" - "[\U0001F1E6-\U0001F1FF]" # regional indicators (flags) - "|[\U0001F300-\U0001F5FF]" # misc symbols & pictographs - "|[\U0001F600-\U0001F64F]" # emoticons - "|[\U0001F680-\U0001F6FF]" # transport & map - "|[\U0001F700-\U0001F77F]" # alchemical - "|[\U0001F780-\U0001F7FF]" # geometric extended - "|[\U0001F800-\U0001F8FF]" # supplemental arrows-C - "|[\U0001F900-\U0001F9FF]" # supplemental symbols & pictographs - "|[\U0001FA00-\U0001FAFF]" # symbols & pictographs ext-A - "|[☀-➿]" # misc symbols & dingbats - "|[⌀-⏿]" # misc technical - "|[⬀-⯿]" # misc symbols & arrows - ")" - "[️‍\U0001F3FB-\U0001F3FF]*" # variation selector, ZWJ, skin tones - "(?:" - "(?:" - "[\U0001F1E6-\U0001F1FF]" - "|[\U0001F300-\U0001F5FF]" - "|[\U0001F600-\U0001F64F]" - "|[\U0001F680-\U0001F6FF]" - "|[\U0001F700-\U0001F77F]" - "|[\U0001F780-\U0001F7FF]" - "|[\U0001F800-\U0001F8FF]" - "|[\U0001F900-\U0001F9FF]" - "|[\U0001FA00-\U0001FAFF]" - "|[☀-➿]" - "|[⌀-⏿]" - "|[⬀-⯿]" - ")" - "[️‍\U0001F3FB-\U0001F3FF]*" - ")*", -) - def _font_b64() -> str: global _FONT_B64 if _FONT_B64 is None: - _FONT_B64 = base64.b64encode(_FONT_PATH.read_bytes()).decode("ascii") + _FONT_B64 = base64.b64encode(FONT_PATH.read_bytes()).decode("ascii") return _FONT_B64 -def _b64_image(data: bytes, mime: str = "image/jpeg") -> str: - return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" - - def _fetch_logo(key: str) -> bytes: if key not in _LOGO_CACHE: url = config.logos[key] @@ -123,15 +92,6 @@ def _accent_color(theme: ThemeColors) -> str: return f"#{r:02x}{g:02x}{b:02x}" -def _image_mime(data: bytes) -> str: - try: - img = Image.open(io.BytesIO(data)) - fmt = (img.format or "JPEG").upper() - return {"JPEG": "image/jpeg", "PNG": "image/png", "GIF": "image/gif", "WEBP": "image/webp"}.get(fmt, "image/jpeg") - except Exception: - return "image/jpeg" - - def _photo_natural_height(data: bytes, display_w: int) -> int: try: img = Image.open(io.BytesIO(data)) @@ -154,29 +114,6 @@ def _photo_sort_key(data: bytes) -> int: return 1 -def _pillow_font(size: int) -> ImageFont.FreeTypeFont: - return ImageFont.truetype(str(_FONT_PATH), size) - - -def _emoji_pillow_font(size: int) -> ImageFont.FreeTypeFont | None: - for p in Path(config.fonts_dir).glob("*.ttf"): - if "emoji" in p.name.lower() or "colr" in p.name.lower(): - return ImageFont.truetype(str(p), size) - return None - - -def _text_width(text: str, size: int) -> int: - font = _pillow_font(size) - return round(font.getlength(text)) - - -def _emoji_text_width(text: str, size: int) -> int: - emoji_font = _emoji_pillow_font(size) - if emoji_font is None: - return _text_width(text, size) - return round(emoji_font.getlength(text)) - - def _word_wrap(text: str, max_width: int, font_size: int) -> list[str]: lines: list[str] = [] for paragraph in text.splitlines(): @@ -187,7 +124,7 @@ def _word_wrap(text: str, max_width: int, font_size: int) -> list[str]: current = "" for word in words: candidate = (current + " " + word).strip() - if _text_width(candidate, font_size) <= max_width: + if text_width(candidate, font_size) <= max_width: current = candidate else: if current: @@ -198,53 +135,6 @@ def _word_wrap(text: str, max_width: int, font_size: int) -> list[str]: return lines or [""] -def _emoji_split(text: str) -> list[tuple[str, bool]]: - out: list[tuple[str, bool]] = [] - pos = 0 - for match in _EMOJI_RE.finditer(text): - s, e = match.span() - if s > pos: - out.append((text[pos:s], False)) - out.append((text[s:e], True)) - pos = e - if pos < len(text): - out.append((text[pos:], False)) - return out or [(text, False)] - - -def _segment_width(text: str, font_size: int, is_emoji: bool) -> int: - if is_emoji: - return _emoji_text_width(text, font_size) - return _text_width(text, font_size) - - -def _render_text_segments( - segments: list[tuple[str, str, str, bool]], - x: int, - y: int, - font_size: int, - fill_default: str, - weight: int = 400, -) -> tuple[list[str], int]: - """Render (text, fill, decoration, is_emoji) tuples as separate elements at computed x. - Avoids the usvg panic caused by font-family switches inside a single with flag emoji. - Bold is achieved via stroke since resvg's variable-font wght axis is inert below size 24.""" - out = [] - cur_x = x - for text, fill, decoration, is_emoji in segments: - family = _EMOJI_FONT_NAME if is_emoji else _FONT_NAME - applied_fill = fill or fill_default - bold_attrs = "" - if weight == 700 and not is_emoji: - bold_attrs = f' stroke="{applied_fill}" stroke-width="0.7" paint-order="stroke"' - out.append( - f'{_escape(text)}', - ) - cur_x += _segment_width(text, font_size, is_emoji) - return out, cur_x - - def _line_to_segments(line: str, normal_fill: str, accent: str) -> list[tuple[str, str, str, bool]]: segments: list[tuple[str, str, str, bool]] = [] parts = _SPECIAL_TOKEN_RE.split(line) @@ -254,7 +144,7 @@ def _line_to_segments(line: str, normal_fill: str, accent: str) -> list[tuple[st is_special = i % 2 == 1 fill = accent if is_special else normal_fill decoration = ' text-decoration="underline"' if is_special else "" - for sub_text, is_emoji in _emoji_split(part): + for sub_text, is_emoji in emoji_split(part): if sub_text: segments.append((sub_text, fill, decoration, is_emoji)) return segments @@ -272,20 +162,6 @@ def _format_datetime(created_at: str | None) -> str: return created_at -def _rounded_rect_path(x: int, y: int, w: int, h: int, tl: int, tr: int, br: int, bl: int) -> str: - return ( - f"M {x + tl},{y} " - f"H {x + w - tr} " - f"Q {x + w},{y} {x + w},{y + tr} " - f"V {y + h - br} " - f"Q {x + w},{y + h} {x + w - br},{y + h} " - f"H {x + bl} " - f"Q {x},{y + h} {x},{y + h - bl} " - f"V {y + tl} " - f"Q {x},{y} {x + tl},{y} Z" - ) - - def _photo_cell_parts( cell_id: str, x: int, @@ -298,7 +174,7 @@ def _photo_cell_parts( br: int, bl: int, ) -> tuple[str, str]: - path = _rounded_rect_path(x, y, w, h, tl, tr, br, bl) + path = rounded_rect_path(x, y, w, h, tl, tr, br, bl) clip = f'' img = ( f' str: cx = CARD_OUTER_PAD # card left edge inner_w = card_width - 2 * CARD_INNER_PAD @@ -327,7 +205,7 @@ def build_svg( # Font defs.append( f'", ) @@ -358,7 +236,7 @@ def build_svg( # Header if profile_bytes: - avatar_b64 = _b64_image(profile_bytes, _image_mime(profile_bytes)) + avatar_b64 = b64_image(profile_bytes, image_mime(profile_bytes)) content.append( f'', @@ -367,7 +245,7 @@ def build_svg( initial = (tweet.user.handle or "?")[0].upper() content.append( f'' - f'{initial}', ) @@ -378,26 +256,26 @@ def build_svg( date_y = name_y + _name_date_span def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: - return [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in _emoji_split(text) if sub] + return [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in emoji_split(text) if sub] if tweet.user.name: - name_elems, name_end_x = _render_text_segments( + name_elems, name_end_x = render_text_segments( _name_segments(tweet.user.name), name_x, name_y, FONT_SIZE_NAME, theme.text_color, weight = 700, ) content.extend(name_elems) - handle_elems, _ = _render_text_segments( + handle_elems, _ = render_text_segments( _name_segments(f" (@{tweet.user.handle})"), name_end_x, name_y, FONT_SIZE_NAME, theme.text_color, weight = 400, ) content.extend(handle_elems) else: - handle_elems, _ = _render_text_segments( + handle_elems, _ = render_text_segments( _name_segments(f"@{tweet.user.handle}"), name_x, name_y, FONT_SIZE_NAME, theme.text_color, weight = 700, ) content.extend(handle_elems) dt_str = _format_datetime(tweet.created_at) if dt_str: content.append( - f'{dt_str}', ) @@ -425,12 +303,37 @@ def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: y += AVATAR_SIZE + CARD_SECTION_GAP - # Divider - content.append( - f'', - ) - y += CARD_SECTION_GAP + # Embedded quoted post (above body text) — replaces divider + if quoted_tweet_data: + quote_line_w = 4 + quote_line_gap = 12 + embed_x = body_x + quote_line_w + quote_line_gap + embed_w = inner_w - quote_line_w - quote_line_gap + ep_defs, ep_content, ep_height = render_embedded_post( + tweet = quoted_tweet_data["tweet"], + x = embed_x, + y = y, + width = embed_w, + theme = theme, + profile_bytes = quoted_tweet_data.get("profile_bytes"), + media_bytes = quoted_tweet_data.get("media_bytes"), + ) + defs.extend(ep_defs) + line_inset = round(ep_height * 0.05) + line_h = ep_height - 2 * line_inset + content.append( + f'', + ) + content.extend(ep_content) + y += ep_height + CARD_SECTION_GAP + else: + # Divider (only when no embedded post) + content.append( + f'', + ) + y += CARD_SECTION_GAP # Tweet body with colored tokens lines = _word_wrap(tweet.text, inner_w, FONT_SIZE_BODY) @@ -440,10 +343,24 @@ def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: segments = _line_to_segments(ln, theme.text_color, accent) if not segments: continue - line_elems, _ = _render_text_segments(segments, body_x, line_y, FONT_SIZE_BODY, theme.text_color) + line_elems, _ = render_text_segments(segments, body_x, line_y, FONT_SIZE_BODY, theme.text_color) content.extend(line_elems) y += len(lines) * LINE_HEIGHT_BODY + CARD_SECTION_GAP + # Link previews (above photos) + if link_preview_data: + lp_defs, lp_content, lp_height = render_link_previews( + link_previews = link_preview_data, + x = body_x, + y = y, + width = inner_w, + theme = theme, + ) + defs.extend(lp_defs) + content.extend(lp_content) + if lp_height > 0: + y += lp_height + CARD_SECTION_GAP + # Photos — sorted portrait → square → landscape if media_bytes: sorted_media = sorted(media_bytes, key = _photo_sort_key) @@ -454,7 +371,7 @@ def _name_segments(text: str) -> list[tuple[str, str, str, bool]]: def _add_cell(photo_data: bytes, cx: int, cy: int, w: int, h: int, tl: int, tr: int, br: int, bl: int) -> None: nonlocal cell - b64 = _b64_image(photo_data, _image_mime(photo_data)) + b64 = b64_image(photo_data, image_mime(photo_data)) clip, img = _photo_cell_parts(f"photo-{cell}", cx, cy, w, h, b64, tl, tr, br, bl) defs.append(clip) content.append(img) @@ -532,8 +449,8 @@ def _add_cell(photo_data: bytes, cx: int, cy: int, w: int, h: int, tl: int, tr: if short_url: display_url = short_url.removeprefix("https://").removeprefix("http://") content.append( - f'{_escape(display_url)}', + f'{escape_xml(display_url)}', ) y += FONT_SIZE_FOOTER + CARD_INNER_PAD @@ -549,7 +466,3 @@ def _add_cell(photo_data: bytes, cx: int, cy: int, w: int, h: int, tl: int, tr: defs_svg = "" + "".join(defs) + "" content_svg = card_rect + "".join(content) return f'{defs_svg}{content_svg}' - - -def _escape(text: str) -> str: - return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) diff --git a/src/features/social_cards/card_utils.py b/src/features/social_cards/card_utils.py new file mode 100644 index 00000000..f8ffca8d --- /dev/null +++ b/src/features/social_cards/card_utils.py @@ -0,0 +1,193 @@ +import base64 +import io +import re +from pathlib import Path + +from PIL import Image, ImageFont + +from util.config import config + +FONT_PATH = Path(config.fonts_dir) / "GoogleSans-Variable.ttf" +FONT_NAME = "Google Sans" +EMOJI_FONT_NAME = "Noto Color Emoji" + +EMOJI_RE = re.compile( + "(?:" + "[\U0001F1E6-\U0001F1FF]" + "|[\U0001F300-\U0001F5FF]" + "|[\U0001F600-\U0001F64F]" + "|[\U0001F680-\U0001F6FF]" + "|[\U0001F700-\U0001F77F]" + "|[\U0001F780-\U0001F7FF]" + "|[\U0001F800-\U0001F8FF]" + "|[\U0001F900-\U0001F9FF]" + "|[\U0001FA00-\U0001FAFF]" + "|[☀-➿]" + "|[⌀-⏿]" + "|[⬀-⯿]" + ")" + "[️‍\U0001F3FB-\U0001F3FF]*" + "(?:" + "(?:" + "[\U0001F1E6-\U0001F1FF]" + "|[\U0001F300-\U0001F5FF]" + "|[\U0001F600-\U0001F64F]" + "|[\U0001F680-\U0001F6FF]" + "|[\U0001F700-\U0001F77F]" + "|[\U0001F780-\U0001F7FF]" + "|[\U0001F800-\U0001F8FF]" + "|[\U0001F900-\U0001F9FF]" + "|[\U0001FA00-\U0001FAFF]" + "|[☀-➿]" + "|[⌀-⏿]" + "|[⬀-⯿]" + ")" + "[️‍\U0001F3FB-\U0001F3FF]*" + ")*", +) + + +def pillow_font(size: int) -> ImageFont.FreeTypeFont: + return ImageFont.truetype(str(FONT_PATH), size) + + +def text_width(text: str, size: int) -> int: + font = pillow_font(size) + return round(font.getlength(text)) + + +def emoji_pillow_font(size: int) -> ImageFont.FreeTypeFont | None: + for p in Path(config.fonts_dir).glob("*.ttf"): + if "emoji" in p.name.lower() or "colr" in p.name.lower(): + return ImageFont.truetype(str(p), size) + return None + + +def emoji_text_width(text: str, size: int) -> int: + emoji_font = emoji_pillow_font(size) + if emoji_font is None: + return text_width(text, size) + return round(emoji_font.getlength(text)) + + +def emoji_split(text: str) -> list[tuple[str, bool]]: + out: list[tuple[str, bool]] = [] + pos = 0 + for match in EMOJI_RE.finditer(text): + s, e = match.span() + if s > pos: + out.append((text[pos:s], False)) + out.append((text[s:e], True)) + pos = e + if pos < len(text): + out.append((text[pos:], False)) + return out or [(text, False)] + + +def segment_width(text: str, font_size: int, is_emoji: bool) -> int: + if is_emoji: + return emoji_text_width(text, font_size) + return text_width(text, font_size) + + +def render_text_segments( + segments: list[tuple[str, str, str, bool]], + x: int, + y: int, + font_size: int, + fill_default: str, + weight: int = 400, +) -> tuple[list[str], int]: + out = [] + cur_x = x + for text, fill, decoration, is_emoji in segments: + family = EMOJI_FONT_NAME if is_emoji else FONT_NAME + applied_fill = fill or fill_default + bold_attrs = "" + if weight == 700 and not is_emoji: + bold_attrs = f' stroke="{applied_fill}" stroke-width="0.7" paint-order="stroke"' + out.append( + f'{escape_xml(text)}', + ) + cur_x += segment_width(text, font_size, is_emoji) + return out, cur_x + + +def b64_image(data: bytes, mime: str = "image/jpeg") -> str: + return f"data:{mime};base64,{base64.b64encode(data).decode('ascii')}" + + +def image_mime(data: bytes) -> str: + try: + img = Image.open(io.BytesIO(data)) + fmt = (img.format or "JPEG").upper() + return { + "JPEG": "image/jpeg", + "PNG": "image/png", + "GIF": "image/gif", + "WEBP": "image/webp", + "ICO": "image/x-icon", + }.get(fmt, "image/jpeg") + except Exception: + return "image/jpeg" + + +def escape_xml(text: str) -> str: + return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """) + + +def rounded_rect_path(x: int, y: int, w: int, h: int, tl: int, tr: int, br: int, bl: int) -> str: + return ( + f"M {x + tl},{y} " + f"H {x + w - tr} " + f"Q {x + w},{y} {x + w},{y + tr} " + f"V {y + h - br} " + f"Q {x + w},{y + h} {x + w - br},{y + h} " + f"H {x + bl} " + f"Q {x},{y + h} {x},{y + h - bl} " + f"V {y + tl} " + f"Q {x},{y} {x + tl},{y} Z" + ) + + +def word_wrap_truncate(text: str, max_width: int, font_size: int, max_lines: int) -> list[str]: + lines: list[str] = [] + words = text.split() + current = "" + remaining = False + for i, word in enumerate(words): + candidate = (current + " " + word).strip() + if text_width(candidate, font_size) <= max_width: + current = candidate + else: + if current: + if len(lines) >= max_lines - 1: + remaining = True + lines.append(trim_trailing_sep(current) + "…") + return lines + lines.append(current) + if text_width(word, font_size) > max_width: + truncated = word + while truncated and text_width(truncated + "…", font_size) > max_width: + truncated = truncated[:-1] + current = truncated + "…" if truncated else word[:1] + "…" + remaining = True + else: + current = word + if i < len(words) - 1 and len(lines) >= max_lines: + remaining = True + break + if current and not remaining: + if len(lines) >= max_lines: + lines[-1] = trim_trailing_sep(lines[-1]) + "…" + else: + lines.append(current) + elif current and remaining: + if len(lines) < max_lines: + lines.append(trim_trailing_sep(current) + "…") + return lines[:max_lines] or [""] + + +def trim_trailing_sep(text: str) -> str: + return text.rstrip(" -–—:|/") diff --git a/src/features/social_cards/embedded_post.py b/src/features/social_cards/embedded_post.py new file mode 100644 index 00000000..ff0bab73 --- /dev/null +++ b/src/features/social_cards/embedded_post.py @@ -0,0 +1,140 @@ +import io +import re + +from PIL import Image + +from features.social_cards.card_utils import ( + FONT_NAME, + b64_image, + emoji_split, + image_mime, + render_text_segments, + rounded_rect_path, + word_wrap_truncate, +) +from features.social_cards.theme import ThemeColors +from features.web_browsing.twitter_status_fetcher import TweetData + +EMBED_PAD = 20 +EMBED_AVATAR_SIZE = 36 +EMBED_AVATAR_GAP = 10 +EMBED_NAME_FONT_SIZE = 16 +EMBED_BODY_FONT_SIZE = 18 +EMBED_BODY_LINE_HEIGHT = 24 +EMBED_BODY_MAX_LINES = 4 +EMBED_PHOTO_MAX_H = 160 +EMBED_PHOTO_CORNER_RADIUS = 12 +EMBED_CORNER_RADIUS = 28 +EMBED_SECTION_GAP = 10 +EMBED_OVERLAY_OPACITY = 0.15 + +_URL_RE = re.compile(r"https?://\S+") + + +def render_embedded_post( + tweet: TweetData, + x: int, + y: int, + width: int, + theme: ThemeColors, + profile_bytes: bytes | None, + media_bytes: bytes | None, +) -> tuple[list[str], list[str], int]: + defs: list[str] = [] + content: list[str] = [] + + R = EMBED_CORNER_RADIUS + pad = EMBED_PAD + inner_w = width - 2 * pad + + clean_text = _URL_RE.sub("", tweet.text).strip() + clean_text = re.sub(r" +", " ", clean_text) + + body_lines = word_wrap_truncate(clean_text, inner_w, EMBED_BODY_FONT_SIZE, EMBED_BODY_MAX_LINES) + body_h = len(body_lines) * EMBED_BODY_LINE_HEIGHT + + photo_h = 0 + photo_data_b64: str | None = None + photo_display_w = inner_w + if media_bytes: + try: + img = Image.open(io.BytesIO(media_bytes)) + natural_h = round(photo_display_w * img.height / img.width) if img.width > 0 else photo_display_w + photo_h = min(natural_h, EMBED_PHOTO_MAX_H) + photo_data_b64 = b64_image(media_bytes, image_mime(media_bytes)) + except Exception: + pass + + header_h = EMBED_AVATAR_SIZE + total_h = pad + header_h + EMBED_SECTION_GAP + body_h + if photo_h > 0: + total_h += EMBED_SECTION_GAP + photo_h + total_h += pad + + # Background rect + hex_bg = theme.gradient_start.lstrip("#") + bg_r, bg_g, bg_b = int(hex_bg[0:2], 16), int(hex_bg[2:4], 16), int(hex_bg[4:6], 16) + luminance = (0.299 * bg_r + 0.587 * bg_g + 0.114 * bg_b) / 255 + overlay_fill = "#ffffff" if luminance < 0.5 else "#000000" + + rect_path = rounded_rect_path(x, y, width, total_h, R, R, R, R) + content.append(f'') + + cur_y = y + pad + + # Avatar + av_x = x + pad + av_cy = cur_y + EMBED_AVATAR_SIZE // 2 + av_cx = av_x + EMBED_AVATAR_SIZE // 2 + clip_id = "embed-avatar-clip" + defs.append(f'') + + if profile_bytes: + av_b64 = b64_image(profile_bytes, image_mime(profile_bytes)) + content.append( + f'', + ) + else: + initial = (tweet.user.handle or "?")[0].upper() + content.append( + f'' + f'{initial}', + ) + + # Name + name_x = av_x + EMBED_AVATAR_SIZE + EMBED_AVATAR_GAP + name_y = cur_y + (EMBED_AVATAR_SIZE + EMBED_NAME_FONT_SIZE) // 2 - 2 + display_name = tweet.user.name or f"@{tweet.user.handle}" + max_name_w = inner_w - EMBED_AVATAR_SIZE - EMBED_AVATAR_GAP + name_lines = word_wrap_truncate(display_name, max_name_w, EMBED_NAME_FONT_SIZE, 1) + name_segments = [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in emoji_split(name_lines[0]) if sub] + name_elems, _ = render_text_segments(name_segments, name_x, name_y, EMBED_NAME_FONT_SIZE, theme.text_color, weight = 700) + content.extend(name_elems) + + cur_y += header_h + EMBED_SECTION_GAP + + # Body text + text_x = x + pad + for line in body_lines: + cur_y += EMBED_BODY_FONT_SIZE + segments = [(sub, theme.text_color, "", is_emoji) for sub, is_emoji in emoji_split(line) if sub] + line_elems, _ = render_text_segments(segments, text_x, cur_y, EMBED_BODY_FONT_SIZE, theme.text_color) + content.extend(line_elems) + cur_y += EMBED_BODY_LINE_HEIGHT - EMBED_BODY_FONT_SIZE + + # Photo (optional, max 1) + if photo_h > 0 and photo_data_b64: + cur_y += EMBED_SECTION_GAP + photo_x = x + pad + PR = EMBED_PHOTO_CORNER_RADIUS + photo_clip_id = "embed-photo-clip" + photo_path = rounded_rect_path(photo_x, cur_y, photo_display_w, photo_h, PR, PR, PR, PR) + defs.append(f'') + content.append( + f'', + ) + + return defs, content, total_h diff --git a/src/features/social_cards/link_preview.py b/src/features/social_cards/link_preview.py new file mode 100644 index 00000000..f4ecc873 --- /dev/null +++ b/src/features/social_cards/link_preview.py @@ -0,0 +1,398 @@ +import io +import re +import urllib.request +from urllib.parse import urlparse + +from PIL import Image + +from features.social_cards.card_layout import PHOTO_CORNER_RADIUS, PHOTO_GAP +from features.social_cards.card_utils import ( + FONT_NAME, + b64_image, + escape_xml, + image_mime, + rounded_rect_path, + word_wrap_truncate, +) +from features.social_cards.theme import ThemeColors +from util import log + +LINK_ASPECT_W = 3 +LINK_ASPECT_H = 2 +OVERLAY_PAD_IMAGE = 12 +OVERLAY_PAD_NO_IMAGE = 18 +OVERLAY_OPACITY_IMAGE = 0.55 +OVERLAY_OPACITY_NO_IMAGE = 0.3 +BLUR_STD_DEV = 12 +TITLE_FONT_SIZE = 20 +TITLE_MAX_LINES = 2 +DESC_FONT_SIZE = 16 +DESC_MAX_LINES = 3 +DOMAIN_FONT_SIZE = 14 +FAVICON_SIZE = 28 +FAVICON_GAP = 8 +TEXT_LINE_HEIGHT_TITLE = 26 +TEXT_LINE_HEIGHT_DESC = 20 +TEXT_LINE_HEIGHT_DOMAIN = 18 +DESC_TOP_GAP = 8 +LINK_PREVIEW_CORNER_RADIUS = 20 + +_FAVICON_TIMEOUT_S = 3 +_OG_FETCH_TIMEOUT_S = 8 +_OG_HEAD_READ_BYTES = 32 * 1024 +_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)" +_OG_META_RE = re.compile(r']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', re.IGNORECASE) +_OG_META_RE_ALT = re.compile(r']+content=["\']([^"\']+)["\'][^>]+property=["\']og:image["\']', re.IGNORECASE) + + +def fetch_og_image_url(page_url: str) -> str | None: + try: + req = urllib.request.Request(page_url, headers = {"User-Agent": _USER_AGENT}) + with urllib.request.urlopen(req, timeout = _OG_FETCH_TIMEOUT_S) as resp: + head = resp.read(_OG_HEAD_READ_BYTES).decode("utf-8", errors = "ignore") + match = _OG_META_RE.search(head) or _OG_META_RE_ALT.search(head) + if match: + log.t(f"Found og:image from page: {match.group(1)}") + return match.group(1) + except Exception as e: + log.t(f"OG image fetch from page failed: {e}") + return None + + +_FAVICON_LINK_RE = re.compile( + r']+rel=["\'](?:icon|shortcut icon|apple-touch-icon)["\'][^>]*>', + re.IGNORECASE, +) +_HREF_RE = re.compile(r'href=["\']([^"\']+)["\']', re.IGNORECASE) + + +def fetch_favicon(domain: str, expanded_url: str | None = None) -> bytes | None: + urls_to_try = [] + if expanded_url: + urls_to_try.append(expanded_url) + urls_to_try.append(f"https://{domain}") + for page_url in urls_to_try: + favicon_url = _find_favicon_url(page_url, domain) + if favicon_url: + result = _download_favicon(favicon_url) + if result: + return result + return None + + +def _find_favicon_url(page_url: str, domain: str) -> str | None: + try: + req = urllib.request.Request(page_url, headers = {"User-Agent": _USER_AGENT}) + with urllib.request.urlopen(req, timeout = _OG_FETCH_TIMEOUT_S) as resp: + final_url = resp.url + head = resp.read(_OG_HEAD_READ_BYTES).decode("utf-8", errors = "ignore") + matches = _FAVICON_LINK_RE.findall(head) + for tag in matches: + href_match = _HREF_RE.search(tag) + if href_match: + href = href_match.group(1) + if href.startswith("//"): + return "https:" + href + if href.startswith("/"): + parsed = urlparse(final_url) + return f"{parsed.scheme}://{parsed.hostname}{href}" + if href.startswith("http"): + return href + parsed = urlparse(final_url) + return f"{parsed.scheme}://{parsed.hostname}/{href}" + except Exception as e: + log.t(f"Favicon URL extraction failed for {page_url}: {e}") + return None + + +def _download_favicon(url: str) -> bytes | None: + try: + log.t(f"Downloading favicon from {url}") + req = urllib.request.Request(url, headers = {"User-Agent": _USER_AGENT}) + with urllib.request.urlopen(req, timeout = _FAVICON_TIMEOUT_S) as resp: + content_type = resp.headers.get("Content-Type", "") + if "html" in content_type: + return None + data = resp.read() + if len(data) < 50: + return None + img = Image.open(io.BytesIO(data)) + img = img.convert("RGBA") + img = img.resize((64, 64), Image.LANCZOS) + r, g, b, a = img.split() + gray = img.convert("LA").split()[0] + img = Image.merge("RGBA", (gray, gray, gray, a)) + buf = io.BytesIO() + img.save(buf, format = "PNG") + return buf.getvalue() + except Exception as e: + log.t(f"Favicon download failed for {url}: {e}") + return None + + +def _dominant_color(data: bytes) -> tuple[int, int, int]: + try: + img = Image.open(io.BytesIO(data)).convert("RGB").resize((32, 32)) + quantized = img.quantize(colors = 4) + palette = quantized.getpalette() + if palette: + return (palette[0], palette[1], palette[2]) + except Exception: + pass + return (128, 128, 128) + + +def _contrast_text_color(rgb: tuple[int, int, int]) -> str: + r, g, b = rgb + luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255 + return "#000000" if luminance > 0.5 else "#ffffff" + + +def _contrast_overlay_color(rgb: tuple[int, int, int]) -> str: + r, g, b = rgb + return "#{:02x}{:02x}{:02x}".format(255 - r, 255 - g, 255 - b) + + +def _globe_icon(cx: int, cy: int, r: int, color: str, opacity: float = 0.6) -> str: + return ( + f'' + f'' + f'' + f'' + f'' + f'' + f"" + ) + + +def render_link_previews( + link_previews: list[dict], + x: int, + y: int, + width: int, + theme: ThemeColors, +) -> tuple[list[str], list[str], int]: + defs: list[str] = [] + content: list[str] = [] + cur_y = y + + for i, preview in enumerate(link_previews): + title = preview.get("title") or "" + description = preview.get("description") or "" + domain = preview.get("domain") or "" + og_image_bytes = preview.get("og_image_bytes") + favicon_bytes = preview.get("favicon_bytes") + uid = f"lp-{i}" + + if og_image_bytes: + d, c, h = _render_with_image( + uid, x, cur_y, width, title, description, domain, + og_image_bytes, favicon_bytes, theme, + ) + else: + d, c, h = _render_without_image( + uid, x, cur_y, width, title, description, domain, + favicon_bytes, theme, + ) + + defs.extend(d) + content.extend(c) + cur_y += h + PHOTO_GAP + + total_h = cur_y - y - (PHOTO_GAP if link_previews else 0) + return defs, content, total_h + + +def _render_with_image( + uid: str, + x: int, + y: int, + width: int, + title: str, + description: str, + domain: str, + og_image_bytes: bytes, + favicon_bytes: bytes | None, + theme: ThemeColors, +) -> tuple[list[str], list[str], int]: + defs: list[str] = [] + content: list[str] = [] + + pad = OVERLAY_PAD_NO_IMAGE + R = PHOTO_CORNER_RADIUS + fav_size = FAVICON_SIZE + + header_text_w = width - 2 * pad - fav_size - FAVICON_GAP + desc_text_w = width - 2 * pad + + title_lines = word_wrap_truncate(title, header_text_w, TITLE_FONT_SIZE, 1) if title else [] + desc_lines = word_wrap_truncate(description, desc_text_w, DESC_FONT_SIZE, DESC_MAX_LINES) if description else [] + header_h = TEXT_LINE_HEIGHT_TITLE + TEXT_LINE_HEIGHT_DOMAIN + desc_h = len(desc_lines) * TEXT_LINE_HEIGHT_DESC + overlay_h = 2 * pad + header_h + DESC_TOP_GAP + desc_h + + total_h = round(width * LINK_ASPECT_H / LINK_ASPECT_W) + if overlay_h > total_h: + total_h = overlay_h + pad + overlay_y = y + total_h - overlay_h + + # Clip for entire box + box_clip_id = f"{uid}-box-clip" + box_path = rounded_rect_path(x, y, width, total_h, R, R, R, R) + defs.append(f'') + + # OG image (sharp, full box) + img_b64 = b64_image(og_image_bytes, image_mime(og_image_bytes)) + content.append( + f'', + ) + + # Overlay region (bottom of card) + overlay_actual_h = total_h - (overlay_y - y) + overlay_path = rounded_rect_path(x, overlay_y, width, overlay_actual_h, 0, 0, R, R) + + dominant = _dominant_color(og_image_bytes) + text_color = _contrast_text_color(dominant) + overlay_fill = "#000000" if text_color == "#ffffff" else "#ffffff" + + # Colored overlay panel (no blur — resvg can't composite filter+clip correctly) + content.append( + f'', + ) + + # Layout: favicon on left spanning title+domain, text on right, desc below full width + fav_x = x + pad + text_x = fav_x + fav_size + FAVICON_GAP + cur_y = overlay_y + pad + fav_y = cur_y + (header_h - fav_size) // 2 + + if favicon_bytes: + fav_b64 = b64_image(favicon_bytes, "image/png") + content.append( + f'', + ) + else: + globe_cx = fav_x + fav_size // 2 + globe_cy = fav_y + fav_size // 2 + globe_r = round(fav_size * 0.35) + content.append(_globe_icon(globe_cx, globe_cy, globe_r, text_color, 1.0)) + + # Title (bold, 1 line) next to favicon + cur_y += TITLE_FONT_SIZE + if title_lines: + content.append( + f'{escape_xml(title_lines[0])}', + ) + cur_y += TEXT_LINE_HEIGHT_TITLE - TITLE_FONT_SIZE + + # Domain next to favicon + cur_y += DOMAIN_FONT_SIZE + content.append( + f'{escape_xml(domain)}', + ) + cur_y += TEXT_LINE_HEIGHT_DOMAIN - DOMAIN_FONT_SIZE + + # Description full width below + cur_y += DESC_TOP_GAP + desc_x = x + pad + for line in desc_lines: + cur_y += DESC_FONT_SIZE + content.append( + f'{escape_xml(line)}', + ) + cur_y += TEXT_LINE_HEIGHT_DESC - DESC_FONT_SIZE + + return defs, content, total_h + + +def _render_without_image( + uid: str, + x: int, + y: int, + width: int, + title: str, + description: str, + domain: str, + favicon_bytes: bytes | None, + theme: ThemeColors, +) -> tuple[list[str], list[str], int]: + content: list[str] = [] + + pad = OVERLAY_PAD_NO_IMAGE + fav_size = FAVICON_SIZE + full_w = width - 2 * pad + header_text_w = full_w - fav_size - FAVICON_GAP + + desc_lines = word_wrap_truncate(description, full_w, DESC_FONT_SIZE, DESC_MAX_LINES) if description else [] + header_h = TEXT_LINE_HEIGHT_TITLE + TEXT_LINE_HEIGHT_DOMAIN + desc_h = len(desc_lines) * TEXT_LINE_HEIGHT_DESC + total_h = 2 * pad + header_h + DESC_TOP_GAP + desc_h + + R = LINK_PREVIEW_CORNER_RADIUS + hex_bg = theme.gradient_start.lstrip("#") + bg_r, bg_g, bg_b = int(hex_bg[0:2], 16), int(hex_bg[2:4], 16), int(hex_bg[4:6], 16) + overlay_fill = _contrast_overlay_color((bg_r, bg_g, bg_b)) + text_color = _contrast_text_color((bg_r, bg_g, bg_b)) + + rect_path = rounded_rect_path(x, y, width, total_h, R, R, R, R) + content.append(f'') + + cur_y = y + pad + fav_x = x + pad + fav_y = cur_y + (header_h - fav_size) // 2 + + if favicon_bytes: + fav_b64 = b64_image(favicon_bytes, "image/png") + content.append( + f'', + ) + else: + globe_cx = fav_x + fav_size // 2 + globe_cy = fav_y + fav_size // 2 + globe_r = round(fav_size * 0.35) + content.append(_globe_icon(globe_cx, globe_cy, globe_r, text_color, 1.0)) + + # Title (bold, 1 line) next to favicon + text_x = fav_x + fav_size + FAVICON_GAP + cur_y += TITLE_FONT_SIZE + if title: + title_lines = word_wrap_truncate(title, header_text_w, TITLE_FONT_SIZE, 1) + content.append( + f'{escape_xml(title_lines[0])}', + ) + cur_y += TEXT_LINE_HEIGHT_TITLE - TITLE_FONT_SIZE + + # Domain/URL next to favicon + cur_y += DOMAIN_FONT_SIZE + content.append( + f'{escape_xml(domain)}', + ) + cur_y += TEXT_LINE_HEIGHT_DOMAIN - DOMAIN_FONT_SIZE + + # Gap before description + cur_y += DESC_TOP_GAP + + # Description full width below + desc_x = x + pad + for line in desc_lines: + cur_y += DESC_FONT_SIZE + content.append( + f'{escape_xml(line)}', + ) + cur_y += TEXT_LINE_HEIGHT_DESC - DESC_FONT_SIZE + + return [], content, total_h diff --git a/src/features/social_cards/social_card_orchestrator.py b/src/features/social_cards/social_card_orchestrator.py index 194134fa..cc3bac25 100644 --- a/src/features/social_cards/social_card_orchestrator.py +++ b/src/features/social_cards/social_card_orchestrator.py @@ -4,6 +4,7 @@ from features.external_tools.configured_tool import ConfiguredTool from features.external_tools.external_tool import ToolType from features.social_cards import card_renderer +from features.social_cards.link_preview import fetch_favicon, fetch_og_image_url from features.social_cards.theme import pick_theme from features.web_browsing.photo_downloader import PhotoDownloader from features.web_browsing.twitter_utils import resolve_tweet_id @@ -15,12 +16,15 @@ class SocialCardOrchestrator: TOOL_TYPE: ToolType = ToolType.api_twitter + VISION_TOOL_TYPE: ToolType = ToolType.vision __x_api_tool: ConfiguredTool + __vision_tool: ConfiguredTool __di: DI - def __init__(self, x_api_tool: ConfiguredTool, di: DI): + def __init__(self, x_api_tool: ConfiguredTool, vision_tool: ConfiguredTool, di: DI): self.__x_api_tool = x_api_tool + self.__vision_tool = vision_tool self.__di = di def execute(self, url: str) -> str: @@ -28,7 +32,7 @@ def execute(self, url: str) -> str: if not tweet_id: raise ValidationError(f"Cannot resolve tweet ID from URL: {url}", WEB_FETCH_FAILED) - fetcher = self.__di.twitter_status_fetcher(tweet_id, self.__x_api_tool, self.__x_api_tool) + fetcher = self.__di.twitter_status_fetcher(tweet_id, self.__x_api_tool, self.__vision_tool) tweet = fetcher.as_structured() downloader = PhotoDownloader() @@ -41,6 +45,58 @@ def execute(self, url: str) -> str: media_urls = [m.url or m.preview_url for m in tweet.media if m.url or m.preview_url] media_bytes = downloader.download_many([u for u in media_urls if u]) + # Fetch link preview assets + has_tweet_media = bool(media_bytes) + link_preview_data: list[dict] = [] + for lp in tweet.link_previews: + og_image_bytes: bytes | None = None + if not has_tweet_media: + if lp.og_image_url: + og_image_bytes = downloader.download(lp.og_image_url) + if not og_image_bytes: + fallback_url = fetch_og_image_url(lp.expanded_url) + if fallback_url: + og_image_bytes = downloader.download(fallback_url) + favicon_bytes = fetch_favicon(lp.domain, expanded_url = lp.expanded_url) + short_link: str | None = None + try: + valid_until = datetime.now() + timedelta(days = 365) + short_link = self.__di.url_shortener(lp.expanded_url, valid_until = valid_until).execute() + except Exception as e: + log.w("Link preview URL shortening failed", e) + short_link = lp.expanded_url + link_preview_data.append({ + "title": lp.title, + "description": lp.description, + "domain": lp.domain, + "og_image_bytes": og_image_bytes, + "favicon_bytes": favicon_bytes, + "short_url": short_link, + }) + + # Fetch referenced tweet (quoted or replied-to) if present + quoted_tweet_data: dict | None = None + referenced_id = tweet.quoted_tweet_id or tweet.replied_to_tweet_id + if referenced_id: + try: + qt_fetcher = self.__di.twitter_status_fetcher(referenced_id, self.__x_api_tool, self.__vision_tool) + qt_tweet = qt_fetcher.as_structured() + qt_profile_bytes: bytes | None = None + if qt_tweet.user.profile_image_url: + qt_bigger_url = qt_tweet.user.profile_image_url.replace("_normal", "_bigger") + qt_profile_bytes = downloader.download(qt_bigger_url) + qt_media_bytes: bytes | None = None + qt_media_urls = [m.url or m.preview_url for m in qt_tweet.media if m.url or m.preview_url] + if qt_media_urls: + qt_media_bytes = downloader.download(qt_media_urls[0]) + quoted_tweet_data = { + "tweet": qt_tweet, + "profile_bytes": qt_profile_bytes, + "media_bytes": qt_media_bytes, + } + except Exception as e: + log.w(f"Failed to fetch referenced tweet {referenced_id}", e) + theme = pick_theme(profile_bytes, media_bytes) short_url: str | None = None @@ -58,6 +114,8 @@ def execute(self, url: str) -> str: profile_bytes = profile_bytes, media_bytes = media_bytes, short_url = short_url, + link_preview_data = link_preview_data, + quoted_tweet_data = quoted_tweet_data, ) except Exception as e: raise ExternalServiceError("Card rendering failed", IMAGE_GENERATION_FAILED) from e diff --git a/src/features/web_browsing/twitter_status_fetcher.py b/src/features/web_browsing/twitter_status_fetcher.py index 06f939e6..8568cad5 100644 --- a/src/features/web_browsing/twitter_status_fetcher.py +++ b/src/features/web_browsing/twitter_status_fetcher.py @@ -1,8 +1,11 @@ +import html import json +import re from dataclasses import dataclass, field from datetime import datetime, timedelta from time import sleep from typing import Any +from urllib.parse import urlparse from db.schema.tools_cache import ToolsCache, ToolsCacheSave from di.di import DI @@ -21,6 +24,15 @@ RATE_LIMIT_DELAY_S = 2 +@dataclass +class TweetLinkPreview: + title: str | None + description: str | None + og_image_url: str | None + expanded_url: str + domain: str + + @dataclass class TweetMediaItem: url: str | None @@ -43,6 +55,10 @@ class TweetData: language: str | None created_at: str | None media: list[TweetMediaItem] = field(default_factory = list) + link_previews: list[TweetLinkPreview] = field(default_factory = list) + quoted_tweet_id: str | None = None + is_reply: bool = False + replied_to_tweet_id: str | None = None class TwitterStatusFetcher: @@ -108,7 +124,7 @@ def __fetch_raw(self) -> dict[str, Any]: params = { "expansions": "author_id,attachments.media_keys", "user.fields": "name,username,description,profile_image_url", - "tweet.fields": "lang,text,created_at,note_tweet", + "tweet.fields": "lang,text,created_at,note_tweet,entities,referenced_tweets", "media.fields": "url,type,preview_image_url", } @@ -165,7 +181,27 @@ def __parse_structured(self, response: dict[str, Any]) -> TweetData: ) note_tweet = post_data.get("note_tweet") or {} - text = note_tweet.get("text") or post_data.get("text") or "" + text = html.unescape(note_tweet.get("text") or post_data.get("text") or "") + + entities = note_tweet.get("entities") or post_data.get("entities") or {} + entity_urls = entities.get("urls") or [] + + referenced_tweets = post_data.get("referenced_tweets") or [] + quoted_tweet_id: str | None = None + is_reply = False + replied_to_tweet_id: str | None = None + for ref in referenced_tweets: + ref_type = ref.get("type") + ref_id = ref.get("id") + if ref_type == "quoted" and ref_id: + quoted_tweet_id = ref_id + elif ref_type == "replied_to" and ref_id: + is_reply = True + replied_to_tweet_id = ref_id + + link_previews, text, url_quoted_id = self.__process_urls(text, entity_urls) + if not quoted_tweet_id and url_quoted_id: + quoted_tweet_id = url_quoted_id return TweetData( user = user, @@ -173,8 +209,77 @@ def __parse_structured(self, response: dict[str, Any]) -> TweetData: language = post_data.get("lang") or None, created_at = post_data.get("created_at") or None, media = media_items, + link_previews = link_previews, + quoted_tweet_id = quoted_tweet_id, + is_reply = is_reply, + replied_to_tweet_id = replied_to_tweet_id, ) + def __process_urls( + self, + text: str, + entity_urls: list[dict[str, Any]], + ) -> tuple[list[TweetLinkPreview], str, str | None]: + link_previews: list[TweetLinkPreview] = [] + tco_urls_to_strip: set[str] = set() + quoted_tweet_id: str | None = None + + for entity in entity_urls: + tco_url = entity.get("url") or "" + expanded = entity.get("expanded_url") or "" + + if not tco_url: + continue + + is_media_self_ref = f"/status/{self.__tweet_id}/" in expanded + is_twitter_domain = any( + d in expanded for d in ["x.com/", "twitter.com/"] + ) + + if is_media_self_ref: + tco_urls_to_strip.add(tco_url) + elif is_twitter_domain: + qt_id = self.__extract_tweet_id_from_url(expanded) + if qt_id: + quoted_tweet_id = qt_id + tco_urls_to_strip.add(tco_url) + else: + tco_urls_to_strip.add(tco_url) + title = entity.get("title") or None + description = entity.get("description") or None + if title or description: + images = entity.get("images") or [] + og_image_url = images[0].get("url") if images else None + parsed = urlparse(expanded) + domain = parsed.hostname or "" + if domain.startswith("www."): + domain = domain[4:] + parts = domain.split(".") + if len(parts) > 2: + domain = ".".join(parts[-2:]) + link_previews.append( + TweetLinkPreview( + title = title, + description = description, + og_image_url = og_image_url, + expanded_url = expanded, + domain = domain, + ), + ) + + for tco_url in tco_urls_to_strip: + text = text.replace(tco_url, "") + + text = re.sub(r" +", " ", text).strip() + return link_previews, text, quoted_tweet_id + + @staticmethod + def __extract_tweet_id_from_url(url: str) -> str | None: + parts = url.split("/status/") + if len(parts) == 2: + return parts[1].split("?")[0].split("/")[0] + return None + def __resolve_content(self, response: dict[str, Any]) -> str: try: post_data = response.get("data") or {} diff --git a/test/features/social_cards/test_card_utils.py b/test/features/social_cards/test_card_utils.py new file mode 100644 index 00000000..08efaa4d --- /dev/null +++ b/test/features/social_cards/test_card_utils.py @@ -0,0 +1,205 @@ +import unittest + +from features.social_cards.card_utils import ( + EMOJI_FONT_NAME, + FONT_NAME, + b64_image, + emoji_split, + escape_xml, + image_mime, + render_text_segments, + rounded_rect_path, + segment_width, + text_width, + trim_trailing_sep, + word_wrap_truncate, +) + + +class CardUtilsTest(unittest.TestCase): + + def test_escape_xml_special_chars(self): + assert escape_xml('a & b < c > d "e"') == "a & b < c > d "e"" + + def test_escape_xml_no_special_chars(self): + assert escape_xml("hello world") == "hello world" + + def test_escape_xml_empty(self): + assert escape_xml("") == "" + + def test_trim_trailing_sep_dashes(self): + assert trim_trailing_sep("hello -") == "hello" + + def test_trim_trailing_sep_pipes(self): + assert trim_trailing_sep("title | ") == "title" + + def test_trim_trailing_sep_mixed(self): + assert trim_trailing_sep("text —/") == "text" + + def test_trim_trailing_sep_no_sep(self): + assert trim_trailing_sep("clean text") == "clean text" + + def test_b64_image_default_mime(self): + result = b64_image(b"\x00\x01\x02") + assert result.startswith("data:image/jpeg;base64,") + + def test_b64_image_custom_mime(self): + result = b64_image(b"\x89PNG", "image/png") + assert result.startswith("data:image/png;base64,") + + def test_image_mime_png(self): + import io + + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (1, 1)).save(buf, format = "PNG") + assert image_mime(buf.getvalue()) == "image/png" + + def test_image_mime_jpeg(self): + import io + + from PIL import Image + buf = io.BytesIO() + Image.new("RGB", (1, 1)).save(buf, format = "JPEG") + assert image_mime(buf.getvalue()) == "image/jpeg" + + def test_image_mime_invalid_data(self): + assert image_mime(b"not an image") == "image/jpeg" + + def test_text_width_returns_positive(self): + width = text_width("hello", 20) + assert width > 0 + + def test_text_width_longer_text_wider(self): + short = text_width("hi", 20) + long = text_width("hello world", 20) + assert long > short + + def test_text_width_larger_font_wider(self): + small = text_width("test", 12) + large = text_width("test", 24) + assert large > small + + def test_rounded_rect_path_format(self): + path = rounded_rect_path(10, 20, 100, 50, 5, 5, 5, 5) + assert path.startswith("M 15,20") + assert "Z" in path + + def test_rounded_rect_path_asymmetric_corners(self): + path = rounded_rect_path(0, 0, 100, 100, 10, 0, 10, 0) + assert "M 10,0" in path + + def test_word_wrap_truncate_short_text(self): + lines = word_wrap_truncate("short", 500, 20, 3) + assert lines == ["short"] + + def test_word_wrap_truncate_single_line_limit(self): + lines = word_wrap_truncate("a b c d e f g h i j k l m n", 50, 20, 1) + assert len(lines) == 1 + assert lines[0].endswith("…") + + def test_word_wrap_truncate_respects_max_lines(self): + long_text = " ".join(["word"] * 50) + lines = word_wrap_truncate(long_text, 100, 14, 3) + assert len(lines) <= 3 + assert lines[-1].endswith("…") + + def test_word_wrap_truncate_no_truncation_needed(self): + lines = word_wrap_truncate("hello world", 500, 20, 5) + assert "…" not in lines[0] + + def test_word_wrap_truncate_empty_text(self): + lines = word_wrap_truncate("", 500, 20, 3) + assert lines == [""] + + def test_word_wrap_truncate_trims_trailing_sep(self): + lines = word_wrap_truncate("title - this is a very long description that wraps", 80, 14, 1) + assert len(lines) == 1 + assert not lines[0].endswith("- …") + assert not lines[0].endswith(" -…") + + # emoji_split tests + + def test_emoji_split_no_emoji(self): + result = emoji_split("hello world") + assert result == [("hello world", False)] + + def test_emoji_split_only_emoji(self): + result = emoji_split("🔥") + assert len(result) == 1 + assert result[0][0] == "🔥" + assert result[0][1] is True + + def test_emoji_split_mixed(self): + result = emoji_split("hello 🌍 world") + assert len(result) == 3 + assert result[0] == ("hello ", False) + assert result[1][1] is True + assert result[2] == (" world", False) + + def test_emoji_split_multiple_emoji(self): + result = emoji_split("🎉🎊") + assert all(is_emoji for _, is_emoji in result) + + def test_emoji_split_empty_string(self): + result = emoji_split("") + assert result == [("", False)] + + # segment_width tests + + def test_segment_width_text(self): + w = segment_width("hello", 20, False) + assert w > 0 + assert w == text_width("hello", 20) + + def test_segment_width_emoji(self): + w = segment_width("🔥", 20, True) + assert w > 0 + + # render_text_segments tests + + def test_render_text_segments_single_text(self): + segments = [("hello", "#ffffff", "", False)] + elems, end_x = render_text_segments(segments, 10, 50, 20, "#ffffff") + assert len(elems) == 1 + assert f'font-family="{FONT_NAME}"' in elems[0] + assert 'fill="#ffffff"' in elems[0] + assert "hello" in elems[0] + assert end_x > 10 + + def test_render_text_segments_emoji_uses_emoji_font(self): + segments = [("🔥", "#000000", "", True)] + elems, _ = render_text_segments(segments, 0, 0, 20, "#000000") + assert len(elems) == 1 + assert f'font-family="{EMOJI_FONT_NAME}"' in elems[0] + + def test_render_text_segments_bold(self): + segments = [("bold", "#111111", "", False)] + elems, _ = render_text_segments(segments, 0, 0, 20, "#111111", weight = 700) + assert 'stroke="#111111"' in elems[0] + assert 'stroke-width="0.7"' in elems[0] + + def test_render_text_segments_multiple(self): + segments = [ + ("hello ", "#ffffff", "", False), + ("🌍", "#ffffff", "", True), + (" world", "#ffffff", "", False), + ] + elems, end_x = render_text_segments(segments, 0, 0, 20, "#ffffff") + assert len(elems) == 3 + assert end_x > 0 + + def test_render_text_segments_escapes_xml(self): + segments = [("