diff --git a/Cargo.lock b/Cargo.lock index 868b4335..1b2ed55d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -915,8 +915,8 @@ name = "craft-print" version = "0.1.0" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree 0.20.0", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1614,9 +1614,9 @@ checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixed_decimal" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79c3c892f121fff406e5dd6b28c1b30096b95111c30701a899d4f2b18da6d1bd" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" dependencies = [ "displaydoc", "smallvec", @@ -4620,9 +4620,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", diff --git a/crates/print/Cargo.toml b/crates/print/Cargo.toml index 1f5aa1a8..1e6a6350 100644 --- a/crates/print/Cargo.toml +++ b/crates/print/Cargo.toml @@ -27,11 +27,11 @@ winsafe = { version = "0.0.29", default-features = false, features = ["winspool" # The job file's private folder. tempfile = "3" # Reading the PrintTicket and PrintCapabilities the driver hands back. -roxmltree = "0.20" +roxmltree = "0.21" # Inflating the print-ready PDF's cross-reference and object streams (src/sheets.rs). -miniz_oxide = "0.8" +miniz_oxide = "0.9" [dev-dependencies] tempfile = "3" -roxmltree = "0.20" -miniz_oxide = "0.8" +roxmltree = "0.21" +miniz_oxide = "0.9" diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index 6e588f61..56303d2f 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -517,6 +517,53 @@ fn indirect<'a>(data: &'a [u8], offset: usize, budget: &mut Budget) -> Option), + /// The output would pass the limit. + Exhausted, + /// A damaged or truncated stream: only the bytes actually written before the damage. + Damaged(Vec), +} + +/// Inflate `raw` (zlib-wrapped or bare deflate) to at most `limit` bytes. Unlike the +/// `decompress_to_vec*` helpers it reports the bytes the decompressor really wrote, so a stream +/// rejected early yields an empty `Damaged`, never the zero-filled output buffer. +fn inflate(raw: &[u8], zlib: bool, limit: usize) -> Inflated { + use miniz_oxide::inflate::TINFLStatus; + use miniz_oxide::inflate::core::{DecompressorOxide, decompress, inflate_flags}; + let flags = inflate_flags::TINFL_FLAG_USING_NON_WRAPPING_OUTPUT_BUF | if zlib { inflate_flags::TINFL_FLAG_PARSE_ZLIB_HEADER } else { 0 }; + let mut out = vec![0u8; raw.len().saturating_mul(2).max(64).min(limit)]; + let mut state = Box::::default(); + let (mut input, mut written) = (raw, 0usize); + loop { + let (status, consumed, wrote) = decompress(&mut state, input, &mut out, written, flags); + written = written.saturating_add(wrote).min(out.len()); + match status { + TINFLStatus::Done => { + out.truncate(written); + return Inflated::Done(out); + } + TINFLStatus::HasMoreOutput => { + if out.len() >= limit { + return Inflated::Exhausted; + } + let Some(rest) = input.get(consumed..) else { + out.truncate(written); + return Inflated::Damaged(out); + }; + input = rest; + out.resize(out.len().saturating_mul(2).min(limit), 0); + } + _ => { + out.truncate(written); + return Inflated::Damaged(out); + } + } + } +} + /// A stream's data with its filters undone: FlateDecode (with PNG predictors), or none. fn decode(dict: &Dict, raw: &[u8], budget: &mut Budget) -> Option> { if budget.exhausted { @@ -547,20 +594,22 @@ fn decode(dict: &Dict, raw: &[u8], budget: &mut Budget) -> Option> { Some(raw.to_vec()) } [b"FlateDecode" | b"Fl"] => { - let inflated = match miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, limit) { - Ok(v) => v, - Err(e) if e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput => { + let inflated = match inflate(raw, true, limit) { + Inflated::Done(v) => v, + Inflated::Exhausted => { budget.exhausted = true; return None; } // A damaged stream: what inflated before the damage, as other readers do. - Err(e) if !e.output.is_empty() => e.output, - Err(_) => match miniz_oxide::inflate::decompress_to_vec_with_limit(raw, limit) { - Ok(v) => v, - Err(e) => { - budget.exhausted |= e.status == miniz_oxide::inflate::TINFLStatus::HasMoreOutput; + Inflated::Damaged(v) if !v.is_empty() => v, + // Rejected before any output: retry as a bare deflate stream. + Inflated::Damaged(_) => match inflate(raw, false, limit) { + Inflated::Done(v) => v, + Inflated::Exhausted => { + budget.exhausted = true; return None; } + Inflated::Damaged(_) => return None, }, }; unpredict(&inflated, parms, limit) diff --git a/crates/print/src/tests/pdf_bounds.rs b/crates/print/src/tests/pdf_bounds.rs index e33922bf..b9bc15a2 100644 --- a/crates/print/src/tests/pdf_bounds.rs +++ b/crates/print/src/tests/pdf_bounds.rs @@ -418,3 +418,61 @@ fn large_inherited_resources_are_written_once_for_every_copy() { assert!(updated.len() < pdf.len() + (4 << 20), "{} bytes", updated.len()); assert_eq!(read(&updated).unwrap().sheets.len(), 3 * pages); } + +fn flate_dict() -> Dict { + vec![(b"Filter".to_vec(), Obj::Name(b"FlateDecode".to_vec()))] +} + +fn varied(len: usize) -> Vec { + (0..len).map(|i| u8::try_from(i * i % 251).unwrap_or(0)).collect() +} + +#[test] +fn a_valid_flate_stream_round_trips() { + let raw = varied(4000); + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6); + let mut budget = Budget::default(); + assert_eq!(decode(&flate_dict(), &packed, &mut budget), Some(raw.clone())); + assert_eq!(budget.decoded, raw.len()); + assert!(!budget.exhausted); +} + +#[test] +fn a_truncated_flate_stream_yields_only_the_bytes_it_inflated() { + let raw = varied(4000); + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&raw, 6); + let cut = packed.get(..packed.len() * 3 / 4).unwrap_or_default(); + let mut budget = Budget::default(); + let got = decode(&flate_dict(), cut, &mut budget).unwrap_or_default(); + assert!(!got.is_empty() && got.len() < raw.len()); + assert!(raw.starts_with(&got), "a prefix of the data, with no zero padding"); + assert!(!budget.exhausted); +} + +#[test] +fn a_stream_rejected_before_any_output_is_none_not_zeros() { + // A zlib header, then a reserved deflate block type: nothing is ever inflated, and the + // bare-deflate retry rejects it too. + let bad = [0x78, 0x9c, 0x07, 0xff, 0xff, 0xff, 0xff, 0xff]; + let mut budget = Budget::default(); + assert!(decode(&flate_dict(), &bad, &mut budget).is_none()); + assert_eq!(budget.decoded, 0); + assert!(!budget.exhausted); +} + +#[test] +fn a_bare_deflate_stream_still_decodes_through_the_retry() { + let raw = varied(500); + let packed = miniz_oxide::deflate::compress_to_vec(&raw, 6); + let mut budget = Budget::default(); + assert_eq!(decode(&flate_dict(), &packed, &mut budget), Some(raw)); +} + +#[test] +fn an_over_limit_flate_stream_sets_exhausted() { + let packed = miniz_oxide::deflate::compress_to_vec_zlib(&vec![7u8; 4096], 6); + let mut budget = Budget { decoded: MAX_DECODED - 100, ..Budget::default() }; + assert!(decode(&flate_dict(), &packed, &mut budget).is_none()); + assert!(budget.exhausted); + assert_eq!(budget.decoded, MAX_DECODED - 100); +} diff --git a/crates/ui/src/docking/iconic_tests.rs b/crates/ui/src/docking/iconic_tests.rs index 58a85fa5..0a353f2c 100644 --- a/crates/ui/src/docking/iconic_tests.rs +++ b/crates/ui/src/docking/iconic_tests.rs @@ -273,6 +273,27 @@ fn group_dropped_on_an_iconic_column_joins_as_an_icon_group() { assert!(first_column(&d.layout).iconic); } +/// A group dropped on the window edge beside an iconic column at that edge docks in a new column +/// there: the preview is the edge line alone, without the column's outline. +#[test] +fn an_edge_drop_next_to_an_iconic_column_highlights_only_the_edge() { + let mut layout = docked(&[&[1], &[2]], true); + layout.floating.push(Floating::single(vec![5, 6], 0, [300.0, 200.0, 240.0, 200.0])); + let mut d = DockDriver::new(layout, Content); + let bar = hit(&d, Hit::DragBar(5)).center(); + let target = pos2(2.0, 300.0); + d.hold(bar, target); + let column = Rect::from_min_max(pos2(0.0, 14.0), pos2(36.0, 600.0)); + assert!(d.filled(Rect::from_x_y_ranges(0.0..=4.0, 0.0..=600.0)), "the edge line"); + assert!(!d.outlined(column.shrink(1.0)), "the iconic column is not outlined"); + d.release(target); + let Some(Node::Split { first, .. }) = &d.layout.root else { panic!("{:?}", d.layout.root) }; + let Node::Dock { columns, .. } = first.as_ref() else { panic!("{first:?}") }; + let panels: Vec> = columns.iter().map(|c| c.groups.iter().flat_map(|g| g.panels.clone()).collect()).collect(); + assert_eq!(panels, vec![vec![5, 6], vec![1, 2]]); + assert!(!columns[0].iconic && columns[1].iconic); +} + /// Every kind of gesture source has a rect, and dragging from it does something. #[test] fn hit_rects_cover_every_gesture_source() { diff --git a/crates/ui/src/docking/render/dock.rs b/crates/ui/src/docking/render/dock.rs index 4eaab778..878d909e 100644 --- a/crates/ui/src/docking/render/dock.rs +++ b/crates/ui/src/docking/render/dock.rs @@ -789,11 +789,15 @@ impl Renderer<'_, P> { } /// The line of a drop that shows no result rect: on the window edge, into an iconic column, - /// or any dock drop with [`DockStyle::side_edge_zones`]. - pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect) { + /// or any dock drop with [`DockStyle::side_edge_zones`]. A line inside an iconic column also + /// outlines the column, unless it is on the window edge (`outer`): a column at that edge holds + /// the line, but the drop docks beside the column, not into it. + pub(super) fn paint_drop_line(&self, ctx: &Context, line: Rect, outer: bool) { if usable(line) { let painter = self.preview_painter(ctx); - self.iconic.outline(&painter, line, self.style); + if !outer { + self.iconic.outline(&painter, line, self.style); + } painter.rect_filled(line, 1.0, self.style.accent); } } diff --git a/crates/ui/src/docking/render/preview.rs b/crates/ui/src/docking/render/preview.rs index 65c15fd3..5404984e 100644 --- a/crates/ui/src/docking/render/preview.rs +++ b/crates/ui/src/docking/render/preview.rs @@ -599,12 +599,12 @@ impl Renderer<'_, P> { if self.style.side_edge_zones { drop_preview(painter, proposal.preview.shrink(1.0), self.style.accent, filled); } else if matches!(proposal.placement, Placement::Edge(_)) { - self.paint_drop_line(ctx, proposal.preview); + self.paint_drop_line(ctx, proposal.preview, true); } else if beside { let to = Target::At { anchor: proposal.anchor.clone(), placement: proposal.placement.clone() }; match self.result_rect(action(to), members) { Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent), - None => self.paint_drop_line(ctx, proposal.preview), + None => self.paint_drop_line(ctx, proposal.preview, false), } } else { tab_outline(painter, proposal.preview.shrink(1.0), self.style.accent); @@ -621,7 +621,7 @@ impl Renderer<'_, P> { let rect = (!self.style.side_edge_zones && !drop.outer).then_some(action).and_then(|action| self.result_rect(action, members)); match rect { Some(rect) => tab_outline(painter, rect.shrink(1.0), self.style.accent), - None => self.paint_drop_line(ctx, drop.line), + None => self.paint_drop_line(ctx, drop.line, drop.outer), } } diff --git a/crates/update/src/dialog.rs b/crates/update/src/dialog.rs index e43c38ae..d6bf1dd4 100644 --- a/crates/update/src/dialog.rs +++ b/crates/update/src/dialog.rs @@ -91,13 +91,22 @@ impl StartupPrompt { ui.label(RichText::new(self.app.name).size(15.0).strong().color(text_primary)); ui.label(RichText::new("UPDATE").size(10.0).monospace().color(accent_color)); }); - let version_pill = format!("v{} → {}", self.running_version, self.release.version_label()); + // ASCII only, like the rest of the card: an app whose fonts lack a glyph may panic + // instead of drawing a box, and this prompt shows up unasked. + let version_pill = format!("v{} to {}", self.running_version, self.release.version_label()); ui.label(RichText::new(version_pill).size(11.0).monospace().color(text_muted)); }); ui.with_layout(Layout::right_to_left(Align::Min), |ui| { - let x_btn = egui::Button::new(RichText::new("✕").size(12.0).color(text_muted)).frame(false); - if ui.add(x_btn).clicked() { + // Two strokes, not a "✕" glyph: none of the bundled faces has U+2715. + let (rect, close) = ui.allocate_exact_size(Vec2::splat(18.0), egui::Sense::click()); + close.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Close")); + let color = if close.hovered() { text_primary } else { text_muted }; + let (center, reach) = (rect.center(), 4.5); + let stroke = egui::Stroke::new(1.5, color); + ui.painter().line_segment([center + Vec2::new(-reach, -reach), center + Vec2::new(reach, reach)], stroke); + ui.painter().line_segment([center + Vec2::new(-reach, reach), center + Vec2::new(reach, -reach)], stroke); + if close.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { response = StartupPromptResponse::Later; } }); @@ -304,7 +313,7 @@ mod tests { for expected in [ "PhotoCraft", "UPDATE", - "v0.1.0 → 0.2.0", + "v0.1.0 to 0.2.0", "PhotoCraft 0.2.0 is available with the latest features and bug fixes.", "What's new", "DOWNLOAD", @@ -313,6 +322,8 @@ mod tests { assert!(painted.contains(expected), "missing {expected:?} in painted text {painted:?}"); } assert!(!painted.contains("UPDATE AND RESTART"), "download branch must not offer the self-update button: {painted:?}"); + // No glyph that a font might lack: apps that panic on a missing glyph would take the prompt down (#343). + assert!(painted.is_ascii(), "the prompt paints a non-ASCII character: {painted:?}"); let fills = rect_fills(&output); let backdrop = fills.iter().any(|(rect, fill)| *fill == Color32::from_black_alpha(150) && rect.size() == screen().size()); assert!(backdrop, "modal backdrop missing: {fills:?}"); diff --git a/crates/update/src/install.rs b/crates/update/src/install.rs index c1433f18..09de4e0c 100644 --- a/crates/update/src/install.rs +++ b/crates/update/src/install.rs @@ -1307,8 +1307,9 @@ pub mod macos { Duration::from_secs(300), ) }; + // diskimages-helper can stay busy for a while on a shared runner; a minute of retries is still quick. let mut attached = attach(); - for wait in [1, 2, 4, 8] { + for wait in [1, 2, 4, 8, 16, 32] { if attached.is_ok() { break; }