// ImageModel — CPU-side data for a raster image quad. // // Holds decoded RGBA pixel data and the world-space quad geometry derived // from the RasterImage entity's insertion point, u/v vectors, and pixel size. use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; /// One textured triangle vertex of an image's visible region: an RTE-split /// world position (`pos` high half / `pos_low` low half) plus its texture UV. #[derive(Clone, Copy, Debug)] pub struct ImageQuadVertex { pub pos: [f32; 3], pub uv: [f32; 2], pub pos_low: [f32; 3], } /// Build the two-triangle quad (6 verts) for an unclipped image from its /// corners. Texel (0,0) maps to the top-left corner. fn quad_verts(corners: &[[f32; 3]; 4], corners_low: &[[f32; 3]; 4]) -> Vec { let [p0, p1, p2, p3] = *corners; let [l0, l1, l2, l3] = *corners_low; vec![ ImageQuadVertex { pos: p0, uv: [0.0, 1.0], pos_low: l0, }, ImageQuadVertex { pos: p1, uv: [1.0, 1.0], pos_low: l1, }, ImageQuadVertex { pos: p2, uv: [1.0, 0.0], pos_low: l2, }, ImageQuadVertex { pos: p0, uv: [0.0, 1.0], pos_low: l0, }, ImageQuadVertex { pos: p2, uv: [1.0, 0.0], pos_low: l2, }, ImageQuadVertex { pos: p3, uv: [0.0, 0.0], pos_low: l3, }, ] } /// Visible-region triangles in image PIXEL space (flat, groups of 3): the whole /// image rectangle when unclipped, else the clip rectangle or the triangulated /// clip polygon, or the image with the polygon cut out when the boundary /// hides its inside. fn clip_triangles_px(img: &codec::entities::RasterImage) -> Vec<[f64; 2]> { use codec::entities::ClipMode; let w = img.size.x; let h = img.size.y; let quad = || vec![[0.0, 0.0], [w, 0.0], [w, h], [0.0, 0.0], [w, h], [0.0, h]]; let Some(poly) = crate::entities::raster_image::image_clip_polygon(img) else { return quad(); }; // Inside mode shows the image with the boundary cut out. let (points, triangles) = if img.clip_boundary.clip_mode == ClipMode::Inside { kernel::geom2d::triangulate(&[[0.0, 0.0], [w, 0.0], [w, h], [0.0, h]], &[poly]) } else { kernel::geom2d::triangulate(&poly, &[]) }; let tris: Vec<[f64; 2]> = triangles .into_iter() .flat_map(|t| t.map(|i| points[i])) .collect(); if tris.is_empty() { quad() } else { tris } } #[derive(Clone, Debug)] pub struct ImageModel { pub render_instance: Option, /// Original file path (used for reload / display in properties). pub file_path: String, /// RGBA8 pixel data in row-major order. Arc-wrapped so cloning ImageModel /// is O(1) — the pixel bytes are shared, not copied. pub pixels: Arc>, pub width: u32, pub height: u32, /// Opacity: 1.0 = opaque, 0.0 = transparent. pub opacity: f32, /// World-space quad corners (CCW), same order as image_corners() helper: /// [0] origin (bottom-left) /// [1] origin + U*W (bottom-right) /// [2] origin + U*W + V*H (top-right) /// [3] origin + V*H (top-left) pub corners: [[f32; 3]; 4], /// Low residual paired with `corners` (double-single) so the GPU keeps /// sub-unit precision at UTM-scale insertion points. pub corners_low: [[f32; 3]; 4], /// Normalized draw-order depth in (0,1); higher draws on top. Fed to the /// image pipeline as a small clip-z bias so the raster orders correctly /// against other entity types. pub draw_depth: f32, /// Textured triangles of the image's VISIBLE region — the full quad, or /// (when the entity carries a clip boundary) the triangulated clip polygon /// — so the raster is drawn only inside its clip boundary. pub verts: Vec, /// Magnified pixels stay square (raster images); otherwise they blend /// (PDF pages, OLE pictures). pub pixelated: bool, /// Pixel alpha applies. A raster image whose transparency is off draws /// every pixel opaque in its stored colour. pub use_alpha: bool, } /// The colour adjustments an underlay's content is drawn with over /// the background: contrast, monochrome, the background adjustment and (DGN) /// the fade. pub(crate) fn underlay_adjust( u: &codec::entities::Underlay, def: &codec::entities::UnderlayDefinition, background: [f32; 4], ) -> super::pdf_raster::PageAdjust { use super::pdf_raster::PageAdjust; use codec::entities::{UnderlayDisplayFlags, UnderlayType}; let page = crate::entities::underlay::page_of(def); // Dark means an HSL lightness under one half: pure blue counts as // light, (0, 128, 0) as dark. let bg_max = background[0].max(background[1]).max(background[2]); let bg_min = background[0].min(background[1]).min(background[2]); let bg_lum = (bg_max + bg_min) / 2.0; let dgn = def.underlay_type == UnderlayType::Dgn; // A DGN model is faded into the background before its contrast is // applied (the reference's order), so it is drawn opaque. let bg_rgb = [0, 1, 2].map(|i| (background[i].clamp(0.0, 1.0) * 255.0).round() as u8); let fade_before_contrast = (dgn && u.fade > 0).then_some((bg_rgb, u.fade.min(100))); PageAdjust { contrast: u.contrast.min(100), // Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are // turned over for a dark background; a DGN model's from its // own colours, over any background. contrast_pivot: match def.underlay_type { UnderlayType::Pdf => 500, UnderlayType::Dwf if u.flags .contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 => { 730 } UnderlayType::Dwf => 243, UnderlayType::Dgn => super::underlay_vector::dgn_contrast_pivot( &def.file_path, page, &super::pdf_layers::hidden_layers(u), super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]), fade_before_contrast, ) .unwrap_or(404), }, monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), // A DGN model's colours follow the background through its // colour table, whatever this setting. adjust_for_background: u .flags .contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && !dgn, dark_background: bg_lum < 0.5, fade_before_contrast, } } impl ImageModel { /// Build an ImageModel from a DXF RasterImage entity. /// Returns `None` if the image file cannot be opened or decoded. pub fn from_raster_image(img: &codec::entities::RasterImage) -> Option { let w = img.size.x; let h = img.size.y; // A degenerate or non-finite size/vectors (hostile entity data) // produce NaN placement math that aborts whole PDF exports; treat // it as an unresolvable reference instead. if !(w.is_finite() && h.is_finite() && w > 0.0 && h > 0.0) { return None; } let vector_ok = |v: &codec::Vector3| { let (x, y, z) = (v.x, v.y, v.z); x.is_finite() && y.is_finite() && z.is_finite() && (x != 0.0 || y != 0.0 || z != 0.0) }; if !vector_ok(&img.u_vector) || !vector_ok(&img.v_vector) { return None; } // Model-space geometry is drawn in (WCS - world_offset) so large UTM- // scale coordinates stay within f32 precision; offset the image too. // Corners come from a large insertion point plus small u/v spans. // Split each into double-single (high, low) f32 so the GPU keeps // sub-unit precision at UTM scale and after a cross-drawing paste. let oxv = img.insertion_point.x; let oyv = img.insertion_point.y; let ozv = img.insertion_point.z; let ux = (img.u_vector.x * w) as f32; let uy = (img.u_vector.y * w) as f32; let uz = (img.u_vector.z * w) as f32; let vx = (img.v_vector.x * h) as f32; let vy = (img.v_vector.y * h) as f32; let vz = (img.v_vector.z * h) as f32; // High/low split of the anchor; the u/v spans are small and added to // the high half (their own residual is below f32 noise at this scale). let ox = oxv as f32; let oy = oyv as f32; let oz = ozv as f32; let oxl = (oxv - ox as f64) as f32; let oyl = (oyv - oy as f64) as f32; let ozl = (ozv - oz as f64) as f32; let corners = [ [ox, oy, oz], [ox + ux, oy + uy, oz + uz], [ox + ux + vx, oy + uy + vy, oz + uz + vz], [ox + vx, oy + vy, oz + vz], ]; let corners_low = [[oxl, oyl, ozl]; 4]; let opacity = 1.0 - img.fade as f32 / 100.0; // Visible region as textured triangles. When the entity carries a clip // boundary this is the triangulated clip polygon (each pixel-space // vertex mapped to world via u/v and to a texel UV), so the raster is // painted only inside its boundary; otherwise it's the full quad. let verts: Vec = clip_triangles_px(img) .iter() .map(|&[px, py]| { let fu = (px / w) as f32; let fv = (py / h) as f32; ImageQuadVertex { pos: [ ox + ux * fu + vx * fv, oy + uy * fu + vy * fv, oz + uz * fu + vz * fv, ], uv: [fu, 1.0 - fv], pos_low: [oxl, oyl, ozl], } }) .collect(); let decoded = resolve_image(&img.file_path)?; Some(Self { render_instance: None, file_path: img.file_path.clone(), pixels: adjusted_image_pixels( &img.file_path, &decoded.pixels, img.brightness, img.contrast, ), width: decoded.width, height: decoded.height, opacity, corners, corners_low, draw_depth: 0.0, verts, pixelated: true, use_alpha: img .flags .contains(codec::entities::ImageDisplayFlags::TRANSPARENCY_ON), }) } } impl ImageModel { /// Build an ImageModel from a PDF UNDERLAY + its definition object. /// /// The page rasterises through `pdf_raster` with no page background, so /// only the drawn content covers the drawing; contrast, monochrome and /// the background adjustment recolour that raster and fade lowers its /// opacity. The world quad is the page size in inches (1 drawing unit per /// page inch) times the entity scale, placed at the insertion with the /// entity rotation. A clip boundary (on, 2+ vertices, in underlay units) /// limits the drawn area to its inside, or to the page outside it when /// inverted. `None` when the underlay is off, non-PDF, or the page can't /// be rendered (caller keeps the outline placeholder). /// `world_per_pixel` is the view's scale: the page is rasterised near /// the size it shows on screen (full size when `None` or when larger), /// so thin lines stay one crisp pixel wide when zoomed out. pub fn from_underlay( u: &codec::entities::Underlay, def: &codec::entities::UnderlayDefinition, background: [f32; 4], world_per_pixel: Option, ) -> Option { use super::pdf_raster; use codec::entities::{UnderlayDisplayFlags, UnderlayType}; if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } if def.unloaded { return None; } let page = crate::entities::underlay::page_of(def); let rect = crate::entities::underlay::definition_rect(def)?; // The page's longest side on screen, pixels (the view scale is taken // a step coarser, so the raster is never reduced on screen and its // one-pixel lines stay unbroken). let screen_side = world_per_pixel.filter(|wpp| *wpp > 0.0).map(|wpp| { let w = (rect[2] - rect[0]) * u.x_scale.abs(); let h = (rect[3] - rect[1]) * u.y_scale.abs(); w.max(h) / wpp }); let (source, raster) = match def.underlay_type { UnderlayType::Pdf => { // Hidden PDF layers come from the underlay's layer overrides. let source = super::pdf_layers::underlay_source(u, &def.file_path); let inches = (rect[2] - rect[0]).max(rect[3] - rect[1]); let raster = match screen_side { Some(side) if inches > 0.0 && side / inches < pdf_raster::DISPLAY_DPI as f64 => { pdf_raster::rasterize_page_display_at( &source, page, (side / inches).max(8.0) as f32, )? } _ => pdf_raster::rasterize_page_display(&source, page)?, }; (source, raster) } // DWF sheets and DGN models are drawn as vector wires // (`underlay_vector::display_wires`). _ => return None, }; let pixels = pdf_raster::adjusted_pixels( &source, page, &raster, underlay_adjust(u, def, background), ); // The page rectangle in drawing units (1 unit per PDF inch, or per // DWF/DGN sheet unit), entity scale applied, from its lower-left // corner. let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]); let w_du = page_w * u.x_scale; let h_du = page_h * u.y_scale; if w_du.abs() <= 0.0 || h_du.abs() <= 0.0 { return None; } let (c, s) = (u.rotation.cos(), u.rotation.sin()); let (uxv, uyv) = (c * w_du, s * w_du); let (vxv, vyv) = (-s * h_du, c * h_du); let [oxv, oyv, ozv] = crate::entities::underlay::local_to_world(u, [rect[0], rect[1]]); let ox = oxv as f32; let oy = oyv as f32; let oz = ozv as f32; let oxl = (oxv - ox as f64) as f32; let oyl = (oyv - oy as f64) as f32; let ozl = (ozv - oz as f64) as f32; let (ux, uy) = (uxv as f32, uyv as f32); let (vx, vy) = (vxv as f32, vyv as f32); let corners = [ [ox, oy, oz], [ox + ux, oy + uy, oz], [ox + ux + vx, oy + uy + vy, oz], [ox + vx, oy + vy, oz], ]; let corners_low = [[oxl, oyl, ozl]; 4]; let tris_uv = underlay_visible_uv(u, rect); let verts: Vec = tris_uv .iter() .map(|&[fu, fv]| { let (fu, fv) = (fu as f32, fv as f32); ImageQuadVertex { pos: [ox + ux * fu + vx * fv, oy + uy * fu + vy * fv, oz], uv: [fu, 1.0 - fv], pos_low: [oxl, oyl, ozl], } }) .collect(); Some(Self { render_instance: None, file_path: def.file_path.clone(), pixels, width: raster.width, height: raster.height, opacity: 1.0 - u.fade.min(100) as f32 / 100.0, corners, corners_low, draw_depth: 0.0, verts, pixelated: false, use_alpha: true, }) } } /// Visible region of an underlay page as triangles in page UV (0..1, y up): /// the whole page, the clip polygon, or — for an inverted clip — the page /// with the polygon cut out. fn underlay_visible_uv(u: &codec::entities::Underlay, rect: [f64; 4]) -> Vec<[f64; 2]> { let (page_w, page_h) = (rect[2] - rect[0], rect[3] - rect[1]); use codec::entities::UnderlayDisplayFlags; let page = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]; let whole = || { vec![ [0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [1.0, 1.0], [0.0, 1.0], ] }; let clip = crate::entities::underlay::clip_polygon_local(u); if !u.flags.contains(UnderlayDisplayFlags::CLIPPING) || clip.len() < 3 { return whole(); } let ring: Vec<[f64; 2]> = clip .iter() .map(|p| [(p[0] - rect[0]) / page_w, (p[1] - rect[1]) / page_h]) .collect(); let (points, triangles) = if u.clip_inverted { kernel::geom2d::triangulate(&page, &[ring]) } else { kernel::geom2d::triangulate(&ring, &[]) }; triangles .into_iter() .flat_map(|t| t.map(|i| points[i])) .collect() } impl ImageModel { /// Build an ImageModel from an OLE2FRAME's embedded presentation. /// The blob's compound file is parsed for the native raster or the cached /// metafile picture (rasterized by the `gdi` player); returns `None` when /// the frame is degenerate or nothing in the blob decodes, so the caller /// falls back to the frame placeholder. pub fn from_ole2frame(ole: &codec::entities::Ole2Frame) -> Option { let payload = ole.encoded_payload(); let (pixels, width, height) = super::ole_pres::decode(&payload)?; // Frame rectangle in WCS. `upper_left`/`lower_right` name the diagonal; // normalise to left/right/top/bottom so the bitmap sits upright. let left = ole.upper_left_corner.x.min(ole.lower_right_corner.x); let right = ole.upper_left_corner.x.max(ole.lower_right_corner.x); let bottom = ole.upper_left_corner.y.min(ole.lower_right_corner.y); let top = ole.upper_left_corner.y.max(ole.lower_right_corner.y); let z = ole.upper_left_corner.z; if (right - left).abs() < 1e-9 || (top - bottom).abs() < 1e-9 { return None; } // Double-single split per corner so the quad stays precise at UTM scale. let split = |x: f64, y: f64| -> ([f32; 3], [f32; 3]) { let (hx, hy, hz) = (x as f32, y as f32, z as f32); ( [hx, hy, hz], [ (x - hx as f64) as f32, (y - hy as f64) as f32, (z - hz as f64) as f32, ], ) }; // corners: [BL, BR, TR, TL] — the image pipeline maps texel (0,0) to TL. let (c0, l0) = split(left, bottom); let (c1, l1) = split(right, bottom); let (c2, l2) = split(right, top); let (c3, l3) = split(left, top); let corners = [c0, c1, c2, c3]; let corners_low = [l0, l1, l2, l3]; let verts = quad_verts(&corners, &corners_low); Some(Self { render_instance: None, file_path: "OLE2FRAME".to_string(), pixels: Arc::new(pixels), width, height, opacity: 1.0, corners, corners_low, draw_depth: 0.0, verts, pixelated: false, use_alpha: true, }) } } /// Decoded RGBA image shared between the raster pipeline and the /// unresolved-reference probe. Cheap to clone — the pixels are `Arc`-shared. #[derive(Clone, Debug)] pub struct DecodedImage { pub pixels: Arc>, pub width: u32, pub height: u32, /// The SOURCE FILE's pixel dimensions (from the pre-decode header probe), /// which remain the entity's sizing truth even when `width`/`height`/ /// `pixels` were display-capped: the `ImageModel` world quad comes from /// the entity's own `size` (never from these fields), and a downsampled /// buffer keeps the aspect ratio, so the on-paper footprint never /// shrinks — while the PDF exporter derives effective DPI from placement /// size × stored pixels and self-corrects. pub source_width: u32, pub source_height: u32, } type ImageCacheEntry = Arc>>; fn image_cache() -> &'static Mutex> { static CACHE: OnceLock>> = OnceLock::new(); CACHE.get_or_init(|| Mutex::new(HashMap::new())) } /// Insertion order of the source cache's keys, so by-name resolution has a /// deterministic winner (the MOST RECENT registration) instead of HashMap's /// unspecified iteration order. fn source_order() -> &'static Mutex> { static ORDER: OnceLock>> = OnceLock::new(); ORDER.get_or_init(|| Mutex::new(Vec::new())) } fn source_cache() -> &'static Mutex>>> { static SOURCES: OnceLock>>>> = OnceLock::new(); SOURCES.get_or_init(|| Mutex::new(HashMap::new())) } /// Lower-cased trailing component of a path-style reference. Drawings store /// image paths as the author wrote them (often an absolute foreign path), so /// name — not full path — is the only component a dropped or picked file on /// another machine can be expected to match. pub(crate) fn file_name_key(path: &str) -> String { path.rsplit(['/', '\\']) .next() .unwrap_or_default() .to_ascii_lowercase() } /// Do two image references name the same file? Compares trailing components /// case-insensitively (the web has no path beyond the name at all, and Windows /// paths are case-insensitive where the drawing was authored). pub(crate) fn same_file_name(a: &str, b: &str) -> bool { let (a, b) = (file_name_key(a), file_name_key(b)); !a.is_empty() && a == b } /// Keep bytes selected through a file dialog or dropped onto the page, keyed /// by the reference the drawing will carry. The browser exposes no reusable /// filesystem path, so `decode_reference` reads these instead of a disk file; /// native builds also skip a second disk read. Registering invalidates every /// memoised answer that names the same file, so a re-pick (or a drop that /// finally supplies a missing reference) re-decodes instead of replaying a /// stale success or a cached failure. pub fn register_source(path: &str, bytes: Arc>) { { let mut sources = source_cache() .lock() .unwrap_or_else(|error| error.into_inner()); if !sources.contains_key(path) { source_order() .lock() .unwrap_or_else(|error| error.into_inner()) .push(path.to_string()); } sources.insert(path.to_string(), bytes); } if let Ok(mut cache) = image_cache().lock() { cache.retain(|cached, _| !same_file_name(cached, path)); } } /// Is a source registered under exactly this reference? The web paste path /// uses it to keep its generated `pasted-…` names unique: the registry is /// last-wins, so a reused name would silently re-point every earlier paste's /// entity at the newest pixels. Exact key match — generated names embed their /// own extension and are all lower-case digits. pub fn has_source(path: &str) -> bool { source_cache() .lock() .unwrap_or_else(|error| error.into_inner()) .contains_key(path) } /// Every reference with bytes currently registered (picked or dropped). /// Used after a web open settles to catch images registered while the load — /// which builds the raster models — was already running. pub fn registered_image_names() -> Vec { source_cache() .lock() .unwrap_or_else(|error| error.into_inner()) .keys() .cloned() .collect() } /// Bytes registered for exactly this reference. fn registered_bytes(path: &str) -> Option>> { source_cache() .lock() .unwrap_or_else(|error| error.into_inner()) .get(path) .cloned() } /// Bytes registered under a different path that names the same file — the /// web fallback for images inside opened drawings, whose stored path is the /// original author's (a foreign absolute path no browser can open) while the /// user dropped or picked only the bare file. fn registered_bytes_by_name(path: &str) -> Option>> { let cache = source_cache() .lock() .unwrap_or_else(|error| error.into_inner()); // Deterministic winner: walk the insertion order newest-first so the // most recent drop/pick wins, instead of HashMap iteration order. let order = source_order() .lock() .unwrap_or_else(|error| error.into_inner()); order .iter() .rev() .filter(|key| cache.contains_key(*key)) .find(|key| same_file_name(key, path)) .and_then(|key| cache.get(key).cloned()) } /// Original image file bytes registered for a reference, resolved the way /// `decode_reference` resolves pixels: exact reference first, then any /// registration that names the same file. The web ARCHIVE/ETRANSMIT /// packages these bytes into the transmittal; `None` means nothing was /// picked or dropped for the reference this session. pub fn registered_source_bytes(path: &str) -> Option>> { let path = path.trim(); if path.is_empty() { return None; } registered_bytes(path).or_else(|| registered_bytes_by_name(path)) } /// Drop every memoised image so the next resolve re-reads / re-fetches. Called /// when a new document is opened — a fresh drawing shouldn't inherit a prior /// one's stale successes or offline failures. pub fn clear_image_cache() { if let Ok(mut cache) = image_cache().lock() { cache.clear(); } } /// Brightness and contrast (0–100, 50 = unchanged) as one lookup table per /// colour channel. Measured against the reference's display of a gradient: /// brightness is a power curve x^g with g = ((100 − B) / B)^0.72 (0 is black, /// 100 white); contrast bends lightness around one half the same way, /// 0.5 ± 0.5·|2x − 1|^h with h = ((100 − C) / C)^0.72 (0 is flat grey, 100 a /// hard step). Brightness is applied first. Fade is the model's opacity. pub(crate) fn brightness_contrast_lut(brightness: u8, contrast: u8) -> [u8; 256] { // ponytail: the 0.72 exponent is a least-squares fit of seven measured // levels (within about 10/255); refine with more reference samples. let curve = |v: u8| -> f64 { let v = v.min(100); (f64::from(100 - v) / f64::from(v.max(1))).powf(0.72) }; let mut lut = [0u8; 256]; for (i, out) in lut.iter_mut().enumerate() { let mut x = i as f64 / 255.0; x = match brightness.min(100) { 0 => 0.0, 100 => if i == 0 { 0.0 } else { 1.0 }, b => x.powf(curve(b)), }; x = match contrast.min(100) { 0 => 0.5, 100 => if x >= 0.5 { 1.0 } else { 0.0 }, c => { let d = 2.0 * x - 1.0; 0.5 + 0.5 * d.signum() * d.abs().powf(curve(c)) } }; *out = (x.clamp(0.0, 1.0) * 255.0).round() as u8; } lut } /// The decoded raster with the entity's brightness and contrast applied, /// memoised per source and setting (the GPU keeps textures per pixel buffer). /// An entry holds its source buffer, so the address in its key cannot be /// handed to another decode (a reloaded file) while the entry lives. fn adjusted_image_pixels( path: &str, pixels: &Arc>, brightness: u8, contrast: u8, ) -> Arc> { if brightness == 50 && contrast == 50 { return Arc::clone(pixels); } type Key = (String, usize, u8, u8); type Entry = (Arc>, Arc>); static CACHE: OnceLock>> = OnceLock::new(); let key = (path.to_string(), Arc::as_ptr(pixels) as usize, brightness, contrast); let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); if let Some((_, hit)) = cache.lock().unwrap_or_else(|p| p.into_inner()).get(&key) { return Arc::clone(hit); } let lut = brightness_contrast_lut(brightness, contrast); let mut out = pixels.as_ref().clone(); for px in out.chunks_exact_mut(4) { for c in &mut px[..3] { *c = lut[*c as usize]; } } let out = Arc::new(out); let mut cache = cache.lock().unwrap_or_else(|p| p.into_inner()); // ponytail: cleared wholesale past 64 entries; an LRU if many images are adjusted. if cache.len() > 64 { cache.clear(); } cache.insert(key, (Arc::clone(pixels), Arc::clone(&out))); out } /// Resolve an image reference to decoded pixels, memoised per path. Handles a /// local file and an `http`/`https` URL (fetched synchronously natively; on /// the web the first resolve kicks a background fetch and returns `None` — /// the placeholder shows until the fetched bytes land and the poll /// re-resolves). Returns `None` for anything that can't be shown (missing /// file, offline, decode error, or a fetch still in flight on the web build). /// The result — including a `None` — is cached so the raster loader and the /// unresolved-reference placeholder agree on one answer without fetching /// twice. pub fn resolve_image(path: &str) -> Option { let path = path.trim(); if path.is_empty() { return None; } let entry = { let mut cache = image_cache() .lock() .unwrap_or_else(|error| error.into_inner()); Arc::clone( cache .entry(path.to_string()) .or_insert_with(|| Arc::new(OnceLock::new())), ) }; entry.get_or_init(|| decode_reference(path)).clone() } /// Decode a reference to RGBA pixels. Priority: registered bytes (picked or /// dropped — the only source on the web, where the reference is a name-only /// path), then the filesystem (native), then any registered file that merely /// shares the name (a drawing's foreign absolute path vs a dropped file). /// `http`/`https` references are a network fetch — synchronous on native, a /// one-shot background fetch on the web whose bytes register under the URL /// (so the exact URL and any same-name path both resolve afterwards). fn decode_reference(path: &str) -> Option { let lower = path.to_ascii_lowercase(); if lower.starts_with("http://") || lower.starts_with("https://") { // The web fetch registers its bytes under the URL itself; a // re-resolve (after the poll re-populates) must read them instead of // spawning another fetch. #[cfg(target_arch = "wasm32")] if let Some(bytes) = registered_bytes(path) { return decode_or_note(decode_from_memory(&bytes), path); } let bytes = fetch_remote(path)?; return decode_or_note(decode_from_memory(&bytes), path); } if let Some(bytes) = registered_bytes(path) { return decode_or_note(decode_from_memory(&bytes), path); } #[cfg(not(target_arch = "wasm32"))] if let Some(decoded) = decode_or_note(decode_from_file(path), path) { return Some(decoded); } registered_bytes_by_name(path) .as_deref() .and_then(|bytes| decode_or_note(decode_from_memory(bytes), path)) } // ── Decode budgets (display-LOD bounding) ─────────────────────────────────── // // A 500 MB aerial/GIS TIFF can decode to multiple GB of RGBA — enough to kill // a browser tab, and a 20000×20000 raster alone is a >1 GB GPU texture (the // image pipeline tiles whatever it receives past `max_texture_dimension_2d`, // so the CPU-side cost is the real problem). Every decode therefore passes // two per-target budgets, applied in `decode_with_budgets` below: /// Ceiling on a full RGBA decode (`width × height × 4`). Decodes that would /// exceed it run with `image::Limits` pinned to the budget, so the decoder /// errors instead of ballooning the heap; this module turns that error into a /// localized refusal (see `refusal_message`) — the pixels are simply not /// available at full size. Desktops have more headroom than a tab, but the /// whole decode transient lands at once, so it stays well below typical RAM; /// wasm lives inside a browser tab whose heap the decode must not pop. #[cfg(not(target_arch = "wasm32"))] const MAX_DECODE_BYTES: u64 = 2 * 1024 * 1024 * 1024; /// wasm: 512 MB of decoded RGBA — comfortably inside a tab's heap. #[cfg(target_arch = "wasm32")] const MAX_DECODE_BYTES: u64 = 512 * 1024 * 1024; /// Ceiling on the STORED pixel count — what feeds the GPU upload, the PDF /// embed, and (on wasm) the session heap. Images over it still decode, up to /// `MAX_DECODE_BYTES`, but are Lanczos3-downsampled to the cap before /// entering an `ImageModel`; the original BYTES stay registered for a future /// re-decode, and `DecodedImage::source_width/height` keep the file's true /// dimensions as the entity's sizing truth. Native plots may want the extra /// headroom (8192² = 67.1 Mpx); the browser keeps a stricter cap /// (4096² = 16.8 Mpx) to bound both heap and texture upload. #[cfg(not(target_arch = "wasm32"))] const MAX_DISPLAY_PIXELS: u64 = 8192 * 8192; #[cfg(target_arch = "wasm32")] const MAX_DISPLAY_PIXELS: u64 = 4096 * 4096; /// How a probed image size may be decoded under the budgets above. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum DecodeTier { /// Store the decoded pixels untouched. Full, /// Decode fully, then downsample to the display cap before storing. Downsample, /// The decode's peak memory would exceed `MAX_DECODE_BYTES`. The probe /// bills the peak of the decoder's NATIVE bytes per pixel and the final /// RGBA buffer (4/px): both transiently coexist mid-decode on 16-bit /// formats, and the decoder's own limits reserve at native bpp — so an /// image is refused up front when either exceeds the budget, without /// decoding. OverBudget, } /// The decoder's own memory accounting for a format: how many bytes per /// pixel the DECODE transiently needs (native layout, plus the RGBA output /// where they coexist) and any codec-specific scratch multiplier on top. struct NativeFootprint { /// Peak bytes per pixel during decode (native bpp + RGBA where both /// live simultaneously; plain 8-bit formats overlap them in place). peak_bpp: u64, /// Extra codec scratch allowance the decoder stacks on the reserve /// (image's TIFF codec needs ~3x in the worst strip/tile layout). codec_factor: u64, } fn native_footprint(format: Option, color: Option) -> NativeFootprint { let native_bpp = color.map_or(4u64, |color| match color { image::ColorType::L8 => 1, image::ColorType::La8 => 2, image::ColorType::Rgb8 => 3, image::ColorType::Rgba8 => 4, image::ColorType::L16 => 2, image::ColorType::La16 => 4, image::ColorType::Rgb16 => 6, image::ColorType::Rgba16 => 8, image::ColorType::Rgb32F => 12, image::ColorType::Rgba32F => 16, _ => 4, }); // `DynamicImage::into_rgba8` only MOVES for Rgba8; every other layout // — including the 4 B/px La16 — allocates the RGBA destination while // the native buffer is still alive, so both coexist (image 0.25 // buffer.rs cast_in_color_space). let peak_bpp = if matches!(color, Some(image::ColorType::Rgba8)) { 4 } else { native_bpp + 4 }; // GIF decodes the frame into its own buffer while the screen-sized // output buffer is live — two RGBA-sized allocations coexist. let peak_bpp = match format { Some(image::ImageFormat::Gif) => peak_bpp.max(8), _ => peak_bpp, }; // tiff 0.11 splits the given budget between the frame buffer and the // chunk scratch (a TOTAL cap), so no multiplier is needed: the honest // peak is the coexisting native+RGBA pair, already in peak_bpp. let codec_factor = 1; NativeFootprint { peak_bpp, codec_factor } } /// Budget decision for probed pixel dimensions. Pure and cfg-independent /// when the budgets are passed explicitly, so tests can probe arbitrary /// budgets; the decode path always passes this target's constants. #[cfg_attr(not(test), allow(dead_code))] fn tier_for( width: u32, height: u32, max_decode_bytes: u64, max_display_pixels: u64, ) -> DecodeTier { tier_for_native(width, height, 4, 1, max_decode_bytes, max_display_pixels) } /// [`tier_for`] with the probed format's native footprint: the caller /// supplies the probe's color type and format so 16-bit images are billed /// at what their decode actually allocates. fn tier_for_native( width: u32, height: u32, peak_bpp: u64, codec_factor: u64, max_decode_bytes: u64, max_display_pixels: u64, ) -> DecodeTier { let pixels = width as u64 * height as u64; if pixels .saturating_mul(peak_bpp) .saturating_mul(codec_factor) > max_decode_bytes { return DecodeTier::OverBudget; } if pixels <= max_display_pixels { return DecodeTier::Full; } // The downsample holds the decoded RGBA, the resize's vertical-pass // intermediate (width × new height in RGBA f32, 16 B/px) and the output // at once, so that sum must fit the budget too. let (target_w, target_h) = downsample_to_cap(width, height, max_display_pixels); let resize_peak = pixels .saturating_mul(4) .saturating_add((width as u64).saturating_mul(target_h as u64).saturating_mul(16)) .saturating_add(target_w as u64 * target_h as u64 * 4); if resize_peak > max_decode_bytes { DecodeTier::OverBudget } else { DecodeTier::Downsample } } /// Largest dimensions that keep `width × height` within `cap` while /// preserving the aspect ratio: each side floors (so the product never /// exceeds the cap while both sides stay above 1 px) and clamps to 1 px — a /// degenerate strip may then stay slightly over, since it cannot shrink /// further. Images already within the cap pass through unchanged. fn downsample_to_cap(width: u32, height: u32, cap: u64) -> (u32, u32) { let pixels = width as u64 * height as u64; if width == 0 || height == 0 || pixels <= cap { return (width, height); } let scale = (cap as f64 / pixels as f64).sqrt(); let shrink = |dim: u32| { let scaled = (dim as f64 * scale).floor() as u64; scaled.clamp(1, dim as u64) as u32 }; (shrink(width), shrink(height)) } /// Outcome of one tiered decode attempt. enum DecodeAttempt { /// Pixels decoded — and display-capped when the tier called for it — /// ready to store. Decoded(DecodedImage), /// The decoder's limits rejected the image: full RGBA would exceed /// `MAX_DECODE_BYTES`. Carries the probed dimensions for the refusal /// note; the caller decides how to surface it. Refused { width: u32, height: u32 }, /// Unreadable, unsupported, or corrupt. Failed, } /// Decode an image through the budget tiers. The header dimensions are /// probed BEFORE any pixel decode, so the tiers act on size alone; `open` is /// called twice — once for the probe, once for the decode — because both /// passes consume the reader. fn decode_with_budgets( open: impl Fn() -> std::io::Result>, max_decode_bytes: u64, max_display_pixels: u64, ) -> DecodeAttempt { // Probe pass: header dimensions AND color type, no pixel decode. The // probe reader carries the full budget too so constructing a decoder on // a huge-but-in-budget header cannot itself fail on image's default // 512 MiB probe limits. let probed = open() .and_then(|reader| reader.with_guessed_format()) .ok() .and_then(|mut reader| { let format = reader.format(); let mut probe_limits = image::Limits::default(); probe_limits.max_alloc = Some(max_decode_bytes); reader.limits(probe_limits); use image::ImageDecoder as _; let decoder = reader.into_decoder().ok()?; let (width, height) = decoder.dimensions(); let color = decoder.color_type(); Some((width, height, format, color)) }); let tier = probed.map_or(DecodeTier::Full, |(width, height, format, color)| { let footprint = native_footprint(format, Some(color)); tier_for_native( width, height, footprint.peak_bpp, footprint.codec_factor, max_decode_bytes, max_display_pixels, ) }); // Trust the probe, not the decoder limits: the tier above bills the // decode's real peak (native bpp, codec scratch), so an OverBudget // image provably cannot fit; refuse without decoding. This covers the // grayscale/RGB over-RGBA-budget balloon AND the 16-bit formats whose // native layout is double the RGBA size. if tier == DecodeTier::OverBudget { return DecodeAttempt::Refused { width: probed.map_or(0, |(width, _, _, _)| width), height: probed.map_or(0, |(_, height, _, _)| height), }; } let mut reader = match open().and_then(|reader| reader.with_guessed_format()) { Ok(reader) => reader, Err(_) => return DecodeAttempt::Failed, }; // For formats that FIT the budget, the limits still guard runaway // allocations mid-decode. GIF is special: the probe reads the LOGICAL // SCREEN, but a crafted frame may declare itself arbitrarily larger // and the gif crate does not enforce frame-vs-screen consistency — so // the frame buffer is capped to the probed size plus slack, refusing // frame bombs into the existing Refused path. TIFF stacks its own // intermediate scratch on top of the reserve (~3x worst case in image // 0.25); the tier already folded that factor in, and the allowance // matches it so an in-tier TIFF never codec-refuses. let mut limits = image::Limits::default(); // tiff 0.11 SPLITS this cap between the frame buffer and chunk scratch // (a total ceiling), so the plain budget is the right allowance; the // ×3 multiplier that image's older tiff codec needed would instead // admit hostile chunk transients up to 3× the budget. limits.max_alloc = Some(match reader.format() { Some(image::ImageFormat::Gif) => probed .map(|(width, height, _, _)| { (width as u64).saturating_mul(height as u64).saturating_mul(4) + 64 * 1024 * 1024 }) .unwrap_or(max_decode_bytes) .min(max_decode_bytes), _ => max_decode_bytes, }); reader.limits(limits); let img = match reader.decode() { Ok(img) => img, Err(image::ImageError::Limits(_)) => { return DecodeAttempt::Refused { width: probed.map_or(0, |(width, _, _, _)| width), height: probed.map_or(0, |(_, height, _, _)| height), }; } Err(_) => return DecodeAttempt::Failed, }; let rgba = img.into_rgba8(); let (width, height) = rgba.dimensions(); // Display cap: an OverBudget decode that succeeded (native headroom) is // necessarily over the cap too — the budgets' invariant guarantees it — // so both capping tiers downsample before storing. let (target_w, target_h) = match tier { DecodeTier::Full => (width, height), DecodeTier::Downsample | DecodeTier::OverBudget => { downsample_to_cap(width, height, max_display_pixels) } }; let rgba = if target_w != width || target_h != height { image::imageops::resize(&rgba, target_w, target_h, image::imageops::FilterType::Lanczos3) } else { rgba }; let (stored_w, stored_h) = rgba.dimensions(); // Sizing truth: the file's probed dimensions, not the (possibly reduced) // buffer's, so placement and aspect come from the original raster. let (source_width, source_height) = probed.map_or((stored_w, stored_h), |(w, h, _, _)| (w, h)); DecodeAttempt::Decoded(DecodedImage { pixels: Arc::new(rgba.into_raw()), width: stored_w, height: stored_h, source_width, source_height, }) } /// Decode in-memory image bytes (picked file, drop, or network body) to RGBA, /// bounded by the decode/display budgets. fn decode_from_memory(bytes: &[u8]) -> DecodeAttempt { decode_with_budgets( || Ok(image::ImageReader::new(std::io::Cursor::new(bytes))), MAX_DECODE_BYTES, MAX_DISPLAY_PIXELS, ) } #[cfg(not(target_arch = "wasm32"))] fn decode_from_file(path: &str) -> DecodeAttempt { decode_with_budgets( || image::ImageReader::open(path), MAX_DECODE_BYTES, MAX_DISPLAY_PIXELS, ) } /// Localized over-budget refusal for `path`: names the image, its probed /// dimensions, the decoded size the budget rejected, and the limit itself. /// TIFFs rejected here have no fallback path — the `tiff` crate is only a /// transitive dependency (via `image`'s tiff feature) and is deliberately /// not promoted to a direct one, so the refusal IS the handling. fn refusal_message(path: &str, width: u32, height: u32) -> String { let name = std::path::Path::new(path) .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or_else(|| path.to_string()); if width == 0 || height == 0 { // The probe could not determine a size (exotic header) — do not // print a self-contradictory "0x0 px" figure. return crate::tf!( "\"{name}\" could not be sized from its header and decoding it exceeded the {limit} MB image limit of this build — it cannot be attached.", name = name, limit = MAX_DECODE_BYTES / (1024 * 1024) ) .into_owned(); } let decoded_mb = (width as u64).saturating_mul(height as u64).saturating_mul(4) / (1024 * 1024); let limit_mb = MAX_DECODE_BYTES / (1024 * 1024); crate::tf!( "\"{name}\" is {width}×{height} px and would decode to {decoded} MB, over the {limit} MB image limit of this build — it cannot be attached. Images within the limit are attached at a reduced display resolution when needed.", name = name, width = width, height = height, decoded = decoded_mb, limit = limit_mb ) .into_owned() } /// Over-budget refusals recorded by `decode_or_note`, drained by the UI /// (open/drop settle, image pick) to reach the command line. fn refusal_notes() -> &'static Mutex> { static NOTES: OnceLock>> = OnceLock::new(); NOTES.get_or_init(|| Mutex::new(Vec::new())) } /// Take and clear every recorded decode refusal (most recent last). /// Peek without draining — callers that only want THEIR note (the pick /// path) must not consume refusals queued for other references. pub fn decode_refusals() -> Vec { refusal_notes() .lock() .unwrap_or_else(|error| error.into_inner()) .clone() } /// Take and clear — for the drains that own every queued note (the poll and /// the open-settle paths). pub fn take_decode_refusals() -> Vec { let mut notes = refusal_notes().lock().unwrap_or_else(|error| error.into_inner()); std::mem::take(&mut *notes) } /// Store one decode outcome: pixels pass through, a refusal is noted for the /// UI, and anything else is a plain `None` (broken-reference placeholder). fn decode_or_note(attempt: DecodeAttempt, path: &str) -> Option { match attempt { DecodeAttempt::Decoded(decoded) => Some(decoded), DecodeAttempt::Refused { width, height } => { // Several references can name the same over-budget file; keep // one note per distinct message so the command line is not // spammed by a drawing that inserts it repeatedly. let message = refusal_message(path, width, height); let mut notes = refusal_notes().lock().unwrap_or_else(|error| error.into_inner()); if notes.last() != Some(&message) { notes.push(message); } None } DecodeAttempt::Failed => None, } } /// Fetch a remote image reference. `http`/`https` only (the caller checks the /// scheme, so `file://` and other schemes are never followed); bounded by a /// request timeout and a response-size cap so a slow or hostile URL can't hang /// the load or exhaust memory. Native builds fetch synchronously through /// `ureq`; the web build defers to [`fetch_remote`] in the wasm block below. #[cfg(not(target_arch = "wasm32"))] fn fetch_remote(url: &str) -> Option> { let agent = crate::network::agent(std::time::Duration::from_secs(8)); let mut resp = agent .get(url) .header( "User-Agent", concat!("OpenCADStudio/", env!("OCS_APP_VERSION")), ) .call() .ok()?; const MAX_BYTES: u64 = 32 * 1024 * 1024; resp.body_mut() .with_config() .limit(MAX_BYTES) .read_to_vec() .ok() } // ── Web remote raster fetch ──────────────────────────────────────────────── // // A browser has no synchronous fetch, so the wasm `fetch_remote` can only // START the request: the first resolve of an `http`/`https` reference spawns // a background task, memoises the miss, and leaves the placeholder showing. // When the task settles it registers its bytes under the URL (the same // source registry a pick or drop uses) and queues an outcome for the 50 ms // control poll, which re-resolves the drawing's image models — the exact // flow a dropped raster file already takes. #[cfg(target_arch = "wasm32")] mod web_fetch { use super::register_source; use std::collections::{HashSet, VecDeque}; use std::sync::{Arc, Mutex, OnceLock}; /// Bodies above this are refused (with a surfaced note) before and after /// the read, so a hostile URL can't exhaust wasm memory. The web cap is /// roomier than the native 32 MB because there is no streaming reader /// behind a `fetch()` body here. const MAX_BYTES: u64 = 64 * 1024 * 1024; /// Connect+body deadline, mirroring the native agent timeout. const TIMEOUT_MS: i32 = 8000; /// Outcomes the poll hasn't drained yet; a stuck app can't grow this /// without bound. Same cap convention as the drop queues. const OUTCOME_CAP: usize = 32; /// Why a remote fetch failed. Kept structured so the surface (command /// line) localizes the sentence; the URL travels alongside. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteFetchError { /// The request never produced a response: offline, DNS, refused, or /// (most commonly on the web) a CORS block — the surfaced note says /// so, because a CORS failure looks like an opaque network error to /// the page. Network, /// The server answered with a non-OK status (404, 500, …). Status(u16), /// The declared or received body exceeded [`MAX_BYTES`]. TooLarge, /// The abort deadline fired first. Timeout, } /// A settled background fetch, drained by the app's control poll. #[derive(Debug)] pub enum RemoteFetchOutcome { /// Bytes landed in the source registry under this URL/name — the /// poll re-resolves image models referencing it (exact URL or a /// same-file-name path both match). Loaded(String), /// The fetch failed; the caller localizes [`RemoteFetchError`]. Failed(String, RemoteFetchError), } fn attempted() -> &'static Mutex> { static ATTEMPTED: OnceLock>> = OnceLock::new(); ATTEMPTED.get_or_init(|| Mutex::new(HashSet::new())) } fn outcomes() -> &'static Mutex> { static OUTCOMES: OnceLock>> = OnceLock::new(); OUTCOMES.get_or_init(|| Mutex::new(VecDeque::new())) } /// Queue a settled outcome for the poll. Extras beyond the cap are /// dropped with a console warning, mirroring the drop queues. fn push_outcome(outcome: RemoteFetchOutcome) { let mut queue = outcomes() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if queue.len() >= OUTCOME_CAP { drop(queue); web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str( "ocs: remote-image outcome queue full; dropping an outcome", )); // The dropped outcome may be a Loaded whose rebuild trigger is // lost; remember that so the next poll does one unconditional // image re-resolution instead of waiting for another event. *overflowed() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = true; return; } queue.push_back(outcome); } /// Whether an outcome was dropped at the cap and a catch-up rebuild is /// owed. [`take_overflowed`] consumes it. fn overflowed() -> &'static Mutex { static FLAG: OnceLock> = OnceLock::new(); FLAG.get_or_init(|| Mutex::new(false)) } /// Consume the dropped-outcome flag (see [`overflowed`]). pub fn take_overflowed() -> bool { let mut flag = overflowed() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); std::mem::replace(&mut *flag, false) } /// Pop the oldest settled remote fetch the poll has not handled yet. pub fn take_outcome() -> Option { outcomes() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .pop_front() } /// Why did the `fetch()` promise reject? An abort arrives as a /// `DOMException` named `AbortError`; everything else (a `TypeError`, /// in practice) is a network-level failure the page cannot distinguish /// from a CORS block. fn rejection_kind(error: &wasm_bindgen::JsValue) -> RemoteFetchError { use wasm_bindgen::JsCast; match error.dyn_ref::() { Some(exception) if exception.name() == "AbortError" => RemoteFetchError::Timeout, _ => RemoteFetchError::Network, } } /// Read a response body incrementally, refusing past [`MAX_BYTES`] the /// moment the cap is crossed (not after buffering the whole body). async fn stream_body_capped( window: &web_sys::Window, response: &web_sys::Response, ) -> Result, RemoteFetchError> { use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; let Some(stream) = response.body() else { // No stream API (or already consumed): fall back to buffering, // with the received-length check still applying after. let buffer = JsFuture::from( response.array_buffer().map_err(|_| RemoteFetchError::Network)?, ) .await .map_err(|error| rejection_kind(&error))?; let bytes = js_sys::Uint8Array::new(&buffer).to_vec(); if bytes.len() as u64 > MAX_BYTES { return Err(RemoteFetchError::TooLarge); } return Ok(bytes); }; let reader = stream.get_reader().dyn_into::() .map_err(|_| RemoteFetchError::Network)?; let mut bytes: Vec = Vec::new(); loop { let chunk = JsFuture::from(reader.read()) .await .map_err(|error| rejection_kind(&error))?; let done = js_sys::Reflect::get(&chunk, &wasm_bindgen::JsValue::from_str("done")) .ok() .and_then(|value| value.as_bool()) .unwrap_or(true); if done { break; } let value = js_sys::Reflect::get(&chunk, &wasm_bindgen::JsValue::from_str("value")) .map_err(|_| RemoteFetchError::Network)?; let view = js_sys::Uint8Array::new(&value); if bytes.len() as u64 + view.length() as u64 > MAX_BYTES { // Release the reader lock so the underlying connection can // be torn down, then refuse. let _ = JsFuture::from(reader.cancel()).await; return Err(RemoteFetchError::TooLarge); } bytes.extend_from_slice(&view.to_vec()); } let _ = window; // reader holds the stream; window kept for symmetry Ok(bytes) } /// The actual request. `GET url` with an abort signal armed at /// [`TIMEOUT_MS`]; the body is refused past [`MAX_BYTES`] using the /// declared `Content-Length` first and the received length second. /// `pub(crate)` so the `?file=` deep link can fetch its drawing through /// this exact reader (see `io::web_link`). pub(crate) async fn fetch_once(url: &str) -> Result, RemoteFetchError> { use wasm_bindgen::JsCast; use wasm_bindgen_futures::JsFuture; let window = web_sys::window().ok_or(RemoteFetchError::Network)?; let controller = web_sys::AbortController::new().map_err(|_| RemoteFetchError::Network)?; let init = web_sys::RequestInit::new(); init.set_method("GET"); init.set_signal(Some(&controller.signal())); let request = web_sys::Request::new_with_str_and_init(url, &init) .map_err(|_| RemoteFetchError::Network)?; // Arm the deadline. The one-shot closure outlives the await on // purpose: aborting a settled request is a no-op, and clearing the // timer would need a handle threaded through the error paths for no // behavioural gain. let abort = wasm_bindgen::closure::Closure::once_into_js(move || controller.abort()); let abort = js_sys::Function::from(abort); window .set_timeout_with_callback_and_timeout_and_arguments( abort.as_ref(), TIMEOUT_MS, &js_sys::Array::new(), ) .map_err(|_| RemoteFetchError::Network)?; let response = JsFuture::from(window.fetch_with_request(&request)) .await .map_err(|error| rejection_kind(&error))? .dyn_into::() .map_err(|_| RemoteFetchError::Network)?; if !response.ok() { return Err(RemoteFetchError::Status(response.status())); } // Pre-check the declared size so an oversized body is refused before // it is buffered; then STREAM the body, aborting the moment the // received bytes pass MAX_BYTES — array_buffer() would materialize // a hostile chunked reply in full before any check could fire. let declared = response .headers() .get("Content-Length") .ok() .flatten() .and_then(|value| value.trim().parse::().ok()); if declared.is_some_and(|declared| declared > MAX_BYTES) { return Err(RemoteFetchError::TooLarge); } stream_body_capped(&window, &response).await } /// Start the background fetch for `url` if one hasn't been attempted this /// session. Each URL is fetched at most once: successes register under /// the URL (and survive `clear_image_cache` — they live in the source /// registry), and failures stay unretried like the font store's `Failed` /// state, so a hostile or dead URL cannot be re-hammered by every scene /// rebuild. pub fn spawn(url: &str) { let url = url.trim().to_string(); { let mut attempted = attempted() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if !attempted.insert(url.clone()) { return; } } wasm_bindgen_futures::spawn_local(async move { match fetch_once(&url).await { Ok(bytes) => { register_source(&url, Arc::new(bytes)); push_outcome(RemoteFetchOutcome::Loaded(url)); } Err(error) => push_outcome(RemoteFetchOutcome::Failed(url, error)), } }); } } /// Web: kick the one-shot background fetch and miss now — the placeholder /// shows until the poll sees the registered bytes (see `web_fetch` above). #[cfg(target_arch = "wasm32")] fn fetch_remote(url: &str) -> Option> { web_fetch::spawn(url); None } #[cfg(target_arch = "wasm32")] pub use web_fetch::{ take_outcome as take_remote_fetch_outcome, take_overflowed, RemoteFetchError, RemoteFetchOutcome, }; // The capped streaming reader itself, shared with the `?file=` deep-link // fetch (`io::web_link`) so both doors enforce the same 64 MB / 8 s bounds. #[cfg(target_arch = "wasm32")] pub(crate) use web_fetch::fetch_once as fetch_url_bytes; #[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; /// The decode peak per layout: into_rgba8 is a MOVE for Rgba8 only — /// every other layout pays native + RGBA coexisting. GIF additionally /// carries the screen-sized output beside a (clamped) frame buffer. #[test] fn native_footprint_peaks_match_the_coexistence_math() { use image::ColorType; let png = image::ImageFormat::Png; let gif = image::ImageFormat::Gif; let cases = [ (ColorType::L8, 5u64), (ColorType::La8, 6), (ColorType::Rgb8, 7), (ColorType::Rgba8, 4), (ColorType::L16, 6), // 4 B/px but NOT a move — the La16 hole this test pins. (ColorType::La16, 8), (ColorType::Rgb16, 10), (ColorType::Rgba16, 12), (ColorType::Rgb32F, 16), (ColorType::Rgba32F, 20), ]; for (color, expected) in cases { assert_eq!( native_footprint(Some(png), Some(color)).peak_bpp, expected, "{color:?}" ); } assert_eq!(native_footprint(Some(gif), Some(ColorType::Rgba8)).peak_bpp, 8); assert_eq!(native_footprint(None, None).peak_bpp, 8); } /// Encode a small deterministic PNG entirely in memory — the same codec /// `decode_from_memory` reads, so no fixture file is needed. fn png_bytes(w: u32, h: u32, tint: u8) -> Vec { let img = image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([x as u8, y as u8, tint, 0xff])); let mut out = Vec::new(); image::DynamicImage::ImageRgba8(img) .write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png) .expect("encode test png"); out } /// Registered bytes decode without any filesystem path — the web attach /// flow, where the reference is the picked file's name and nothing else. #[test] fn registered_bytes_decode_without_a_filesystem_path() { let path = "memory://image-registry-exact.png"; register_source(path, Arc::new(png_bytes(7, 5, 0x42))); let decoded = resolve_image(path).expect("registered bytes should decode"); assert_eq!((decoded.width, decoded.height), (7, 5)); assert_eq!(decoded.pixels.len(), 7 * 5 * 4); // First texel is (x=0, y=0) — row 0 counted from the top in the PNG. assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x42]); } /// A re-registration replaces the bytes AND the memoised decode: picking /// the same name again must show the new pixels, not the cached old ones. #[test] fn reregistering_replaces_the_memoised_decode() { let path = "memory://image-registry-repick.png"; register_source(path, Arc::new(png_bytes(3, 3, 0x11))); assert_eq!(resolve_image(path).expect("first decode").width, 3); register_source(path, Arc::new(png_bytes(9, 4, 0x22))); let decoded = resolve_image(path).expect("second decode"); assert_eq!((decoded.width, decoded.height), (9, 4)); assert_eq!(&decoded.pixels[0..3], &[0, 0, 0x22]); } /// The opened-drawing flow: the entity stores the author's absolute path, /// which exists on no machine but this one's — a registered file that /// merely shares the (case-insensitive) name resolves it. #[test] fn a_name_only_match_resolves_a_foreign_path() { register_source("Registry-Name.png", Arc::new(png_bytes(4, 2, 0x33))); let foreign = if cfg!(windows) { "C:\\far away\\registry-name.PNG" } else { "/mnt/far away/registry-name.PNG" }; let decoded = resolve_image(foreign).expect("name match should decode"); assert_eq!((decoded.width, decoded.height), (4, 2)); assert_eq!(decoded.pixels.len(), 4 * 2 * 4); } /// A cached failure must not outlive the registration that fixes it: the /// drop flow registers bytes after the open already memoised `None`. #[test] fn registering_after_a_failed_resolve_retries_the_decode() { let path = "memory://image-registry-late.png"; let foreign = format!("/definitely/not/here/{path}"); assert!(resolve_image(&foreign).is_none(), "nothing registered yet"); register_source(path, Arc::new(png_bytes(2, 2, 0x44))); let decoded = resolve_image(&foreign).expect("late registration should resolve"); assert_eq!((decoded.width, decoded.height), (2, 2)); } /// A reference nothing can satisfy stays `None` (and stays memoised as /// such) — the placeholder keeps its broken-reference treatment. #[test] fn an_unmatchable_reference_stays_none() { assert!(resolve_image("memory://image-registry-missing.png").is_none()); assert!(resolve_image("").is_none()); assert!(resolve_image(" ").is_none()); } /// The name comparison keys on the trailing component only, ignoring /// case and separators, and rejects empty names. #[test] fn file_name_comparison_rules() { assert!(same_file_name("C:\\A\\B\\site.png", "site.png")); assert!(same_file_name("/a/b/SITE.PNG", "site.PNG")); assert!(!same_file_name("site.png", "site.jpg")); assert!(!same_file_name("", "")); assert!(!same_file_name("C:\\dir\\", "dir")); } // ── Display-LOD budgets ────────────────────────────────────────────── /// The tier decision is exact at the budget boundaries and monotone in /// size, and u32 extremes must not overflow into a wrong tier. #[test] fn decode_tiers_split_at_the_budget_boundaries() { // Well within both budgets. assert_eq!(tier_for(64, 64, 1_000_000, 10_000), DecodeTier::Full); // Pixels over the display cap while RGBA stays within the decode // budget: decode, then downsample before storing. assert_eq!(tier_for(400, 400, 2_000_000, 10_000), DecodeTier::Downsample); // …unless the resize transient (decoded + f32 intermediate + output) // is what overflows the budget. assert_eq!(tier_for(400, 400, 1_000_000, 10_000), DecodeTier::OverBudget); // RGBA over the decode budget. assert_eq!(tier_for(2_000, 2_000, 1_000_000, 10_000), DecodeTier::OverBudget); // Exact boundary counts stay in the lower tier (limits are ">"). assert_eq!(tier_for(50, 200, 1_000_000, 10_000), DecodeTier::Full); assert_eq!(tier_for(500, 500, 1_000_000, 1_000_000), DecodeTier::Full); // Extreme dimensions: the u64 math saturates rather than wrapping. assert_eq!( tier_for(u32::MAX, u32::MAX, u64::MAX, u64::MAX), DecodeTier::Full ); assert_eq!( tier_for(u32::MAX, u32::MAX, MAX_DECODE_BYTES, MAX_DISPLAY_PIXELS), DecodeTier::OverBudget ); } /// The per-target constants keep their invariant: the decode budget can /// always hold a display-capped image's RGBA, so an OverBudget image /// that nevertheless decodes (native headroom) always downsamples too. #[test] fn budget_constants_are_consistent() { assert!(MAX_DECODE_BYTES >= MAX_DISPLAY_PIXELS * 4); assert!(MAX_DISPLAY_PIXELS > 0); } /// Downsample targets keep the aspect ratio, stay within the cap, and /// never collapse a dimension below 1 px. #[test] fn downsample_targets_preserve_aspect_and_cap() { // Within the cap (or degenerate): untouched. assert_eq!(downsample_to_cap(100, 50, 10_000), (100, 50)); assert_eq!(downsample_to_cap(0, 50, 10_000), (0, 50)); // Over the cap: the product fits while both sides exceed 1 px, and // the aspect survives to within rounding (unless a side is too // small to express it — a 3 px side clamps to 1 px by design). for (width, height) in [(4097u32, 4096u32), (20000, 15000), (12345, 6700), (3, 20000000)] { let cap = 4096u64 * 4096; let (w, h) = downsample_to_cap(width, height, cap); assert!((w as u64) * h as u64 <= cap, "{width}×{height} -> {w}×{h} over cap"); assert!(w >= 1 && h >= 1); if w >= 8 && h >= 8 { let aspect = width as f64 / height as f64; assert!( (w as f64 / h as f64 - aspect).abs() / aspect < 0.05, "{width}×{height} -> {w}×{h} lost the aspect" ); } } // A degenerate strip: the 1 px side cannot shrink further, so the // other side alone absorbs the reduction. let (w, h) = downsample_to_cap(100_000_000, 1, 4096 * 4096); assert_eq!(h, 1); assert!(w < 100_000_000); // Extreme reduction bottoms out at 1×1, never 0. assert_eq!(downsample_to_cap(50_000, 50_000, 1), (1, 1)); } /// A real decode keeps the metadata-vs-buffer split: the stored buffer /// obeys the display cap while `source_*` keep the file's true pixel /// dimensions, and a decode over the decode budget is refused with its /// probed dimensions instead of allocating. #[test] fn decode_budgets_split_metadata_from_pixels() { let bytes = png_bytes(120, 80, 0x5a); let open = || Ok(image::ImageReader::new(std::io::Cursor::new(&bytes))); // Full tier: buffer and metadata agree. match decode_with_budgets(open, 1_000_000, 1_000_000) { DecodeAttempt::Decoded(image) => { assert_eq!((image.width, image.height), (120, 80)); assert_eq!((image.source_width, image.source_height), (120, 80)); assert_eq!(image.pixels.len(), 120 * 80 * 4); } _ => panic!("a small png must decode"), } // Display cap only: the buffer shrinks (aspect kept, ≤ cap), the // metadata does not — the sizing truth survives the reduction. match decode_with_budgets(open, 1_000_000, 3_000) { DecodeAttempt::Decoded(image) => { assert_eq!((image.source_width, image.source_height), (120, 80)); assert!((image.width as u64) * image.height as u64 <= 3_000); assert!(image.width >= 1 && image.height >= 1); assert_eq!( image.pixels.len(), image.width as usize * image.height as usize * 4 ); assert!((image.width as f64 / image.height as f64 - 1.5).abs() < 0.1); } _ => panic!("a display-capped png must still decode"), } // Decode budget below the full RGBA size: refused, carrying the // probed dimensions — no allocation was needed to discover them. match decode_with_budgets(open, 1_000, 1_000_000) { DecodeAttempt::Refused { width, height } => assert_eq!((width, height), (120, 80)), _ => panic!("an over-budget png must be refused"), } } /// Refusals flow through `decode_or_note` into the note queue once per /// distinct message, carrying the dimensions and the file name. #[test] fn refusals_are_noted_once_per_message() { let _ = take_decode_refusals(); let refuse = || DecodeAttempt::Refused { width: 30000, height: 30000 }; assert!(decode_or_note(refuse(), "C:/gis/aerial.tif").is_none()); assert!(decode_or_note(refuse(), "C:/gis/aerial.tif").is_none()); let notes = take_decode_refusals(); assert_eq!(notes.len(), 1, "duplicate messages collapse: {notes:?}"); assert!(notes[0].contains("30000×30000"), "dimensions in: {}", notes[0]); assert!(notes[0].contains("aerial.tif"), "file name in: {}", notes[0]); assert!(take_decode_refusals().is_empty(), "the drain cleared the queue"); } }