From e9f2cdb25d3c75d4a43a12e022a3a31854457816 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Wed, 22 Jul 2026 18:49:27 +0200 Subject: [PATCH] Support HEIC media imports --- Cargo.lock | 7 + Cargo.toml | 1 + DOCUMENTATION.md | 2 +- README.md | 2 +- crates/bds-cli/src/lib.rs | 8 +- crates/bds-core/Cargo.toml | 1 + crates/bds-core/src/engine/media.rs | 121 ++++++++++++- crates/bds-core/src/scripting/core_host.rs | 25 ++- crates/bds-core/src/util/thumbnail.rs | 194 ++++++++++++++++++++- crates/bds-core/testdata/close.heic | Bin 0 -> 1456 bytes 10 files changed, 335 insertions(+), 26 deletions(-) create mode 100644 crates/bds-core/testdata/close.heic diff --git a/Cargo.lock b/Cargo.lock index e48c01c..8feb42f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -932,6 +932,7 @@ dependencies = [ "fastembed", "fluent-bundle", "fluent-syntax", + "hpvcd", "htmd", "image 0.25.10", "keyring", @@ -4047,6 +4048,12 @@ version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +[[package]] +name = "hpvcd" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f06638af0669411fb9ab793bb86c534d560bf8b384fb48975afc82a52cdd341" + [[package]] name = "htmd" version = "0.5.4" diff --git a/Cargo.toml b/Cargo.toml index 1262828..1588ab2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ axum = "0.8" walkdir = "2" futures = "0.3" image = "0.25" +hpvcd = "0.3.1" webp = { version = "0.3.1", default-features = false } rust-stemmers = "1" sys-locale = "0.3" diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index cf0a032..995d123 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -150,7 +150,7 @@ The Media section is where you import, describe, and maintain assets used by pos When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse. -From the post editor you can insert already-imported media at the cursor position, drag supported image files onto an open post editor, or use the Gallery workflow to import several images at once. A dropped image is imported into the media library, linked to the current post, and inserted at the current cursor with a host-absolute `/media/...` URL that remains valid in rendered HTML. Multiple dropped files are processed in order with progress in the Tasks panel. Optional AI enrichment and media-metadata translation continue in the background; when the active AI endpoint is unavailable, the import still completes and RuDS reports that enrichment was skipped. +From the post editor you can insert already-imported media at the cursor position, drag supported image files—including HEIC and HEIF photos—onto an open post editor, or use the Gallery workflow to import several images at once. A dropped image is imported into the media library, linked to the current post, and inserted at the current cursor with a host-absolute `/media/...` URL that remains valid in rendered HTML. Multiple dropped files are processed in order with progress in the Tasks panel. Optional AI enrichment and media-metadata translation continue in the background; when the active AI endpoint is unavailable, the import still completes and RuDS reports that enrichment was skipped. The Gallery workflow imports its selected images into the media library, links them to the current post, runs the same optional AI enrichment for titles and alt text, and inserts the gallery block into the post. diff --git a/README.md b/README.md index 7cbca9c..9e8e06f 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The project is under active development. Core blogging workflows are broadly ava - Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars, relative-dated entity lists with row deletion, dialogs, tasks, embedded Wry previews, and a live Pico CSS theme editor. - Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import. -- Media import, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. +- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors. - WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping. - Post, Liquid template, and Lua script editing with dedicated syntax highlighting, explicit syntax-check feedback, change-aware published/draft lifecycle, normalized collision-safe template/script slug changes, reference-safe template renames, and publish-time enforcement of the bDS2 Liquid tag/filter/operator subset, using a custom Ropey/Syntect/Cosmic Text editor and the documented, bDS2-signature-compatible project-scoped [`bds` host API](docs/scripting/API_REFERENCE.md) across utilities, rendered macros, and Blogmark transforms, including airplane-gated Git sync. - SQLite and filesystem persistence with frontmatter, relationship-safe media sidecars, rebuild, bidirectional metadata diff/repair including project categories and publishing preferences, stale post-path cleanup on republish, bDS2-compatible checksums and NFD slug generation, and FTS5 search. diff --git a/crates/bds-cli/src/lib.rs b/crates/bds-cli/src/lib.rs index a7f0272..1047e66 100644 --- a/crates/bds-cli/src/lib.rs +++ b/crates/bds-cli/src/lib.rs @@ -388,6 +388,7 @@ fn rebuild(db: &Database, incremental: bool) -> Result { "scripts_created": report.scripts_created, "scripts_updated": report.scripts_updated, "thumbnails_generated": thumbnails.thumbnails_generated, + "thumbnail_media_failed": thumbnails.media_failed, }), ); result.progress = progress @@ -446,7 +447,12 @@ fn repair(db: &Database, part: RepairPart) -> Result { })?; Ok(output( "Missing thumbnails regenerated", - json!({"media_repaired": report.media_repaired, "thumbnails_generated": report.thumbnails_generated}), + json!({ + "media_processed": report.media_processed, + "media_repaired": report.media_repaired, + "media_failed": report.media_failed, + "thumbnails_generated": report.thumbnails_generated, + }), )) } RepairPart::Search => { diff --git a/crates/bds-core/Cargo.toml b/crates/bds-core/Cargo.toml index bd7bacc..e19f992 100644 --- a/crates/bds-core/Cargo.toml +++ b/crates/bds-core/Cargo.toml @@ -19,6 +19,7 @@ unicode-normalization = { workspace = true } thiserror = { workspace = true } walkdir = { workspace = true } image = { workspace = true } +hpvcd = { workspace = true } webp = { workspace = true } rust-stemmers = { workspace = true } sys-locale = { workspace = true } diff --git a/crates/bds-core/src/engine/media.rs b/crates/bds-core/src/engine/media.rs index bb229da..fc49704 100644 --- a/crates/bds-core/src/engine/media.rs +++ b/crates/bds-core/src/engine/media.rs @@ -41,7 +41,9 @@ pub struct MediaLinkRebuildReport { #[derive(Debug, Default, PartialEq, Eq)] pub struct ThumbnailRepairReport { + pub media_processed: usize, pub media_repaired: usize, + pub media_failed: usize, pub thumbnails_generated: usize, } @@ -111,9 +113,10 @@ pub fn regenerate_missing_thumbnails( ) -> EngineResult { let mut report = ThumbnailRepairReport::default(); for item in qm::list_media_by_project(conn, project_id)? { - if !item.mime_type.starts_with("image/") { + if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") { continue; } + report.media_processed += 1; let prefix = &item.id[..2.min(item.id.len())]; let missing = THUMBNAIL_SIZES .iter() @@ -132,14 +135,17 @@ pub fn regenerate_missing_thumbnails( if missing == 0 { continue; } - generate_all_thumbnails( + match generate_all_thumbnails( &data_dir.join(&item.file_path), &data_dir.join("thumbnails"), &item.id, - ) - .map_err(EngineError::Parse)?; - report.media_repaired += 1; - report.thumbnails_generated += missing; + ) { + Ok(_) => { + report.media_repaired += 1; + report.thumbnails_generated += missing; + } + Err(_) => report.media_failed += 1, + } } Ok(report) } @@ -968,6 +974,12 @@ mod tests { path } + fn heic_fixture() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join("close.heic") + } + #[test] fn import_media_creates_artifacts() { let (db, dir) = setup(); @@ -1032,6 +1044,103 @@ mod tests { } } + #[test] + fn import_heic_and_heif_create_dimensions_and_thumbnails() { + let (db, dir) = setup(); + + for (original_name, mime_type) in + [("photo.heic", "image/heic"), ("photo.heif", "image/heif")] + { + let media = import_media( + db.conn(), + dir.path(), + "p1", + &heic_fixture(), + original_name, + None, + None, + None, + None, + None, + vec![], + ) + .unwrap(); + + assert_eq!(media.mime_type, mime_type); + assert_eq!((media.width, media.height), (Some(27), Some(27))); + for size in THUMBNAIL_SIZES { + let extension = match size.format { + ThumbnailFormat::Webp => "webp", + ThumbnailFormat::Jpeg => "jpg", + }; + assert!( + dir.path() + .join("thumbnails") + .join(&media.id[..2]) + .join(format!("{}-{}.{}", media.id, size.name, extension)) + .is_file(), + "missing {} thumbnail", + size.name + ); + } + } + } + + #[test] + fn regenerate_missing_thumbnails_reports_undecodable_media_and_continues() { + let (db, dir) = setup(); + let good = import_media( + db.conn(), + dir.path(), + "p1", + &create_test_png(dir.path()), + "good.png", + None, + None, + None, + None, + None, + vec![], + ) + .unwrap(); + let bad_source = dir.path().join("bad.png"); + fs::write(&bad_source, "not an image").unwrap(); + let bad = import_media( + db.conn(), + dir.path(), + "p1", + &bad_source, + "bad.png", + None, + None, + None, + None, + None, + vec![], + ) + .unwrap(); + let good_small = dir + .path() + .join("thumbnails") + .join(&good.id[..2]) + .join(format!("{}-small.webp", good.id)); + fs::remove_file(good_small).unwrap(); + + let report = regenerate_missing_thumbnails(db.conn(), dir.path(), "p1").unwrap(); + + assert_eq!(report.media_processed, 2); + assert_eq!(report.media_repaired, 1); + assert_eq!(report.media_failed, 1); + assert_eq!(report.thumbnails_generated, 1); + assert!( + !dir.path() + .join("thumbnails") + .join(&bad.id[..2]) + .join(format!("{}-small.webp", bad.id)) + .exists() + ); + } + #[test] fn update_media_rewrites_sidecar() { let (db, dir) = setup(); diff --git a/crates/bds-core/src/scripting/core_host.rs b/crates/bds-core/src/scripting/core_host.rs index e25406e..7e30706 100644 --- a/crates/bds-core/src/scripting/core_host.rs +++ b/crates/bds-core/src/scripting/core_host.rs @@ -881,21 +881,16 @@ impl CoreHost { Ok(json!({"generated": true, "media_id": item.id})) } "regenerate_missing_thumbnails" => { - let mut generated = 0; - for item in media::list_media_by_project(db.conn(), &self.project_id)? { - let path = self - .data_dir - .join(crate::util::thumbnail_path(&item.id, "small", "webp")); - if !path.is_file() { - crate::util::thumbnail::generate_all_thumbnails( - &self.data_dir.join(&item.file_path), - &self.data_dir.join("thumbnails"), - &item.id, - )?; - generated += 1; - } - } - Ok(json!({"generated": generated})) + let report = engine::media::regenerate_missing_thumbnails( + db.conn(), + &self.data_dir, + &self.project_id, + )?; + Ok(json!({ + "processed": report.media_processed, + "generated": report.thumbnails_generated, + "failed": report.media_failed, + })) } "reindex_text" => { engine::search::reindex_project(db.conn(), &self.project_id, None)?; diff --git a/crates/bds-core/src/util/thumbnail.rs b/crates/bds-core/src/util/thumbnail.rs index 288119f..3c93063 100644 --- a/crates/bds-core/src/util/thumbnail.rs +++ b/crates/bds-core/src/util/thumbnail.rs @@ -1,5 +1,5 @@ use image::imageops::FilterType; -use image::{DynamicImage, GenericImageView, ImageReader}; +use image::{DynamicImage, GenericImageView, ImageReader, RgbImage, RgbaImage}; use std::fs; use std::io::Cursor; use std::path::Path; @@ -70,7 +70,15 @@ pub fn generate_thumbnail( quality: u8, ) -> Result<(), String> { let img = load_and_orient(source)?; + generate_thumbnail_from_image(&img, dest, size, quality) +} +fn generate_thumbnail_from_image( + img: &DynamicImage, + dest: &Path, + size: &ThumbnailSize, + quality: u8, +) -> Result<(), String> { let resized = match size.fit { ThumbnailFit::Inside => { let (orig_w, orig_h) = img.dimensions(); @@ -129,6 +137,7 @@ pub fn generate_all_thumbnails( ) -> Result, String> { let mut paths = Vec::new(); let prefix = &media_id[..2.min(media_id.len())]; + let image = load_and_orient(source)?; for size in THUMBNAIL_SIZES { let ext = match size.format { @@ -143,7 +152,7 @@ pub fn generate_all_thumbnails( } else { 80 }; - generate_thumbnail(source, &dest, size, quality)?; + generate_thumbnail_from_image(&image, &dest, size, quality)?; paths.push(dest.to_string_lossy().to_string()); } @@ -152,6 +161,10 @@ pub fn generate_all_thumbnails( /// Load an image and apply EXIF orientation. fn load_and_orient(path: &Path) -> Result { + if is_heic_path(path) { + return load_heic(path); + } + let reader = ImageReader::open(path) .map_err(|e| format!("open image: {e}"))? .with_guessed_format() @@ -169,6 +182,135 @@ fn load_and_orient(path: &Path) -> Result { Ok(img) } +fn is_heic_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "heic" | "heif")) +} + +fn load_heic(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|error| format!("read HEIC image: {error}"))?; + let decoded = hpvcd::Decoder::default() + .with_decode_gain_map(false) + .decode(&bytes) + .map_err(|error| format!("decode HEIC image: {error}"))?; + let shift = decoded.bit_depth.minus8(); + let rgb = match decoded.pixels { + hpvcd::ImageBuffer::Rgb8(pixels) => pixels, + hpvcd::ImageBuffer::Rgb16(samples) => samples + .into_iter() + .map(|sample| (sample >> shift) as u8) + .collect(), + hpvcd::ImageBuffer::Luma8(samples) => { + samples.into_iter().flat_map(|sample| [sample; 3]).collect() + } + hpvcd::ImageBuffer::Luma16(samples) => samples + .into_iter() + .flat_map(|sample| [(sample >> shift) as u8; 3]) + .collect(), + }; + let mut image = if let Some(alpha) = decoded.alpha { + let alpha: Vec = match alpha { + hpvcd::SampleBuf::U8(samples) => samples, + hpvcd::SampleBuf::U16(samples) => samples + .into_iter() + .map(|sample| (sample >> shift) as u8) + .collect(), + }; + if alpha.len().checked_mul(3) != Some(rgb.len()) { + return Err("decode HEIC image: decoder returned an invalid alpha buffer".to_string()); + } + let rgba = rgb + .chunks_exact(3) + .zip(alpha) + .flat_map(|(rgb, alpha)| [rgb[0], rgb[1], rgb[2], alpha]) + .collect(); + DynamicImage::ImageRgba8( + RgbaImage::from_raw(decoded.width, decoded.height, rgba).ok_or_else(|| { + "decode HEIC image: decoder returned an invalid pixel buffer".to_string() + })?, + ) + } else { + DynamicImage::ImageRgb8( + RgbImage::from_raw(decoded.width, decoded.height, rgb).ok_or_else(|| { + "decode HEIC image: decoder returned an invalid pixel buffer".to_string() + })?, + ) + }; + + if let Some(aperture) = decoded.clean_aperture + && let Some((x, y, width, height)) = + clean_aperture_crop(decoded.width, decoded.height, decoded.orientation, aperture) + { + image = image.crop_imm(x, y, width, height); + } + + Ok(image) +} + +fn clean_aperture_crop( + oriented_width: u32, + oriented_height: u32, + orientation: hpvcd::Orientation, + aperture: hpvcd::CleanAperture, +) -> Option<(u32, u32, u32, u32)> { + let width = aperture.width_pixels()?; + let height = aperture.height_pixels()?; + if width == 0 || height == 0 { + return None; + } + + let swaps_axes = matches!( + orientation, + hpvcd::Orientation::Rotate90 + | hpvcd::Orientation::Rotate270 + | hpvcd::Orientation::Transpose + | hpvcd::Orientation::Transverse + ); + let (source_width, source_height) = if swaps_axes { + (oriented_height, oriented_width) + } else { + (oriented_width, oriented_height) + }; + if width > source_width || height > source_height { + return None; + } + + let horizontal_offset = + f64::from(aperture.horiz_off_n) / f64::from(aperture.horiz_off_d.max(1)); + let vertical_offset = f64::from(aperture.vert_off_n) / f64::from(aperture.vert_off_d.max(1)); + let source_x = ((f64::from(source_width - width) / 2.0) + horizontal_offset) + .round() + .clamp(0.0, f64::from(source_width - width)) as u32; + let source_y = ((f64::from(source_height - height) / 2.0) + vertical_offset) + .round() + .clamp(0.0, f64::from(source_height - height)) as u32; + + let crop = match orientation { + hpvcd::Orientation::Normal => (source_x, source_y, width, height), + hpvcd::Orientation::FlipH => (source_width - source_x - width, source_y, width, height), + hpvcd::Orientation::FlipV => (source_x, source_height - source_y - height, width, height), + hpvcd::Orientation::Rotate180 => ( + source_width - source_x - width, + source_height - source_y - height, + width, + height, + ), + hpvcd::Orientation::Rotate90 => { + (source_height - source_y - height, source_x, height, width) + } + hpvcd::Orientation::Rotate270 => (source_y, source_width - source_x - width, height, width), + hpvcd::Orientation::Transpose => ( + source_height - source_y - height, + source_width - source_x - width, + height, + width, + ), + hpvcd::Orientation::Transverse => (source_y, source_x, height, width), + }; + (crop.0 + crop.2 <= oriented_width && crop.1 + crop.3 <= oriented_height).then_some(crop) +} + /// Read EXIF orientation tag from raw file bytes. /// Returns orientation value 1-8, or None if not found. fn read_exif_orientation(data: &[u8]) -> Option { @@ -268,6 +410,31 @@ fn apply_orientation(img: DynamicImage, orientation: u16) -> DynamicImage { /// Extract image dimensions from a file (header-only, no full decode). pub fn image_dimensions(path: &Path) -> Result<(u32, u32), String> { + if is_heic_path(path) { + let bytes = fs::read(path).map_err(|error| format!("read HEIC image: {error}"))?; + let info = + hpvcd::read_heic_info(&bytes).map_err(|error| format!("HEIC dimensions: {error}"))?; + let swaps_axes = matches!( + info.orientation, + hpvcd::Orientation::Rotate90 + | hpvcd::Orientation::Rotate270 + | hpvcd::Orientation::Transpose + | hpvcd::Orientation::Transverse + ); + if let Some(aperture) = info.clean_aperture + && let (Some(width), Some(height)) = (aperture.width_pixels(), aperture.height_pixels()) + && width > 0 + && height > 0 + { + return Ok(if swaps_axes { + (height, width) + } else { + (width, height) + }); + } + return Ok((info.width, info.height)); + } + let reader = ImageReader::open(path) .map_err(|e| format!("open: {e}"))? .with_guessed_format() @@ -311,6 +478,12 @@ mod tests { path } + fn heic_fixture() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join("close.heic") + } + #[test] fn generate_small_thumbnail() { let dir = TempDir::new().unwrap(); @@ -357,6 +530,8 @@ mod tests { assert_eq!(mime_from_extension("jpg"), "image/jpeg"); assert_eq!(mime_from_extension("PNG"), "image/png"); assert_eq!(mime_from_extension("webp"), "image/webp"); + assert_eq!(mime_from_extension("HEIC"), "image/heic"); + assert_eq!(mime_from_extension("heif"), "image/heif"); assert_eq!(mime_from_extension("xyz"), "application/octet-stream"); } @@ -368,4 +543,19 @@ mod tests { assert_eq!(w, 100); assert_eq!(h, 80); } + + #[test] + fn heic_dimensions_and_thumbnails() { + let dir = TempDir::new().unwrap(); + let source = heic_fixture(); + + assert_eq!(image_dimensions(&source).unwrap(), (27, 27)); + + let paths = + generate_all_thumbnails(&source, &dir.path().join("thumbnails"), "heic-test").unwrap(); + assert_eq!(paths.len(), THUMBNAIL_SIZES.len()); + assert!(paths.iter().all(|path| Path::new(path).is_file())); + assert_eq!(image_dimensions(Path::new(&paths[0])).unwrap(), (27, 27)); + assert_eq!(image_dimensions(Path::new(&paths[3])).unwrap(), (448, 448)); + } } diff --git a/crates/bds-core/testdata/close.heic b/crates/bds-core/testdata/close.heic new file mode 100644 index 0000000000000000000000000000000000000000..f478f6739eef9b0eaae99524153a4c4d78de9ddc GIT binary patch literal 1456 zcmZWn4K!3)9KUbIGK0jjM&)BDCrU}tGCrE}87sos6-CU!f8fD@S%a&5^7MZ|@syyJ!FB-ShkXzwYmU-@O0;i^SAS zJeiMj0W=Z?Xrc=Ml#2p>Du;|gvrXn7j&f6%_!6$+p~6JySn@=C5=legG8Bt2 z<{~%3(NqEC3mJEGp#v(x$Oq8jlZ5O_PUFG>-USh|WGT23eWzf=5ocg9iI81L8;Xl? zi04t9n*!TSxha@P$i*^X1qJBlz>pyq!%%P+upu`?58`}?q**R52QJ2eTYyz?k>hKT zP(zjjz!E}_j*3#W|G*eWGf)@+R2q#20iJQeM@a8OIG-<>6^n!{0nSfiCG*p`tj(KT z+*mM$@>tq)8o~%nSeq)N(i%gN+y>1VI*1 z$qFj@S>tQY!QAQ{h_>@ziE=aMcJ*=ki1p~6sD2vFf>HtN=zfTrBWm@FK^biA`dP!ChKN4p$nY4cdyA}mkqqu-W3PFR{ zrzfFMb@OwN0g5hFFRuU+0Gv=PMf)WEv?VYCFNE>{f)ci9J)Pqa97!(VaTcE>g_i(?wm=(C=aBS&q= zt-*;9jw0}tWn7b2Pu3ny&(sTo`01r}>ku)|?e2VeKKxMD+w5-*rIPr>hjMc!Ql*=s zDyyANu;=l&$T3!E!Jl6r)~Q5e12DAUw72VzIi}7XT(QY@o3r(jaUf4!lQ# z>%qlNHao0jwtBa|51G>Mo4S8SZgPA3?y=xYF)>FzEy}rVu`51oZ^Oor8>7ZebI%46 zh6(zMq2;D>AJs@fRZB-<_DUAp?`EIVYcJ<9Pkp*!MTu|YAvJ*uhB#Lt-OXLPgK-OjxvE#t-~%b@^~{g!q@p5Gl|zSZ>&uor$e zBtE23s!Hkls^avs*LLO~xmeREHo0_vL+G@_`<#*S%%EO|kI}}w{iYE&6~j6%s;aFijlU)M?z$OF<1VPu}j4aOd?swBnu5FAwXZ(QcWF!?V@iveA=efv<15706nmv8n_c z$DNH4>9yIkjWNTIPFb4oU+4I7l~|J`@UiRtz0@6qJ-uJ_utQ2)rgM;C-nOjJ;nZEt znUd-Nx=`=ZOkeu5bPwXRinUAwl*Y{6TRrCIi7y$s2A%sjtM)RsO}UrdBmd&mdxFY4 zRxiC;+SFlJ{A&tXoq<%%)nl(1Udm={Fv%apIZ21N73V$ZS`k*M47=R2;=&)@iYE2n DFBSDy literal 0 HcmV?d00001