fix(mobile): preserve Markdown attachment destinations (#8168)

## Summary
Fix mobile “Image unavailable” errors caused by angle-bracketed Markdown
URLs. Preserve attachment destinations across images, galleries, videos,
and file links, including spaces, parentheses, and optional titles.

### Related issue
Related: #8086 (attachment filenames).

### Testing
- `just mobile-install mobile-check mobile-test`
- Regression coverage decodes PNG/JPEG/GIF/WebP/BMP and verifies gallery
viewers, video routing, and authenticated file links.
- Native device playback not tested.

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-10-08 14:05:46 -04:00
committed by GitHub
parent 836e7d0d06
commit 1ac0a1271b
7 changed files with 606 additions and 15 deletions

View File

@@ -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,

View File

@@ -66,7 +66,9 @@ List<MarkdownComponent> _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<MarkdownScope> 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'(?<!!)\[[^\[\]\n]*\]\([^\s]*\)');
}

View File

@@ -1,3 +1,8 @@
/// Escapes destination characters that the mobile Markdown renderer treats as
/// syntax. Apply the same conversion to metadata keys to preserve media types.
String normalizeMarkdownDestination(String url) =>
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 = <int>[];
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]})',

View File

@@ -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,
),
);

View File

@@ -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<List<String>> tags = const [],
List<Override> 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<void> _waitForImages(WidgetTester tester) async {
for (var attempt = 0; attempt < 200; attempt++) {
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 10)),
);
await tester.pump(const Duration(milliseconds: 16));
final images = tester.widgetList<RawImage>(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 = <http.Request>[];
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<MediaImageViewerPage>(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<String, String>? 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<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
expect(viewer.galleryItems!.map((item) => item.url), [
normalizedFirst,
second,
]);
},
);
}

View File

@@ -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 &#91;Q4&#93;.pdf'),
(r'only \[ opening', 'only &#91; opening'),
(r'only \] closing', 'only &#93; closing'),
(
r'backslash \\ and \[brackets\]',
'backslash &#92; and &#91;brackets&#93;',
),
('nested [label]', 'nested &#91;label&#93;'),
]) {
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](<https://relay.example/file?q=[> "Title [") '
r'[second \[Q4\]](<https://relay.example/file.pdf>)',
),
'[first](https://relay.example/file?q=[) '
'[second &#91;Q4&#93;](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\]](<https://relay.example/report.pdf>)',
),
'$prose$prefix[outer &#91;inner&#93; label]($media) '
'[report &#91;Q4&#93;](https://relay.example/report.pdf)',
);
});
}
}
test('near-limit unmatched prose preserves every later attachment', () {
const source = '[file](<https://relay.example/file.pdf>) ';
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})>[
(

View File

@@ -0,0 +1,26 @@
// Tiny locally generated images exercise real Flutter decoders.
const mediaCodecFixtures = <String, String>{
'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==',
};