diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index f5890e31ff..4c3504322c 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -219,11 +219,17 @@ class MessageContent extends HookConsumerWidget { ..sort((a, b) => a.key.compareTo(b.key)))) '${entry.key}\u0000${entry.value}', ].join('\u0001'); - final imetaByUrl = parseImetaTags(tags); + final imetaByUrl = { + for (final entry in parseImetaTags(tags).entries) + normalizeMarkdownDestination(entry.key): entry.value, + }; + final normalizedContent = useMemoized(() => normalizeBareLinks(content), [ + content, + ]); final trailingGallery = maxLines == null - ? extractTrailingImageGallery(content, imetaByUrl) + ? extractTrailingImageGallery(normalizedContent, imetaByUrl) : null; - final markdownContent = trailingGallery?.content ?? content; + final markdownContent = trailingGallery?.content ?? normalizedContent; final customEmoji = _mergeCustomEmoji( customEmojiFromTags(tags), ref.watch(customEmojiListProvider), @@ -257,17 +263,12 @@ class MessageContent extends HookConsumerWidget { ? kEmojiOnlyCustomEmojiSize : kCustomEmojiInlineSize; - final linkNormalizedContent = useMemoized( - () => normalizeBareLinks(markdownContent), - [markdownContent], - ); - final finalContent = useMemoized(() { // Replace spaces with non-breaking spaces inside known mention names // so the gpt_markdown combined regex can match multi-word names // even when caseSensitive is not preserved. // Skip content inside backticks to avoid altering inline code. - final mentionParts = linkNormalizedContent.split('`'); + final mentionParts = markdownContent.split('`'); final mentionBuf = StringBuffer(); for (var i = 0; i < mentionParts.length; i++) { if (i.isOdd) { @@ -295,7 +296,7 @@ class MessageContent extends HookConsumerWidget { result = '\u200B$result'; } return result; - }, [linkNormalizedContent, mentionPresentationKey]); + }, [markdownContent, mentionPresentationKey]); final inlineComponents = _useMessageInlineComponents( content: content, diff --git a/mobile/lib/features/channels/message_content/inline_components.dart b/mobile/lib/features/channels/message_content/inline_components.dart index fed6f6a902..def9bb56ae 100644 --- a/mobile/lib/features/channels/message_content/inline_components.dart +++ b/mobile/lib/features/channels/message_content/inline_components.dart @@ -66,7 +66,9 @@ List _useMessageInlineComponents({ channelNames: inputs.channelNames, onChannelTap: channelTap, ), - ...MarkdownComponent.inlineComponents, + _MarkdownLabelEntityMd(), + for (final component in MarkdownComponent.inlineComponents) + component is ATagMd ? _MessageLinkMd() : component, ], [inputs], ); @@ -141,3 +143,27 @@ class _InlineComponentInputs { int get hashCode => Object.hash(content, finalContent, emojiSize, hasMentionHandler); } + +// Encoded label punctuation must remain literal even inside nested formatting. +class _MarkdownLabelEntityMd extends InlineMd { + @override + RegExp get exp => RegExp(r'&#(?:91|92|93);'); + + @override + Set get scopes => const {MarkdownScope.linkLabel}; + + @override + InlineSpan span( + BuildContext context, + String text, + GptMarkdownConfig config, + ) => TextSpan(text: decodeMarkdownLabelSyntax(text), style: config.style); +} + +// Labels are already normalized, including literal/nested brackets. The +// package's permissive .*? label matcher can otherwise start at a prose '[' +// and swallow a later image or link before its own component gets to render. +class _MessageLinkMd extends ATagMd { + @override + RegExp get exp => RegExp(r'(? + url.replaceAll(' ', '%20').replaceAll('(', '%28').replaceAll(')', '%29'); + const _markdownDelimiters = ['***', '___', '**', '__', '~~', '*', '_']; final _autolinkPattern = RegExp( @@ -110,7 +115,149 @@ bool _hasInlineCloser(String content, int start, int delimiterLength) { return false; } +// Numeric entities shield literal label punctuation from gpt_markdown's +// bracket-blind image/link matchers and its earlier \[...\] math pass. +String _normalizeMarkdownLabel(String label) => label.replaceAllMapped( + RegExp(r'\\([\\\[\]])|[\[\]]'), + (match) => '&#${(match[1] ?? match[0]!).codeUnitAt(0)};', +); + +/// Restores the literal punctuation encoded for Markdown attachment labels. +String decodeMarkdownLabelSyntax(String label) => label.replaceAllMapped( + RegExp(r'&#(91|92|93);'), + (match) => String.fromCharCode(int.parse(match[1]!)), +); + +// Scan each label character once, treating escaped brackets as label text. +// Nested brackets are balanced without restarting at every opening bracket. +({int start, int end})? _findMarkdownLinkStart(String segment, int offset) { + final openers = []; + for (var cursor = offset; cursor < segment.length; cursor++) { + final char = segment[cursor]; + if (char == r'\' && + cursor + 1 < segment.length && + segment[cursor + 1] != '\n') { + cursor++; + continue; + } + if (char == '\n') { + openers.clear(); + } else if (char == '[') { + openers.add(cursor); + } else if (char == ']' && openers.isNotEmpty) { + final opener = openers.removeLast(); + // A destination belongs to this matching opener even if an earlier + // prose bracket never closed. Inner label brackets without a destination + // still balance normally, preserving true nested labels. + if (cursor + 1 < segment.length && segment[cursor + 1] == '(') { + final start = opener > 0 && segment[opener - 1] == '!' + ? opener - 1 + : opener; + return (start: start, end: cursor + 2); + } + } + } + return null; +} + String _normalizeLinkSegment(String segment) { + final result = StringBuffer(); + var offset = 0; + while (offset < segment.length) { + final match = _findMarkdownLinkStart(segment, offset); + if (match == null) break; + var cursor = match.end; + if (cursor >= segment.length) break; + final angled = segment[cursor] == '<'; + final start = angled ? ++cursor : cursor; + var depth = 0; + while (cursor < segment.length) { + final char = segment[cursor]; + if (angled) { + if (char == '>' || char == '\n' || char == '<') break; + } else { + if (char.trim().isEmpty) break; + if (char == '(') depth++; + if (char == ')') { + if (depth == 0) break; + depth--; + } + } + cursor++; + } + final destinationEnd = cursor; + var valid = cursor > start && cursor < segment.length; + if (angled && valid) { + valid = segment[cursor] == '>'; + if (valid) cursor++; + } + if (valid) { + // gpt_markdown has no title parameter. Consume all Markdown title + // delimiters and escaped delimiters before removing the title. + final suffix = _scanLinkSuffix(segment, cursor); + cursor = suffix.end; + valid = suffix.valid; + } + result.write(_normalizeProseLinks(segment.substring(offset, match.start))); + if (valid) { + final destination = segment.substring(start, destinationEnd); + final image = segment[match.start] == '!'; + final labelStart = match.start + (image ? 2 : 1); + final label = _normalizeMarkdownLabel( + segment.substring(labelStart, match.end - 2), + ); + result.write( + '${image ? '!' : ''}[$label](${normalizeMarkdownDestination(destination)})', + ); + } else { + // Preserve malformed source, and never scan its inspected suffix again. + // This cursor advances on failure as well as success, bounding work even + // for a relay-sized message made entirely of unterminated link openers. + result.write(segment.substring(match.start, cursor)); + } + offset = cursor; + } + result.write(_normalizeProseLinks(segment.substring(offset))); + return result.toString(); +} + +({int end, bool valid}) _scanLinkSuffix(String segment, int cursor) { + final start = cursor; + while (cursor < segment.length && segment[cursor].trim().isEmpty) { + cursor++; + } + if (cursor < segment.length && segment[cursor] == ')') { + return (end: cursor + 1, valid: true); + } + if (cursor == start || cursor == segment.length) { + return (end: cursor, valid: false); + } + final opener = segment[cursor]; + if (opener != '"' && opener != "'" && opener != '(') { + return (end: cursor, valid: false); + } + final closer = opener == '(' ? ')' : opener; + cursor++; + while (cursor < segment.length) { + final char = segment[cursor++]; + if (char == r'\' && cursor < segment.length) { + cursor++; + } else if (char == closer) { + while (cursor < segment.length && segment[cursor].trim().isEmpty) { + cursor++; + } + if (cursor < segment.length && segment[cursor] == ')') { + return (end: cursor + 1, valid: true); + } + return (end: cursor, valid: false); + } else if (opener == '(' && char == '(') { + return (end: cursor, valid: false); + } + } + return (end: cursor, valid: false); +} + +String _normalizeProseLinks(String segment) { var normalized = segment.replaceAllMapped( _autolinkPattern, (match) => '[${match[1]}](${match[1]})', diff --git a/mobile/lib/features/channels/message_gallery.dart b/mobile/lib/features/channels/message_gallery.dart index 3cdd51f64b..418a987a99 100644 --- a/mobile/lib/features/channels/message_gallery.dart +++ b/mobile/lib/features/channels/message_gallery.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'message_media.dart'; +import 'message_content/link_normalizer.dart'; /// Image metadata shared by carousel rendering and loading projection. @immutable @@ -47,15 +48,13 @@ TrailingImageGallery? extractTrailingImageGallery( if (mediaKind != MessageMediaKind.image) { break; } - final markdownLabel = match.group(1)?.trim(); + final markdownLabel = decodeMarkdownLabelSyntax(match.group(1)!).trim(); items.add( MessageGalleryItem( url: url, semanticLabel: imeta?.alt ?? - (markdownLabel?.isNotEmpty == true - ? markdownLabel! - : 'Message image'), + (markdownLabel.isNotEmpty ? markdownLabel : 'Message image'), aspectRatio: imeta?.aspectRatio, ), ); diff --git a/mobile/test/features/channels/message_attachment_rendering_test.dart b/mobile/test/features/channels/message_attachment_rendering_test.dart new file mode 100644 index 0000000000..df478e7865 --- /dev/null +++ b/mobile/test/features/channels/message_attachment_rendering_test.dart @@ -0,0 +1,273 @@ +import 'dart:convert'; + +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/media_loading_placeholder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../../helpers/media_codec_fixtures.dart'; + +const _base = 'https://relay.example'; + +Widget _app( + String content, { + List> tags = const [], + List overrides = const [], +}) => ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: MessageContent( + content: content, + tags: tags, + channelNames: const {'general': 'channel-id'}, + ), + ), + ), +); + +// Image codecs complete outside the fake test clock. Wait for actual pixels, +// rather than waiting for an indefinitely animated loading shimmer to settle. +Future _waitForImages(WidgetTester tester) async { + for (var attempt = 0; attempt < 200; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 16)); + final images = tester.widgetList(find.byType(RawImage)); + if (images.any((image) => image.image != null) && + find.byType(MediaLoadingPlaceholder).evaluate().isEmpty) { + return; + } + } + fail('Media did not produce a decoded image frame'); +} + +void main() { + setUp(() { + MediaImageProvider.debugResetCooldowns(); + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + }); + + for (final fixture in mediaCodecFixtures.entries) { + for (final angled in [false, true]) { + testWidgets( + '${fixture.key} ${angled ? 'angle' : 'plain'} image downloads and opens', + (tester) async { + final url = '$_base/media/photo.${fixture.key}?download=1&v=2'; + final requests = []; + final client = MockClient((request) async { + requests.add(request); + return http.Response.bytes(base64Decode(fixture.value), 200); + }); + final auth = MediaGetAuthService( + baseUrl: _base, + nsec: nostr.Keys.generate().nsec, + ); + await tester.pumpWidget( + _app( + '${angled ? 'oops [ text ' : 'Look\n'}' + '![photo \\[Q4\\]](${angled ? '<$url>' : url})', + tags: [ + [ + 'imeta', + 'url $url', + 'm image/${fixture.key}', + 'dim 2x2', + 'alt Photo', + ], + ], + overrides: [ + mediaHttpClientProvider.overrideWithValue(client), + mediaGetAuthServiceProvider.overrideWithValue(auth), + ], + ), + ); + await _waitForImages(tester); + await tester.pumpAndSettle(); + expect(requests, hasLength(1)); + expect(requests.single.url.toString(), url); + expect( + requests.single.headers['Authorization'], + startsWith('Nostr '), + ); + expect(find.text('Image unavailable'), findsNothing); + expect(tester.takeException(), isNull); + await tester.tap( + find.byKey(ValueKey('message-media-image-preview:$url')), + ); + await _waitForImages(tester); + await tester.pumpAndSettle(); + expect( + tester + .widget(find.byType(MediaImageViewerPage)) + .imageUrl, + url, + ); + expect(find.text('Failed to load image'), findsNothing); + }, + ); + } + } + + for (final title in ['(Video title)', r'"A \"preview\""']) { + for (final item in [ + ('mp4', 'video/mp4'), + ('mov', 'video/quicktime'), + ('webm', 'video/webm'), + ('bin', 'video/mp4'), + ]) { + testWidgets('${item.$1} uses video metadata with title $title', ( + tester, + ) async { + final url = '$_base/media/clip (1).${item.$1}'; + final expected = '$_base/media/clip%20%281%29.${item.$1}'; + String? requested; + await tester.pumpWidget( + _app( + 'oops [ text ![clip \\[Q4\\]](<$url> $title)', + tags: [ + ['imeta', 'url $url', 'm ${item.$2}'], + ], + overrides: [ + videoPreviewFrameLoaderProvider.overrideWithValue((url) async { + requested = url; + return null; + }), + ], + ), + ); + await tester.pumpAndSettle(); + expect(requested, expected); + expect( + find.byKey(ValueKey('message-media-video-preview:$expected')), + findsOneWidget, + ); + expect(find.text('Image unavailable'), findsNothing); + }); + } + + for (final (stem, angled) in [ + ('report [Q4]', false), + ('report [Q4]', true), + ('report [Q4', true), + ('report Q4]', true), + (r'report \ [Q4]', true), + ]) { + for (final extension in ['pdf', 'txt', 'zip', 'mp3', 'svg']) { + testWidgets( + '$stem.$extension angled=$angled title $title opens with auth and filename', + (tester) async { + final url = '$_base/media/report.$extension'; + final blob = BlobDescriptor( + url: url, + sha256: 'a' * 64, + size: 1, + type: 'application/octet-stream', + uploaded: 0, + filename: '$stem.$extension', + ); + final markdown = blob.toMarkdownImage().replaceFirst( + '($url)', + angled ? '(<$url> $title)' : '($url)', + ); + String? opened; + String? filename; + Map? headers; + await tester.pumpWidget( + _app( + '${angled ? 'oops [ then ' : ''}$markdown', + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue( + MediaGetAuthService( + baseUrl: _base, + nsec: nostr.Keys.generate().nsec, + ), + ), + openDownloadedFileProvider.overrideWithValue(( + url, + auth, + name, + ) async { + opened = url; + headers = auth; + filename = name; + }), + ], + ), + ); + await tester.tap(find.text('$stem.$extension')); + await tester.pump(); + expect(opened, url); + expect(filename, '$stem.$extension'); + expect(headers?['Authorization'], startsWith('Nostr ')); + expect(find.byType(MediaImage), findsNothing); + }, + ); + } + } + } + + testWidgets( + 'mixed angle/plain gallery retains URLs, metadata and viewer items', + (tester) async { + const first = '$_base/media/photo(1).png'; + const second = '$_base/media/photo2.png'; + const normalizedFirst = '$_base/media/photo%281%29.png'; + final client = MockClient( + (_) async => + http.Response.bytes(base64Decode(mediaCodecFixtures['png']!), 200), + ); + await tester.pumpWidget( + _app( + 'oops [ text ![inline](<$second>)\n![first](<$first>)\n![second \\[Q4\\]]($second)', + tags: [ + [ + 'imeta', + 'url $first', + 'm image/png', + 'dim 2x2', + 'alt First photo', + ], + ['imeta', 'url $second', 'm image/png', 'dim 2x2'], + ], + overrides: [mediaHttpClientProvider.overrideWithValue(client)], + ), + ); + await _waitForImages(tester); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('message-media-image-preview:$second')), + findsOneWidget, + ); + expect(find.text('2 images'), findsOneWidget); + expect(find.bySemanticsLabel('Open second [Q4]'), findsOneWidget); + expect(find.bySemanticsLabel('Open First photo'), findsOneWidget); + expect(find.text('Image unavailable'), findsNothing); + await tester.tap( + find.byKey( + const ValueKey('message-media-carousel-item:$normalizedFirst'), + ), + ); + await _waitForImages(tester); + await tester.pumpAndSettle(); + final viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + expect(viewer.galleryItems!.map((item) => item.url), [ + normalizedFirst, + second, + ]); + }, + ); +} diff --git a/mobile/test/features/channels/message_content/link_normalizer_test.dart b/mobile/test/features/channels/message_content/link_normalizer_test.dart index f7a118eb81..281220b6a5 100644 --- a/mobile/test/features/channels/message_content/link_normalizer_test.dart +++ b/mobile/test/features/channels/message_content/link_normalizer_test.dart @@ -30,6 +30,125 @@ void main() { ); }); + group('attachment destinations', () { + const media = 'https://relay.example/media/image.png'; + for (final prefix in ['!', '']) { + test('$prefix preserves angle destinations and optional titles', () { + for (final destination in ['<$media>', media, '<$media> "Title"']) { + expect( + normalizeBareLinks('$prefix[label]($destination)'), + '$prefix[label]($media)', + ); + } + }); + } + for (final title in [ + '(Video title)', + r'"A \"preview\""', + r"'A \'preview\''", + r'(A \(preview\))', + ]) { + for (final prefix in ['!', '']) { + test('$prefix supports title $title', () { + expect( + normalizeBareLinks('$prefix[label](<$media> $title)'), + '$prefix[label]($media)', + ); + }); + } + } + test('near-limit malformed openers complete without suffix rescans', () { + // A generous wall-clock ceiling catches the original ~20 second freeze + // while allowing slow CI hosts ample headroom for the linear scanner. + for (final unit in ['[x](', '[', r'[x\[', r'[x\]']) { + final input = unit * (256 * 1024 ~/ unit.length); + final watch = Stopwatch()..start(); + final result = normalizeBareLinks(input); + watch.stop(); + expect(result == input, isTrue); + expect(watch.elapsed, lessThan(const Duration(seconds: 2))); + } + }); + for (final (label, rendered) in [ + (r'report \[Q4\].pdf', 'report [Q4].pdf'), + (r'only \[ opening', 'only [ opening'), + (r'only \] closing', 'only ] closing'), + ( + r'backslash \\ and \[brackets\]', + 'backslash \ and [brackets]', + ), + ('nested [label]', 'nested [label]'), + ]) { + for (final prefix in ['!', '']) { + test('$prefix preserves escaped label $label', () { + expect( + normalizeBareLinks('$prefix[$label](<$media>)'), + '$prefix[$rendered]($media)', + ); + }); + } + } + test('URL and title brackets do not capture a later attachment label', () { + expect( + normalizeBareLinks( + '[first]( "Title [") ' + r'[second \[Q4\]]()', + ), + '[first](https://relay.example/file?q=[) ' + '[second [Q4]](https://relay.example/file.pdf)', + ); + }); + for (final prose in ['oops [ then ', 'oops [[ text ', 'oops [x] [ ']) { + for (final prefix in ['!', '']) { + test('$prefix recovers after unmatched prose $prose', () { + expect( + normalizeBareLinks( + '$prose$prefix[outer [inner] label](<$media>) ' + r'[report \[Q4\]]()', + ), + '$prose$prefix[outer [inner] label]($media) ' + '[report [Q4]](https://relay.example/report.pdf)', + ); + }); + } + } + test('near-limit unmatched prose preserves every later attachment', () { + const source = '[file]() '; + const expected = '[file](https://relay.example/file.pdf) '; + final count = (256 * 1024 - 2) ~/ source.length; + final watch = Stopwatch()..start(); + final output = normalizeBareLinks('[ ${source * count}'); + watch.stop(); + expect(output == '[ ${expected * count}', isTrue); + expect(watch.elapsed, lessThan(const Duration(seconds: 2))); + }); + test('escapes spaces and parentheses without double-encoding URLs', () { + const raw = 'https://relay.example/media/photo (1).png?q=a%20b&v=2'; + const encoded = + 'https://relay.example/media/photo%20%281%29.png?q=a%20b&v=2'; + expect(normalizeBareLinks('![photo](<$raw>)'), '![photo]($encoded)'); + expect(normalizeBareLinks('![photo]($encoded)'), '![photo]($encoded)'); + expect( + normalizeBareLinks('![photo](https://relay.example/a(b).png)'), + '![photo](https://relay.example/a%28b%29.png)', + ); + }); + test('normalizes prose surrounding multiple authored destinations', () { + expect( + normalizeBareLinks('See <$media> ![one](<$media>) [two](<$media>)'), + 'See [$media]($media) ![one]($media) [two]($media)', + ); + }); + test('preserves attachment examples inside inline and fenced code', () { + const example = '![photo](<$media>)'; + expect(normalizeBareLinks('`$example`'), '`$example`'); + expect( + normalizeBareLinks('```md\n$example\n```'), + '```md\n$example\n```', + ); + }); + }); + group('code boundaries', () { final cases = <({String name, String input, String expected})>[ ( diff --git a/mobile/test/helpers/media_codec_fixtures.dart b/mobile/test/helpers/media_codec_fixtures.dart new file mode 100644 index 0000000000..c065ee956b --- /dev/null +++ b/mobile/test/helpers/media_codec_fixtures.dart @@ -0,0 +1,26 @@ +// Tiny locally generated images exercise real Flutter decoders. +const mediaCodecFixtures = { + 'png': + 'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGOUm+bNwMDAxAAGAAvx' + 'AQN7uo8CAAAAAElFTkSuQmCC', + 'jpg': + '/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a' + 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIy' + 'MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAACAAIDASIA' + 'AhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQA' + 'AAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3' + 'ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWm' + 'p6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEA' + 'AwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSEx' + 'BhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElK' + 'U1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3' + 'uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDJooor' + 'gPjz/9k=', + 'gif': 'R0lGODdhAgACAIEAAB6WSwAAAAAAAAAAACwAAAAAAgACAAAIBgABCAQQEAA7', + 'webp': + 'UklGRjgAAABXRUJQVlA4ICwAAACwAQCdASoCAAIAAUAmJaACdLoABDAAAP7r3i9fAFPVH/00j/jS' + 'P+NI+U0AAA==', + 'bmp': + 'Qk1GAAAAAAAAADYAAAAoAAAAAgAAAAIAAAABABgAAAAAABAAAADEDgAAxA4AAAAAAAAAAAAAS5Ye' + 'S5YeAABLlh5Llh4AAA==', +};