From 1c06a67afdf589f74756f1dcb330334ca92780d7 Mon Sep 17 00:00:00 2001 From: Zed Date: Tue, 10 Feb 2026 22:42:06 +0100 Subject: [PATCH] Support image alt text Fixes #559 --- src/parser.nim | 16 ++++++++++++---- src/parserutils.nim | 2 +- src/routes/status.nim | 2 +- src/sass/tweet/media.scss | 35 +++++++++++++++++++++++++++++++---- src/types.nim | 6 +++++- src/views/general.nim | 2 +- src/views/renderutils.nim | 4 ++-- src/views/rss.nimf | 2 +- src/views/tweet.nim | 10 ++++++---- 9 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/parser.nim b/src/parser.nim index 9b4dc2f..f6b2ee7 100644 --- a/src/parser.nim +++ b/src/parser.nim @@ -139,7 +139,10 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) = for m in jsMedia: case m.getTypeName: of "photo": - result.photos.add m{"media_url_https"}.getImageStr + result.photos.add Photo( + url: m{"media_url_https"}.getImageStr, + altText: m{"ext_alt_text"}.getStr + ) of "video": result.video = some(parseVideo(m)) with user, m{"additional_media_info", "source_user"}: @@ -165,7 +168,10 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) = with mediaInfo, mediaEntity{"media_results", "result", "media_info"}: case mediaInfo.getTypeName of "ApiImage": - result.photos.add mediaInfo{"original_img_url"}.getImageStr + result.photos.add Photo( + url: mediaInfo{"original_img_url"}.getImageStr, + altText: mediaInfo{"alt_text"}.getStr + ) of "ApiVideo": let status = mediaEntity{"media_results", "result", "media_availability_v2", "status"} result.video = some Video( @@ -332,11 +338,13 @@ proc parseTweet(js: JsonNode; jsCard: JsonNode = newJNull()): Tweet = let name = jsCard{"name"}.getStr if "poll" in name: if "image" in name: - result.photos.add jsCard{"binding_values", "image_large"}.getImageVal + result.photos.add Photo( + url: jsCard{"binding_values", "image_large"}.getImageVal + ) result.poll = some parsePoll(jsCard) elif name == "amplify": - result.video = some(parsePromoVideo(jsCard{"binding_values"})) + result.video = some parsePromoVideo(jsCard{"binding_values"}) else: result.card = some parseCard(jsCard, js{"entities", "urls"}) diff --git a/src/parserutils.nim b/src/parserutils.nim index b6ccd52..b88c8d2 100644 --- a/src/parserutils.nim +++ b/src/parserutils.nim @@ -333,7 +333,7 @@ proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = proc extractGalleryPhoto*(t: Tweet): GalleryPhoto = let url = - if t.photos.len > 0: t.photos[0] + if t.photos.len > 0: t.photos[0].url elif t.video.isSome: get(t.video).thumb elif t.gif.isSome: get(t.gif).thumb elif t.card.isSome: get(t.card).image diff --git a/src/routes/status.nim b/src/routes/status.nim index 838b327..7967f77 100644 --- a/src/routes/status.nim +++ b/src/routes/status.nim @@ -44,7 +44,7 @@ proc createStatusRouter*(cfg: Config) = desc = conv.tweet.text var - images = conv.tweet.photos + images = conv.tweet.photos.mapIt(it.url) video = "" if conv.tweet.video.isSome(): diff --git a/src/sass/tweet/media.scss b/src/sass/tweet/media.scss index 66a300f..040edda 100644 --- a/src/sass/tweet/media.scss +++ b/src/sass/tweet/media.scss @@ -4,7 +4,6 @@ display: flex; flex-direction: row; flex-wrap: nowrap; - align-items: center; overflow: hidden; flex-grow: 1; max-height: 379.5px; @@ -13,7 +12,7 @@ .still-image { width: 100%; - display: flex; + align-self: center; } } @@ -58,7 +57,6 @@ .still-image { max-height: 379.5px; max-width: 533px; - justify-content: center; img { object-fit: cover; @@ -69,8 +67,37 @@ } } +.alt-text { + margin: 0px; + padding: 11px 7px; + box-sizing: border-box; + position: absolute; + bottom: 10px; + left: 10px; + width: 2.98em; + max-height: 25px; + white-space: pre; + overflow: hidden; + border-radius: 10px; + color: var(--fg_color); + font-size: 12px; + font-weight: bold; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(12px); +} + +.alt-text:hover { + padding: 7px; + width: Min(230px, calc(100% - 10px * 2)); + max-height: calc(100% - 10px); + line-height: 1.2em; + white-space: pre-wrap; + transition-duration: 0.4s; + transition-property: max-height; +} + .image { - display: inline-block; + display: flex; } // .single-image { diff --git a/src/types.nim b/src/types.nim index 90487ab..c90e789 100644 --- a/src/types.nim +++ b/src/types.nim @@ -137,6 +137,10 @@ type url*: string thumb*: string + Photo* = object + url*: string + altText*: string + GalleryPhoto* = object url*: string tweetId*: string @@ -217,7 +221,7 @@ type poll*: Option[Poll] gif*: Option[Gif] video*: Option[Video] - photos*: seq[string] + photos*: seq[Photo] Tweets* = seq[Tweet] diff --git a/src/views/general.nim b/src/views/general.nim index 8e01131..27fa103 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -50,7 +50,7 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" buildHtml(head): - link(rel="stylesheet", type="text/css", href="/css/style.css?v=23") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=24") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=4") if theme.len > 0: diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 6753c5a..0045a5a 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -97,9 +97,9 @@ proc genNumberInput*(pref, label, state, placeholder: string; class=""; autofocu label(`for`=pref): text label input(name=pref, `type`="number", placeholder=p, value=state, autofocus=(autofocus and state.len == 0), min=min, step="1") -proc genImg*(url: string; class=""): VNode = +proc genImg*(url: string; class=""; alt=""): VNode = buildHtml(): - img(src=getPicUrl(url), class=class, alt="", loading="lazy") + img(src=getPicUrl(url), class=class, alt=alt, loading="lazy") proc getTabClass*(query: Query; tab: QueryKind): string = if query.kind == tab: "tab-item active" diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 46d7eaf..6070238 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -56,7 +56,7 @@ Twitter feed for: ${desc}. Generated by ${getUrlPrefix(cfg)}

${text.replace("\n", "
\n")}

#if tweet.photos.len > 0: # for photo in tweet.photos: - + # end for #elif tweet.video.isSome: diff --git a/src/views/tweet.nim b/src/views/tweet.nim index d680509..9263539 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -50,10 +50,12 @@ proc renderAlbum(tweet: Tweet): VNode = for photo in photos: tdiv(class="attachment image"): let - named = "name=" in photo - small = if named: photo else: photo & smallWebp - a(href=getOrigPicUrl(photo), class="still-image", target="_blank"): - genImg(small) + named = "name=" in photo.url + small = if named: photo.url else: photo.url & smallWebp + a(href=getOrigPicUrl(photo.url), class="still-image", target="_blank"): + genImg(small, alt=photo.altText) + if photo.altText.len > 0: + p(class="alt-text"): text "ALT " & photo.altText proc isPlaybackEnabled(prefs: Prefs; playbackType: VideoType): bool = case playbackType