From 7d1ed99f51b25c08107be4b2495a5e6a22c56058 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:50 +0000 Subject: [PATCH 01/33] Refactor annot for strict Clippy checks --- crates/annot/src/appearance.rs | 1112 +++++++++++++----------- crates/annot/src/appearance/symbols.rs | 2 +- crates/annot/src/create.rs | 90 +- crates/annot/src/draw.rs | 2 +- crates/annot/src/info.rs | 2 +- crates/annot/src/lookup.rs | 2 +- crates/annot/src/modify.rs | 2 +- crates/annot/src/types.rs | 2 +- 8 files changed, 664 insertions(+), 550 deletions(-) diff --git a/crates/annot/src/appearance.rs b/crates/annot/src/appearance.rs index 508d2bee4..abc746c0d 100644 --- a/crates/annot/src/appearance.rs +++ b/crates/annot/src/appearance.rs @@ -373,16 +373,60 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>) (caret, Some((caret[2] + gap * scale, caret[1] + size * 0.18, size))) } +#[derive(Clone, Copy)] +struct AppearanceContext<'a> { + dict: &'a Dict, + subtype: &'a [u8], + rect: [f64; 4], + cloudy: bool, + opacity: f64, + stroke: Option, + width: f64, +} + +#[derive(Default)] +struct AppearanceDrawing { + resources: Dict, + content: String, + tail: Vec, +} + +impl AppearanceDrawing { + fn new(context: &AppearanceContext<'_>) -> Self { + let mut drawing = Self::default(); + if let Some(gs) = ext_gstate(context.opacity, context.subtype == b"Highlight") { + drawing.resources.set(b"ExtGState".to_vec(), Object::Dict(gs)); + drawing.content.push_str("/GS0 gs\n"); + } + drawing + } +} + /// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported. /// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and /// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`). pub fn build(d: &Dict) -> Option { + let context = appearance_context(d)?; + let drawing = AppearanceDrawing::new(&context); + match context.subtype { + b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing), + b"FileAttachment" => return draw_icon(&context, "PushPin", [0.0, 0.47, 0.84], drawing), + b"Stamp" => return draw_stamp(&context, drawing), + b"FreeText" => return draw_free_text(&context, drawing), + _ => {} + } + let mut drawing = drawing; + draw_body(&context, &mut drawing)?; + finish_appearance(&context, drawing) +} + +fn appearance_context(d: &Dict) -> Option> { // Imported measurement appearances may include leaders, captions and formatting that // this builder cannot reproduce. Preserve them rather than silently losing detail. if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") { return None; } - let subtype = d.name(b"Subtype")?.to_vec(); + let subtype = d.name(b"Subtype")?; let rect = nums(d, b"Rect").filter(|r| r.len() == 4)?; let rect = [rect[0].min(rect[2]), rect[1].min(rect[3]), rect[0].max(rect[2]), rect[1].max(rect[3])]; // Cloudy borders (§12.5.4) are drawn for polygons only. @@ -390,520 +434,576 @@ pub fn build(d: &Dict) -> Option { if cloudy && subtype != b"Polygon" { return None; } - let opacity = d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0); - let stroke = color(d, b"C")?; - let w = border_width(d); - let mut res = Dict::new(); - let mut c = String::new(); - // Content after `c` that isn't UTF-8 (WinAnsi text). - let mut tail: Vec = Vec::new(); - let markup = matches!(subtype.as_slice(), b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly"); - if let Some(gs) = ext_gstate(opacity, subtype == b"Highlight") { - res.set(b"ExtGState".to_vec(), Object::Dict(gs)); - c.push_str("/GS0 gs\n"); - } - match subtype.as_slice() { - b"Text" => { - let col = stroke.unwrap_or([1.0, 0.82, 0.0]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "Note".into()); - c.push_str(¬e_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - b"FileAttachment" => { - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| "PushPin".into()); - c.push_str(&attach_icon(&icon, col)); - return Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], c.as_bytes(), res)); - } - _ if markup => { - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke?; - for quad in q.as_chunks::<8>().0.iter() { - let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); - let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); - let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); - // A point a fraction `t` of the way from the bottom edge to the top edge. - let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); - match subtype.as_slice() { - b"Highlight" => { - c.push_str(&rg(col)); - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h f\n", - n(p1.0), - n(p1.1), - n(p2.0), - n(p2.1), - n(p4.0), - n(p4.1), - n(p3.0), - n(p3.1) - )); - } - b"Underline" | b"StrikeOut" => { - let lw = (h * 0.07).clamp(0.5, 3.0); - let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; - let (a, b) = (at(p3, p1, t), at(p4, p2, t)); - c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); - } - _ => { - // Squiggly: a zigzag along the bottom of the quad. - let lw = (h * 0.05).clamp(0.5, 2.0); - let amp = (h * 0.06).max(0.75); - let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); - let step = (h / 4.0).max(1.5); - let steps = ((len / step).ceil() as usize).clamp(1, 10_000); - c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); - for i in 0..=steps { - let t = i as f64 / steps as f64; - let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); - let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; - let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; - let (x, y) = (base.0 + ux * up, base.1 + uy * up); - c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - } - } - } - } - b"Redact" => { - // While marked: the outline of each area (the fill comes when applied). - let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; - let col = stroke.unwrap_or([0.89, 0.13, 0.13]); - c.push_str(&format!("{}1 w\n", rg_stroke(col))); - for quad in q.as_chunks::<8>().0.iter() { - c.push_str(&format!( - "{} {} m {} {} l {} {} l {} {} l h S\n", - n(quad[0]), - n(quad[1]), - n(quad[2]), - n(quad[3]), - n(quad[6]), - n(quad[7]), - n(quad[4]), - n(quad[5]) - )); - } - } - b"Square" | b"Circle" => { - let fill = interior_color(d, None); - if stroke.is_none() && fill.is_none() { - return Some(form(rect, c.as_bytes(), res)); - } - let inset = w / 2.0; - let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = stroke { - c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); - } - if subtype == b"Square" { - c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); - } else { - c.push_str(&ellipse(x0, y0, x1, y1)); - } - c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - } - b"Line" => { - let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; - let col = stroke?; - let ends: Vec> = match d.get(b"LE") { - None => vec![b"None".to_vec(), b"None".to_vec()], - Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, - }; - if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { - return None; - } - let fill = interior_color(d, Some(col)).unwrap_or(col); - let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); - let [p, q] = layout.line; - c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); - for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { - c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); - } - c.push_str("[] 0 d\n"); - for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { - if !line_end(&mut c, end, tip, from, w) { - return None; - } - } - if let Some((text, m)) = layout.caption { - // The caption is black, in the standard font the measurement label uses. - tail.extend( - format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])) - .bytes(), - ); - // WinAnsi bytes go into the stream as-is, not through UTF-8. - tail.extend(literal(&win_ansi(&text))); - tail.extend_from_slice(b" Tj ET Q\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - } - } - b"Polygon" | b"PolyLine" => { - let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; - let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let col = stroke; - let closed = subtype == b"Polygon"; - let fill = if closed { interior_color(d, None) } else { None }; - let ends = if subtype == b"PolyLine" { - match d.get(b"LE") { - None => None, - Some(o) => { - let names: Vec> = - o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; - if names.iter().any(|e| !known_ending(e)) { - return None; - } - Some(names) - } - } - } else { - None - }; - if let Some(f) = fill { - c.push_str(&rg(f)); - } - if let Some(s) = col { - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); - } - if cloudy { - let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); - c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); - } else { - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - if closed { - c.push_str("h\n"); - } - } - c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { - (true, true) => "B\n", - (true, false) => "f\n", - (false, true) => "S\n", - (false, false) => "n\n", - }); - if let Some(ends) = ends { - let col = col?; - let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; - let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; - let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; - let fill = interior_color(d, Some(col)).unwrap_or(col); - c.push_str(&format!("[] 0 d\n{}", rg(fill))); - if !line_end(&mut c, start, tip0, from0, w) || !line_end(&mut c, end, tip1, from1, w) { - return None; - } - } - } - b"Caret" => { - // A filled caret: two curved flanks meeting at the top centre. - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); - let (caret, pilcrow) = caret_layout(d, rect); - let [x0, y0, x1, y1] = caret; - let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); - c.push_str(&format!( - "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", - rg(col), - n(x0), - n(y0), - n(cx - (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(cx), - n(y1 - h * 0.25), - n(cx), - n(y1), - n(cx), - n(y1 - h * 0.25), - n(cx + (x1 - x0) * 0.1), - n(y0 + h * 0.2), - n(x1), - n(y0) - )); - if let Some((x, y, size)) = pilcrow { - // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. - // 0xB6 is the pilcrow in WinAnsiEncoding. - let mut out = c.into_bytes(); - out.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); - out.push(0xB6); - out.extend_from_slice(b") Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - return Some(form(rect, &out, res)); - } - } - b"Ink" => { - let col = stroke?; - let list = d.get(b"InkList")?.as_array()?; - // `/BS` gives an ink stroke its width and dash pattern (Table 182). - c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); - for s in list { - let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; - let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - let Some(first) = pts.first() else { continue }; - c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); - match pts.as_slice() { - [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), - [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), - _ => c.push_str(&smooth_curve(&pts)), - } - c.push_str("S\n"); - } - } - b"Stamp" => { - // A typed signature: filled outlines normalised to the rectangle. - if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); - for contour in outline { - let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); - for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); - } - if v.len() >= 6 { - c.push_str("h\n"); - } - } - c.push_str("f\n"); - return Some(form(rect, c.as_bytes(), res)); - } - // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills - // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets - // the standard counter-rotation as its form `/Matrix`: viewers fit the turned - // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. - if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { - let [x0, y0, x1, y1] = rect; - let (w, h) = (x1 - x0, y1 - y0); - let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); - let place = if image { - format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) - } else { - let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; - format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) - }; - c.push_str(&format!("q {place} /Pic Do Q\n")); - let mut xo = Dict::new(); - xo.set(b"Pic".to_vec(), Object::Ref(pic)); - res.set(b"XObject".to_vec(), Object::Dict(xo)); - let mut stream = form(rect, c.as_bytes(), res); - let turn = picture_turn(d); - if image && turn != 0 { - // The page's view matrix without its offset: display axes in user space. - let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); - let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; - stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); - } - return Some(stream); - } - // Only PdfCraft's own Fill & Sign marks are drawn here. - let name = d.name(b"Name")?; - let col = stroke.unwrap_or([0.0; 3]); - // A mark or stamp placed on a turned page is drawn upright in its displayed box, then - // mapped into `/Rect` the way the page maps display space into user space. Viewers - // fit that box to `/Rect`; the page rotation then shows it the right way up. - let turn = stamp_turn(d); - let draw = if turn == 0 { - rect - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - [0.0, 0.0, dw, dh] - }; - let turned = |mut stream: Stream| { - if turn != 0 { - let m = pdfcraft_model::view_matrix_for(turn, rect); - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - stream - }; - let [x0, y0, x1, y1] = draw; - let (w, h) = (x1 - x0, y1 - y0); - let lw = w.min(h) * 0.12; - match name { - b"PCCheck" => c.push_str(&format!( - "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.15), - n(y0 + h * 0.5), - n(x0 + w * 0.4), - n(y0 + h * 0.2), - n(x0 + w * 0.88), - n(y0 + h * 0.85) - )), - b"PCCross" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", - rg_stroke(col), - n(lw), - n(x0 + w * 0.18), - n(y0 + h * 0.18), - n(x1 - w * 0.18), - n(y1 - h * 0.18), - n(x0 + w * 0.18), - n(y1 - h * 0.18), - n(x1 - w * 0.18), - n(y0 + h * 0.18) - )), - b"PCDot" => { - let r = w.min(h) * 0.3; - c.push_str(&rg(col)); - c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); - c.push_str("f\n"); - } - b"PCLine" => c.push_str(&format!( - "{}{} w 1 J\n{} {} m {} {} l S\n", - rg_stroke(col), - n(h.clamp(0.5, 2.0)), - n(x0), - n(y0 + h / 2.0), - n(x1), - n(y0 + h / 2.0) - )), - other => { - // Only stamps PdfCraft made: others keep their own artwork. - if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { - return None; - } - let kind = crate::StampKind::from_name(other)?; - let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); - let sign_here = kind.group() == crate::StampGroup::SignHere; - return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); - } - } - if turn != 0 { - return Some(turned(form(draw, c.as_bytes(), res))); - } - } - b"FreeText" => { - let (text_color, size) = parse_da(d); - let bg = stroke; - let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; - // A callout: the leader line (arrowhead at its first point), and the text box inside - // `/Rect` by `/RD`. - // Text added to a turned page is drawn upright in its displayed box, which the form - // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. - let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; - let (rect, matrix) = if turn == 0 { - (rect, None) - } else { - let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); - let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; - if dw < 1.0 || dh < 1.0 { - return None; - } - ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) - }; - let full = rect; - let mut rect = rect; - if d.contains(b"CL") { - let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; - let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; - rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; - if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { - return None; - } - let end = match d.get(b"LE") { - None => b"None".to_vec(), - Some(o) => o.as_name()?.to_vec(), - }; - if !known_ending(&end) { - return None; - } - let lw = bw.max(0.5); - let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); - if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { - return None; - } - c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); - for (i, p) in pts.iter().enumerate() { - c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); - } - c.push_str("S\n"); - if !line_end(&mut c, &end, pts[0], pts[1], lw) { - return None; - } - } - if let Some(bg) = bg { - c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); - } - if bw > 0.0 { - let h = bw / 2.0; - c.push_str(&format!( - "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", - rg_stroke(text_color), - n(bw), - dash(d), - n(rect[0] + h), - n(rect[1] + h), - n(rect[2] - rect[0] - bw), - n(rect[3] - rect[1] - bw) - )); - } - let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); - let pad = 2.0 + bw; - let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); - let q = d.int(b"Q").unwrap_or(0); - c.push_str(&format!( - "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", - n(rect[0]), - n(rect[1]), - n(rect[2] - rect[0]), - n(rect[3] - rect[1]), - n(size), - rg(text_color) - )); - let mut out = c.into_bytes(); - let mut y = rect[3] - pad - size * 0.9; - for line in wrap(&text, size, width) { - if y < rect[1] - size { - break; - } - let lw = text_width(&line, size); - let x = match q { - 1 => rect[0] + pad + (width - lw) / 2.0, - 2 => rect[2] - pad - lw, - _ => rect[0] + pad, - }; - out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); - out.extend(literal(&win_ansi(&line))); - out.extend_from_slice(b" Tj\n"); - y -= size * 1.2; - } - out.extend_from_slice(b"ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); - let mut stream = form(full, &out, res); - if let Some(m) = matrix { - stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); - } - return Some(stream); - } - _ => return None, + Some(AppearanceContext { + dict: d, + subtype, + rect, + cloudy, + opacity: d.get(b"CA").and_then(|o| o.as_f64()).unwrap_or(1.0).clamp(0.0, 1.0), + stroke: color(d, b"C")?, + width: border_width(d), + }) +} + +fn draw_body(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + match context.subtype { + b"Highlight" | b"Underline" | b"StrikeOut" | b"Squiggly" => draw_markup(context, drawing), + b"Redact" => draw_redaction(context, drawing), + b"Square" | b"Circle" => draw_box(context, drawing), + b"Line" => draw_line(context, drawing), + b"Polygon" | b"PolyLine" => draw_polygon(context, drawing), + b"Caret" => draw_caret(context, drawing), + b"Ink" => draw_ink(context, drawing), + _ => None, } +} + +fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { // PdfCraft measurement captions are kept separate from the comment's free-form text. // A restyle regenerates the path and its value together. - let mut out = c.into_bytes(); - out.extend(tail); - if let Some(value) = d.get(b"PCMeasureValue").and_then(Object::as_string) { + let mut out = drawing.content.into_bytes(); + out.extend(drawing.tail); + if let Some(value) = context.dict.get(b"PCMeasureValue").and_then(Object::as_string) { let value = value.to_text(); - if value.chars().count() <= 256 && matches!(subtype.as_slice(), b"Line" | b"PolyLine" | b"Polygon") { + if value.chars().count() <= 256 && matches!(context.subtype, b"Line" | b"PolyLine" | b"Polygon") { let size = 10.0; - let x = (rect[0] + rect[2] - text_width(&value, size)) * 0.5; - let y = rect[3] - 12.0; - let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let x = (context.rect[0] + context.rect[2] - text_width(&value, size)) * 0.5; + let y = context.rect[3] - 12.0; + let col = context.stroke.unwrap_or([0.0, 0.47, 0.84]); out.extend(format!("{}BT /Helv {} Tf {} {} Td ", rg(col), n(size), n(x), n(y)).bytes()); // WinAnsi bytes (e.g. 0xB2 for "²") go into the stream as-is, not through UTF-8. out.extend(literal(&win_ansi(&value))); out.extend_from_slice(b" Tj ET\n"); - res.set(b"Font".to_vec(), helvetica_resource()); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); } } - Some(form(rect, &out, res)) + Some(form(context.rect, &out, drawing.resources)) +} + +fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let c = &mut drawing.content; + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke?; + for quad in q.as_chunks::<8>().0.iter() { + let p = |i: usize| (quad[2 * i], quad[2 * i + 1]); + let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3)); + let h = (p1.0 - p3.0).hypot(p1.1 - p3.1); + // A point a fraction `t` of the way from the bottom edge to the top edge. + let at = |bottom: (f64, f64), top: (f64, f64), t: f64| (bottom.0 + (top.0 - bottom.0) * t, bottom.1 + (top.1 - bottom.1) * t); + match subtype { + b"Highlight" => { + c.push_str(&rg(col)); + c.push_str(&format!("{} {} m {} {} l {} {} l {} {} l h f\n", n(p1.0), n(p1.1), n(p2.0), n(p2.1), n(p4.0), n(p4.1), n(p3.0), n(p3.1))); + } + b"Underline" | b"StrikeOut" => { + let lw = (h * 0.07).clamp(0.5, 3.0); + let t = if subtype == b"Underline" { 0.08 } else { 0.42 }; + let (a, b) = (at(p3, p1, t), at(p4, p2, t)); + c.push_str(&format!("{}{} w\n{} {} m {} {} l S\n", rg_stroke(col), n(lw), n(a.0), n(a.1), n(b.0), n(b.1))); + } + _ => { + // Squiggly: a zigzag along the bottom of the quad. + let lw = (h * 0.05).clamp(0.5, 2.0); + let amp = (h * 0.06).max(0.75); + let len = (p4.0 - p3.0).hypot(p4.1 - p3.1); + let step = (h / 4.0).max(1.5); + let steps = ((len / step).ceil() as usize).clamp(1, 10_000); + c.push_str(&format!("{}{} w 1 j\n", rg_stroke(col), n(lw))); + for i in 0..=steps { + let t = i as f64 / steps as f64; + let base = (p3.0 + (p4.0 - p3.0) * t, p3.1 + (p4.1 - p3.1) * t); + let up = if i % 2 == 0 { amp * 2.0 } else { 0.0 }; + let (ux, uy) = if h > 0.0 { ((p1.0 - p3.0) / h, (p1.1 - p3.1) / h) } else { (0.0, 1.0) }; + let (x, y) = (base.0 + ux * up, base.1 + uy * up); + c.push_str(&format!("{} {} {}\n", n(x), n(y), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + } + } + } + Some(()) +} + +fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let c = &mut drawing.content; + // While marked: the outline of each area (the fill comes when applied). + let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?; + let col = stroke.unwrap_or([0.89, 0.13, 0.13]); + c.push_str(&format!("{}1 w\n", rg_stroke(col))); + for quad in q.as_chunks::<8>().0.iter() { + c.push_str(&format!( + "{} {} m {} {} l {} {} l {} {} l h S\n", + n(quad[0]), + n(quad[1]), + n(quad[2]), + n(quad[3]), + n(quad[6]), + n(quad[7]), + n(quad[4]), + n(quad[5]) + )); + } + Some(()) +} + +fn draw_box(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let rect = context.rect; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let fill = interior_color(d, None); + if stroke.is_none() && fill.is_none() { + return Some(()); + } + let inset = w / 2.0; + let [x0, y0, x1, y1] = [rect[0] + inset, rect[1] + inset, rect[2] - inset, rect[3] - inset]; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = stroke { + c.push_str(&format!("{}{} w\n{}", rg_stroke(s), n(w), dash(d))); + } + if subtype == b"Square" { + c.push_str(&format!("{} {} {} {} re\n", n(x0), n(y0), n(x1 - x0), n(y1 - y0))); + } else { + c.push_str(&ellipse(x0, y0, x1, y1)); + } + c.push_str(match (fill.is_some(), stroke.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + Some(()) +} + +fn draw_line(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let res = &mut drawing.resources; + let tail = &mut drawing.tail; + let l = nums(d, b"L").filter(|l| l.len() == 4 && l.iter().all(|x| x.is_finite()))?; + let col = stroke?; + let ends: Vec> = match d.get(b"LE") { + None => vec![b"None".to_vec(), b"None".to_vec()], + Some(o) => o.as_array()?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?, + }; + if ends.len() != 2 || ends.iter().any(|e| !known_ending(e)) { + return None; + } + let fill = interior_color(d, Some(col)).unwrap_or(col); + let layout = line_layout(d, [(l[0], l[1]), (l[2], l[3])], w); + let [p, q] = layout.line; + c.push_str(&format!("{}{}{} w 1 J 1 j\n{}", rg_stroke(col), rg(fill), n(w), dash(d))); + for [s, e] in layout.leaders.iter().chain(&layout.strokes).chain(&layout.tick) { + c.push_str(&format!("{} {} m {} {} l S\n", n(s.0), n(s.1), n(e.0), n(e.1))); + } + c.push_str("[] 0 d\n"); + for (end, (tip, from)) in ends.iter().zip([(p, q), (q, p)]) { + if !line_end(c, end, tip, from, w) { + return None; + } + } + if let Some((text, m)) = layout.caption { + // The caption is black, in the standard font the measurement label uses. + tail.extend( + format!("q 0 g BT /Helv {} Tf {} {} {} {} {} {} Tm ", n(CAPTION_SIZE), n(m[0]), n(m[1]), n(m[2]), n(m[3]), n(m[4]), n(m[5])).bytes(), + ); + // WinAnsi bytes go into the stream as-is, not through UTF-8. + tail.extend(literal(&win_ansi(&text))); + tail.extend_from_slice(b" Tj ET Q\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_polygon(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let subtype = context.subtype; + let stroke = context.stroke; + let w = context.width; + let cloudy = context.cloudy; + let c = &mut drawing.content; + let v = nums(d, b"Vertices").filter(|v| v.len() >= 4 && v.len() % 2 == 0 && v.iter().all(|x| x.is_finite()))?; + let pts: Vec<(f64, f64)> = v.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let col = stroke; + let closed = subtype == b"Polygon"; + let fill = if closed { interior_color(d, None) } else { None }; + let ends = if subtype == b"PolyLine" { + match d.get(b"LE") { + None => None, + Some(o) => { + let names: Vec> = + o.as_array().filter(|a| a.len() == 2)?.iter().map(|e| e.as_name().map(<[u8]>::to_vec)).collect::>()?; + if names.iter().any(|e| !known_ending(e)) { + return None; + } + Some(names) + } + } + } else { + None + }; + if let Some(f) = fill { + c.push_str(&rg(f)); + } + if let Some(s) = col { + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(s), n(w), dash(d))); + } + if cloudy { + let intensity = d.get(b"BE").and_then(|b| b.as_dict()).and_then(|b| b.get(b"I")).and_then(|i| i.as_f64()).unwrap_or(1.0); + c.push_str(&cloud_path(&pts, cloud_radius(w) * intensity.clamp(0.5, 2.0))); + } else { + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + if closed { + c.push_str("h\n"); + } + } + c.push_str(match (fill.is_some(), col.is_some() && w > 0.0) { + (true, true) => "B\n", + (true, false) => "f\n", + (false, true) => "S\n", + (false, false) => "n\n", + }); + if let Some(ends) = ends { + let col = col?; + let (Some(start), Some(end)) = (ends.first(), ends.get(1)) else { return None }; + let (Some(&tip0), Some(&from0)) = (pts.first(), pts.get(1)) else { return None }; + let (Some(&tip1), Some(&from1)) = (pts.last(), pts.get(pts.len().saturating_sub(2))) else { return None }; + let fill = interior_color(d, Some(col)).unwrap_or(col); + c.push_str(&format!("[] 0 d\n{}", rg(fill))); + if !line_end(c, start, tip0, from0, w) || !line_end(c, end, tip1, from1, w) { + return None; + } + } + Some(()) +} + +fn draw_caret(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let c = &mut drawing.content; + // A filled caret: two curved flanks meeting at the top centre. + let col = stroke.unwrap_or([0.0, 0.47, 0.84]); + let (caret, pilcrow) = caret_layout(d, rect); + let [x0, y0, x1, y1] = caret; + let (cx, h) = ((x0 + x1) / 2.0, y1 - y0); + c.push_str(&format!( + "{}{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c h f\n", + rg(col), + n(x0), + n(y0), + n(cx - (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(cx), + n(y1 - h * 0.25), + n(cx), + n(y1), + n(cx), + n(y1 - h * 0.25), + n(cx + (x1 - x0) * 0.1), + n(y0 + h * 0.2), + n(x1), + n(y0) + )); + if let Some((x, y, size)) = pilcrow { + // `/Sy /P`: a new paragraph symbol beside the caret (Table 183), in its colour. + // 0xB6 is the pilcrow in WinAnsiEncoding. + drawing.tail.extend(format!("BT /Helv {} Tf {} {} Td (", n(size), n(x), n(y)).bytes()); + drawing.tail.push(0xB6); + drawing.tail.extend_from_slice(b") Tj ET\n"); + drawing.resources.set(b"Font".to_vec(), helvetica_resource()); + } + Some(()) +} + +fn draw_ink(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> { + let d = context.dict; + let stroke = context.stroke; + let w = context.width; + let c = &mut drawing.content; + let col = stroke?; + let list = d.get(b"InkList")?.as_array()?; + // `/BS` gives an ink stroke its width and dash pattern (Table 182). + c.push_str(&format!("{}{} w 1 J 1 j\n{}", rg_stroke(col), n(w), dash(d))); + for s in list { + let pts: Vec = s.as_array()?.iter().map(|o| o.as_f64()).collect::>()?; + let pts: Vec<(f64, f64)> = pts.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + let Some(first) = pts.first() else { continue }; + c.push_str(&format!("{} {} m\n", n(first.0), n(first.1))); + match pts.as_slice() { + [_] => c.push_str(&format!("{} {} l\n", n(first.0 + 0.01), n(first.1))), + [_, end] => c.push_str(&format!("{} {} l\n", n(end.0), n(end.1))), + _ => c.push_str(&smooth_curve(&pts)), + } + c.push_str("S\n"); + } + Some(()) +} + +fn draw_stamp(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let opacity = context.opacity; + let c = &mut drawing.content; + let mut res = std::mem::take(&mut drawing.resources); + // A typed signature: filled outlines normalised to the rectangle. + if let Some(outline) = d.get(b"PCOutline").and_then(|o| o.as_array()) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + c.push_str(&rg(stroke.unwrap_or([0.0; 3]))); + for contour in outline { + let v: Vec = contour.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()).unwrap_or_default(); + for (i, p) in v.as_chunks::<2>().0.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(x0 + p[0] * w), n(y0 + p[1] * h), if i == 0 { "m" } else { "l" })); + } + if v.len() >= 6 { + c.push_str("h\n"); + } + } + c.push_str("f\n"); + return Some(form(rect, c.as_bytes(), res)); + } + // A custom stamp: its picture (an image, or a form mapped to /PCPictureSize) fills + // the rectangle. An image signature added to a turned page (`/PCPictureRotate`) gets + // the standard counter-rotation as its form `/Matrix`: viewers fit the turned + // bounding box to `/Rect` (ISO 32000-2 §12.5.5), so it reads upright as displayed. + if let Some(pic) = d.get(b"PCPicture").and_then(Object::as_ref) { + let [x0, y0, x1, y1] = rect; + let (w, h) = (x1 - x0, y1 - y0); + let image = matches!(d.get(b"PCPictureImage"), Some(Object::Bool(true))); + let place = if image { + format!("{} 0 0 {} {} {} cm", n(w), n(h), n(x0), n(y0)) + } else { + let size = nums(d, b"PCPictureSize").filter(|s| s.len() == 2 && s[0] > 0.0 && s[1] > 0.0)?; + format!("{} 0 0 {} {} {} cm", n(w / size[0]), n(h / size[1]), n(x0), n(y0)) + }; + c.push_str(&format!("q {place} /Pic Do Q\n")); + let mut xo = Dict::new(); + xo.set(b"Pic".to_vec(), Object::Ref(pic)); + res.set(b"XObject".to_vec(), Object::Dict(xo)); + let mut stream = form(rect, c.as_bytes(), res); + let turn = picture_turn(d); + if image && turn != 0 { + // The page's view matrix without its offset: display axes in user space. + let m = pdfcraft_model::view_matrix_for(turn, [0.0; 4]); + let matrix = [m[0], m[1], m[2], m[3], 0.0, 0.0]; + stream.dict.set(b"Matrix".to_vec(), Object::Array(matrix.iter().map(|x| Object::Real(*x)).collect())); + } + return Some(stream); + } + // Only PdfCraft's own Fill & Sign marks are drawn here. + let name = d.name(b"Name")?; + let col = stroke.unwrap_or([0.0; 3]); + // A mark or stamp placed on a turned page is drawn upright in its displayed box, then + // mapped into `/Rect` the way the page maps display space into user space. Viewers + // fit that box to `/Rect`; the page rotation then shows it the right way up. + let turn = stamp_turn(d); + let draw = if turn == 0 { + rect + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + [0.0, 0.0, dw, dh] + }; + let turned = |mut stream: Stream| { + if turn != 0 { + let m = pdfcraft_model::view_matrix_for(turn, rect); + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + stream + }; + let [x0, y0, x1, y1] = draw; + let (w, h) = (x1 - x0, y1 - y0); + let lw = w.min(h) * 0.12; + match name { + b"PCCheck" => c.push_str(&format!( + "{}{} w 1 J 1 j\n{} {} m {} {} l {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.15), + n(y0 + h * 0.5), + n(x0 + w * 0.4), + n(y0 + h * 0.2), + n(x0 + w * 0.88), + n(y0 + h * 0.85) + )), + b"PCCross" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l {} {} m {} {} l S\n", + rg_stroke(col), + n(lw), + n(x0 + w * 0.18), + n(y0 + h * 0.18), + n(x1 - w * 0.18), + n(y1 - h * 0.18), + n(x0 + w * 0.18), + n(y1 - h * 0.18), + n(x1 - w * 0.18), + n(y0 + h * 0.18) + )), + b"PCDot" => { + let r = w.min(h) * 0.3; + c.push_str(&rg(col)); + c.push_str(&ellipse(x0 + w / 2.0 - r, y0 + h / 2.0 - r, x0 + w / 2.0 + r, y0 + h / 2.0 + r)); + c.push_str("f\n"); + } + b"PCLine" => c.push_str(&format!( + "{}{} w 1 J\n{} {} m {} {} l S\n", + rg_stroke(col), + n(h.clamp(0.5, 2.0)), + n(x0), + n(y0 + h / 2.0), + n(x1), + n(y0 + h / 2.0) + )), + other => { + // Only stamps PdfCraft made: others keep their own artwork. + if !matches!(d.get(b"PCStamp"), Some(Object::Bool(true))) { + return None; + } + let kind = crate::StampKind::from_name(other)?; + let by = d.get(b"PCByLine").and_then(|o| o.as_string()).map(PdfString::to_text); + let sign_here = kind.group() == crate::StampGroup::SignHere; + return Some(turned(stamp(kind.label(), sign_here, draw, stroke.unwrap_or(kind.color()), by.as_deref(), opacity, res))); + } + } + if turn != 0 { + return Some(turned(form(draw, c.as_bytes(), res))); + } + Some(form(rect, c.as_bytes(), res)) +} + +fn draw_free_text(context: &AppearanceContext<'_>, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let rect = context.rect; + let stroke = context.stroke; + let mut c = std::mem::take(&mut drawing.content); + let mut res = std::mem::take(&mut drawing.resources); + let _tail = std::mem::take(&mut drawing.tail); + let (text_color, size) = parse_da(d); + let bg = stroke; + let bw = if d.contains(b"BS") || d.contains(b"Border") { border_width(d) } else { 0.0 }; + // A callout: the leader line (arrowhead at its first point), and the text box inside + // `/Rect` by `/RD`. + // Text added to a turned page is drawn upright in its displayed box, which the form + // `/Matrix` then maps into `/Rect` (as for stamps). Callouts keep user-space axes. + let turn = if d.contains(b"CL") { 0 } else { text_turn(d) }; + let (rect, matrix) = if turn == 0 { + (rect, None) + } else { + let (rw, rh) = (rect[2] - rect[0], rect[3] - rect[1]); + let (dw, dh) = if matches!(turn, 90 | 270) { (rh, rw) } else { (rw, rh) }; + if dw < 1.0 || dh < 1.0 { + return None; + } + ([0.0, 0.0, dw, dh], Some(pdfcraft_model::view_matrix_for(turn, rect))) + }; + let full = rect; + let mut rect = rect; + if d.contains(b"CL") { + let cl = nums(d, b"CL").filter(|l| l.len() == 4 || l.len() == 6)?; + let rd = nums(d, b"RD").filter(|r| r.len() == 4 && r.iter().all(|x| *x >= 0.0))?; + rect = [full[0] + rd[0], full[1] + rd[1], full[2] - rd[2], full[3] - rd[3]]; + if rect[2] - rect[0] < 1.0 || rect[3] - rect[1] < 1.0 { + return None; + } + let end = match d.get(b"LE") { + None => b"None".to_vec(), + Some(o) => o.as_name()?.to_vec(), + }; + if !known_ending(&end) { + return None; + } + let lw = bw.max(0.5); + let pts: Vec<(f64, f64)> = cl.as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(); + if pts.len() < 2 || pts.iter().any(|p| !p.0.is_finite() || !p.1.is_finite()) { + return None; + } + c.push_str(&format!("{}{}{} w 1 J 1 j\n", rg_stroke(text_color), rg(bg.unwrap_or([1.0; 3])), n(lw))); + for (i, p) in pts.iter().enumerate() { + c.push_str(&format!("{} {} {}\n", n(p.0), n(p.1), if i == 0 { "m" } else { "l" })); + } + c.push_str("S\n"); + if !line_end(&mut c, &end, pts[0], pts[1], lw) { + return None; + } + } + if let Some(bg) = bg { + c.push_str(&format!("{}{} {} {} {} re f\n", rg(bg), n(rect[0]), n(rect[1]), n(rect[2] - rect[0]), n(rect[3] - rect[1]))); + } + if bw > 0.0 { + let h = bw / 2.0; + c.push_str(&format!( + "{}{} w\n{}{} {} {} {} re S\n[] 0 d\n", + rg_stroke(text_color), + n(bw), + dash(d), + n(rect[0] + h), + n(rect[1] + h), + n(rect[2] - rect[0] - bw), + n(rect[3] - rect[1] - bw) + )); + } + let text = d.get(b"Contents").and_then(|o| o.as_string()).map(PdfString::to_text).unwrap_or_default(); + let pad = 2.0 + bw; + let width = (rect[2] - rect[0] - 2.0 * pad).max(1.0); + let q = d.int(b"Q").unwrap_or(0); + c.push_str(&format!( + "{} {} {} {} re W n\nBT\n/Helv {} Tf\n{}", + n(rect[0]), + n(rect[1]), + n(rect[2] - rect[0]), + n(rect[3] - rect[1]), + n(size), + rg(text_color) + )); + let mut out = c.into_bytes(); + let mut y = rect[3] - pad - size * 0.9; + for line in wrap(&text, size, width) { + if y < rect[1] - size { + break; + } + let lw = text_width(&line, size); + let x = match q { + 1 => rect[0] + pad + (width - lw) / 2.0, + 2 => rect[2] - pad - lw, + _ => rect[0] + pad, + }; + out.extend(format!("1 0 0 1 {} {} Tm ", n(x), n(y)).bytes()); + out.extend(literal(&win_ansi(&line))); + out.extend_from_slice(b" Tj\n"); + y -= size * 1.2; + } + out.extend_from_slice(b"ET\n"); + res.set(b"Font".to_vec(), helvetica_resource()); + let mut stream = form(full, &out, res); + if let Some(m) = matrix { + stream.dict.set(b"Matrix".to_vec(), Object::Array(m.iter().copied().map(Object::Real).collect())); + } + Some(stream) +} + +fn draw_icon(context: &AppearanceContext<'_>, name: &str, fallback: Rgb, mut drawing: AppearanceDrawing) -> Option { + let d = context.dict; + let col = context.stroke.unwrap_or(fallback); + let icon = d.name(b"Name").map(|n| String::from_utf8_lossy(n).into_owned()).unwrap_or_else(|| name.into()); + let content = if name == "Note" { note_icon(&icon, col) } else { attach_icon(&icon, col) }; + drawing.content.push_str(&content); + Some(form([0.0, 0.0, NOTE_SIZE, NOTE_SIZE], drawing.content.as_bytes(), drawing.resources)) } diff --git a/crates/annot/src/appearance/symbols.rs b/crates/annot/src/appearance/symbols.rs index d49224cd9..3b3366252 100644 --- a/crates/annot/src/appearance/symbols.rs +++ b/crates/annot/src/appearance/symbols.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Object, PdfString, Rgb, Stream, color, ext_gstate, form, literal, n, nums, rg, rg_stroke, text_width, win_ansi}; /// The label and colour of a standard rubber stamp name (ISO 32000-2 Table 184), for a stamp /// another program wrote without an appearance. Names are matched byte for byte, as PDF names diff --git a/crates/annot/src/create.rs b/crates/annot/src/create.rs index bedd1c6f1..a6db5b940 100644 --- a/crates/annot/src/create.rs +++ b/crates/annot/src/create.rs @@ -1,8 +1,10 @@ use super::draw::draw_appearance; use super::lookup::{annot_dict, annot_ref, annots, fill_sign_shape, page_ref, set_annots}; use super::modify::{n, rich_text}; -use super::types::{FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, Rgb, Shape, StampGroup, Style}; -use super::*; +use super::types::{ + FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, OverlayLook, Rgb, Shape, StampGroup, Style, +}; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; pub(crate) fn num_array(v: &[f64]) -> Object { Object::Array(v.iter().map(|x| Object::Real(*x)).collect()) @@ -203,16 +205,12 @@ pub fn callout_attach(rect: [f64; 4], knee: [f64; 2]) -> [f64; 2] { pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> Result { let page = page_ref(doc, new.page)?; let style = &new.style; - if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { - return Err(AnnotError::Invalid("invalid style".into())); - } + validate_style(style)?; let rect = rect_for(&new.shape, style)?; let mut d = base_dict(new.shape.subtype(), rect, page, &new.contents, &new.author, meta); d.set(b"Subj".to_vec(), PdfString::text(subject(&new.shape))); let opacity = style.opacity.clamp(0.0, 1.0); - if opacity < 1.0 { - d.set(b"CA".to_vec(), Object::Real(opacity)); - } + set_non_opaque_style(&mut d, opacity); let border = |d: &mut Dict| { let mut bs = Dict::new(); bs.set(b"W".to_vec(), Object::Real(style.width.max(0.0))); @@ -232,24 +230,7 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R d.set(b"C".to_vec(), rgb(style.color)); d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); } - Shape::Redact { quads, overlay, look } => { - d.set(b"C".to_vec(), rgb(style.color)); - d.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); - d.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); - if !overlay.is_empty() { - d.set(b"OverlayText".to_vec(), PdfString::text(overlay)); - let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); - let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; - d.set( - b"DA".to_vec(), - PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes()), - ); - d.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); - if look.repeat { - d.set(b"Repeat".to_vec(), Object::Bool(true)); - } - } - } + Shape::Redact { quads, overlay, look } => set_redaction_properties(&mut d, style, quads, overlay, look), Shape::Rectangle { .. } | Shape::Oval { .. } => { d.set(b"C".to_vec(), rgb(style.color)); if let Some(f) = style.fill { @@ -426,22 +407,55 @@ pub fn add_annotation(doc: &mut Document, new: &NewAnnotation, meta: &Meta) -> R let mut list = annots(doc, page); let index = list.len(); list.push(Object::Ref(r)); - if let Some(pr) = popup { - let mut p = Dict::new(); - p.set(b"Type".to_vec(), Object::name("Annot")); - p.set(b"Subtype".to_vec(), Object::name("Popup")); - p.set(b"Rect".to_vec(), num_array(&pr)); - p.set(b"Parent".to_vec(), Object::Ref(r)); - p.set(b"Open".to_vec(), Object::Bool(false)); - p.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); - let pref = doc.add(Object::Dict(p)); - doc.update_dict(r, |d| d.set(b"Popup".to_vec(), Object::Ref(pref)))?; - list.push(Object::Ref(pref)); - } + add_popup(doc, r, popup, &mut list)?; set_annots(doc, page, list)?; Ok(index) } +fn validate_style(style: &Style) -> Result<(), AnnotError> { + if !finite(&style.color) || !style.opacity.is_finite() || !style.width.is_finite() { + return Err(AnnotError::Invalid("invalid style".into())); + } + Ok(()) +} + +fn set_redaction_properties(dict: &mut Dict, style: &Style, quads: &[[f64; 8]], overlay: &str, look: &OverlayLook) { + dict.set(b"C".to_vec(), rgb(style.color)); + dict.set(b"IC".to_vec(), rgb(style.fill.unwrap_or([0.0, 0.0, 0.0]))); + dict.set(b"QuadPoints".to_vec(), num_array(&quads.concat())); + if !overlay.is_empty() { + dict.set(b"OverlayText".to_vec(), PdfString::text(overlay)); + let [r, g, b] = look.color.map(|x| x.clamp(0.0, 1.0)); + let size = if look.size > 0.0 { look.size.min(400.0) } else { 0.0 }; + dict.set(b"DA".to_vec(), PdfString::literal(format!("{} {} {} rg /{} {} Tf", n(r), n(g), n(b), look.font.resource(), n(size)).into_bytes())); + dict.set(b"Q".to_vec(), Object::Int(look.align.min(2) as i64)); + if look.repeat { + dict.set(b"Repeat".to_vec(), Object::Bool(true)); + } + } +} + +fn set_non_opaque_style(dict: &mut Dict, opacity: f64) { + if opacity < 1.0 { + dict.set(b"CA".to_vec(), Object::Real(opacity)); + } +} + +fn add_popup(doc: &mut Document, parent: ObjRef, rect: Option<[f64; 4]>, annots: &mut Vec) -> Result<(), AnnotError> { + let Some(rect) = rect else { return Ok(()) }; + let mut popup = Dict::new(); + popup.set(b"Type".to_vec(), Object::name("Annot")); + popup.set(b"Subtype".to_vec(), Object::name("Popup")); + popup.set(b"Rect".to_vec(), num_array(&rect)); + popup.set(b"Parent".to_vec(), Object::Ref(parent)); + popup.set(b"Open".to_vec(), Object::Bool(false)); + popup.set(b"F".to_vec(), Object::Int(FLAG_PRINT | FLAG_NO_ZOOM | FLAG_NO_ROTATE)); + let popup_ref = doc.add(Object::Dict(popup)); + doc.update_dict(parent, |dict| dict.set(b"Popup".to_vec(), Object::Ref(popup_ref)))?; + annots.push(Object::Ref(popup_ref)); + Ok(()) +} + /// Counterrotate a newly placed picture stamp's appearance into displayed-page axes. /// The picture may be an image or a PDF page. The normal appearance's transformed bounding /// box is fitted to `/Rect` by PDF viewers. diff --git a/crates/annot/src/draw.rs b/crates/annot/src/draw.rs index 189845441..5e698332d 100644 --- a/crates/annot/src/draw.rs +++ b/crates/annot/src/draw.rs @@ -2,7 +2,7 @@ use super::create::{finite, num_array}; use super::lookup::{annot_dict, annots, page_refs}; use super::modify::refuse_foreign_text; use super::types::NOTE_SIZE; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, Stream, appearance}; /// (Re)generate `/AP /N` for the annotation `r` from its dictionary, for an edit that keeps its /// text (a restyle, a new size or colour). diff --git a/crates/annot/src/info.rs b/crates/annot/src/info.rs index bea14d9ee..b56e0de20 100644 --- a/crates/annot/src/info.rs +++ b/crates/annot/src/info.rs @@ -3,7 +3,7 @@ use super::draw::set_appearance; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, drawn_upright, is_fill_sign, page_ref, page_refs}; use super::modify::{fillable, touch, unlocked}; use super::types::{FLAG_LOCKED, LineEnding, Meta, NoteIcon, Rgb}; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, PdfString, appearance}; // ── reading ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/lookup.rs b/crates/annot/src/lookup.rs index 00ce23c37..c81ec78ef 100644 --- a/crates/annot/src/lookup.rs +++ b/crates/annot/src/lookup.rs @@ -1,6 +1,6 @@ use super::info::text_value; use super::types::{FLAG_HIDDEN, FLAG_NO_ROTATE, FLAG_NO_VIEW, Shape}; -use super::*; +use super::{AnnotError, Dict, Document, ObjRef, Object, appearance}; // ── pages and /Annots ─────────────────────────────────────────────────────────────────────── diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 992b3d0c5..7872a6c81 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -7,7 +7,7 @@ use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, s use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, }; -use super::*; +use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; /// A comment another program drew, whose text the standard fonts can't draw. Redrawing it would /// replace those characters with `?`, so the edit is refused before the dictionary changes. diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 632e990fe..40ed744df 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -1,5 +1,5 @@ use super::create::normalize; -use super::*; +use super::{ObjRef, appearance}; pub type Rgb = [f64; 3]; From 9d97546081c9e83acdd81b46c801ccfa8ff86cdd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 02/33] Refactor automation for strict Clippy checks --- crates/automation/src/a11y/documents.rs | 2 +- crates/automation/src/a11y/form_tools.rs | 2 +- crates/automation/src/a11y/ocr.rs | 2 +- crates/automation/src/assemble.rs | 2 +- crates/automation/src/dispatch.rs | 2 +- crates/automation/src/documents.rs | 2 +- crates/automation/src/json.rs | 2 +- crates/automation/src/pages.rs | 2 +- crates/automation/src/paths.rs | 2 +- crates/automation/tests/it/automation.rs | 192 +++++++++++++++-------- 10 files changed, 137 insertions(+), 73 deletions(-) diff --git a/crates/automation/src/a11y/documents.rs b/crates/automation/src/a11y/documents.rs index f623ef714..f2c9f6b77 100644 --- a/crates/automation/src/a11y/documents.rs +++ b/crates/automation/src/a11y/documents.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, unused, write_atomic}; impl Automation { pub(crate) fn doc_export_office(&self, a: &Args) -> Result { diff --git a/crates/automation/src/a11y/form_tools.rs b/crates/automation/src/a11y/form_tools.rs index 5fb14f136..7272a8d66 100644 --- a/crates/automation/src/a11y/form_tools.rs +++ b/crates/automation/src/a11y/form_tools.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, failed, json, write_atomic}; /// A script's request to the viewer as the JSON that running a script reports; a side panel /// goes by its fixed id. diff --git a/crates/automation/src/a11y/ocr.rs b/crates/automation/src/a11y/ocr.rs index afc671a8c..82e521ddd 100644 --- a/crates/automation/src/a11y/ocr.rs +++ b/crates/automation/src/a11y/ocr.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic}; impl Automation { /// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused diff --git a/crates/automation/src/assemble.rs b/crates/automation/src/assemble.rs index 40ae65ea8..deb2efa18 100644 --- a/crates/automation/src/assemble.rs +++ b/crates/automation/src/assemble.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based, summary}; use super::paths::{child, read_input, read_input_bytes, unused, write_atomic}; -use super::*; +use super::{Arc, Automation, CombineSource, Edit, Path, PathBuf, Value, json}; impl Automation { pub(crate) fn doc_create(&mut self, a: &Args) -> Result { diff --git a/crates/automation/src/dispatch.rs b/crates/automation/src/dispatch.rs index 26e1f9536..9ccd18c55 100644 --- a/crates/automation/src/dispatch.rs +++ b/crates/automation/src/dispatch.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, document_attachment_keys, info, one_based, parse_style_ranges, security, summary}; use super::paths::{read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, Edit, Value, block_overlapping, comments, json, tools}; /// One piece of a tool's result. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 3ce312f39..476c45102 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -1,7 +1,7 @@ use super::error::{Result, ToolError, failed}; use super::json::{Args, nest_rows, security, summary}; use super::paths::{child, read_input, write_atomic}; -use super::*; +use super::{Arc, Automation, BlockKey, DocId, Document, Edit, Path, PathBuf, TextBlock, Value, comments, file_access, json}; /// How far a watermark may be moved from the page centre, in points (a page is at most 14,400). const WATERMARK_MAX_OFFSET: f64 = 20_000.0; diff --git a/crates/automation/src/json.rs b/crates/automation/src/json.rs index c8710aa50..6f0884226 100644 --- a/crates/automation/src/json.rs +++ b/crates/automation/src/json.rs @@ -1,5 +1,5 @@ use super::error::{Result, ToolError}; -use super::*; +use super::{Document, Value, comments, json}; /// How far from the page's corner a bookmark's position may be given, in points (a PDF page is at /// most 14 400 across). diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index d9ea9e5c4..647a72e1d 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -1,7 +1,7 @@ use super::dispatch::Content; use super::error::{Result, ToolError, failed}; use super::json::{Args, one_based}; -use super::*; +use super::{Arc, Automation, DocId, HashMap, PageRenderer, PageText, RenderConfig, RenderRequest, RequestKind, Value, commands, json, tools}; /// Default and maximum resolution for `page_render`. pub(crate) const DEFAULT_DPI: f64 = 96.0; diff --git a/crates/automation/src/paths.rs b/crates/automation/src/paths.rs index fb514df69..e13d0a145 100644 --- a/crates/automation/src/paths.rs +++ b/crates/automation/src/paths.rs @@ -1,5 +1,5 @@ use super::error::{Result, failed}; -use super::*; +use super::{Automation, Path, PathBuf, StagingName, file_access, foreign_share, staging_suffixes}; impl Automation { // ---- paths ------------------------------------------------------------------------------- diff --git a/crates/automation/tests/it/automation.rs b/crates/automation/tests/it/automation.rs index 23351db9a..78038d8eb 100644 --- a/crates/automation/tests/it/automation.rs +++ b/crates/automation/tests/it/automation.rs @@ -2030,21 +2030,33 @@ fn comments_through_tools() { let dir = workdir("comments"); let mut a = auto(&dir); let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + let id = add_comment_examples(&mut a, doc); + let list = ok(&mut a, "comment_list", json!({ "doc": doc })); + let rect = verify_comment_listing(&list, &id); + edit_rectangle_comment(&mut a, doc, &rect); + edit_text_comment(&mut a, doc, &list); + verify_comment_errors(&mut a, doc, &id); + save_and_reopen_comments(&dir, &mut a, doc); +} + +fn add_comment_examples(a: &mut Automation, doc: u64) -> String { // "Page 2" sits at y = 150 (user space) = 150 from the top of the 300 pt page. - let hl = ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); + let hl = ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "highlight", "find": "page 2", "contents": "check", "author": "Ada" })); assert_eq!(hl["comment"]["lines"], 1); let id = hl["comment"]["id"].as_str().unwrap().to_string(); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); - ok(&mut a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); - ok(&mut a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 2, "type": "note", "at": [150, 20], "contents": "Sticky" })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "rectangle", "rect": [10, 10, 60, 40], "color": "blue", "width": 3 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "textbox", "rect": [10, 200, 190, 240], "contents": "Hello", "font_size": 10 })); + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "ink", "strokes": [[[10, 280], [50, 260], [90, 285]]] })); + ok(a, "comment_add", json!({ "doc": doc, "page": 3, "type": "arrow", "from": [10, 10], "to": [100, 100] })); + ok(a, "comment_reply", json!({ "doc": doc, "id": id, "text": "Looks right", "author": "Bob" })); + ok(a, "comment_set_status", json!({ "doc": doc, "id": id, "status": "accepted", "author": "Bob" })); + id +} - let list = ok(&mut a, "comment_list", json!({ "doc": doc })); +fn verify_comment_listing(list: &Value, id: &str) -> Value { assert_eq!(list["count"], 6, "{list}"); - let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id.as_str()).unwrap().clone(); + let c = list["comments"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone(); assert_eq!((c["type"].as_str(), c["author"].as_str(), c["contents"].as_str()), (Some("Highlight"), Some("Ada"), Some("check"))); assert_eq!(c["status"], "Accepted"); assert_eq!(c["replies"].as_array().unwrap().len(), 1); @@ -2057,33 +2069,38 @@ fn comments_through_tools() { assert_eq!(rect["rect"], json!([10.0, 10.0, 60.0, 40.0])); let note = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Text").unwrap().clone(); assert_eq!(note["rect"], json!([150.0, 20.0, 170.0, 40.0]), "note icon hangs from its top-left point"); + rect +} +fn edit_rectangle_comment(a: &mut Automation, doc: u64, original: &Value) { // Edit by page + index; several changes are one undo step. - let (page, index) = (rect["page"].as_u64().unwrap(), rect["index"].as_u64().unwrap()); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + let (page, index) = (original["page"].as_u64().unwrap(), original["index"].as_u64().unwrap()); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "color": "#FF0000", "move": [5, 5], "contents": "moved" })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let rect = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "Square").unwrap().clone(); assert_eq!((rect["color"].as_str(), rect["contents"].as_str()), (Some("#FF0000"), Some("moved"))); assert_eq!(rect["rect"], json!([15.0, 15.0, 65.0, 45.0])); - let undo = ok(&mut a, "edit_undo", json!({ "doc": doc })); + let undo = ok(a, "edit_undo", json!({ "doc": doc })); assert_eq!(undo["undone"], "Edit comment"); - ok(&mut a, "edit_redo", json!({ "doc": doc })); + ok(a, "edit_redo", json!({ "doc": doc })); // Rectangles, ovals and polygons take a fill (#686); "none" removes it. Lines have none. - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "yellow" })); + ok(a, "comment_edit", json!({ "doc": doc, "page": page, "index": index, "fill": "none" })); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": page, "index": index, "fill": "mauve" })), Err(ToolError::InvalidArgs(_)))); - let arrow = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); + let arrow = ok(a, "comment_list", json!({ "doc": doc, "page": 3 }))["comments"][0].clone(); assert!(matches!( a.call("comment_edit", &json!({ "doc": doc, "page": arrow["page"], "index": arrow["index"], "fill": "red" })), Err(ToolError::Failed(_)) )); +} +fn edit_text_comment(a: &mut Automation, doc: u64, original_list: &Value) { // Editing a text box re-fits its rectangle to the new text: the wrap width and top edge // stay, the height follows the wrapped lines. - let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); + let tb = original_list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let long = "the quick brown fox jumps over the lazy dog ".repeat(3); - ok(&mut a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); - let list = ok(&mut a, "comment_list", json!({ "doc": doc, "page": 1 })); + ok(a, "comment_edit", json!({ "doc": doc, "page": tb["page"], "index": tb["index"], "contents": long.trim_end() })); + let list = ok(a, "comment_list", json!({ "doc": doc, "page": 1 })); let tb = list["comments"].as_array().unwrap().iter().find(|c| c["type"] == "FreeText").unwrap().clone(); let r: Vec = tb["rect"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); assert_eq!((r[0], r[1], r[2] - r[0]), (10.0, 200.0, 180.0), "width and top edge stay: {r:?}"); @@ -2092,9 +2109,11 @@ fn comments_through_tools() { // The rendered page shows the rectangle's border. let png = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap(); assert!(matches!(png[0], Content::Png { .. })); +} - ok(&mut a, "comment_delete", json!({ "doc": doc, "id": id })); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": doc }))["count"], 5); +fn verify_comment_errors(a: &mut Automation, doc: u64, id: &str) { + ok(a, "comment_delete", json!({ "doc": doc, "id": id })); + assert_eq!(ok(a, "comment_list", json!({ "doc": doc }))["count"], 5); assert!(matches!(a.call("comment_delete", &json!({ "doc": doc, "id": id })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "highlight", "find": "nowhere" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("comment_add", &json!({ "doc": doc, "page": 1, "type": "rectangle" })), Err(ToolError::InvalidArgs(_)))); @@ -2103,10 +2122,11 @@ fn comments_through_tools() { Err(ToolError::InvalidArgs(_)) )); assert!(matches!(a.call("comment_edit", &json!({ "doc": doc, "page": 1, "index": 1 })), Err(ToolError::InvalidArgs(_)))); +} - // Comments survive a save and reopen. - ok(&mut a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); - let mut b = auto(&dir); +fn save_and_reopen_comments(dir: &Path, a: &mut Automation, doc: u64) { + ok(a, "doc_save", json!({ "doc": doc, "path": "commented.pdf" })); + let mut b = auto(dir); let re = ok(&mut b, "doc_open", json!({ "path": "commented.pdf" }))["doc"].as_u64().unwrap(); assert_eq!(ok(&mut b, "comment_list", json!({ "doc": re }))["count"], 5); } @@ -2809,6 +2829,12 @@ fn accessibility_check_report_and_fixes_through_tools() { let manual_html = std::fs::read_to_string(dir.join("manual-report.html")).unwrap(); assert!(manual_html.contains("

Needs manual check: 1

")); assert!(!manual_html.contains("no problems")); +} + +#[test] +fn accessibility_figure_tags_and_alternative_text_through_tools() { + let dir = workdir("a11y-figures"); + let mut a = auto(&dir); // Figures: list, describe, mark decorative. std::fs::write( dir.join("figures.pdf"), @@ -5260,6 +5286,18 @@ fn local_gotor_link_open_refuses_directories_devices_and_non_pdf_targets() { #[test] fn local_gotor_link_open_applies_destinations_and_honors_root() { + let root = local_gotor_fixtures(); + let mut a = auto(&root); + assert_relative_gotor_destination(&mut a); + assert_absolute_gotor_policy(&mut a); + assert_unapplied_gotor_destinations(&mut a); + assert_encrypted_gotor_passwords(&mut a); + assert_unsupported_gotor_targets(&mut a); + assert_gotor_root_boundaries(&mut a); + assert_url_links_cannot_open_as_gotor(&mut a); +} + +fn local_gotor_fixtures() -> PathBuf { let (base, root) = sandbox("gotor-open"); std::fs::create_dir_all(root.join("sub")).unwrap(); std::fs::write(root.join("target.pdf"), fixture(3)).unwrap(); @@ -5277,12 +5315,14 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { std::fs::write(root.join("absolute.pdf"), remote_link_pdf(root.join("target.pdf").to_str().unwrap(), Some("[1 /Fit]"))).unwrap(); link_dir(&root, "outside-link", &base.join("outside")); std::fs::write(root.join("symlink.pdf"), remote_link_pdf("outside-link/secret.pdf", Some("[0 /Fit]"))).unwrap(); + root +} - let mut a = auto(&root); - let source = ok(&mut a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); - let listed = ok(&mut a, "link_list", json!({ "doc": source })); +fn assert_relative_gotor_destination(a: &mut Automation) { + let source = ok(a, "doc_open", json!({ "path": "sub/source.pdf" }))["doc"].as_u64().unwrap(); + let listed = ok(a, "link_list", json!({ "doc": source })); assert_eq!(listed["links"][0]["other"], "GoToR"); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination_applied"], true); assert_eq!(opened["destination"]["page"], 3); @@ -5290,51 +5330,63 @@ fn local_gotor_link_open_applies_destinations_and_honors_root() { assert_eq!(opened["document"]["pages"], 3); let target_id = opened["document"]["doc"].as_u64().unwrap(); assert_eq!(a.session().get(pdfcraft_engine::DocId(target_id)).unwrap().info.pages.len(), 3); +} - let absolute = ok(&mut a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); +fn assert_absolute_gotor_policy(a: &mut Automation) { + let absolute = ok(a, "doc_open", json!({ "path": "absolute.pdf" }))["doc"].as_u64().unwrap(); let before = a.session().docs().len(); for args in [json!({ "doc": absolute, "page": 1, "index": 1 }), json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": false })] { - assert!(matches!(err(&mut a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); + assert!(matches!(err(a, "link_open", args), ToolError::Failed(m) if m.contains("allow_absolute"))); assert_eq!(a.session().docs().len(), before); } - let opened = ok(&mut a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); + let opened = ok(a, "link_open", json!({ "doc": absolute, "page": 1, "index": 1, "allow_absolute": true })); assert_eq!(opened["path"], "target.pdf"); assert_eq!(opened["destination"]["page"], 2); +} +fn assert_unapplied_gotor_destinations(a: &mut Automation) { for (source_path, warning) in [("named.pdf", "named destination"), ("range.pdf", "out of range")] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); - let opened = ok(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let opened = ok(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })); assert_eq!(opened["destination_applied"], false); assert!(opened["warning"].as_str().unwrap().contains(warning), "{opened}"); } +} - let password_source = ok(&mut a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); +fn assert_encrypted_gotor_passwords(a: &mut Automation) { + let password_source = ok(a, "doc_open", json!({ "path": "password-source.pdf" }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "wrong" })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before); - let opened = ok(&mut a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); + let opened = ok(a, "link_open", json!({ "doc": password_source, "page": 1, "index": 1, "password": "secret" })); assert_eq!(opened["document"]["encrypted"], true); +} +fn assert_unsupported_gotor_targets(a: &mut Automation) { for source_path in ["url.pdf", "missing-source.pdf", "non-pdf-source.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(_))); assert_eq!(a.session().docs().len(), documents_before, "a rejected target is not opened: {source_path}"); } +} +fn assert_gotor_root_boundaries(a: &mut Automation) { for source_path in ["sub/escape.pdf", "symlink.pdf"] { - let source = ok(&mut a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); + let source = ok(a, "doc_open", json!({ "path": source_path }))["doc"].as_u64().unwrap(); let documents_before = a.session().docs().len(); - assert!(matches!(err(&mut a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); + assert!(matches!(err(a, "link_open", json!({ "doc": source, "page": 1, "index": 1 })), ToolError::Failed(m) if m.contains("outside"))); assert_eq!(a.session().docs().len(), documents_before, "a refused target is not opened"); } +} - let missing_source = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - ok(&mut a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); +fn assert_url_links_cannot_open_as_gotor(a: &mut Automation) { + let missing_source = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + ok(a, "link_add", json!({ "doc": missing_source, "page": 1, "rect": [20, 30, 80, 50], "url": "https://example.org" })); assert!( - matches!(err(&mut a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) + matches!(err(a, "link_open", json!({ "doc": missing_source, "page": 1, "index": 1 })), ToolError::InvalidArgs(m) if m.contains("not a supported local PDF GoToR")) ); } @@ -5887,8 +5939,14 @@ fn a_dynamic_xfa_forms_signature_box_is_signed_under_its_som_path() { fn digital_ids_signing_and_validation_through_tools() { let dir = workdir("signing"); let mut a = auto(&dir); + create_signing_identity(&mut a); + let doc = sign_document_and_verify_trust(&mut a, &dir); + verify_signature_revisions_and_bundle(&mut a, doc, &dir); +} + +fn create_signing_identity(a: &mut Automation) { let id = ok( - &mut a, + a, "sign_id_create", json!({ "name": "Ada Lovelace", "organization": "Analytical Engines", "email": "ada@example.com", "country": "GB", "key": "p256", "password": "secret12", "path": "ada.p12" }), ); @@ -5897,13 +5955,15 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "short", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); // Eight characters at least (#1847): seven is refused. assert!(matches!(a.call("sign_id_create", &json!({ "name": "X", "password": "seven77", "path": "x.p12" })), Err(ToolError::InvalidArgs(_)))); +} - let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["count"], 0); +fn sign_document_and_verify_trust(a: &mut Automation, dir: &Path) -> u64 { + let doc = ok(a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["count"], 0); // A Keychain identity that doesn't exist (macOS) or Keychains at all (elsewhere). assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "keychain:No Such Signer", "out": "k.pdf" })), Err(ToolError::Failed(_)))); assert!(matches!(a.call("sign_document", &json!({ "doc": doc, "id": "windows:No Such Signer", "out": "w.pdf" })), Err(ToolError::Failed(_)))); - let store = ok(&mut a, "sign_windows_ids", json!({})); + let store = ok(a, "sign_windows_ids", json!({})); let ids = store["ids"].as_array().unwrap(); assert_eq!(store["count"].as_u64().unwrap(), ids.len() as u64); assert!(ids.iter().all(|id| id["id"].as_str().unwrap().starts_with("windows:"))); @@ -5917,7 +5977,7 @@ fn digital_ids_signing_and_validation_through_tools() { Err(ToolError::InvalidArgs(_)) )); let r = ok( - &mut a, + a, "sign_document", json!({ "doc": doc, "id": "ada.p12", "password": "secret12", "page": 2, "rect": [20, 200, 180, 250], "reason": "Approved", "location": "London", "out": "signed.pdf" }), ); @@ -5929,33 +5989,37 @@ fn digital_ids_signing_and_validation_through_tools() { assert!(dir.join("signed.pdf").exists()); // Trust the ID: valid; comment afterwards: still valid, change allowed. - let t = ok(&mut a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); + let t = ok(a, "sign_trust", json!({ "paths": ["ada.p12"], "password": "secret12" })); assert_eq!(t["trusted"].as_array().unwrap().len(), 1); - let list = ok(&mut a, "sign_list", json!({ "doc": doc })); + let list = ok(a, "sign_list", json!({ "doc": doc })); assert_eq!((list["all_valid"].as_bool(), list["signatures"][0]["status"].as_str()), (Some(true), Some("valid"))); - ok(&mut a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); - let s = &ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]; + ok(a, "comment_add", json!({ "doc": doc, "page": 1, "type": "note", "at": [20, 20], "contents": "ok" })); + let s = &ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]; assert_eq!((s["status"].as_str(), s["modification"].as_str()), (Some("valid"), Some("allowed"))); + doc +} + +fn verify_signature_revisions_and_bundle(a: &mut Automation, doc: u64, dir: &Path) { // A full rewrite is refused for a signed document. assert!(matches!(a.call("doc_save", &json!({ "doc": doc, "path": "copy.pdf", "full": true })), Err(ToolError::Failed(_)))); - ok(&mut a, "doc_save", json!({ "doc": doc })); + ok(a, "doc_save", json!({ "doc": doc })); // Three revisions: the original, the signature, the comment; the middle one is signed. - let revs = ok(&mut a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); + let revs = ok(a, "doc_revisions", json!({ "doc": doc }))["revisions"].as_array().cloned().unwrap(); assert_eq!(revs.len(), 3, "{revs:?}"); assert!(revs[0]["signed_by"].as_array().unwrap().is_empty() && revs[1]["signed_by"].as_array().unwrap().len() == 1, "{revs:?}"); - let old = ok(&mut a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); - assert_eq!(ok(&mut a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": old }))["count"], 1); + let old = ok(a, "doc_open_revision", json!({ "doc": doc, "revision": 2 }))["doc"].as_u64().unwrap(); + assert_eq!(ok(a, "comment_list", json!({ "doc": old }))["comments"].as_array().map(Vec::len), Some(0), "before the comment"); + assert_eq!(ok(a, "sign_list", json!({ "doc": old }))["count"], 1); assert!(matches!(a.call("doc_open_revision", &json!({ "doc": doc, "revision": 4 })), Err(ToolError::Failed(_)))); - assert_eq!(ok(&mut a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); + assert_eq!(ok(a, "sign_trust", json!({ "clear": true }))["trusted"].as_array().unwrap().len(), 0); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "unknown"); // The same certificate from a PKCS #7 bundle (.p7b), as Windows, Acrobat and CAs export one. use pdfcraft_engine::sign::{cms, der, pkcs12}; let cert = pkcs12::open(&std::fs::read(dir.join("ada.p12")).unwrap(), "secret12").unwrap().certificate.raw; let sd = der::seq(&[&der::int(1), &der::set_of(&[]), &der::seq(&[&der::oid(cms::DATA)]), &der::explicit(0, &cert), &der::set_of(&[])]); std::fs::write(dir.join("ada.p7b"), der::seq(&[&der::oid(cms::SIGNED_DATA), &der::explicit(0, &sd)])).unwrap(); - assert_eq!(ok(&mut a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); - assert_eq!(ok(&mut a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); + assert_eq!(ok(a, "sign_trust", json!({ "paths": ["ada.p7b"] }))["trusted"].as_array().unwrap().len(), 1); + assert_eq!(ok(a, "sign_list", json!({ "doc": doc }))["signatures"][0]["status"], "valid"); } /// The decoded text of every visible signature appearance (Helvetica forms) in a signed file. From 84c873d2e1af5348455f226cd770708d725a31c7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 03/33] Refactor cos for strict Clippy checks --- crates/cos/src/document/objects.rs | 2 +- crates/cos/src/document/open.rs | 2 +- crates/cos/src/document/reader.rs | 2 +- crates/cos/src/document/state.rs | 2 +- crates/cos/src/document/xref.rs | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/cos/src/document/objects.rs b/crates/cos/src/document/objects.rs index 2ff729f1f..8744a3ea3 100644 --- a/crates/cos/src/document/objects.rs +++ b/crates/cos/src/document/objects.rs @@ -1,7 +1,7 @@ use super::reader::find; use super::state::{LENGTHS, LoadGuard, ObjStm, ObjectReader, ReadState, Slot, SourceIdentity, objstm_member, owned_bytes}; use super::xref::{Revision, XrefEntry}; -use super::*; +use super::{Arc, CosError, Dict, Document, Lexer, ObjRef, Object, is_whitespace, parse_indirect_shared}; impl Document { pub fn bytes(&self) -> &Arc> { diff --git a/crates/cos/src/document/open.rs b/crates/cos/src/document/open.rs index c90d5c090..d41d564e3 100644 --- a/crates/cos/src/document/open.rs +++ b/crates/cos/src/document/open.rs @@ -1,7 +1,7 @@ use super::reader::{find, generated_id}; use super::state::Credentials; use super::xref::XrefIndex; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, ObjRef, Object}; impl Document { /// A new document with an empty page tree (for split / extract / combine). It has no diff --git a/crates/cos/src/document/reader.rs b/crates/cos/src/document/reader.rs index ead09c36e..66129c142 100644 --- a/crates/cos/src/document/reader.rs +++ b/crates/cos/src/document/reader.rs @@ -3,7 +3,7 @@ use super::state::SECTION_READS; use super::xref::{ MAX_COMPACT_XREF_BYTES, MAX_COMPACT_XREF_ROWS, Revision, XrefEntry, XrefIndex, XrefRange, XrefRevisionSection, XrefSection, XrefSectionData, }; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, HashSet, Lexer, ObjRef, Object, is_whitespace, parse_indirect, parse_indirect_shared}; impl Document { #[allow(clippy::type_complexity)] diff --git a/crates/cos/src/document/state.rs b/crates/cos/src/document/state.rs index 30f193e4e..b4323f982 100644 --- a/crates/cos/src/document/state.rs +++ b/crates/cos/src/document/state.rs @@ -1,5 +1,5 @@ use super::xref::XrefEntry; -use super::*; +use super::{Arc, BTreeMap, CosError, Dict, Document, HashMap, ObjRef, Object, Weak}; thread_local! { /// The objects being loaded on this thread, innermost last (see `Document::try_get`). diff --git a/crates/cos/src/document/xref.rs b/crates/cos/src/document/xref.rs index 96a782c1a..02390ff7d 100644 --- a/crates/cos/src/document/xref.rs +++ b/crates/cos/src/document/xref.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Lexer}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum XrefEntry { From de73c35e174aa84284a4c995ab2341c0a00e190f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 04/33] Refactor edit for strict Clippy checks --- crates/edit/src/added/draw.rs | 5 +- crates/edit/src/added/layout.rs | 5 +- crates/edit/src/added/store.rs | 2 +- crates/edit/src/added/types.rs | 2 +- crates/edit/src/apply.rs | 2 +- crates/edit/src/marks.rs | 2 +- crates/edit/src/place.rs | 2 +- crates/edit/src/tests.rs | 193 +++-- crates/edit/src/text/blocks.rs | 2 +- crates/edit/src/text/editor.rs | 2 +- crates/edit/src/text/fallback.rs | 4 +- crates/edit/src/text/interp.rs | 532 ++++++------ crates/edit/src/text/lines.rs | 2 +- crates/edit/src/text/rewrite.rs | 936 ++++++++++++-------- crates/edit/src/text_regions.rs | 7 +- crates/edit/src/text_regions/grouping.rs | 2 +- crates/edit/src/text_regions/styled.rs | 1010 +++++++++++++--------- crates/edit/src/text_session.rs | 5 +- 18 files changed, 1634 insertions(+), 1081 deletions(-) diff --git a/crates/edit/src/added/draw.rs b/crates/edit/src/added/draw.rs index 0fc7aa510..61297bcf2 100644 --- a/crates/edit/src/added/draw.rs +++ b/crates/edit/src/added/draw.rs @@ -2,7 +2,10 @@ use super::layout::{ Piece, arabic_error, hebrew_error, is_arabic, is_hebrew, is_thai, latin_width, lines, norm, shaped_layout, text_rect, thai_error, wrapped, }; use super::types::{AddedText, Align, Family}; -use super::*; +use super::{ + Content, Dict, Document, EditError, GlyphError, GlyphOutline, HashMap, HashSet, ObjRef, Object, PdfString, ShapedCluster, ShapedScript, Stream, + literal, matrix_number, n, shaped_glyph, win_ansi, +}; fn font_name(base: &str) -> String { format!("PCF{}", base.replace('-', "")) diff --git a/crates/edit/src/added/layout.rs b/crates/edit/src/added/layout.rs index 0e971b92c..67d80be40 100644 --- a/crates/edit/src/added/layout.rs +++ b/crates/edit/src/added/layout.rs @@ -1,5 +1,8 @@ use super::types::{AddedImage, AddedText}; -use super::*; +use super::{ + Content, Document, EditError, GlyphError, Level, ObjRef, Object, ParagraphBidiInfo, ShapedCluster, ShapedScript, shape_arabic, shape_hebrew, + shape_thai, +}; pub(super) fn nums(doc: &Document, o: Option<&Object>) -> Vec { o.map(|o| doc.resolve(o)).and_then(|a| a.as_array().map(|a| a.iter().filter_map(Object::as_f64).collect())).unwrap_or_default() diff --git a/crates/edit/src/added/store.rs b/crates/edit/src/added/store.rs index b89d42900..5ce5e15d1 100644 --- a/crates/edit/src/added/store.rs +++ b/crates/edit/src/added/store.rs @@ -1,7 +1,7 @@ use super::draw::{draw, params}; use super::layout::{first_undrawable, is_thai, nums, shaped_arabic, thai_error}; use super::types::{Added, AddedImage, AddedText, Align, CHAR_SPACING_LIMIT, Family, MarkerScan, TAG}; -use super::*; +use super::{Content, Dict, Document, EditError, GlyphError, MarkKind, ObjRef, Object, Stream, check, contents, page_list, place_tagged}; fn parse(doc: &Document, d: &Dict) -> Option { let r = nums(doc, d.get(b"Rect")); diff --git a/crates/edit/src/added/types.rs b/crates/edit/src/added/types.rs index 0a9f92f92..28b61d586 100644 --- a/crates/edit/src/added/types.rs +++ b/crates/edit/src/added/types.rs @@ -1,5 +1,5 @@ use super::layout::norm; -use super::*; +use super::{Content, MarkKind, ObjRef, Std14}; pub(super) const TAG: &str = "Added"; diff --git a/crates/edit/src/apply.rs b/crates/edit/src/apply.rs index 1461dc78e..427f51f94 100644 --- a/crates/edit/src/apply.rs +++ b/crates/edit/src/apply.rs @@ -5,7 +5,7 @@ use super::place::{ END, Isolation, add_page_resources, add_resources, begin, check, check_on_top, contents, free_font_name, isolation, page_fonts, page_list, place, place_inside_wrapper, stream_tag, text_op, undrawable, wrapper_role, }; -use super::*; +use super::{Document, EditError, Object, added, helvetica_width}; /// Add a header and footer to `pages` (0-based). With `replace`, existing headers and footers on /// those pages are removed first (Acrobat's Replace Existing). diff --git a/crates/edit/src/marks.rs b/crates/edit/src/marks.rs index 507e2afa8..00c53ec27 100644 --- a/crates/edit/src/marks.rs +++ b/crates/edit/src/marks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Document, EditError, Object, Rgb, added}; /// The kinds of page marks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/edit/src/place.rs b/crates/edit/src/place.rs index b6d50a646..2f2c24c86 100644 --- a/crates/edit/src/place.rs +++ b/crates/edit/src/place.rs @@ -1,5 +1,5 @@ use super::marks::{MarkKind, matrix_number, n}; -use super::*; +use super::{Dict, Document, EditError, ObjRef, Object, Stream, literal, win_ansi}; pub(crate) fn page_list(doc: &Document) -> Vec { pdfcraft_model::pages(doc) diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 0f8adc651..7c78d31fc 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -6664,12 +6664,124 @@ fn actual_text_pairs_past_the_marked_content_cap() { } } -/// #2992: the colour a page is painted with before anything else is its background, for exports -/// that keep a page colour. Edit a PDF ▸ Background's fill is found under the text it goes -/// behind; a page that paints anything else first, a fill that doesn't cover the page, a -/// translucent or clipped one, or a colour that isn't plain gray, RGB or CMYK has none. +fn page_background_from_content(content: &str, resources: &str) -> Option<[f64; 3]> { + let objects = [ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]; + let mut bytes = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(bytes.len()); + bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref_offset = bytes.len(); + bytes.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); + for offset in offsets { + bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + bytes.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n").as_bytes()); + page_background(&Document::open(Arc::new(bytes)).unwrap(), 0).unwrap() +} + +fn assert_page_background_fill_cases(gs: &str) { + assert_eq!(page_background_from_content("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); + assert_eq!(page_background_from_content("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); + assert_eq!(page_background_from_content("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); + assert_eq!(page_background_from_content("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "one of the rectangles covers it" + ); + // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the + // other way under nonzero. + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); + assert_eq!(page_background_from_content("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), + Some([1.0, 0.0, 0.0]), + "nonzero, same winding: no hole" + ); + assert_eq!( + page_background_from_content("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), + None, + "reversed by a mirroring cm" + ); + assert_eq!(page_background_from_content("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); + assert_eq!(page_background_from_content("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); + assert_eq!(page_background_from_content("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); + assert_eq!(page_background_from_content("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); + assert_eq!(page_background_from_content("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); + assert_eq!(page_background_from_content("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); + assert_eq!(page_background_from_content("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); + assert_eq!( + page_background_from_content("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), + None, + "a page fill with a hole cut in it" + ); +} + +fn assert_page_background_transform_and_clip_cases() { + // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop + // box's corners are inside it; its bounding box covering the crop box isn't enough. This one + // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but + // 354 along the diagonals, short of the corners. One reaching 707 covers them. + let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), + None, + "a turned fill short of the corners" + ); + assert_eq!( + page_background_from_content(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned fill over them" + ); + // The same for a clip. + assert_eq!( + page_background_from_content(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + None, + "a turned clip" + ); + assert_eq!( + page_background_from_content(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), + Some([1.0, 0.0, 0.0]), + "a turned clip over the corners" + ); + // Clips add up: two that each cover the page leave it covered, one that doesn't among them + // doesn't. + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + Some([1.0, 0.0, 0.0]), + "two covering clips" + ); + assert_eq!( + page_background_from_content("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), + None, + "the second clip doesn't cover" + ); + // Past 64 clipping rectangles the clip counts as unknown. + let clips = "0 0 600 800 re W n ".repeat(65); + assert_eq!(page_background_from_content(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); + // A rectangle drawn the other way round (negative width) covers the page all the same. + assert_eq!(page_background_from_content("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); + // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the + // edge of that included. + assert_eq!(page_background_from_content("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); + assert_eq!(page_background_from_content("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); + assert_eq!(page_background_from_content("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); +} + +/// #2992: page background detection follows the first full-page fill under text and ignores +/// partial, translucent, clipped, or unsupported fills. #[test] fn a_page_painted_first_with_a_full_page_fill_has_that_background() { + let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; let mut doc = fixture(); assert_eq!(page_background(&doc, 0).unwrap(), None, "text first"); add_background(&mut doc, &[0, 1], &Background { color: [0.0, 0.5, 0.5], opacity: 1.0, ..Background::default() }, false).unwrap(); @@ -6680,77 +6792,8 @@ fn a_page_painted_first_with_a_full_page_fill_has_that_background() { assert_eq!(page_background(&doc, 2).unwrap(), None, "a page with no content"); assert!(page_background(&doc, 9).is_err()); - let page = |content: &str, resources: &str| { - let objs: Vec = vec![ - "<< /Type /Catalog /Pages 2 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), - format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /CropBox [0 0 500 700] /Contents 4 0 R /Resources << {resources} >> >>"), - format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), - ]; - let mut out = b"%PDF-1.7\n".to_vec(); - let mut offs = Vec::new(); - for (i, o) in objs.iter().enumerate() { - offs.push(out.len()); - out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); - } - let x = out.len(); - out.extend_from_slice(b"xref\n0 5\n0000000000 65535 f \n"); - for o in offs { - out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); - } - out.extend_from_slice(format!("trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n").as_bytes()); - page_background(&Document::open(Arc::new(out)).unwrap(), 0).unwrap() - }; - let gs = "/ExtGState << /Half << /ca 0.5 >> /Mul << /BM /Multiply >> /Full << /ca 1 /SMask /None >> >>"; - assert_eq!(page("1 g 0 0 600 800 re f", ""), Some([1.0; 3]), "a producer's white page fill"); - assert_eq!(page("0 0 1 0 k 0 0 500 700 re f", ""), Some([1.0, 1.0, 0.0]), "CMYK, covering the crop box"); - assert_eq!(page("/DeviceRGB cs 1 0 0 sc 0 0 500 700 re f", ""), Some([1.0, 0.0, 0.0]), "cs and sc"); - assert_eq!(page("/Full gs q 2 0 0 2 0 0 cm 0 1 0 rg 0 0 300 400 re f Q", gs), Some([0.0, 1.0, 0.0]), "under a cm"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re f", ""), None, "half the page"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 300 0 300 800 re f", ""), None, "two halves are not one covering rectangle"); - assert_eq!(page("1 0 0 rg 0 0 300 800 re 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "one of the rectangles covers it"); - // #2994 review: a second rectangle that cuts a hole: any one under even-odd, one wound the - // other way under nonzero. - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f*", ""), None, "even-odd hole"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 300 100 -200 200 re f", ""), None, "nonzero, reversed inner rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 200 200 re f", ""), Some([1.0, 0.0, 0.0]), "nonzero, same winding: no hole"); - assert_eq!(page("1 0 0 rg q -1 0 0 1 600 0 cm 0 0 600 800 re Q 100 100 200 200 re f", ""), None, "reversed by a mirroring cm"); - assert_eq!(page("/Half gs 1 0 0 rg 0 0 600 800 re f", gs), None, "translucent"); - assert_eq!(page("/Mul gs 1 0 0 rg 0 0 600 800 re f", gs), None, "blended"); - assert_eq!(page("0 0 100 100 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "clipped to a corner"); - assert_eq!(page("0 0 600 800 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "a page-sized clip"); - assert_eq!(page("/Pattern cs /P1 scn 0 0 600 800 re f", ""), None, "a pattern"); - assert_eq!(page("BT ET 0 0 600 800 re S 1 0 0 rg 0 0 600 800 re f", ""), None, "a stroke first"); - assert_eq!(page("0 0 m 600 0 l 600 800 l 0 800 l h f", ""), None, "a path that isn't a rectangle"); - assert_eq!(page("1 0 0 rg 0 0 600 800 re 100 100 m 200 100 l 200 200 l h f*", ""), None, "a page fill with a hole cut in it"); - // #2994 review: a rectangle turned 45° about the crop box's centre covers it only if the crop - // box's corners are inside it; its bounding box covering the crop box isn't enough. This one - // reaches 500 from the centre along each axis (its box covers the 500 × 700 crop box) but - // 354 along the diagonals, short of the corners. One reaching 707 covers them. - let turned = "0.70710678 0.70710678 -0.70710678 0.70710678 250 350 cm"; - assert_eq!(page(&format!("1 0 0 rg {turned} -353.553 -353.553 707.107 707.107 re f"), ""), None, "a turned fill short of the corners"); - assert_eq!(page(&format!("1 0 0 rg {turned} -500 -500 1000 1000 re f"), ""), Some([1.0, 0.0, 0.0]), "a turned fill over them"); - // The same for a clip. - assert_eq!(page(&format!("{turned} -353.553 -353.553 707.107 707.107 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), None, "a turned clip"); - assert_eq!( - page(&format!("{turned} -500 -500 1000 1000 re W n 1 0 0 rg -2000 -2000 4000 4000 re f"), ""), - Some([1.0, 0.0, 0.0]), - "a turned clip over the corners" - ); - // Clips add up: two that each cover the page leave it covered, one that doesn't among them - // doesn't. - assert_eq!(page("0 0 600 800 re W n 0 0 550 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), Some([1.0, 0.0, 0.0]), "two covering clips"); - assert_eq!(page("0 0 600 800 re W n 0 0 300 750 re W n 1 0 0 rg 0 0 600 800 re f", ""), None, "the second clip doesn't cover"); - // Past 64 clipping rectangles the clip counts as unknown. - let clips = "0 0 600 800 re W n ".repeat(65); - assert_eq!(page(&format!("{clips}1 0 0 rg 0 0 600 800 re f"), ""), None, "too many clips to follow"); - // A rectangle drawn the other way round (negative width) covers the page all the same. - assert_eq!(page("1 0 0 rg 600 0 -600 800 re f", ""), Some([1.0, 0.0, 0.0]), "drawn clockwise"); - // The crop box counts as covered within half a percent of its size (2.5 × 3.5 here), the - // edge of that included. - assert_eq!(page("1 0 0 rg 2 3 496 694 re f", ""), Some([1.0, 0.0, 0.0]), "within the tolerance"); - assert_eq!(page("1 0 0 rg 2.5 3.5 495 693 re f", ""), Some([1.0, 0.0, 0.0]), "on the tolerance's edge"); - assert_eq!(page("1 0 0 rg 3 4 494 692 re f", ""), None, "past the tolerance"); + assert_page_background_fill_cases(gs); + assert_page_background_transform_and_clip_cases(); } #[test] diff --git a/crates/edit/src/text/blocks.rs b/crates/edit/src/text/blocks.rs index 8d3d224da..615e7699a 100644 --- a/crates/edit/src/text/blocks.rs +++ b/crates/edit/src/text/blocks.rs @@ -1,7 +1,7 @@ use super::interp::{Carry, interpret}; use super::lines::{content_streams, page_dict, reading_lines, rewritten_contents, splice, text_lines}; use super::rewrite::{LineEdit, coincident_ops, rewrite_block}; -use super::*; +use super::{Document, EditError, HashMap, Object, Op, Pieces, TextLine, group_blocks, regions}; /// An editable text region: adjacent runs on visual rows, followed by aligned rows. /// A region may contain several source fonts, sizes, colours and weights. diff --git a/crates/edit/src/text/editor.rs b/crates/edit/src/text/editor.rs index b2bd2b9f9..f7aa1f47b 100644 --- a/crates/edit/src/text/editor.rs +++ b/crates/edit/src/text/editor.rs @@ -2,7 +2,7 @@ use super::blocks::{BlockStyle, TextBlock}; use super::fallback::MAX_TYPE3_GLYPHS; use super::lines::{page_dict, text_lines}; use super::rewrite::rewrite_block_from; -use super::*; +use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession}; /// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and /// are never installed, copied into assets, or used as a font for another document. diff --git a/crates/edit/src/text/fallback.rs b/crates/edit/src/text/fallback.rs index 0c19a6680..b8f45a221 100644 --- a/crates/edit/src/text/fallback.rs +++ b/crates/edit/src/text/fallback.rs @@ -1,6 +1,8 @@ use super::interp::Ts; use super::lines::page_dict; -use super::*; +use super::{ + CraftFont, Dict, Document, EditError, GlyphError, HashMap, Metrics, Object, Op, PdfString, Stream, TextLine, japanese_glyph_from, parse, +}; /// Text → the runs that show it: each run's font resource name and the bytes shown in that font. pub(super) type Encoder = Box Option)>>>; diff --git a/crates/edit/src/text/interp.rs b/crates/edit/src/text/interp.rs index ca0d63cef..e4248aede 100644 --- a/crates/edit/src/text/interp.rs +++ b/crates/edit/src/text/interp.rs @@ -1,6 +1,6 @@ use super::fallback::is_type3_fallback; use super::rewrite::vertical_advance; -use super::*; +use super::{Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, Rc}; /// Text and graphics state captured for rewriting. #[derive(Clone, Debug, PartialEq)] @@ -472,6 +472,280 @@ type CachedFont = (Rc, Option, Result, String>); /// A `Do` met while interpreting: its operator index, XObject name and the state there. type Invocation = (usize, Vec, Ts); +#[derive(Default)] +struct MarkedContent { + regions: Vec>, + layers: Vec>>, + actual_text: Vec>, +} + +impl MarkedContent { + fn begin(&mut self, doc: &Document, op: &Op, index: usize, logical_text: Option<&Dict>, shown_count: usize) { + // Marked text paragraphs/headings are authoritative boundaries. Ignore artifacts and + // inner spans, which inherit their enclosing region. + let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); + self.regions.push(if region { Some(index) } else { self.regions.last().copied().flatten() }); + // A layer named in the resources is told apart by its name, an inline one by where it opens. + let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); + self.layers + .push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{index}").into_bytes(), <[u8]>::to_vec))); + // The property list is inline or named in the resources' /Properties (§14.6.2). + let list = logical_text.and_then(|properties| match op.operands.get(1) { + Some(Object::Name(name)) => properties.get(name).and_then(|object| doc.dict(object)), + other => other.and_then(Object::as_dict).cloned(), + }); + let logical = list.as_ref().and_then(|dict| dict.get(b"ActualText")).and_then(|value| match value { + Object::String(value) if value.bytes.len() <= 4_000_000 => Some(value.to_text()), + _ => None, + }); + self.actual_text.push(logical.map(|text| (shown_count, index, text))); + } + + fn end(&mut self, index: usize, shown: &mut Vec) { + self.regions.pop(); + self.layers.pop(); + if let Some(Some((start, open, logical))) = self.actual_text.pop() { + let tail = shown.split_off(start.min(shown.len())); + let mut items = tail.into_iter(); + if let Some(mut first) = items.next() { + for item in items { + first.glyph_count = first.glyph_count.saturating_add(item.glyph_count); + first.extra_ops.push(item.op); + first.extra_ops.extend(item.extra_ops); + first.rect = [ + first.rect[0].min(item.rect[0]), + first.rect[1].min(item.rect[1]), + first.rect[2].max(item.rect[2]), + first.rect[3].max(item.rect[3]), + ]; + first.frame = union(first.frame, item.frame); + first.ink.extend(item.ink); + } + first.extra_ops.extend([open, index]); + // Only right-to-left ActualText is already in typing order and must stay out of + // bidi recovery. Word and Chrome also wrap Latin ligatures mid-word. + first.logical_order = logical.chars().any(crate::bidi::is_rtl); + first.actual_text = true; + first.text = logical; + first.start_x = first.rect[0]; + first.end_x = first.rect[2]; + first.end_y = first.baseline; + first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); + shown.push(first); + } + } + } +} + +struct TextRunState<'a> { + ts: &'a mut Ts, + tm: &'a mut Matrix, + tlm: &'a mut Matrix, + at_bt: &'a (usize, Ts), + bt: usize, + marked: &'a MarkedContent, + carry: &'a mut Carry, + out: &'a mut Vec, +} + +fn show_text(op: &Op, i: usize, state: &mut TextRunState<'_>) -> Result<(), EditError> { + let ts = &mut *state.ts; + let tm = &mut *state.tm; + let tlm = &mut *state.tlm; + let at_bt = state.at_bt; + let bt = state.bt; + let marked = state.marked; + let carry = &mut *state.carry; + let out = &mut *state.out; + if matches!(op.op.as_slice(), b"'" | b"\"") { + if op.is("\"") { + ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); + ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); + } + *tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(tlm); + *tm = *tlm; + } + let Some((name, m)) = ts.font.clone() else { return Ok(()) }; + // Pieces: strings, and TJ number adjustments. + let pieces: Vec = match op.op.as_slice() { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569) -- not the first, which a reader counting operands + // from the front of a surplus list would otherwise take. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Tc is already included in the pen advance; TJ may encode tracking instead. + let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; + let trm0 = tm.then(&ts.ctm); + let start = trm0.apply(0.0, ts.rise); + let mut text = String::new(); + let mut decodable = true; + let mut x_text = 0.0; + // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred + // on it horizontally; `half` is half the widest glyph. + let vertical = ts.vertical; + let (mut y_text, mut half) = (0.0, 0.0_f64); + // Where each word's glyphs start and end along the run (text space), and whether + // the next glyph continues the last word. + let mut words: Vec<(f64, f64)> = Vec::new(); + let mut in_word = false; + let mut glyph_count = 0_usize; + for p in &pieces { + match p { + Object::String(s) => { + for (code, len) in m.codes(&s.bytes) { + glyph_count = glyph_count.saturating_add(1); + let shown = m.text_of(code); + let blank = m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); + match shown { + Some(t) => { + carry.text_left = carry + .text_left + .checked_sub(t.len()) + .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; + text.push_str(&t); + } + None => decodable = false, + } + let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + if let Some(w1) = vertical { + half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); + if !blank { + let ink_end = y_text + w1 * ts.size; + let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + y_text += w1 * ts.size + spacing; + continue; + } + if !blank { + let ink_end = x_text + m.width(code) * ts.size * ts.scale; + // A negative font size or `Tz` runs the glyphs backwards: + // the word is the union of its glyphs, whichever way they go. + let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); + match words.last_mut() { + Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), + _ => words.push((lo, hi)), + } + } + in_word = !blank; + let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; + x_text += w * ts.scale; + } + } + other => { + if let Some(n) = other.as_f64() { + if vertical.is_some() { + // Down by n/1000 em, not scaled by `Tz`. + let dy = -n / 1000.0 * ts.size; + if -dy > ts.size * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + if dy.abs() > ts.size.abs() * 0.2 { + in_word = false; + } + y_text += dy; + continue; + } + let dx = -n / 1000.0 * ts.size * ts.scale; + // A large gap inside TJ reads as a space. + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { + text.push(' '); + } + // Tracking and kerning keep a word together; a larger jump + // ends it (whatever the sign of the size or `Tz`). + if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { + in_word = false; + } + x_text += dx; + } + } + } + } + *tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(tm); + let end = tm.then(&ts.ctm).apply(0.0, ts.rise); + // The box from `a` to `b` along the run: from descent to ascent across it, or for + // vertical text, the widest glyph's width centred on the pen. + let span = |a: f64, b: f64| match vertical { + Some(_) => [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)], + None => [ + trm0.apply(a, ts.rise + m.descent * ts.size), + trm0.apply(b, ts.rise + m.descent * ts.size), + trm0.apply(a, ts.rise + m.ascent * ts.size), + trm0.apply(b, ts.rise + m.ascent * ts.size), + ], + }; + let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); + let rect = corners.iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + // The same corners along and across the baseline: the run's own box, not its + // upright bounding box turned (which is wider by the box's height on a slant). + let dir = direction(&trm0); + let frame = corners + .iter() + .map(|&p| to_frame(dir, p)) + .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); + let ink = words + .iter() + .map(|&(a, b)| { + span(a, b).iter().fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) + }) + .collect(); + let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; + // A numeric-only TJ adjusts the current text matrix but paints no glyphs. + // It must not create a phantom editable line or enlarge its neighbours' boxes. + if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { + return Ok(()); + } + out.push(Shown { + op: i, + extra_ops: Vec::new(), + glyph_count, + // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the + // preview divides by size × `Tz`, so `Tz` is put back here. + advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, + tracking: 0.0, + tm: Matrix([ + tm.0[0], + tm.0[1], + tm.0[2], + tm.0[3], + tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], + tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], + ]), + state: ts.clone(), + tlm: *tlm, + bt_op: at_bt.0, + bt_state: at_bt.1.clone(), + text, + rect, + frame, + ink, + baseline: start.1, + start_x: start.0, + end_x: end.0, + end_y: end.1, + bt, + font: name, + base_font: m.base_font.clone(), + face: ts.face.clone(), + size: if size_user > 0.0 { size_user } else { ts.size }, + bold: m.bold, + italic: m.italic, + visual_order: m.visual_order, + decodable, + scope: marked.regions.last().copied().flatten(), + paint: Paint { layers: marked.layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, + logical_order: false, + actual_text: false, + }); + Ok(()) +} + pub(super) fn interpret( doc: &Document, ops: &[Op], @@ -490,66 +764,17 @@ pub(super) fn interpret( let mut stack: Vec = std::mem::take(&mut carry.stack); let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); let mut bt = 0usize; - let mut marked = std::mem::take(&mut carry.marked); - let mut layers = std::mem::take(&mut carry.layers); - let mut actual: Vec> = Vec::new(); + let mut marked = + MarkedContent { regions: std::mem::take(&mut carry.marked), layers: std::mem::take(&mut carry.layers), ..MarkedContent::default() }; for (i, op) in ops.iter().enumerate() { match op.op.as_slice() { // No depth cap: every `EMC` pops, so a capped push would pair it with the enclosing // sequence (#3573). Both stacks stay bounded by the number of operators. b"BMC" | b"BDC" => { - // Marked text paragraphs/headings are authoritative boundaries. Ignore - // artifacts and inner spans, which inherit their enclosing region. - let region = matches!(op.name(0), Some(b"P" | b"H" | b"H1" | b"H2" | b"H3" | b"H4" | b"H5" | b"H6" | b"LI" | b"TD" | b"TH")); - marked.push(if region { Some(i) } else { marked.last().copied().flatten() }); - // A layer named in the resources is told apart by its name, an inline one by where it opens. - let layer = op.is("BDC") && op.name(0) == Some(b"OC".as_slice()); - layers.push(layer.then(|| op.operands.get(1).and_then(Object::as_name).map_or_else(|| format!("@{i}").into_bytes(), <[u8]>::to_vec))); - // The property list is inline or named in the resources' /Properties (§14.6.2). - let list = logical_text.and_then(|properties| match op.operands.get(1) { - Some(Object::Name(n)) => properties.get(n).and_then(|o| doc.dict(o)), - other => other.and_then(Object::as_dict).cloned(), - }); - let logical = list.as_ref().and_then(|d| d.get(b"ActualText")).and_then(|s| match s { - Object::String(s) if s.bytes.len() <= 4_000_000 => Some(s.to_text()), - _ => None, - }); - actual.push(logical.map(|s| (out.len(), i, s))); + marked.begin(doc, op, i, logical_text, out.len()); } b"EMC" => { - marked.pop(); - layers.pop(); - if let Some(Some((start, open, logical))) = actual.pop() { - let tail = out.split_off(start.min(out.len())); - let mut items = tail.into_iter(); - if let Some(mut first) = items.next() { - for shown in items { - first.glyph_count = first.glyph_count.saturating_add(shown.glyph_count); - first.extra_ops.push(shown.op); - first.extra_ops.extend(shown.extra_ops); - first.rect = [ - first.rect[0].min(shown.rect[0]), - first.rect[1].min(shown.rect[1]), - first.rect[2].max(shown.rect[2]), - first.rect[3].max(shown.rect[3]), - ]; - first.frame = union(first.frame, shown.frame); - first.ink.extend(shown.ink); - } - first.extra_ops.extend([open, i]); - // Only right-to-left ActualText is already in typing order and must stay - // out of the line's bidi recovery. Word and Chrome wrap Latin ligatures - // (`fi`, `fl`) in ActualText spans mid-word; those keep joining their line. - first.logical_order = logical.chars().any(crate::bidi::is_rtl); - first.actual_text = true; - first.text = logical; - first.start_x = first.rect[0]; - first.end_x = first.rect[2]; - first.end_y = first.baseline; - first.advance = (first.end_x - first.start_x) / first.tm.then(&first.state.ctm).0[0].abs().max(1e-6); - out.push(first); - } - } + marked.end(i, &mut out); } b"q" => stack.push(ts.clone()), b"Q" => { @@ -650,207 +875,16 @@ pub(super) fn interpret( tm = tlm; } b"Tj" | b"TJ" | b"'" | b"\"" => { - if matches!(op.op.as_slice(), b"'" | b"\"") { - if op.is("\"") { - ts.word_spacing = op.num(0).unwrap_or(ts.word_spacing); - ts.char_spacing = op.num(1).unwrap_or(ts.char_spacing); - } - tlm = Matrix([1.0, 0.0, 0.0, 1.0, 0.0, -ts.leading]).then(&tlm); - tm = tlm; - } - let Some((name, m)) = ts.font.clone() else { continue }; - // Pieces: strings, and TJ number adjustments. - let pieces: Vec = match op.op.as_slice() { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569) -- not the first, which a reader counting operands - // from the front of a surplus list would otherwise take. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Tc is already included in the pen advance; TJ may encode tracking instead. - let tracking = if op.is("TJ") { tj_tracking(&pieces, &m, ts.size, ts.scale) } else { 0.0 }; - let trm0 = tm.then(&ts.ctm); - let start = trm0.apply(0.0, ts.rise); - let mut text = String::new(); - let mut decodable = true; - let mut x_text = 0.0; - // Vertical text (§9.4.4): the pen moves along y instead, and each glyph is centred - // on it horizontally; `half` is half the widest glyph. - let vertical = ts.vertical; - let (mut y_text, mut half) = (0.0, 0.0_f64); - // Where each word's glyphs start and end along the run (text space), and whether - // the next glyph continues the last word. - let mut words: Vec<(f64, f64)> = Vec::new(); - let mut in_word = false; - let mut glyph_count = 0_usize; - for p in &pieces { - match p { - Object::String(s) => { - for (code, len) in m.codes(&s.bytes) { - glyph_count = glyph_count.saturating_add(1); - let shown = m.text_of(code); - let blank = - m.is_space(code, len) || shown.as_deref().is_some_and(|t| !t.is_empty() && t.chars().all(char::is_whitespace)); - match shown { - Some(t) => { - carry.text_left = carry - .text_left - .checked_sub(t.len()) - .ok_or_else(|| EditError::Invalid("the page's text is too large to read".into()))?; - text.push_str(&t); - } - None => decodable = false, - } - let spacing = ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - if let Some(w1) = vertical { - half = half.max((m.width(code) * ts.size * ts.scale).abs() / 2.0); - if !blank { - let ink_end = y_text + w1 * ts.size; - let (lo, hi) = (y_text.min(ink_end), y_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - y_text += w1 * ts.size + spacing; - continue; - } - if !blank { - let ink_end = x_text + m.width(code) * ts.size * ts.scale; - // A negative font size or `Tz` runs the glyphs backwards: - // the word is the union of its glyphs, whichever way they go. - let (lo, hi) = (x_text.min(ink_end), x_text.max(ink_end)); - match words.last_mut() { - Some(word) if in_word => *word = (word.0.min(lo), word.1.max(hi)), - _ => words.push((lo, hi)), - } - } - in_word = !blank; - let w = m.width(code) * ts.size + ts.char_spacing + if m.is_space(code, len) { ts.word_spacing } else { 0.0 }; - x_text += w * ts.scale; - } - } - other => { - if let Some(n) = other.as_f64() { - if vertical.is_some() { - // Down by n/1000 em, not scaled by `Tz`. - let dy = -n / 1000.0 * ts.size; - if -dy > ts.size * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - if dy.abs() > ts.size.abs() * 0.2 { - in_word = false; - } - y_text += dy; - continue; - } - let dx = -n / 1000.0 * ts.size * ts.scale; - // A large gap inside TJ reads as a space. - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 && !text.ends_with(' ') { - text.push(' '); - } - // Tracking and kerning keep a word together; a larger jump - // ends it (whatever the sign of the size or `Tz`). - if dx - tracking > (ts.size * ts.scale).abs() * 0.2 || dx < -(ts.size * ts.scale).abs() * 0.2 { - in_word = false; - } - x_text += dx; - } - } - } - } - tm = Matrix([1.0, 0.0, 0.0, 1.0, x_text, y_text]).then(&tm); - let end = tm.then(&ts.ctm).apply(0.0, ts.rise); - // The box from `a` to `b` along the run: from descent to ascent across it, or for - // vertical text, the widest glyph's width centred on the pen. - let span = |a: f64, b: f64| match vertical { - Some(_) => { - [trm0.apply(-half, ts.rise + a), trm0.apply(half, ts.rise + a), trm0.apply(-half, ts.rise + b), trm0.apply(half, ts.rise + b)] - } - None => [ - trm0.apply(a, ts.rise + m.descent * ts.size), - trm0.apply(b, ts.rise + m.descent * ts.size), - trm0.apply(a, ts.rise + m.ascent * ts.size), - trm0.apply(b, ts.rise + m.ascent * ts.size), - ], - }; - let corners = span(0.0, if vertical.is_some() { y_text } else { x_text }); - let rect = corners - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - // The same corners along and across the baseline: the run's own box, not its - // upright bounding box turned (which is wider by the box's height on a slant). - let dir = direction(&trm0); - let frame = corners - .iter() - .map(|&p| to_frame(dir, p)) - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |b, p| [b[0].min(p.0), b[1].min(p.1), b[2].max(p.0), b[3].max(p.1)]); - let ink = words - .iter() - .map(|&(a, b)| { - span(a, b) - .iter() - .fold([f64::MAX, f64::MAX, f64::MIN, f64::MIN], |r, p| [r[0].min(p.0), r[1].min(p.1), r[2].max(p.0), r[3].max(p.1)]) - }) - .collect(); - let size_user = (trm0.0[2].powi(2) + trm0.0[3].powi(2)).sqrt() * ts.size; - // A numeric-only TJ adjusts the current text matrix but paints no glyphs. - // It must not create a phantom editable line or enlarge its neighbours' boxes. - if !pieces.iter().any(|p| matches!(p, Object::String(s) if !s.bytes.is_empty())) { - continue; - } - out.push(Shown { - op: i, - extra_ops: Vec::new(), - glyph_count, - // Vertical text: the TJ adjustment that undoes the run is n = -1000 y / size; the - // preview divides by size × `Tz`, so `Tz` is put back here. - advance: if vertical.is_some() { y_text * ts.scale } else { x_text }, - tracking: 0.0, - tm: Matrix([ - tm.0[0], - tm.0[1], - tm.0[2], - tm.0[3], - tm.0[4] - x_text * tm.0[0] - y_text * tm.0[2], - tm.0[5] - x_text * tm.0[1] - y_text * tm.0[3], - ]), - state: ts.clone(), - tlm, - bt_op: at_bt.0, - bt_state: at_bt.1.clone(), - text, - rect, - frame, - ink, - baseline: start.1, - start_x: start.0, - end_x: end.0, - end_y: end.1, - bt, - font: name, - base_font: m.base_font.clone(), - face: ts.face.clone(), - size: if size_user > 0.0 { size_user } else { ts.size }, - bold: m.bold, - italic: m.italic, - visual_order: m.visual_order, - decodable, - scope: marked.last().copied().flatten(), - paint: Paint { layers: layers.iter().flatten().cloned().collect(), gs: ts.gs.clone() }, - logical_order: false, - actual_text: false, - }); + let mut state = TextRunState { ts: &mut ts, tm: &mut tm, tlm: &mut tlm, at_bt: &at_bt, bt, marked: &marked, carry, out: &mut out }; + show_text(op, i, &mut state)?; } _ => {} } } carry.ts = ts; carry.stack = stack; - carry.marked = marked; - carry.layers = layers; + carry.marked = marked.regions; + carry.layers = marked.layers; positioned_tracking(&mut out); Ok(out) } diff --git a/crates/edit/src/text/lines.rs b/crates/edit/src/text/lines.rs index 7543adeb3..c48522984 100644 --- a/crates/edit/src/text/lines.rs +++ b/crates/edit/src/text/lines.rs @@ -1,6 +1,6 @@ use super::fallback::{same_font, same_paint}; use super::interp::{Carry, Origin, Pen, Shown, TextState, Ts, direction, interpret, to_frame, union}; -use super::*; +use super::{DecodeBudget, Dict, Document, EditError, HashMap, Matrix, Object, Op, Pieces, Stream, TextLine, parse, serialize_ops}; impl TextLine { /// The baseline's height in user space. diff --git a/crates/edit/src/text/rewrite.rs b/crates/edit/src/text/rewrite.rs index dd58c7289..513077a02 100644 --- a/crates/edit/src/text/rewrite.rs +++ b/crates/edit/src/text/rewrite.rs @@ -1,11 +1,14 @@ use super::blocks::{BlockStyle, TextBlock, paragraph_separator, wrap}; use super::fallback::{ - Encoder, MAX_TYPE3_GLYPHS, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, source_program_completes, - source_show, source_space, source_width, type3_encode, type3_font, + Encoder, MAX_TYPE3_GLYPHS, Type3Fallback, drop_unused_fallback_fonts, needs_type3, no_japanese_font, source_encodes, source_family, + source_program_completes, source_show, source_space, source_width, type3_encode, type3_font, }; -use super::interp::{Carry, interpret}; +use super::interp::{Carry, Origin, TextState, interpret}; use super::lines::{content_streams, page_dict, rewritten_contents, splice, text_lines}; -use super::*; +use super::{ + Dict, Document, EditError, HashMap, Matrix, Metrics, Object, Op, PdfString, Pieces, TextLine, TextSession, group_blocks, is_centred, parse, + rewrite_styled_block, +}; /// What replacing a line did. #[derive(Clone, Debug, PartialEq)] @@ -206,6 +209,532 @@ pub(crate) fn rewrite_block_with_limit( TextSession::open(doc, page, block)?.commit_with_limit(doc, text, style, max_glyphs) } +struct RewriteDispatch { + upright: bool, + use_styled_path: bool, +} + +fn choose_rewrite_path( + doc: &Document, + fonts: &Dict, + block: &TextBlock, + members: &[&TextLine], + first: &TextLine, + text: Option<&str>, + style: &BlockStyle, +) -> Result { + let registered_face = fonts.iter().any(|(name, _)| name.starts_with(b"PCFull")); + // Hebrew is written back in drawing order by the single-style path. Styled regions do not + // reorder right-to-left runs. + let hebrew = text.unwrap_or(&block.text).chars().any(crate::bidi::is_hebrew); + if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid( + "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), + )); + } + // Turned text is rewritten in its own text space by the single-style path. Styled layout uses + // page x-coordinates and cannot safely apply those operations to rotated runs. + let upright = members.iter().all(|line| line.upright()); + if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { + return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); + } + let visual_indic = members.iter().any(|line| line.visual_order); + let has_special_text = text.is_some_and(|draft| { + draft.contains(['\n', '\r']) + || pdfcraft_fonts::source_shaping::required(draft) + || !pdfcraft_fonts::embedding::matching_faces(&block.base_font).is_empty() + }); + let has_multiple_runs = block.rows.iter().any(|row| row.len() > 1) + || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color); + let source_requires_styled_path = + has_special_text || registered_face || text.is_some_and(|draft| source_program_completes(doc, fonts, first, draft)) || has_multiple_runs; + let explicit_styled_text = !style.ranges.is_empty() || style.resource.is_some(); + Ok(RewriteDispatch { + upright, + use_styled_path: upright + && !hebrew + && !visual_indic + && (explicit_styled_text || (source_requires_styled_path && style.family.is_none() && style.bold.is_none())), + }) +} + +/// The paragraph's text for writing back: explicit line breaks stay (#1443), and so does each typed +/// U+0020 space: a run of them is not folded into one (#4199, #3797). `\r\n` and a lone `\r` are +/// breaks too; other white space is written as one space. +fn paragraph_text(text: &str) -> String { + text.replace("\r\n", "\n") + .chars() + .map(|c| match c { + '\r' => '\n', + c if c != '\n' && paragraph_separator(c) => ' ', + c => c, + }) + .collect() +} + +/// Where a paragraph sits on its page, for choosing the width it is laid out in. +struct ParaGeometry { + rect: [f64; 4], + frame: [f64; 4], + crop: [f64; 4], + upright: bool, + /// The text-to-user scale. + k: f64, + /// The size in text space. + size: f64, + /// The requested horizontal move, in points. + dx: f64, + single_line: bool, + anchored_right: bool, + centred: bool, +} + +impl ParaGeometry { + /// A paragraph keeps its width (or takes the one asked for); a single line grows to the right + /// (to the left when right-to-left), up to the page's margin. + fn width(&self, requested: Option) -> Result { + let [left, _, right, _] = self.rect; + let own = (right - left).max(self.size * self.k); + let width = match requested { + // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that + // (`clamp` panics when its minimum is the larger, or NaN). + Some(w) if w.is_finite() => w.clamp((self.size * self.k).min(14_400.0), 14_400.0), + Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), + // Turned text: the page's margins are along another axis. A single line grows (below). + None if !self.upright => (self.frame[2] - self.frame[0]).max(self.size * self.k), + None if self.anchored_right => own.max(right + self.dx - (self.crop[0] + 36.0)), + // A centred line is not stretched to the margin: its own box is the frame it is centred in. + None if self.single_line && !self.centred => own.max(self.crop[2] - 36.0 - (left + self.dx)), + None => own, + }; + if width.is_finite() { Ok(width) } else { Err(EditError::Invalid("the paragraph's width must be a number".into())) } + } +} + +/// How far a baseline starting at `origin` and running along `direction` goes before it is 36 pt +/// from the page edge `low` or `high`. +fn margin_reach(origin: f64, direction: f64, low: f64, high: f64) -> f64 { + if direction > 1e-6 { + (high - 36.0 - origin) / direction + } else if direction < -1e-6 { + (low + 36.0 - origin) / direction + } else { + f64::INFINITY + } +} + +/// A turned single line grows to fit, up to where its baseline reaches the page's margin. +fn turned_line_width(first: &TextLine, crop: [f64; 4], offset: [f64; 2], width: f64, text: &str, advance: &Advance<'_>) -> f64 { + let (x0, y0) = (first.origin.x + offset[0], first.origin.baseline + offset[1]); + let (ux, uy) = first.direction(); + let tx = margin_reach(x0, ux, crop[0], crop[2]); + let ty = margin_reach(y0, uy, crop[1], crop[3]); + let cap = width.max(tx.min(ty)).min(14_400.0); + text.split('\n').map(|line| advance.of(line)).fold(width, f64::max).min(cap) +} + +/// The widths of the paragraph's text in the font it will be written in, in user space. +struct Advance<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + family: crate::added::Family, + bold: bool, + italic: bool, + size: f64, + space: f64, + char_spacing: f64, + word_spacing: f64, + scale: f64, + k: f64, +} + +impl Advance<'_> { + fn of(&self, s: &str) -> f64 { + let extra = s.chars().count() as f64 * self.char_spacing; + let t = match (self.source, self.type3) { + (Some(m), _) => { + let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; + source_width(m, &visual_s, self.size, self.space, self.char_spacing, self.word_spacing) + } + (None, Some(fallback)) => { + // Each character's count in `s`, from one pass over `s`. + let mut counts: HashMap = HashMap::new(); + for c in s.chars() { + let count = counts.entry(c).or_insert(0); + *count = count.saturating_add(1); + } + fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * self.size).sum::() + extra + } + (None, None) => self.family.std14(self.bold, self.italic).text_width(s, self.size) + extra, + }; + t * self.scale * self.k + } +} + +/// The font a rewritten paragraph is shown in, and how its text is encoded. +struct FontChoice { + show_font: String, + encode: Encoder, + /// The stand-in face to tell the caller about. + substituted: Option, +} + +/// What [`choose_font`] needs to know about the paragraph. +struct FontInputs<'a> { + /// The source font, when its glyphs are reused. + source: Option<&'a Metrics>, + type3: Option<&'a Type3Fallback>, + font_name: &'a str, + flat: &'a str, + source_base_font: &'a str, + family: crate::added::Family, + bold: bool, + italic: bool, + family_requested: bool, +} + +fn choose_font(inputs: &FontInputs<'_>, fonts_res: &mut Dict) -> Result { + if let Some(m) = inputs.source.cloned() { + let name = inputs.font_name.to_owned(); + return Ok(FontChoice { + show_font: name.clone(), + encode: Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)])), + substituted: None, + }); + } + if let Some(fallback) = inputs.type3.cloned() { + let substituted = Some(format!("{} Type3", fallback.family)); + let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; + return Ok(FontChoice { show_font: first, encode: Box::new(move |s: &str| type3_encode(&fallback, s)), substituted }); + } + let flat = inputs.flat; + let win = pdfcraft_fonts::win_ansi(flat); + let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); + let base = inputs.family.base_font(inputs.bold, inputs.italic); + if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { + return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", inputs.source_base_font))); + } + let mut f = Dict::new(); + f.set(b"Type".to_vec(), Object::name("Font")); + f.set(b"Subtype".to_vec(), Object::name("Type1")); + f.set(b"BaseFont".to_vec(), Object::name(base)); + f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + // Named after its face, as the line and region rewrites name theirs: one name for every + // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). + let name = format!("PCEd{}", base.replace('-', "")); + fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); + let substituted = (!inputs.family_requested).then(|| base.to_string()); + let shown = name.clone(); + Ok(FontChoice { show_font: name, encode: Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))])), substituted }) +} + +/// The paragraph's move in the space it is drawn in: the move is in page space, so it is taken +/// back through the CTM's linear part. Checked before the fallback fonts are written: a refused +/// paragraph leaves the document as it was. +fn drawn_movement(ctm: [f64; 6], dx: f64, dy: f64) -> Result, EditError> { + if dx == 0.0 && dy == 0.0 { + return Ok(None); + } + let back = Matrix([ctm[0], ctm[1], ctm[2], ctm[3], 0.0, 0.0]) + .invert() + .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; + Ok(Some(back.apply(dx, dy))) +} + +/// The paragraph's first text matrix, taken from where its text starts to where it is written. +fn emit_text_matrix(first: &Origin, emit: &Origin) -> Result<[f64; 6], EditError> { + if first.ctm == emit.ctm { + return Ok(first.tm); + } + let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; + Ok(Matrix(first.tm).then(&Matrix(first.ctm)).then(&emit_inv).0) +} + +/// Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. +struct LeadScale { + k: f64, + size: f64, + old_size: f64, + leading: f64, +} + +fn line_lead(block: &TextBlock, lines: &[TextLine], style: &BlockStyle, scale: &LeadScale) -> f64 { + if let Some(m) = style.line_spacing { + return scale.size * m.clamp(0.5, 5.0); + } + // From the first two rows (a row may hold several runs). + let rows = block.rows.first().zip(block.rows.get(1)).and_then(|(upper, lower)| upper.first().zip(lower.first())); + if let Some((upper, lower)) = rows.and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) { + return (upper.frame_origin().1 - lower.frame_origin().1) / scale.k * scale.size / scale.old_size; + } + if scale.leading > 0.0 { scale.leading } else { scale.size * 1.2 } +} + +/// The stroking twin of a fill colour operator. +fn stroke_twin(fill: &Op) -> Op { + let name = match fill.op.as_slice() { + b"g" => "G", + b"rg" => "RG", + b"k" => "K", + b"cs" => "CS", + b"sc" => "SC", + _ => "SCN", + }; + Op::new(name, fill.operands.clone()) +} + +/// Underlines in text space (the paragraph's text matrix), in its fill colour. +fn underline_ops(fill: &[Op], tm: &[f64; 6], size: f64, underlines: &[(f64, f64, f64)]) -> Vec { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![]), Op::new("cm", tm.iter().map(|v| n(*v)).collect())]; + ops.extend(fill.iter().map(stroke_twin)); + ops.push(Op::new("w", vec![n(size * 0.06)])); + for (x0, x1, y) in underlines { + ops.push(Op::new("m", vec![n(*x0), n(*y)])); + ops.push(Op::new("l", vec![n(*x1), n(*y)])); + } + ops.push(Op::new("S", vec![])); + ops.push(Op::new("Q", vec![])); + ops +} + +/// Alignment: each line's offset from the left edge. +struct Alignment { + align: Option, + width: f64, + k: f64, + anchored_right: bool, + right_edge: f64, + rtl: bool, +} + +impl Alignment { + /// `visible` is the width the line is aligned on, in user space; the offset is in text space. + fn offset(&self, visible: f64) -> f64 { + let free = (self.width - visible).max(0.0) / self.k; + match self.align { + Some(crate::added::Align::Center) => free / 2.0, + // A right-to-left line anchored at its right edge may start left of the old box. + Some(crate::added::Align::Right) if self.anchored_right => (self.right_edge - visible) / self.k, + Some(crate::added::Align::Right) => free, + // A justified right-to-left paragraph ends its last line at the right edge. + Some(crate::added::Align::Justify) if self.rtl => free, + _ => 0.0, + } + } +} + +/// Everything the wrapped lines of one paragraph are written with. +struct LineWriter<'a> { + advance: &'a Advance<'a>, + alignment: Alignment, + encode: &'a Encoder, + source: Option<&'a Metrics>, + justify: bool, + /// Whether `Tw` reaches every shown space. + word_spacing_reaches_spaces: bool, + width: f64, + scale: f64, + k: f64, + size: f64, + space: f64, + char_spacing: f64, + lead: f64, +} + +/// What carries over from one line to the next. +struct LineCursor { + x: f64, + tw_set: f64, + shown_font: String, +} + +impl LineWriter<'_> { + /// Justify: each line but the last fills the width. A stretched line shows only its words, as + /// before #4199: word spacing would reach the spaces before its first word and after its + /// last, so they are left out. Nothing to stretch over, or a paragraph drawn at `0 Tz`, isn't + /// justified. + fn stretch(&self, body: &str, last: bool) -> f64 { + let interior = body.matches(' ').count(); + if self.justify && !last && interior > 0 && self.scale.abs() > 1e-6 { + Some((self.width - self.advance.of(body)).max(0.0) / self.k / self.scale / interior as f64).filter(|tw| tw.is_finite()).unwrap_or(0.0) + } else { + 0.0 + } + } + + /// The text to show for a line and the width it is aligned on. Other lines show every space + /// typed. A left-to-right one is aligned on its width up to its last word; a right-to-left + /// one on its whole width, since the spaces typed after its last word are drawn on its left + /// and the ones before its first on its right. + fn shown<'t>(&self, line: &'t str, body: &'t str, tw: f64) -> (&'t str, f64) { + if tw > 0.0 { + (body, self.advance.of(body)) + } else if crate::bidi::rtl_base(line) { + (line, self.advance.of(line)) + } else { + (line, self.advance.of(line.trim_end_matches(' '))) + } + } + + /// Writes one wrapped line, returning its underline (x0, x1, y) in text space. + fn write_line(&self, index: usize, line: &str, last: bool, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(f64, f64, f64), EditError> { + let n = pdfcraft_content::num; + let body = line.trim_matches(' '); + let interior = body.matches(' ').count(); + let tw = self.stretch(body, last); + let (shown, visible) = self.shown(line, body, tw); + let dx = if tw > 0.0 { 0.0 } else { self.alignment.offset(visible) }; + if index > 0 || dx != 0.0 { + ops.push(Op::new("Td", vec![n(dx - cursor.x), n(if index > 0 { -self.lead } else { 0.0 })])); + } + cursor.x = dx; + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3), so where a space is + // shown with another code (a composite font, a generated Type 3 font, a subset without a + // space) the extra space follows each space as a TJ adjustment instead. + let stretch = if self.word_spacing_reaches_spaces { 0.0 } else { tw }; + let tw = tw - stretch; + if tw != cursor.tw_set { + ops.push(Op::new("Tw", vec![n(tw)])); + cursor.tw_set = tw; + } + // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). + let visual = crate::bidi::to_visual(shown); + // Stretched without word spacing: shown up to and including each space, then moved on. + let pieces: Vec<&str> = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; + for piece in pieces { + self.show_piece(piece, line, tw, cursor, ops)?; + if stretch > 0.0 && piece.ends_with(' ') { + ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / self.size)])])); + } + } + let tw = tw + stretch; + // Underlined to the line's last word, over its stretched gaps. + let w = (visible + tw * interior as f64 * self.scale * self.k) / self.k; + Ok((dx, dx + w, -(index as f64) * self.lead - self.size * 0.12)) + } + + fn show_piece(&self, piece: &str, line: &str, tw: f64, cursor: &mut LineCursor, ops: &mut Vec) -> Result<(), EditError> { + let n = pdfcraft_content::num; + if let Some(metrics) = self.source { + let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; + ops.extend(source_show(metrics, &indic_piece, self.size, self.space, self.char_spacing, tw)?); + return Ok(()); + } + for (font, bytes) in (self.encode)(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { + if font != cursor.shown_font { + ops.push(Op::new("Tf", vec![Object::name(&font), n(self.size)])); + cursor.shown_font = font; + } + ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); + } + Ok(()) + } + + /// Writes every wrapped line, returning their underlines. + fn write_all(&self, wrapped: &[String], cursor: &mut LineCursor, ops: &mut Vec) -> Result, EditError> { + wrapped.iter().enumerate().map(|(i, line)| self.write_line(i, line, i + 1 == wrapped.len(), cursor, ops)).collect() + } +} + +/// The operators that write a paragraph in its own graphics state, so a new colour (or anything +/// else) stops at the paragraph. +struct BlockPlan<'a> { + block: &'a TextBlock, + lines: &'a [TextLine], + style: &'a BlockStyle, + emit: &'a Origin, + after: &'a TextState, + movement: Option<(f64, f64)>, + state: TextState, + tm: [f64; 6], + size: f64, + underline: bool, +} + +fn block_ops(plan: &BlockPlan<'_>, writer: &LineWriter<'_>, wrapped: &[String], show_font: String) -> Result, EditError> { + let n = pdfcraft_content::num; + let mut ops = vec![Op::new("q", vec![])]; + ops.extend(reveal_ops(plan.block, plan.lines, plan.style, plan.emit.ctm)?); + // Moved: a translation inside that state. + if let Some((ax, ay)) = plan.movement { + ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); + } + // Painted as the paragraph was, including what its text object set before the first show. + ops.extend(plan.state.paint_ops(plan.after)); + ops.push(Op::new("BT", vec![])); + let mut state = plan.state.clone(); + state.word_spacing = 0.0; + // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. + let mut cursor = LineCursor { x: 0.0, tw_set: 0.0, shown_font: show_font.clone() }; + state.font = Some((show_font, plan.size)); + if let Some([r, g, bl]) = plan.style.color { + state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; + } + ops.extend(state.ops()); + ops.push(Op::new("Tm", plan.tm.iter().map(|v| n(*v)).collect())); + let underlines = writer.write_all(wrapped, &mut cursor, &mut ops)?; + ops.push(Op::new("ET", vec![])); + if plan.underline { + ops.extend(underline_ops(&state.fill, &plan.tm, plan.size, &underlines)); + } + ops.push(Op::new("Q", vec![])); + Ok(ops) +} + +/// Whether text shown earlier in the same text object stays first: the paragraph then goes where +/// its last row was, after any backgrounds between the rows, or between two halves of the text +/// object. The paragraph's own operators (and another drawing on the same baseline: a fake-bold +/// second copy, an invisible text layer) go, so the new text is all that may show. +fn shows_before_paragraph(ops: &[Op], from: usize, to: usize, dropped: &std::collections::HashSet) -> bool { + ops.get(from..to) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !dropped.contains(&(from + i))) +} + +/// A paragraph's single style, with the text-space values read from where it starts. +struct ParaStyle { + state: TextState, + size: f64, + old_size: f64, + k: f64, +} + +/// Spacing and scaling (text state), with the chosen values. +fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> { + let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; + let k = o.k.max(1e-6); + // The size in text space: points ÷ the text-to-user scale. + let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); + let mut state = o.state.clone(); + if let Some(c) = style.char_spacing { + state.char_spacing = c / k; + } + if let Some(sc) = style.scale { + state.scale = (sc / 100.0).clamp(0.1, 10.0); + } + Ok((font_name, ParaStyle { state, size, old_size, k })) +} + +/// The font the paragraph's existing runs use, for reusing its glyphs. +struct SourceFont { + metrics: Option, + reuse: bool, + space: f64, +} + +fn source_font(doc: &Document, fonts_res: &Dict, font_name: &str, first: &TextLine, flat: &str, style: &BlockStyle) -> SourceFont { + let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); + let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; + let indic_text = if visual_indic { crate::indic::to_visual(flat) } else { flat.to_owned() }; + let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); + let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, fonts_res, m)); + SourceFont { metrics, reuse, space } +} + /// [`rewrite_block_with_limit`] with the page's [`text_lines`], read from `doc` as it is now. pub(crate) fn rewrite_block_from( doc: &mut Document, @@ -228,86 +757,26 @@ pub(crate) fn rewrite_block_from( let p = page_dict(doc, page)?; let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default(); let mut fonts_res = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default(); - let registered_face = fonts_res.iter().any(|(name, _)| name.starts_with(b"PCFull")); - // Hebrew is written back in drawing order (and with the Hebrew fallback face) by the - // single-style path below; the styled region path does not reorder right-to-left runs. - let hebrew = text.unwrap_or(&b.text).chars().any(crate::bidi::is_hebrew); - if hebrew && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid( - "formatting part of right-to-left (Hebrew) text, or choosing a document font for it, isn't supported yet".into(), - )); - } - // Text turned on the page (a landscape page drawn a quarter turn round) is rewritten in its - // own text space below; the styled region path lays rows out along the page's x axis. - let upright = members.iter().all(|line| line.upright()); - if !upright && (!style.ranges.is_empty() || style.resource.is_some()) { - return Err(EditError::Invalid("formatting part of turned text, or choosing a document font for it, isn't supported yet".into())); - } - let visual_indic = members.iter().any(|line| line.visual_order); - if upright - && !hebrew - && !visual_indic - && (!style.ranges.is_empty() - || style.resource.is_some() - || (text.is_some_and(|draft| { - // A row break, `\n` or a carriage return, takes the path that keeps untouched rows. - draft.contains(['\n', '\r']) - || pdfcraft_fonts::source_shaping::required(draft) - || !pdfcraft_fonts::embedding::matching_faces(&b.base_font).is_empty() - }) || registered_face - || text.is_some_and(|draft| source_program_completes(doc, &fonts_res, first, draft)) - || b.rows.iter().any(|row| row.len() > 1) - || members.iter().any(|line| line.font != first.font || line.size != first.size || line.color != first.color)) - && style.family.is_none() - && style.bold.is_none()) - { + let dispatch = choose_rewrite_path(doc, &fonts_res, &b, &members, first, text, style)?; + if dispatch.use_styled_path { return rewrite_styled_block(doc, page, &b, &lines, text, style, max_glyphs); } + let upright = dispatch.upright; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; - // Explicit line breaks stay (#1443), and so does each typed U+0020 space: a run of them is - // not folded into one (#4199, #3797). `\r\n` and a lone `\r` are breaks too; other white - // space is written as one space. - let text: String = text - .unwrap_or(&b.text) - .replace("\r\n", "\n") - .chars() - .map(|c| match c { - '\r' => '\n', - c if c != '\n' && paragraph_separator(c) => ' ', - c => c, - }) - .collect(); + let text = paragraph_text(text.unwrap_or(&b.text)); // The characters to show, for choosing fonts: the breaks are moves, not glyphs. let flat = text.replace('\n', " "); let o = &first.origin; let last = members.last().copied().unwrap_or(first); let emit = &last.origin; - let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?; - let k = o.k.max(1e-6); - // The size in text space: points ÷ the text-to-user scale. - let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1)); - // Spacing and scaling (text state), with the chosen values. - let mut ts_state = o.state.clone(); - if let Some(c) = style.char_spacing { - ts_state.char_spacing = c / k; - } - if let Some(sc) = style.scale { - ts_state.scale = (sc / 100.0).clamp(0.1, 10.0); - } - let o_state = ts_state.clone(); - let metrics = fonts_res.get(font_name.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()).map(|d| Metrics::from_dict(doc, &d)); - let visual_indic = metrics.as_ref().is_some_and(|m| m.visual_order) || first.visual_order; - let indic_text = if visual_indic { crate::indic::to_visual(&flat) } else { flat.clone() }; - let reuse = style.family.is_none() && style.bold.is_none() && metrics.as_ref().is_some_and(|m| source_encodes(m, &indic_text)); - let space = metrics.as_ref().map_or(0.33, |m| source_space(doc, &fonts_res, m)); + let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?; + let source = source_font(doc, &fonts_res, &font_name, first, &flat, style); + let reuse = source.reuse; // The chosen style also determines the real Japanese fallback outlines and advances. let (family, bold, italic) = style.family.unwrap_or((source_family(&b.base_font), b.bold, b.italic)); let bold = style.bold.unwrap_or(bold); - // The face is chosen after `bold` settles, so a bold request measures with the bold metrics. - let face = family.std14(bold, italic); - let std_width = move |s: &str, size: f64| -> f64 { face.text_width(s, size) }; let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); if !(dx.is_finite() && dy.is_finite()) { return Err(EditError::Invalid("the paragraph can't be moved that far".into())); @@ -327,287 +796,79 @@ pub(crate) fn rewrite_block_from( // Turned text is left alone: its centre lives along its own baseline, not in page space. let crop = p.crop(doc); let centred = upright && !rtl && style.width.is_none() && style.align.is_none() && is_centred(&b, &lines, (crop[0] + crop[2]) / 2.0, first.size); - // A paragraph keeps its width (or takes the one asked for); a single line grows to the right - // (to the left when right-to-left), up to the page's margin. - let width = match style.width { - // Capped at the largest page PDF allows (14 400 pt), even for text set larger than that - // (`clamp` panics when its minimum is the larger, or NaN). - Some(w) if w.is_finite() => w.clamp((size * k).min(14_400.0), 14_400.0), - Some(_) => return Err(EditError::Invalid("the paragraph's width must be a number".into())), - // Turned text: the page's margins are along another axis. A single line grows (below). - None if !upright => (frame[2] - frame[0]).max(size * k), - None if anchored_right => (b.rect[2] - b.rect[0]).max(size * k).max(b.rect[2] + dx - (crop[0] + 36.0)), - // A centred line is not stretched to the margin: its own box is the frame it is centred in. - None if members.len() == 1 && !centred => (b.rect[2] - b.rect[0]).max(size * k).max(crop[2] - 36.0 - (b.rect[0] + dx)), - None => (b.rect[2] - b.rect[0]).max(size * k), - }; - if !width.is_finite() { - return Err(EditError::Invalid("the paragraph's width must be a number".into())); - } + let geometry = ParaGeometry { rect: b.rect, frame, crop, upright, k, size, dx, single_line: members.len() == 1, anchored_right, centred }; + let width = geometry.width(style.width)?; let align = if centred { Some(crate::added::Align::Center) } else { style.align.or(rtl.then_some(crate::added::Align::Right)) }; // Where a right-aligned line ends, from the paragraph's left edge (user space). let right_edge = if anchored_right { (b.rect[2] - b.rect[0]).max(0.0) } else { width }; - // The move and the width are checked here, before the fallback fonts are written: a refused - // paragraph leaves the document as it was. The move is in page space, so it is taken back through - // the CTM's linear part into the space the paragraph is drawn in. - let movement = if dx != 0.0 || dy != 0.0 { - let c = emit.ctm; - let back = Matrix([c[0], c[1], c[2], c[3], 0.0, 0.0]) - .invert() - .ok_or_else(|| EditError::Invalid("the paragraph is drawn in a space it can't be moved in".into()))?; - Some(back.apply(dx, dy)) - } else { - None - }; - let tm = if o.ctm == emit.ctm { - o.tm - } else { - let emit_inv = Matrix(emit.ctm).invert().ok_or_else(|| EditError::Invalid("the paragraph has a singular transform".into()))?; - Matrix(o.tm).then(&Matrix(o.ctm)).then(&emit_inv).0 - }; + let movement = drawn_movement(emit.ctm, dx, dy)?; + let tm = emit_text_matrix(o, emit)?; let type3 = if !reuse && needs_type3(&flat) { Some(type3_font(doc, &mut fonts_res, &flat, family, bold, max_glyphs)?) } else { None }; - let advance = |s: &str| -> f64 { - let t = match (&metrics, reuse) { - (Some(m), true) => { - let visual_s = if m.visual_order { crate::indic::to_visual(s) } else { s.to_owned() }; - source_width(m, &visual_s, size, space, o_state.char_spacing, o_state.word_spacing) - } - _ if let Some(fallback) = &type3 => { - // Each character's count in `s`, from one pass over `s`. - let mut counts: HashMap = HashMap::new(); - for c in s.chars() { - let count = counts.entry(c).or_insert(0); - *count = count.saturating_add(1); - } - fallback.widths.iter().map(|(ch, width)| counts.get(ch).copied().unwrap_or(0) as f64 * width * size).sum::() - + s.chars().count() as f64 * o_state.char_spacing - } - _ => std_width(s, size) + s.chars().count() as f64 * o_state.char_spacing, - }; - t * o_state.scale * k + let source_metrics = source.metrics.as_ref().filter(|_| reuse); + let advance = Advance { + source: source_metrics, + type3: type3.as_ref(), + family, + bold, + italic, + size, + space: source.space, + char_spacing: o_state.char_spacing, + word_spacing: o_state.word_spacing, + scale: o_state.scale, + k, }; - // A turned single line grows to fit, up to where its baseline reaches the page's margin. let width = if !upright && style.width.is_none() && members.len() == 1 { - let crop = p.crop(doc); - let (x0, y0) = (first.origin.x + dx, first.origin.baseline + dy); - let (ux, uy) = first.direction(); - let tx = if ux > 1e-6 { - (crop[2] - 36.0 - x0) / ux - } else if ux < -1e-6 { - (crop[0] + 36.0 - x0) / ux - } else { - f64::INFINITY - }; - let ty = if uy > 1e-6 { - (crop[3] - 36.0 - y0) / uy - } else if uy < -1e-6 { - (crop[1] + 36.0 - y0) / uy - } else { - f64::INFINITY - }; - let cap = width.max(tx.min(ty)).min(14_400.0); - text.split('\n').map(&advance).fold(width, f64::max).min(cap) + turned_line_width(first, crop, [dx, dy], width, &text, &advance) } else { width }; let right_edge = if anchored_right { right_edge } else { width }; - let wrapped = wrap(&text, width + 0.5, advance); - let mut substituted = None; - let new_font = !reuse; - let (show_font, encode): (String, Encoder) = if let Some(m) = metrics.clone().filter(|_| reuse) { - let name = font_name.clone(); - (font_name.clone(), Box::new(move |s: &str| m.encode(s).map(|bytes| vec![(name.clone(), bytes)]))) - } else if let Some(fallback) = type3.clone() { - substituted = Some(format!("{} Type3", fallback.family)); - let first = fallback.names.first().cloned().ok_or_else(no_japanese_font)?; - (first, Box::new(move |s: &str| type3_encode(&fallback, s))) - } else { - let win = pdfcraft_fonts::win_ansi(&flat); - let back: String = win.iter().map(|c| char::from_u32(u32::from(*c)).unwrap_or('?')).collect(); - let base = family.base_font(bold, italic); - if flat.chars().zip(back.chars()).any(|(a, c)| c == '?' && a != '?') { - return Err(EditError::Invalid(format!("\"{flat}\" has characters neither {} nor {base} can show", b.base_font))); - } - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - // Named after its face, as the line and region rewrites name theirs: one name for every - // stand-in would repoint the paragraphs set in an earlier one at this face (#4280). - let name = format!("PCEd{}", base.replace('-', "")); - fonts_res.set(name.clone().into_bytes(), Object::Dict(f)); - if style.family.is_none() { - substituted = Some(base.to_string()); - } - let shown = name.clone(); - (name, Box::new(move |s: &str| Some(vec![(shown.clone(), pdfcraft_fonts::win_ansi(s))]))) - }; - // Line spacing in text space: the paragraph's own (scaled with the size), or 1.2 × the size. - let lead = match style.line_spacing { - Some(m) => size * m.clamp(0.5, 5.0), - // From the first two rows (a row may hold several runs). - None if let Some((upper, lower)) = b - .rows - .first() - .zip(b.rows.get(1)) - .and_then(|(upper, lower)| upper.first().zip(lower.first())) - .and_then(|(upper, lower)| lines.get(*upper).zip(lines.get(*lower))) => - { - (upper.frame_origin().1 - lower.frame_origin().1) / k * size / old_size - } - None if o_state.leading > 0.0 => o_state.leading, - None => size * 1.2, - }; - let n = pdfcraft_content::num; - // The paragraph's operators go, and so does another drawing on the same baseline (a fake-bold - // second copy, an invisible text layer): the new text is all that may show. - let drop = dropped; - // Text shown earlier in the same text object stays first: the paragraph then goes where its - // last row was, after any backgrounds between the rows, or between two halves of the text object. + let wrapped = wrap(&text, width + 0.5, |s| advance.of(s)); + let font = choose_font( + &FontInputs { + source: source_metrics, + type3: type3.as_ref(), + font_name: &font_name, + flat: &flat, + source_base_font: &b.base_font, + family, + bold, + italic, + family_requested: style.family.is_some(), + }, + &mut fonts_res, + )?; + let lead = line_lead(&b, &lines, style, &LeadScale { k, size, old_size, leading: o_state.leading }); let start = last.ops.first().copied().unwrap_or(emit.bt_op); - let split = ops - .get(emit.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(emit.bt_op + i))); + let split = shows_before_paragraph(&ops, emit.bt_op, start, &dropped); // What the text after the paragraph expects, and what is in force where the paragraph is // drawn: the state at its BT (or where it was split). let after = if split { &emit.state } else { &emit.bt_state }; - // In its own graphics state, so a new colour (or anything else) stops at the paragraph. - let mut block_ops = vec![Op::new("q", vec![])]; - block_ops.extend(reveal_ops(&b, &lines, style, emit.ctm)?); - // Moved: a translation inside that state. - if let Some((ax, ay)) = movement { - block_ops.push(Op::new("cm", vec![n(1.0), n(0.0), n(0.0), n(1.0), n(ax), n(ay)])); - } - // Painted as the paragraph was, including what its text object set before the first show. - block_ops.extend(o_state.paint_ops(after)); - block_ops.push(Op::new("BT", vec![])); - let mut state = o_state.clone(); - state.word_spacing = 0.0; - // The font the next run is shown in: the one the BT sets, until a run of another fallback subset. - let mut shown_font = show_font.clone(); - state.font = Some((show_font, size)); - if let Some([r, g, bl]) = style.color { - state.fill = vec![Op::new("rg", vec![n(r), n(g), n(bl)])]; - } - block_ops.extend(state.ops()); - block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect())); - // Alignment: each line's offset from the left edge, in text space. - // `visible` is the width the line is aligned on (see the loop below). - let offset = |visible: f64| -> f64 { - let free = (width - visible).max(0.0) / k; - match align { - Some(crate::added::Align::Center) => free / 2.0, - // A right-to-left line anchored at its right edge may start left of the old box. - Some(crate::added::Align::Right) if anchored_right => (right_edge - visible) / k, - Some(crate::added::Align::Right) => free, - // A justified right-to-left paragraph ends its last line at the right edge. - Some(crate::added::Align::Justify) if rtl => free, - _ => 0.0, - } - }; - // Justify: each line but the last fills the width. Word spacing reaches only single-byte code 32 - // (ISO 32000-1 §9.3.3), so where a space is shown with another code (a composite font, a - // generated Type 3 font, a subset without a space) the extra space follows each space as a - // TJ adjustment instead, and code 32, which may then be a letter, gets none. - let justify = align == Some(crate::added::Align::Justify); - let word_spacing_reaches_spaces = match metrics.as_ref().filter(|_| reuse) { + // Word spacing reaches only single-byte code 32 (ISO 32000-1 §9.3.3); see `write_line`. + let word_spacing_reaches_spaces = match source_metrics { Some(m) => m.encode(" ").is_some_and(|b| m.codes(&b).iter().all(|(c, len)| m.is_space(*c, *len))), None => type3.is_none(), }; - let mut x = 0.0; - let mut tw_set = 0.0; - let mut underlines: Vec<(f64, f64, f64)> = Vec::new(); // (x0, x1, y) in text space - for (i, line) in wrapped.iter().enumerate() { - // A stretched (justified) line shows only its words, as before #4199: word spacing would - // reach the spaces before its first word and after its last, so they are left out, and - // the line fills the width from the left edge. Nothing to stretch over, or a paragraph - // drawn at `0 Tz`, isn't justified. - let body = line.trim_matches(' '); - let interior = body.matches(' ').count(); - let tw = if justify && i + 1 < wrapped.len() && interior > 0 && o_state.scale.abs() > 1e-6 { - Some((width - advance(body)).max(0.0) / k / o_state.scale / interior as f64).filter(|tw| tw.is_finite()) - } else { - None - } - .unwrap_or(0.0); - // Other lines show every space typed. A left-to-right one is aligned on its width up to - // its last word; a right-to-left one on its whole width, since the spaces typed after its - // last word are drawn on its left and the ones before its first on its right. - let (shown, visible) = if tw > 0.0 { - (body, advance(body)) - } else if crate::bidi::rtl_base(line) { - (line.as_str(), advance(line)) - } else { - (line.as_str(), advance(line.trim_end_matches(' '))) - }; - let dx = if tw > 0.0 { 0.0 } else { offset(visible) }; - if i > 0 || dx != 0.0 { - block_ops.push(Op::new("Td", vec![n(dx - x), n(if i > 0 { -lead } else { 0.0 })])); - } - x = dx; - let stretch = if word_spacing_reaches_spaces { 0.0 } else { tw }; - let tw = tw - stretch; - if tw != tw_set { - block_ops.push(Op::new("Tw", vec![n(tw)])); - tw_set = tw; - } - // Each wrapped line in typing order; drawn left to right (right-to-left runs reversed). - let visual = crate::bidi::to_visual(shown); - // Stretched without word spacing: shown up to and including each space, then moved on. - let pieces = if stretch > 0.0 { visual.split_inclusive(' ').collect() } else { vec![visual.as_str()] }; - for piece in pieces { - if let Some(metrics) = metrics.as_ref().filter(|_| reuse) { - let indic_piece = if metrics.visual_order { crate::indic::to_visual(piece) } else { piece.to_owned() }; - block_ops.extend(source_show(metrics, &indic_piece, size, space, o_state.char_spacing, tw)?); - } else { - for (font, bytes) in encode(piece).ok_or_else(|| EditError::Invalid(format!("\"{line}\" can't be shown")))? { - if font != shown_font { - block_ops.push(Op::new("Tf", vec![Object::name(&font), n(size)])); - shown_font = font; - } - block_ops.push(Op::new("Tj", vec![Object::String(PdfString::literal(bytes))])); - } - } - if stretch > 0.0 && piece.ends_with(' ') { - block_ops.push(Op::new("TJ", vec![Object::Array(vec![n(-1000.0 * stretch / size)])])); - } - } - let tw = tw + stretch; - // Underlined to the line's last word, over its stretched gaps. - let w = (visible + tw * interior as f64 * o_state.scale * k) / k; - underlines.push((dx, dx + w, -(i as f64) * lead - size * 0.12)); - } - block_ops.push(Op::new("ET", vec![])); - if style.underline == Some(true) { - // In text space (the paragraph's text matrix), in its fill colour. - let colour: Vec = state - .fill - .iter() - .map(|f| match f.op.as_slice() { - b"g" => Op::new("G", f.operands.clone()), - b"rg" => Op::new("RG", f.operands.clone()), - b"k" => Op::new("K", f.operands.clone()), - b"cs" => Op::new("CS", f.operands.clone()), - b"sc" => Op::new("SC", f.operands.clone()), - _ => Op::new("SCN", f.operands.clone()), - }) - .collect(); - block_ops.push(Op::new("q", vec![])); - block_ops.push(Op::new("cm", tm.iter().map(|v| n(*v)).collect())); - block_ops.extend(colour); - block_ops.push(Op::new("w", vec![n(size * 0.06)])); - for (x0, x1, y) in underlines { - block_ops.push(Op::new("m", vec![n(x0), n(y)])); - block_ops.push(Op::new("l", vec![n(x1), n(y)])); - } - block_ops.push(Op::new("S", vec![])); - block_ops.push(Op::new("Q", vec![])); - } - block_ops.push(Op::new("Q", vec![])); + let writer = LineWriter { + advance: &advance, + alignment: Alignment { align, width, k, anchored_right, right_edge, rtl }, + encode: &font.encode, + source: source_metrics, + justify: align == Some(crate::added::Align::Justify), + word_spacing_reaches_spaces, + width, + scale: o_state.scale, + k, + size, + space: source.space, + char_spacing: o_state.char_spacing, + lead, + }; + let plan = + BlockPlan { block: &b, lines: &lines, style, emit, after, movement, state: o_state, tm, size, underline: style.underline == Some(true) }; + let mut block_ops = block_ops(&plan, &writer, &wrapped, font.show_font.clone())?; + let n = pdfcraft_content::num; block_ops.extend(after.ops().into_iter().filter(|op| !op.is("Tf") || after.font.is_some())); if split { block_ops.insert(0, Op::new("ET", vec![])); @@ -617,9 +878,10 @@ pub(crate) fn rewrite_block_from( let at = if split { start } else { emit.bt_op }; let new_data = splice(&pieces, &ops, |i| { let insert = if i == at { std::mem::take(&mut block_ops) } else { Vec::new() }; - (insert, !drop.contains(&i)) + (insert, !dropped.contains(&i)) }); let contents = rewritten_contents(doc, &streams, new_data); + let new_font = !reuse; if new_font { res.set(b"Font".to_vec(), Object::Dict(fonts_res)); } @@ -630,7 +892,7 @@ pub(crate) fn rewrite_block_from( d.set(b"Resources".to_vec(), Object::Dict(res)); } })?; - Ok(LineEdit { substituted }) + Ok(LineEdit { substituted: font.substituted }) } /// The cover (when [`BlockStyle::cover`] asks for one) and visible rendering that start a diff --git a/crates/edit/src/text_regions.rs b/crates/edit/src/text_regions.rs index 7516cb793..be5b4dffa 100644 --- a/crates/edit/src/text_regions.rs +++ b/crates/edit/src/text_regions.rs @@ -1,6 +1,11 @@ //! Geometry-based regions and mixed-style rewriting of existing text. -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family, + source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs, +}; #[path = "text_regions/grouping.rs"] mod grouping; diff --git a/crates/edit/src/text_regions/grouping.rs b/crates/edit/src/text_regions/grouping.rs index 29004b8e3..b8a5abca1 100644 --- a/crates/edit/src/text_regions/grouping.rs +++ b/crates/edit/src/text_regions/grouping.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HashMap, Matrix, TextBlock, TextLine, paragraph_separator, same_base_font, same_font, strip_subset_tag, union}; fn same_plane(a: &TextLine, b: &TextLine) -> bool { let at = Matrix(a.origin.tm).then(&Matrix(a.origin.ctm)); diff --git a/crates/edit/src/text_regions/styled.rs b/crates/edit/src/text_regions/styled.rs index 09baf1ac7..e8b3b683d 100644 --- a/crates/edit/src/text_regions/styled.rs +++ b/crates/edit/src/text_regions/styled.rs @@ -1,5 +1,10 @@ use super::grouping::{across, is_centred, row_rect}; -use super::*; +use super::{ + BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces, + RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font, + no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width, + splice, type3_faces, type3_glyph, write_type3_glyphs, +}; // The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces. // A glyph budget worth 64 full batches (15,360 glyphs with the production batch size). @@ -13,6 +18,526 @@ struct FallbackGroup { positions: Vec, } +struct FallbackBuilder { + groups: Vec, + names: std::collections::BTreeMap<(String, bool, char), usize>, + glyphs: usize, + per_font: usize, + budget: usize, +} + +struct FontResolver<'a> { + doc: &'a mut Document, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + full_faces: HashMap, + programs: HashMap>>>, +} + +impl FontResolver<'_> { + fn encodable_font(&mut self, source: &TextLine, text: &str, requested: Option<&str>) -> Result, EditError> { + if let Some(resource) = requested { + if !self.metrics.get(resource).is_some_and(|metrics| source_encodes(metrics, text)) { + return Err(EditError::Invalid(format!( + "the selected font resource {resource} cannot show U+{:04X}", + text.chars().next().map_or(0, u32::from) + ))); + } + return Ok(Some(resource.to_owned())); + } + if self.metrics.get(&source.font).is_some_and(|metrics| source_encodes(metrics, text)) { + return Ok(Some(source.font.clone())); + } + if let Some((name, _)) = self.metrics.iter().find(|(_, metrics)| { + pdfcraft_fonts::embedding::face_key(&metrics.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) + && source_encodes(metrics, text) + }) { + return Ok(Some(name.clone())); + } + if let Some(face) = + pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|face| pdfcraft_fonts::embedding::covers(face.bytes, text)) + { + return self.embed_face(face.name(), face.bytes, face.family, "the full source font could not be loaded").map(Some); + } + let Some(bytes) = self + .programs + .entry(source.font.clone()) + .or_insert_with(|| source_program(self.doc, self.fonts, &source.font).map(std::sync::Arc::new)) + .clone() + .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, text)) + else { + return Ok(None); + }; + let key = format!("source {}", source.font); + if let Some(name) = self.full_faces.get(&key) { + return Ok(Some(name.clone())); + } + let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); + self.embed_face(key, &bytes, &family, "the source font could not be loaded").map(Some) + } + + fn embed_face(&mut self, key: String, bytes: &[u8], family: &str, missing_message: &str) -> Result { + if let Some(name) = self.full_faces.get(&key) { + return Ok(name.clone()); + } + let name = pdfcraft_fonts::embedding::embed(self.doc, self.fonts, bytes, family).map_err(EditError::Invalid)?; + let font = + self.fonts.get(name.as_bytes()).and_then(|resource| self.doc.dict(resource)).ok_or_else(|| EditError::Invalid(missing_message.into()))?; + self.metrics.insert(name.clone(), Metrics::from_dict(self.doc, &font)); + self.full_faces.insert(key, name.clone()); + Ok(name) + } + + fn substitute_font(&mut self, source: &TextLine) -> String { + let family = source_family(&source.base_font); + let base = family.base_font(source.bold, source.italic); + let name = format!("PCEd{}", base.replace('-', "")); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(base)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + self.metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(self.doc, &font)); + self.fonts.set(name.clone().into_bytes(), Object::Dict(font)); + name + } +} + +struct PreparedCharacters { + values: Vec, + substituted: Option, +} + +struct CharacterPreparation<'a> { + doc: &'a mut Document, + first: &'a TextLine, + lines: &'a [TextLine], + draft: &'a str, + spans: Vec<(std::ops::Range, usize)>, + style: &'a BlockStyle, + overrides: &'a HashMap, + fonts: &'a mut Dict, + metrics: &'a mut std::collections::BTreeMap, + max_glyphs: usize, +} + +impl CharacterPreparation<'_> { + fn prepare(self) -> Result { + let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self; + let mut chars = Vec::new(); + let mut substituted = None; + let mut fallback = FallbackBuilder::new(max_glyphs); + let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() }; + for (range, index) in spans { + let source = lines.get(index).unwrap_or(first); + let start = range.start; + for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { + if ch == '\n' { + chars.push((ch, index, source.font.clone())); + continue; + } + if ch == '\r' { + let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); + chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); + continue; + } + let text = ch.to_string(); + let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref()); + let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? { + name + } else if needs_type3(&text) { + fallback.prepare(source, ch, &text, chars.len())? + } else { + let name = resolver.substitute_font(source); + substituted = Some(source_family(&source.base_font).base_font(source.bold, source.italic).to_owned()); + name + }; + chars.push((ch, index, name)); + } + } + drop(resolver); + for group in fallback.groups { + let written = write_type3_glyphs(doc, fonts, group.face, false, group.glyphs, fallback.per_font); + for name in &written.names { + let font = fonts + .get(name.as_bytes()) + .and_then(|resource| doc.dict(resource)) + .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; + metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); + } + for position in group.positions { + let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; + let (font, _) = written.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; + item.2 = written.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; + } + substituted = Some(written.family.to_owned()); + } + Ok(PreparedCharacters { values: chars, substituted }) + } +} + +impl FallbackBuilder { + fn new(max_glyphs: usize) -> Self { + let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); + Self { + groups: Vec::new(), + names: std::collections::BTreeMap::new(), + glyphs: 0, + per_font, + budget: per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES), + } + } + + fn prepare(&mut self, source: &TextLine, ch: char, text: &str, position: usize) -> Result { + let key = (source.base_font.clone(), source.bold, ch); + let group_index = if let Some(index) = self.names.get(&key) { + *index + } else { + let (faces, _) = type3_faces(text, source_family(&source.base_font), source.bold)?; + let face = fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(text))?; + let index = self.groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { + let index = self.groups.len(); + self.groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); + index + }); + self.names.insert(key, index); + index + }; + let group = self.groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; + if group.seen.insert(ch) { + if self.glyphs >= self.budget { + return Err(EditError::Invalid(format!( + "the replacement needs more than {} fallback glyphs; edit a smaller paragraph or load a complete font", + self.budget + ))); + } + let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; + let code = u8::try_from((group.glyphs.len() % self.per_font).saturating_add(1)) + .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; + group.glyphs.push(Type3Glyph { code, ch, path, width }); + self.glyphs = self.glyphs.saturating_add(1); + } + group.positions.push(position); + Ok(String::new()) + } +} + +struct StyledLines<'a> { + lines: Vec, + spans: Vec<(std::ops::Range, usize)>, + underlines: HashMap, + overrides: HashMap, +} + +type StyledChar = (char, usize, String); +type StyledRows = Vec>; +type RowRanges = Vec>; +type ShapedRunCache = HashMap<(usize, usize), (Vec, pdfcraft_fonts::source_shaping::ShapedRun, String)>; + +struct ShapedRows { + runs: ShapedRunCache, + advances: HashMap<(usize, usize), f64>, +} + +fn prepare_shaped_rows( + doc: &Document, + fonts: &Dict, + rows: &[Vec], + kept: &[bool], + advance: impl Fn(&StyledChar) -> f64, +) -> Result { + let mut runs = HashMap::new(); + let mut advances = HashMap::new(); + for (row_index, row) in rows.iter().enumerate().filter(|(index, _)| !kept.get(*index).copied().unwrap_or(false)) { + let mut at = 0; + while let Some(item) = row.get(at) { + let mut end = at + 1; + while row.get(end).is_some_and(|next| next.1 == item.1 && next.2 == item.2) { + end += 1; + } + let segment = row.get(at..end).unwrap_or_default(); + advances.insert((row_index, at), segment.iter().map(&advance).sum::()); + let text: String = segment.iter().map(|character| character.0).collect(); + if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? { + runs.insert((row_index, at), (bytes, shaped, family)); + } + at = end; + } + } + Ok(ShapedRows { runs, advances }) +} + +fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result, EditError> { + let mut resources = HashMap::new(); + for (key, (bytes, shaped, family)) in runs { + let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect(); + let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; + resources.insert(*key, resource); + } + Ok(resources) +} + +fn validate_contextual_rows(rows: &[Vec], kept: &[bool]) -> Result<(), EditError> { + for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) { + let text: String = row.iter().map(|character| character.0).collect(); + if pdfcraft_fonts::source_shaping::required(&text) + && row.first().is_some_and(|first| row.iter().any(|character| character.1 != first.1 || character.2 != first.2)) + { + return Err(EditError::Invalid( + "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" + .into(), + )); + } + } + Ok(()) +} + +fn calibration_factors( + lines: &[TextLine], + metrics: &std::collections::BTreeMap, + style: &BlockStyle, + overrides: &HashMap, + space_of: impl Fn(&str, &Metrics) -> f64, +) -> HashMap { + let mut calibration = HashMap::new(); + for (index, source) in lines.iter().enumerate() { + if let Some(metrics) = metrics.get(&source.font) { + let nominal = source_width( + metrics, + &source.text, + source.origin.state.font.as_ref().map_or(1.0, |(_, size)| *size), + space_of(&source.font, metrics), + source.origin.state.char_spacing, + source.origin.state.word_spacing, + ) * source.origin.state.scale + * across(source); + let changed_face = + style.resource.is_some() || overrides.get(&index).is_some_and(|range| range.family.is_some() || range.resource.is_some()); + let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; + calibration.insert(index, factor); + } + } + calibration +} + +fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result, EditError> { + let mut ranges: Vec<_> = style.ranges.iter().collect(); + ranges.sort_by_key(|range| range.range.start); + let invalid_range = ranges.iter().any(|range| { + range.range.start >= range.range.end + || draft.get(range.range.clone()).is_none() + || [range.size, range.char_spacing, range.scale].iter().flatten().any(|value| !value.is_finite()) + || range.size.is_some_and(|value| value <= 0.0 || value > 14_400.0) + || range.color.is_some_and(|color| color.iter().any(|value| !value.is_finite() || !(0.0..=1.0).contains(value))) + }); + if ranges.len() > 1024 || invalid_range || ranges.windows(2).any(|pair| pair[0].range.end > pair[1].range.start) { + return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); + } + Ok(ranges) +} + +fn replacement_text<'a>(block: &'a TextBlock, text: Option<&'a str>) -> Result<&'a str, EditError> { + let draft = match text { + Some(text) if text != block.text => text, + _ => &block.editor_text, + }; + if draft.len() > 4_000_000 { + return Err(EditError::Invalid("the replacement text is too large".into())); + } + Ok(draft) +} + +fn has_no_effective_style_change(block: &TextBlock, draft: &str, style: &BlockStyle) -> bool { + draft == block.editor_text && *style == BlockStyle::default() +} + +fn page_font_metrics( + doc: &Document, + resource_dict: &Dict, + block: &TextBlock, + lines: &[TextLine], +) -> (Dict, std::collections::BTreeMap) { + let mut fonts = resource_dict.get(b"Font").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("Type1")); + font.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); + font.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); + fonts.set(line.font.as_bytes().to_vec(), Object::Dict(font)); + } + let mut metrics = std::collections::BTreeMap::new(); + for (name, object) in fonts.iter().take(256) { + if let Some(font) = doc.resolve(object).as_dict() { + metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); + } + } + for index in &block.lines { + if let Some(line) = lines.get(*index) + && !metrics.contains_key(&line.font) + && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|resource| doc.resolve(resource).as_dict().cloned()) + { + metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); + } + } + (fonts, metrics) +} + +fn paragraph_geometry(doc: &Document, page: &pdfcraft_model::Page, block: &TextBlock, style: &BlockStyle) -> Result<(f64, f64, f64), EditError> { + if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|value| !value.is_finite()) + || style.color.is_some_and(|color| color.iter().any(|value| !value.is_finite())) + { + return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); + } + let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); + if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|width| !width.is_finite()) { + return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); + } + let natural = if block.invisible || block.rows.len() == 1 { + (page.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) + } else { + block.rect[2] - block.rect[0] + }; + Ok((dx, dy, style.width.unwrap_or(natural).clamp(1.0, 14_400.0))) +} + +fn wrap_styled_rows( + chars: &[StyledChar], + width: f64, + measure: impl Fn(&[StyledChar]) -> Result, +) -> Result<(StyledRows, RowRanges), EditError> { + let mut rows: Vec> = Vec::new(); + let mut row_ranges = Vec::new(); + let mut row_start = 0; + let mut row = Vec::new(); + let mut row_width = 0.0; + let mut at = 0; + while at < chars.len() { + let Some(item) = chars.get(at) else { break }; + if item.0 == '\r' { + at += 1; + continue; + } + if item.0 == '\n' { + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + at += 1; + row_start = at; + continue; + } + let mut end = at + 1; + if !item.0.is_whitespace() { + // A word ends at a space, or where Chinese or Japanese text may break between characters. + while let (Some(before), Some(current)) = (chars.get(end - 1), chars.get(end)) + && !current.0.is_whitespace() + && !crate::linebreak::between(before.0, current.0) + { + end += 1; + } + } + let word = chars.get(at..end).unwrap_or_default(); + let word_width = measure(word)?; + if !row.is_empty() && !item.0.is_whitespace() && row_width + word_width > width + 0.5 { + while row.last().is_some_and(|c: &StyledChar| c.0.is_whitespace()) { + row.pop(); + } + row_ranges.push(row_start..row_start + row.len()); + rows.push(std::mem::take(&mut row)); + row_width = 0.0; + row_start = at; + } + row.extend_from_slice(word); + row_width += word_width; + at = end; + } + if !row.is_empty() { + row_ranges.push(row_start..row_start + row.len()); + rows.push(row); + } + Ok((rows, row_ranges)) +} + +fn apply_range_styles<'a>( + first: &TextLine, + block: &TextBlock, + lines: &[TextLine], + draft: &str, + ranges: &[&'a RangeStyle], + style: &BlockStyle, +) -> StyledLines<'a> { + let mut styled_lines = lines.to_vec(); + let mut styled_spans = Vec::new(); + let mut underlines = HashMap::new(); + let mut overrides = HashMap::new(); + for (span, source) in block.edited_spans(draft) { + let mut edges = vec![span.start, span.end]; + for range in ranges { + for edge in [range.range.start, range.range.end] { + if span.contains(&edge) { + edges.push(edge); + } + } + } + edges.sort_unstable(); + edges.dedup(); + for bounds in edges.windows(2) { + let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; + let selected = ranges.iter().find(|range| range.range.contains(&start)).copied(); + let mut index = source; + if selected.is_some() || style.family.is_some() || style.bold.is_some() { + let default_range = RangeStyle { + range: start..end, + family: None, + resource: None, + size: None, + color: None, + underline: None, + char_spacing: None, + scale: None, + }; + let range = selected.unwrap_or(&default_range); + let mut line = lines.get(source).unwrap_or(first).clone(); + if let Some((family, bold, italic)) = + range.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) + { + let base = family.base_font(bold, italic); + line.font = format!("PCRange{}", base.replace('-', "")); + line.base_font = base.to_owned(); + line.bold = bold; + line.italic = italic; + } + if let Some(resource) = &range.resource { + line.font = resource.clone(); + } + if let Some(size) = range.size { + line.size = size; + } + if let Some(color) = range.color { + line.color = color; + line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; + } + if let Some(spacing) = range.char_spacing { + line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); + } + if let Some(scale) = range.scale { + line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); + } + index = styled_lines.len(); + styled_lines.push(line); + if let Some(range) = selected { + overrides.insert(index, range); + } + if let Some(underline) = range.underline { + underlines.insert(index, underline); + } + } + styled_spans.push((start..end, index)); + } + } + StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides } +} + /// Shape in the source or an explicitly registered matching full face. Never use interface fonts. fn shape_source( doc: &Document, @@ -84,6 +609,48 @@ fn untouched_rows(block: &TextBlock, draft: &str, rows: &[std::ops::Range .collect() } +struct RewriteAnchor<'a> { + lead: &'a TextLine, + drop: std::collections::HashSet, + start: usize, + split: bool, +} + +fn rewrite_anchor<'a>( + block: &TextBlock, + lines: &'a [TextLine], + first: &'a TextLine, + ops: &[Op], + kept_lines: &std::collections::HashSet, + kept: &mut [bool], +) -> RewriteAnchor<'a> { + let rewritten: Vec<&TextLine> = block.lines.iter().filter(|index| !kept_lines.contains(index)).filter_map(|index| lines.get(*index)).collect(); + let lead = rewritten + .last() + .copied() + .filter(|_| !rewritten.iter().flat_map(|line| &line.ops).any(|index| ops.get(*index).is_some_and(|op| op.is("'") || op.is("\"")))); + let (members, lead) = match lead { + Some(lead) if !kept_lines.is_empty() => (rewritten, lead), + _ => { + kept.iter_mut().for_each(|value| *value = false); + let all: Vec<&TextLine> = block.lines.iter().filter_map(|index| lines.get(*index)).collect(); + let last = all.last().copied().unwrap_or(first); + (all, last) + } + }; + let origin = &lead.origin; + let mut drop = coincident_ops(lines, &members); + drop.extend(members.iter().flat_map(|line| line.ops.iter().copied())); + let start = lead.ops.first().copied().unwrap_or(origin.bt_op); + let split = ops + .get(origin.bt_op..start) + .unwrap_or_default() + .iter() + .enumerate() + .any(|(index, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(origin.bt_op + index))); + RewriteAnchor { lead, drop, start, split } +} + /// Rewrite visual rows while keeping the source style on each surviving character. Resource /// names may differ between Unicode subsets of the same face; encoding searches those subsets. pub(crate) fn rewrite_styled_block( @@ -96,291 +663,36 @@ pub(crate) fn rewrite_styled_block( max_glyphs: usize, ) -> Result { let first = block.lines.first().and_then(|i| lines.get(*i)).ok_or_else(|| EditError::Invalid("the paragraph has no source runs".into()))?; - let draft = match text { - Some(t) if t != block.text => t, - _ => &block.editor_text, - }; - if draft.len() > 4_000_000 { - return Err(EditError::Invalid("the replacement text is too large".into())); - } - if draft == block.editor_text && *style == BlockStyle::default() { + let draft = replacement_text(block, text)?; + if has_no_effective_style_change(block, draft, style) { return Ok(LineEdit { substituted: None }); } - let mut ranges: Vec<_> = style.ranges.iter().collect(); - ranges.sort_by_key(|r| r.range.start); - if ranges.len() > 1024 - || ranges.iter().any(|r| { - r.range.start >= r.range.end - || draft.get(r.range.clone()).is_none() - || [r.size, r.char_spacing, r.scale].iter().flatten().any(|n| !n.is_finite()) - || r.size.is_some_and(|n| n <= 0.0 || n > 14400.0) - || r.color.is_some_and(|c| c.iter().any(|n| !n.is_finite() || !(0.0..=1.0).contains(n))) - }) - || ranges.windows(2).any(|r| r[0].range.end > r[1].range.start) - { - return Err(EditError::Invalid("text style ranges must be disjoint UTF-8 boundaries with finite formatting (at most 1024 ranges)".into())); - } + let ranges = validated_ranges(draft, style)?; let source_count = lines.len(); - let mut styled_lines = lines.to_vec(); - let mut styled_spans = Vec::new(); - let mut underlines = HashMap::new(); - let mut overrides = HashMap::new(); - for (span, source) in block.edited_spans(draft) { - let mut edges = vec![span.start, span.end]; - for r in &ranges { - for edge in [r.range.start, r.range.end] { - if span.contains(&edge) { - edges.push(edge); - } - } - } - edges.sort_unstable(); - edges.dedup(); - for bounds in edges.windows(2) { - let (Some(start), Some(end)) = (bounds.first().copied(), bounds.get(1).copied()) else { continue }; - let mut index = source; - let selected = ranges.iter().find(|r| r.range.contains(&start)).copied(); - if selected.is_some() || style.family.is_some() || style.bold.is_some() { - let default_range = RangeStyle { - range: start..end, - family: None, - resource: None, - size: None, - color: None, - underline: None, - char_spacing: None, - scale: None, - }; - let r = selected.unwrap_or(&default_range); - let mut line = lines.get(source).unwrap_or(first).clone(); - if let Some((family, bold, italic)) = - r.family.or(style.family).or_else(|| style.bold.map(|b| (source_family(&line.base_font), b, line.italic))) - { - let base = family.base_font(bold, italic); - line.font = format!("PCRange{}", base.replace('-', "")); - line.base_font = base.to_owned(); - line.bold = bold; - line.italic = italic; - } - if let Some(resource) = &r.resource { - line.font = resource.clone(); - } - if let Some(size) = r.size { - line.size = size; - } - if let Some(color) = r.color { - line.color = color; - line.origin.state.fill = vec![Op::new("rg", color.map(Object::Real).to_vec())]; - } - if let Some(spacing) = r.char_spacing { - line.origin.state.char_spacing = spacing / line.origin.k.max(1e-6); - } - if let Some(scale) = r.scale { - line.origin.state.scale = (scale / 100.0).clamp(0.1, 10.0); - } - index = styled_lines.len(); - styled_lines.push(line); - if let Some(r) = selected { - overrides.insert(index, r); - } - if let Some(underline) = r.underline { - underlines.insert(index, underline); - } - } - styled_spans.push((start..end, index)); - } - } - let lines = styled_lines.as_slice(); + let styled = apply_range_styles(first, block, lines, draft, &ranges, style); + let lines = styled.lines.as_slice(); + let styled_spans = styled.spans; + let underlines = styled.underlines; + let overrides = styled.overrides; let p = page_dict(doc, page)?; - let mut res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let mut fonts = res.get(b"Font").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - for line in lines.iter().filter(|line| line.font.starts_with("PCRange")) { - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(&line.base_font)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - fonts.set(line.font.as_bytes().to_vec(), Object::Dict(f)); - } - let mut metrics = std::collections::BTreeMap::new(); - for (name, object) in fonts.iter().take(256) { - if let Some(font) = doc.resolve(object).as_dict() { - metrics.insert(String::from_utf8_lossy(name).into_owned(), Metrics::from_dict(doc, font)); - } - } - for index in &block.lines { - if let Some(line) = lines.get(*index) - && !metrics.contains_key(&line.font) - && let Some(font) = fonts.get(line.font.as_bytes()).and_then(|f| doc.resolve(f).as_dict().cloned()) - { - metrics.insert(line.font.clone(), Metrics::from_dict(doc, &font)); - } - } - if [style.size, style.line_spacing, style.char_spacing, style.scale, style.width].iter().flatten().any(|v| !v.is_finite()) - || style.color.is_some_and(|c| c.iter().any(|v| !v.is_finite())) - { - return Err(EditError::Invalid("the paragraph's formatting must use finite numbers".into())); - } - let [dx, dy] = style.offset.unwrap_or([0.0, 0.0]); - if !dx.is_finite() || !dy.is_finite() || style.width.is_some_and(|w| !w.is_finite()) { - return Err(EditError::Invalid("the paragraph's position and width must be finite".into())); - } - // A visual row grows even when it contains several source/font runs. Multi-row - // paragraphs retain their container width; an explicit width always wins. - let natural = if block.invisible || block.rows.len() == 1 { - (p.crop(doc)[2] - 36.0 - (block.rect[0] + dx)).max(block.rect[2] - block.rect[0]) - } else { - block.rect[2] - block.rect[0] - }; - let width = style.width.unwrap_or(natural).clamp(1.0, 14_400.0); - // Each tuple is a character, its source style and an encodable resource name. - let mut chars = Vec::new(); - let mut substituted = None; - let mut fallback_groups: Vec = Vec::new(); - let mut fallback_names: std::collections::BTreeMap<(String, bool, char), usize> = std::collections::BTreeMap::new(); - let mut fallback_glyphs = 0_usize; - let per_font = max_glyphs.clamp(1, MAX_TYPE3_GLYPHS); - let fallback_budget = per_font.saturating_mul(MAX_STYLED_FALLBACK_BATCHES); - let mut full_faces: HashMap = HashMap::new(); - let mut programs: HashMap>>> = HashMap::new(); - for (range, index) in styled_spans { - let source = lines.get(index).unwrap_or(first); - let start = range.start; - for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() { - if ch == '\n' { - chars.push((ch, index, source.font.clone())); - continue; - } - // A carriage return breaks the row too. Before `\n` the pair is one break, so the - // CR stays a `\r` the rows skip. Either way it keeps its place among the draft's - // characters, which rows are counted in. - if ch == '\r' { - let crlf = start.checked_add(offset).and_then(|at| draft.get(at.saturating_add(1)..)).is_some_and(|rest| rest.starts_with('\n')); - chars.push((if crlf { '\r' } else { '\n' }, index, source.font.clone())); - continue; - } - let s = ch.to_string(); - let name = if let Some(resource) = overrides.get(&index).and_then(|r| r.resource.as_ref()).or(style.resource.as_ref()) { - if !metrics.get(resource).is_some_and(|m| source_encodes(m, &s)) { - return Err(EditError::Invalid(format!("the selected font resource {resource} cannot show U+{:04X}", u32::from(ch)))); - } - resource.clone() - } else if metrics.get(&source.font).is_some_and(|m| source_encodes(m, &s)) { - source.font.clone() - } else if let Some((name, _)) = metrics.iter().find(|(_, m)| { - pdfcraft_fonts::embedding::face_key(&m.base_font) == pdfcraft_fonts::embedding::face_key(&source.base_font) && source_encodes(m, &s) - }) { - name.clone() - } else if let Some(face) = - pdfcraft_fonts::embedding::matching_faces(&source.base_font).into_iter().find(|f| pdfcraft_fonts::embedding::covers(f.bytes, &s)) - { - let key = face.name(); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, face.bytes, face.family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the full source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else if let Some(bytes) = programs - .entry(source.font.clone()) - .or_insert_with(|| source_program(doc, &fonts, &source.font).map(std::sync::Arc::new)) - .clone() - .filter(|bytes| pdfcraft_fonts::embedding::covers(bytes, &s)) - { - // The document's own complete program of this font has the glyph: draw it with - // that face rather than another family's fallback. - let key = format!("source {}", source.font); - if let Some(name) = full_faces.get(&key) { - name.clone() - } else { - let family = pdfcraft_fonts::embedding::names(&bytes).map_or_else(|_| source.base_font.clone(), |(family, _)| family); - let name = pdfcraft_fonts::embedding::embed(doc, &mut fonts, &bytes, &family).map_err(EditError::Invalid)?; - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the source font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - full_faces.insert(key, name.clone()); - name - } - } else { - let family = source_family(&source.base_font); - if needs_type3(&s) { - let key = (source.base_font.clone(), source.bold, ch); - let group_index = if let Some(index) = fallback_names.get(&key) { - *index - } else { - // Keep the old per-character face choice: a serif Japanese character - // and a Cyrillic character may need different faces even in one style. - // Hebrew uses the separate RTL path before reaching this writer. - let (faces, _) = type3_faces(&s, family, source.bold)?; - let face = - fallback_face(&faces, &[ch], |face, ch| japanese_glyph_from(face, ch).is_ok()).ok_or_else(|| no_fallback_font(&s))?; - let index = fallback_groups.iter().position(|group| std::ptr::eq(group.face, face)).unwrap_or_else(|| { - let index = fallback_groups.len(); - fallback_groups.push(FallbackGroup { face, seen: Default::default(), glyphs: Vec::new(), positions: Vec::new() }); - index - }); - fallback_names.insert(key, index); - index - }; - let group = fallback_groups.get_mut(group_index).ok_or_else(|| EditError::Invalid("the fallback face is missing".into()))?; - if group.seen.insert(ch) { - if fallback_glyphs >= fallback_budget { - return Err(EditError::Invalid(format!( - "the replacement needs more than {fallback_budget} fallback glyphs; edit a smaller paragraph or load a complete font" - ))); - } - let (path, width) = type3_glyph(group.face, ch)?.ok_or_else(|| no_glyph_for(group.face, ch))?; - let code = u8::try_from((group.glyphs.len() % per_font).saturating_add(1)) - .map_err(|_| EditError::Invalid("replacement text has too many unique characters".into()))?; - group.glyphs.push(Type3Glyph { code, ch, path, width }); - fallback_glyphs = fallback_glyphs.saturating_add(1); - } - group.positions.push(chars.len()); - // Filled from the prepared font/code map before measuring or drawing. - String::new() - } else { - let base = family.base_font(source.bold, source.italic); - let name = format!("PCEd{}", base.replace('-', "")); - let mut f = Dict::new(); - f.set(b"Type".to_vec(), Object::name("Font")); - f.set(b"Subtype".to_vec(), Object::name("Type1")); - f.set(b"BaseFont".to_vec(), Object::name(base)); - f.set(b"Encoding".to_vec(), Object::name("WinAnsiEncoding")); - metrics.entry(name.clone()).or_insert_with(|| Metrics::from_dict(doc, &f)); - fonts.set(name.clone().into_bytes(), Object::Dict(f)); - substituted = Some(base.to_string()); - name - } - }; - chars.push((ch, index, name)); - } - } - // All fallback outlines have now succeeded. Batch by actual face, preserving source - // style indices; the existing writer then coalesces adjacent equal-font/style characters. - for group in fallback_groups { - let fallback = write_type3_glyphs(doc, &mut fonts, group.face, false, group.glyphs, per_font); - for name in &fallback.names { - let font = fonts - .get(name.as_bytes()) - .and_then(|f| doc.dict(f)) - .ok_or_else(|| EditError::Invalid("the fallback font could not be loaded".into()))?; - metrics.insert(name.clone(), Metrics::from_dict(doc, &font)); - } - for position in group.positions { - let item = chars.get_mut(position).ok_or_else(|| EditError::Invalid("the fallback character is missing".into()))?; - let (font, _) = fallback.codes.get(&item.0).ok_or_else(|| EditError::Invalid("the fallback glyph is missing".into()))?; - item.2 = fallback.names.get(*font).cloned().ok_or_else(|| EditError::Invalid("the fallback font is missing".into()))?; - } - substituted = Some(fallback.family.to_owned()); + let mut res = p.dict.get(b"Resources").and_then(|resource| doc.resolve(resource).as_dict().cloned()).unwrap_or_default(); + let (mut fonts, mut metrics) = page_font_metrics(doc, &res, block, lines); + let (dx, dy, width) = paragraph_geometry(doc, &p, block, style)?; + let prepared = CharacterPreparation { + doc, + first, + lines, + draft, + spans: styled_spans, + style, + overrides: &overrides, + fonts: &mut fonts, + metrics: &mut metrics, + max_glyphs, } + .prepare()?; + let chars = prepared.values; + let substituted = prepared.substituted; let size_of = |source: &TextLine, index: usize| { overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6) }; @@ -400,23 +712,7 @@ pub(crate) fn rewrite_styled_block( spaces.borrow_mut().insert(name.to_owned(), w); w }; - let mut calibration = HashMap::new(); - for (index, source) in lines.iter().enumerate() { - if let Some(m) = metrics.get(&source.font) { - let nominal = source_width( - m, - &source.text, - source.origin.state.font.as_ref().map_or(1.0, |(_, s)| *s), - space_of(&source.font, m), - source.origin.state.char_spacing, - source.origin.state.word_spacing, - ) * source.origin.state.scale - * across(source); - let changed_face = style.resource.is_some() || overrides.get(&index).is_some_and(|r| r.family.is_some() || r.resource.is_some()); - let factor = if nominal > 0.0 && !changed_face { ((source.rect[2] - source.rect[0]) / nominal).clamp(0.5, 2.0) } else { 1.0 }; - calibration.insert(index, factor); - } - } + let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of); let advance = |item: &(char, usize, String)| -> f64 { let source = lines.get(item.1).unwrap_or(first); let Some(m) = metrics.get(&item.2) else { return 0.0 }; @@ -466,58 +762,7 @@ pub(crate) fn rewrite_styled_block( } Ok(width) }; - let mut rows: Vec> = Vec::new(); - // Each row's characters, as indexes into the draft's characters (`chars` holds all of them). - let mut row_ranges = Vec::new(); - let mut row_start = 0; - let mut row = Vec::new(); - let mut row_width = 0.0; - // Wrap at word boundaries, retaining explicit source row breaks and whitespace. - let mut at = 0; - while at < chars.len() { - let Some(item) = chars.get(at) else { break }; - // The CR of a CRLF: the `\n` after it breaks the row. - if item.0 == '\r' { - at += 1; - continue; - } - if item.0 == '\n' { - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - at += 1; - row_start = at; - continue; - } - let mut end = at + 1; - if !item.0.is_whitespace() { - // A word ends at a space, or where Chinese or Japanese text may break between characters. - while let (Some(before), Some(c)) = (chars.get(end - 1), chars.get(end)) - && !c.0.is_whitespace() - && !crate::linebreak::between(before.0, c.0) - { - end += 1; - } - } - let word = chars.get(at..end).unwrap_or_default(); - let w = measure(word)?; - if !row.is_empty() && !item.0.is_whitespace() && row_width + w > width + 0.5 { - while row.last().is_some_and(|c: &(char, usize, String)| c.0.is_whitespace()) { - row.pop(); - } - row_ranges.push(row_start..row_start + row.len()); - rows.push(std::mem::take(&mut row)); - row_width = 0.0; - row_start = at; - } - row.extend_from_slice(word); - row_width += w; - at = end; - } - if !row.is_empty() { - row_ranges.push(row_start..row_start + row.len()); - rows.push(row); - } + let (rows, row_ranges) = wrap_styled_rows(&chars, width, measure)?; let streams = content_streams(doc, &p.dict)?; let pieces = Pieces::join(&streams.iter().map(|(_, d)| d.as_slice()).collect::>()); let ops = pieces.parse().ops; @@ -531,65 +776,18 @@ pub(crate) fn rewrite_styled_block( }; let kept_lines: std::collections::HashSet = kept.iter().enumerate().filter(|(_, k)| **k).filter_map(|(r, _)| block.rows.get(r)).flatten().copied().collect(); - let rewritten: Vec<&TextLine> = block.lines.iter().filter(|i| !kept_lines.contains(i)).filter_map(|i| lines.get(*i)).collect(); - // The rewrite goes where the last rewritten row was drawn, after any backgrounds painted - // between the rows. A dropped `'` or `"` also moved to the next line, which a kept row relies on. - let lead = - rewritten.last().copied().filter(|_| !rewritten.iter().flat_map(|l| &l.ops).any(|i| ops.get(*i).is_some_and(|op| op.is("'") || op.is("\"")))); - let (members, lead) = match lead { - Some(lead) if !kept_lines.is_empty() => (rewritten, lead), - _ => { - kept.iter_mut().for_each(|k| *k = false); - let all: Vec<&TextLine> = block.lines.iter().filter_map(|i| lines.get(*i)).collect(); - let last = all.last().copied().unwrap_or(first); - (all, last) - } - }; + // The rewrite is placed after the last rewritten row unless a text-show operator there + // would make a kept following row depend on that position. + let anchor = rewrite_anchor(block, lines, first, &ops, &kept_lines, &mut kept); + let RewriteAnchor { lead, drop, start, split } = anchor; let o = &lead.origin; - let mut drop = coincident_ops(lines, &members); - drop.extend(members.iter().flat_map(|l| l.ops.iter().copied())); - let start = lead.ops.first().copied().unwrap_or(o.bt_op); - let split = ops - .get(o.bt_op..start) - .unwrap_or_default() - .iter() - .enumerate() - .any(|(i, op)| matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"") && !drop.contains(&(o.bt_op + i))); // What is in force where the rewrite is drawn, and what the text after it expects. let after = if split { &o.state } else { &o.bt_state }; - for (row, _) in rows.iter().zip(&kept).filter(|(_, k)| !**k) { - let text: String = row.iter().map(|v| v.0).collect(); - if pdfcraft_fonts::source_shaping::required(&text) && row.first().is_some_and(|first| row.iter().any(|v| v.1 != first.1 || v.2 != first.2)) { - return Err(EditError::Invalid( - "contextual/bidi text across font or style boundaries needs a uniform full-face row; mixed contextual runs are not yet supported" - .into(), - )); - } - } - let measured: Vec<_> = rows.iter().map(|r| measure(r)).collect::>()?; - let mut shaped_runs = HashMap::new(); - let mut segment_advances = HashMap::new(); - for (row_index, row) in rows.iter().enumerate().filter(|(r, _)| !kept.get(*r).copied().unwrap_or(false)) { - let mut at = 0; - while let Some(item) = row.get(at) { - let mut end = at + 1; - while row.get(end).is_some_and(|v| v.1 == item.1 && v.2 == item.2) { - end += 1; - } - segment_advances.insert((row_index, at), row.get(at..end).unwrap_or_default().iter().map(&advance).sum::()); - let string: String = row.get(at..end).unwrap_or_default().iter().map(|v| v.0).collect(); - if let Some((bytes, shaped, family)) = shape_source(doc, &fonts, &item.2, &string)? { - shaped_runs.insert((row_index, at), (bytes, shaped, family)); - } - at = end; - } - } - let mut glyph_resources = HashMap::new(); - for (key, (bytes, shaped, family)) in &shaped_runs { - let mappings: Vec<_> = shaped.glyphs.iter().map(|g| (g.text.clone(), g.id)).collect(); - let resource = pdfcraft_fonts::embedding::embed_mapping(doc, &mut fonts, bytes, family, &mappings).map_err(EditError::Invalid)?; - glyph_resources.insert(*key, resource); - } + validate_contextual_rows(&rows, &kept)?; + let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::>()?; + let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?; + let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped; + let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?; let n = pdfcraft_content::num; let mut block_ops = vec![Op::new("q", vec![])]; block_ops.extend(reveal_ops(block, lines, style, o.ctm)?); diff --git a/crates/edit/src/text_session.rs b/crates/edit/src/text_session.rs index 893a0e4d2..fe1aa7868 100644 --- a/crates/edit/src/text_session.rs +++ b/crates/edit/src/text_session.rs @@ -3,7 +3,10 @@ //! another one's output; the commit writes into the document it was opened on, and refuses if //! that document has changed since. -use super::*; +use super::{ + BlockStyle, Document, EditError, LineEdit, MAX_TYPE3_GLYPHS, TextBlock, TextEditLayout, TextLine, group_blocks, layout_draft, rewrite_block_from, + text_lines, +}; /// One paragraph being edited: the document as it was opened, and the page's lines then. pub struct TextSession { From 166f706e3e73ae69ef15931b467d1c87bcbe5e98 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 05/33] Refactor engine for strict Clippy checks --- crates/engine/src/doc_state.rs | 2 +- crates/engine/src/edit/labels.rs | 2 +- crates/engine/src/edit/run.rs | 5 +- crates/engine/src/session/editing.rs | 9 +- crates/engine/src/session/files.rs | 6 +- crates/engine/src/session/save.rs | 5 +- crates/engine/src/session/view.rs | 5 +- crates/engine/src/tests.rs | 149 +++++++++++++++------------ 8 files changed, 110 insertions(+), 73 deletions(-) diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index d25cc6503..d28429de6 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EditError, Hidden, SignatureInfo, TrustStore, optimize}; /// A change to an existing page image. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/engine/src/edit/labels.rs b/crates/engine/src/edit/labels.rs index 5e25ae465..ea2589806 100644 --- a/crates/engine/src/edit/labels.rs +++ b/crates/engine/src/edit/labels.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Edit, FillMark, ImageEdit, Label, LineEnding, MarkKind, Markup, PageBox, ReviewState, Shape, field_table}; /// Every wording [`Edit::label`] can produce, for an interface to translate. `Edit::Batch` is not /// here: its wording is only the hole, because a batch carries its name as text (see diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 7901a3b7d..7bf5467fc 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + AddedContent, Arc, Edit, EditError, FieldValue, ImageEdit, NewAnnotation, Shape, a11y, copy_image, decode_data_file, embed_image, fit_picture, + image_content_type, is_xfa_data_file, js, mark_source, measure, open_source, strip_xfa, unrestricted, xfa, xfa_after_data_change, +}; /// Whether the opening password allows an edit (§7.6.4.2, Table 22). pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), EditError> { diff --git a/crates/engine/src/session/editing.rs b/crates/engine/src/session/editing.rs index 9b1a8340a..f8d5648a2 100644 --- a/crates/engine/src/session/editing.rs +++ b/crates/engine/src/session/editing.rs @@ -1,4 +1,11 @@ -use super::*; +#[cfg(test)] +use super::check_refresh_panic; +use super::{ + Arc, DocId, Document, Edit, EditCtx, EditError, Label, MAX_UNDO, ObjectKind, OnceLock, RenderPool, SaveOptions, Scope, Session, + XFA_REMOVED_PREFIX, XFA_SCRIPTS_OFF, check_permission, comment_list, display_bytes, guard, info_fields, inspect, js, keys_after, note_warnings, + render_threads, run_edit, scan_colours, scope_of, shown_under_redactions, signatures_of, sync_xfa_state, undrawn_notices, uses_scripts, + write_incremental, xfa, xfa_sync_datasets, +}; impl Session { /// Apply an edit. On success the previous state is undoable and the view data is refreshed. diff --git a/crates/engine/src/session/files.rs b/crates/engine/src/session/files.rs index 6d4e11d39..1e8cd9b08 100644 --- a/crates/engine/src/session/files.rs +++ b/crates/engine/src/session/files.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, CombineSource, Combined, DataFormat, DocId, EditError, Hidden, ImageResolution, LOW_RESOLUTION_PRINT_DPI, OptimizeReport, SaveOptions, + Session, SourceKind, SplitPart, decode_text, guard, open_source, open_source_with, optimize, print, print_raster, set_default_layer_state, + source_kind, writable, write_full, +}; impl Session { /// The page count of another PDF (Replace Pages, Insert Pages dialogs). diff --git a/crates/engine/src/session/save.rs b/crates/engine/src/session/save.rs index 23994aee3..d4156a2eb 100644 --- a/crates/engine/src/session/save.rs +++ b/crates/engine/src/session/save.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, DocId, EditError, Keys, OpenError, RecoverySnapshot, RenderPool, SaveOptions, Session, SignOptions, TrustStore, display_bytes, guard, + render_threads, signatures_of, writable, write_full, write_incremental, xfa_sync_datasets, +}; impl Session { /// The bytes to write for Save: an incremental update of the file as opened/last saved, diff --git a/crates/engine/src/session/view.rs b/crates/engine/src/session/view.rs index cef539189..055437f56 100644 --- a/crates/engine/src/session/view.rs +++ b/crates/engine/src/session/view.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Arc, BookmarkPage, BookmarkRow, DocId, Document, EditError, LayerOp, OpenError, ReadingColors, RenderConfig, RenderPool, Session, SummarySort, + apply_layer_state, comment_summary, local_utc_offset, outline_rows, parse_ymd, render_threads, use_layer_choices, +}; impl Session { pub fn new() -> Self { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e7e68a758..14a61b263 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3068,7 +3068,12 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { assert!(doc.links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 1), "repeated reads reuse the link list"); } + metadata_and_comment_edits_keep_the_indexes(&mut session, id); + link_edits_rebuild_only_the_link_list(&mut session, id); + content_and_marker_edits_rebuild_the_markers(&mut session, id); +} +fn metadata_and_comment_edits_keep_the_indexes(session: &mut Session, id: DocId) { session.apply(id, Edit::SetInfo { key: "Title".into(), value: "Changed".into() }).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert!(session.get(id).unwrap().marks().is_empty()); @@ -3077,7 +3082,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, rect_comment(0, [20.0, 20.0, 60.0, 60.0])).unwrap(); assert!(session.get(id).unwrap().links().is_empty()); assert_eq!(secondary_scan_counts(), (1, 2), "comment edits invalidate the annotation-position link index"); +} +fn link_edits_rebuild_only_the_link_list(session: &mut Session, id: DocId) { session .apply( id, @@ -3101,7 +3108,9 @@ fn secondary_indexes_load_once_and_follow_edit_scopes() { session.apply(id, Edit::DeleteAnnotation { page: 0, index: 0 }).unwrap(); assert_eq!(session.get(id).unwrap().links()[0].index, 0, "deleting an earlier annotation shifts link indexes"); assert_eq!(secondary_scan_counts(), (1, 6), "comment deletion rebuilds annotation-position indexes"); +} +fn content_and_marker_edits_rebuild_the_markers(session: &mut Session, id: DocId) { session .apply(id, Edit::AddText { page: 0, text: AddedText { rect: [20.0, 250.0, 100.0, 280.0], text: "Added".into(), ..Default::default() } }) .unwrap(); @@ -8106,7 +8115,12 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { // Redo now succeeds cleanly. assert_eq!(s.redo(id).unwrap().as_str(), "Rotate page"); assert_eq!(s.get(id).unwrap().info.pages[0].rotation, 90); + rolls_back_metadata_scope(&mut s, id); + rolls_back_protection_keys(&mut s, id); +} +/// Undo/redo of a metadata edit rolled back after a refresh panic. +fn rolls_back_metadata_scope(s: &mut Session, id: DocId) { // 5. Metadata scope: a panic after `doc.info.title` was written rolls back `info.title` and keeps `edit_notices`. s.apply(id, Edit::SetInfo { key: "Title".into(), value: "Draft".into() }).unwrap(); let notice = TextNotice { field: None, page: 0, ch: '☺', redrawn_elsewhere: false }; @@ -8124,7 +8138,10 @@ fn undo_and_redo_roll_back_on_a_refresh_panic_or_error() { assert_eq!(s.get(id).unwrap().info.title, None); assert_eq!(s.redo(id).unwrap().as_str(), "Change Title"); assert_eq!(s.get(id).unwrap().info.title.as_deref(), Some("Draft")); +} +/// Undo/redo of a protection edit rolled back after a refresh panic. +fn rolls_back_protection_keys(s: &mut Session, id: DocId) { // 6. Protection keys: undo/redo rollback restores `editor.keys` and `doc.password` before re-inspecting. let protect = Protection { open_password: Some("u".into()), permissions_password: Some("o".into()), ..Protection::default() }; s.apply(id, Edit::Protect(protect)).unwrap(); @@ -8720,69 +8737,69 @@ fn pdf_with_annots(annots: &[&[u8]]) -> Vec { bytes } -/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its -/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the -/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked -/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without -/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. -#[test] -fn need_appearances_buttons_are_displayed_from_their_captions() { - let pdf = |need: bool| { - let flag = if need { "/NeedAppearances true " } else { "" }; - format!( - "%PDF-1.7\n\ - 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ - 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ - 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ - 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ - 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ - 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ - 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ - trailer << /Root 1 0 R >>\n%%EOF" - ) - .into_bytes() - }; - let render = |bytes: Arc>| { - let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); - r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) - }; - // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. - let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { - let (px, _) = page.rgba.as_chunks::<4>(); - (200 - y1..200 - y0) - .flat_map(|y| (x0..x1).map(move |x| (x, y))) - .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) - .count() - }; - // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside - // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). - let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); - let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); - let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); - let mut s = Session::new(); - let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; - let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); - let printed = render(Arc::new(pictures)); - for (page, how) in [(&shown, "shown"), (&printed, "printed")] { - let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); - assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); - assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); - assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); - assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); - let dot = ink(page, (188, 108, 212, 132)); - assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); - } - let saved = s.save_bytes(id).unwrap(); - assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); - - // Without /NeedAppearances the file's filled squares are shown. - let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); - let shown = render(s.get(id).unwrap().display.clone()); - for x in [20, 100, 180] { - assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); - } -} +/// ISO 32000-2 Table 224 (`/NeedAppearances`) and Table 192 (`/MK /CA`): a form that leaves its +/// appearances to the viewer shows its check boxes and radio buttons drawn from `/MK`, over the +/// filled squares the file carries: a checked box its check mark (ZapfDingbats `4`), an unchecked +/// one its empty border, a radio button its dot (`l`), on screen and printed as an image. Without +/// `/NeedAppearances` the file's own squares show. Saving writes the file as it was. +#[test] +fn need_appearances_buttons_are_displayed_from_their_captions() { + let pdf = |need: bool| { + let flag = if need { "/NeedAppearances true " } else { "" }; + format!( + "%PDF-1.7\n\ + 1 0 obj << /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >> endobj\n\ + 2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\ + 3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 400 200] /Annots [6 0 R 7 0 R 8 0 R] >> endobj\n\ + 4 0 obj << /Fields [6 0 R 7 0 R 8 0 R] {flag}/DA (/Helv 0 Tf 0 g) /DR << /Font << /Helv 5 0 R >> >> >> endobj\n\ + 5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\n\ + 6 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (on) /V /Yes /AS /Yes /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [20 100 60 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 7 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /T (off) /V /Off /AS /Off /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (4) >> /Rect [100 100 140 140] /F 4 /P 3 0 R /AP << /N << /Yes 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 8 0 obj << /Type /Annot /Subtype /Widget /FT /Btn /Ff 32768 /T (radio) /V /B /AS /B /DA (/Helv 0 Tf 0 g) /MK << /BC [0 0 0] /CA (l) >> /Rect [180 100 220 140] /F 4 /P 3 0 R /AP << /N << /B 9 0 R /Off 9 0 R >> >> >> endobj\n\ + 9 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 40 40] /Length 18 >> stream\n0 g 4 4 32 32 re f\nendstream endobj\n\ + trailer << /Root 1 0 R >>\n%%EOF" + ) + .into_bytes() + }; + let render = |bytes: Arc>| { + let mut r = pdfcraft_render::PageRenderer::new(bytes, pdfcraft_render::RenderConfig::default()); + r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }) + }; + // Dark pixels of the page (drawn at scale 1, 400 x 200) in the user-space box x0..x1, y0..y1. + let ink = |page: &pdfcraft_render::RenderedPage, (x0, y0, x1, y1): (u32, u32, u32, u32)| { + let (px, _) = page.rgba.as_chunks::<4>(); + (200 - y1..200 - y0) + .flat_map(|y| (x0..x1).map(move |x| (x, y))) + .filter(|&(x, y)| px.get((y * page.width + x) as usize).is_some_and(|p| p[0] < 128 && p[1] < 128 && p[2] < 128)) + .count() + }; + // In a widget whose lower-left corner is (x, y): its whole inside, the top-left of its inside + // (where a check mark leaves the box empty), and the lower-left corner (outside a dot). + let inside = |x: u32, y: u32| (x + 6, y + 6, x + 34, y + 34); + let top_left = |x: u32, y: u32| (x + 6, y + 26, x + 14, y + 34); + let corner = |x: u32, y: u32| (x + 5, y + 5, x + 9, y + 9); + let mut s = Session::new(); + let id = s.open("form.pdf", None, Arc::new(pdf(true)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + let raster = pdfcraft_print::Raster { dpi: 72.0, grayscale: false }; + let pictures = crate::print_raster::rasterize(s.get(id).unwrap(), &[0], &raster, pdfcraft_print::Content::Document).unwrap(); + let printed = render(Arc::new(pictures)); + for (page, how) in [(&shown, "shown"), (&printed, "printed")] { + let (check, check_corner) = (ink(page, inside(20, 100)), ink(page, top_left(20, 100))); + assert!(check > 100 && check < 600, "{how}: a check mark, not the 28 x 28 square: {check}"); + assert_eq!(check_corner, 0, "{how}: the check mark leaves the top left empty"); + assert_eq!(ink(page, inside(100, 100)), 0, "{how}: the unchecked box is empty"); + assert!(ink(page, (100, 100, 140, 102)) > 30, "{how}: and keeps its border"); + let dot = ink(page, (188, 108, 212, 132)); + assert!(dot > 300 && ink(page, corner(180, 100)) == 0, "{how}: the radio button's dot: {dot}"); + } + let saved = s.save_bytes(id).unwrap(); + assert!(saved.windows(18).any(|w| w == b"0 g 4 4 32 32 re f"), "the file keeps its own appearances"); + + // Without /NeedAppearances the file's filled squares are shown. + let id = s.open("plain.pdf", None, Arc::new(pdf(false)), None).unwrap(); + let shown = render(s.get(id).unwrap().display.clone()); + for x in [20, 100, 180] { + assert!(ink(&shown, inside(x, 100)) > 700, "the file's square at {x}"); + } +} From 5e75b9c3e6c48e44da1d1e2223d3115ce9416a01 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 06/33] Refactor export for strict Clippy checks --- crates/export/src/as_docx.rs | 2 +- crates/export/src/as_rtf.rs | 2 +- crates/export/src/layout.rs | 2 +- crates/export/src/lib.rs | 2 -- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/crates/export/src/as_docx.rs b/crates/export/src/as_docx.rs index d8fb84f7d..ab7451dc0 100644 --- a/crates/export/src/as_docx.rs +++ b/crates/export/src/as_docx.rs @@ -1,6 +1,6 @@ +use super::Zip; use super::as_html::esc; use super::layout::{Image, Item, Page, Table, body_size, items}; -use super::*; /// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0). pub(crate) fn rgb8(c: [f64; 3]) -> [u8; 3] { diff --git a/crates/export/src/as_rtf.rs b/crates/export/src/as_rtf.rs index d7495df13..61585d82f 100644 --- a/crates/export/src/as_rtf.rs +++ b/crates/export/src/as_rtf.rs @@ -1,7 +1,7 @@ use super::as_docx::rgb8; use super::as_html::allowed; use super::layout::{Block, Item, Page, count_lookup_steps, items}; -use super::*; +use std::collections::HashMap; fn rtf_text(s: &str) -> String { let mut o = String::new(); diff --git a/crates/export/src/layout.rs b/crates/export/src/layout.rs index 8a0605089..d1d074ac6 100644 --- a/crates/export/src/layout.rs +++ b/crates/export/src/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use std::collections::HashMap; /// A paragraph of a page. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index cf32ea793..5275b0243 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -10,8 +10,6 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -use std::collections::HashMap; - mod pptx; mod zip; From 4965e23bfe4f2b0e37f5d978654e20594885e538 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 07/33] Refactor forms for strict Clippy checks --- crates/forms/src/af/script.rs | 2 +- crates/forms/src/appearances.rs | 2 +- crates/forms/src/author/edit.rs | 231 +++++++++++++++------------ crates/forms/src/author/lifecycle.rs | 5 +- crates/forms/src/author/look.rs | 2 +- crates/forms/src/author/tree.rs | 2 +- crates/forms/src/read.rs | 2 +- crates/forms/src/tab_order.rs | 2 +- crates/forms/src/tests.rs | 203 +++++++++++++---------- crates/forms/src/values.rs | 5 +- 10 files changed, 263 insertions(+), 193 deletions(-) diff --git a/crates/forms/src/af/script.rs b/crates/forms/src/af/script.rs index c807f2d7d..9aed297b4 100644 --- a/crates/forms/src/af/script.rs +++ b/crates/forms/src/af/script.rs @@ -1,6 +1,6 @@ use super::calc::{js_tokens, notation_js, notation_range}; use super::format::{DATE_PRESETS, TIME_PRESETS}; -use super::*; +use super::{CalcOp, Calculate, Format, Validate}; // ── parsing scripts ───────────────────────────────────────────────────────────────────────── diff --git a/crates/forms/src/appearances.rs b/crates/forms/src/appearances.rs index d035feb4e..64569fa3c 100644 --- a/crates/forms/src/appearances.rs +++ b/crates/forms/src/appearances.rs @@ -1,6 +1,6 @@ use super::read::{acroform, fields}; use super::values::merged_appearance; -use super::*; +use super::{Dict, Document, FieldKind, ObjRef, Object, Widget, appearance, name_bytes}; /// Whether the form asks viewers to draw its fields' appearances themselves (`/NeedAppearances`). pub fn need_appearances(doc: &Document) -> bool { diff --git a/crates/forms/src/author/edit.rs b/crates/forms/src/author/edit.rs index dc7d91d4f..d61cfe28f 100644 --- a/crates/forms/src/author/edit.rs +++ b/crates/forms/src/author/edit.rs @@ -1,6 +1,6 @@ use super::look::{BorderStyle, da_string, patch_da, patch_widget_look, redraw_widgets}; use super::tree::{attach, check_place, child_named, clash, detach, ensure_form, inherited, name_parts, own_inherited}; -use super::*; +use super::{Dict, Document, Field, FieldKind, FieldProps, FormError, MIN_FIELD_SIDE, ObjRef, Object, PdfString, fields, flags, invalid}; /// Swap width and height, keeping the center. `None` when that is not a usable widget. fn swapped_around_center(r: [f64; 4]) -> Option<[f64; 4]> { @@ -70,59 +70,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result own, - _ => n, - }; - if n.contains('.') { - // A full name: the field moves under the parent fields it names (#2696). - let parts = name_parts(n)?; - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { - return invalid(why); - } - let af = ensure_form(doc)?; - // Checked before anything changes, so a refused move leaves the field as it was. - let add = inherited(doc, f.obj); - check_place(doc, af, f.obj, &parts, &add)?; - // From here a failure (a write that `check_place` couldn't foresee) would leave the - // field half moved in `doc`. The engine runs every edit on a copy of the document and - // keeps it only when the edit succeeds (`Session::apply`), so the open document never - // sees that. - own_inherited(doc, f.obj, &add)?; - detach(doc, f.obj)?; - attach(doc, af, f.obj, &parts)?; - new_name = n.to_string(); - } else { - if n.is_empty() { - return invalid("field names can't be empty"); - } - // Rename the terminal part (the field's own /T). The parents' part stays, so the name - // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO - // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. - let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); - new_name = format!("{prefix}{n}"); - if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { - return invalid(why); - } - // A node of that name beside it that isn't a terminal field, such as a parent - // field left empty by a move. - let af = ensure_form(doc)?; - let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); - if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { - return invalid(format!("a field named {new_name:?} already exists")); - } - doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; - } - // Calculations read the field by its new name, unless another field keeps the old one. - if all.iter().filter(|x| x.name == f.name).count() == 1 { - rename_in_calculations(doc, &all, &f.name, &new_name)?; - } - } + let new_name = rename_field(doc, &all, &f, name, props)?; // The new default appearance when the size or the look changes. let size = props.font_size.unwrap_or_else(|| crate::appearance::parse_da(&f.da).size); let new_da = match &props.look { @@ -248,51 +196,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result mk.set(b"BC".to_vec(), arr(c)), - None => { - mk.remove(b"BC"); - } - } - match l.fill { - Some(c) => mk.set(b"BG".to_vec(), arr(c)), - None => { - mk.remove(b"BG"); - } - } - d.set(b"MK".to_vec(), Object::Dict(mk)); - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); - bs.set(b"S".to_vec(), Object::name(l.style.code())); - if l.style == BorderStyle::Dashed { - bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); - } - d.set(b"BS".to_vec(), Object::Dict(bs)); - })?; - } - } - if let Some(style) = props.check_style { - for w in &f.widgets { - // Resolved first: an indirect /MK keeps its colours. - let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); - mk.set(b"CA".to_vec(), PdfString::text(style.code())); - doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; - } - } - if let Some((wi, r)) = props.rect { - let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; - let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; - if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { - return invalid("the field is too small"); - } - doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; - } + apply_widget_style_props(doc, &f, name, props)?; if props.format.is_some() || props.validate.is_some() || props.calculate.is_some() { if !matches!(f.kind, FieldKind::Text | FieldKind::Combo) { return invalid("only text fields and drop-down lists have format, validate and calculate scripts"); @@ -341,6 +245,127 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result { + let mut new_name = name.to_string(); + if let Some(n) = props.name.as_deref().map(str::trim).filter(|n| *n != f.name) { + // A full name under the same parents only renames the field's own part (the editor + // shows nested fields by their full name): it isn't moved, so it keeps its place and + // goes on inheriting from its parents. + let n = match (n.rsplit_once('.'), f.name.rsplit_once('.')) { + (Some((parents, own)), Some((current, _))) if parents == current => own, + _ => n, + }; + if n.contains('.') { + // A full name: the field moves under the parent fields it names (#2696). + let parts = name_parts(n)?; + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(n, &x.name)) { + return invalid(why); + } + let af = ensure_form(doc)?; + // Checked before anything changes, so a refused move leaves the field as it was. + let add = inherited(doc, f.obj); + check_place(doc, af, f.obj, &parts, &add)?; + // From here a failure (a write that `check_place` couldn't foresee) would leave the + // field half moved in `doc`. The engine runs every edit on a copy of the document and + // keeps it only when the edit succeeds (`Session::apply`), so the open document never + // sees that. + own_inherited(doc, f.obj, &add)?; + detach(doc, f.obj)?; + attach(doc, af, f.obj, &parts)?; + new_name = n.to_string(); + } else { + if n.is_empty() { + return invalid("field names can't be empty"); + } + // Rename the terminal part (the field's own /T). The parents' part stays, so the name + // that must be free is the full one: `MASTER.a` renamed to `b` takes `MASTER.b` (ISO + // 32000-2 §12.7.4.2). The field itself is no clash when it keeps its own name. + let prefix = f.name.rsplit_once('.').map(|(p, _)| format!("{p}.")).unwrap_or_default(); + new_name = format!("{prefix}{n}"); + if let Some(why) = all.iter().filter(|x| x.obj != f.obj).find_map(|x| clash(&new_name, &x.name)) { + return invalid(why); + } + // A node of that name beside it that isn't a terminal field, such as a parent + // field left empty by a move. + let af = ensure_form(doc)?; + let parent = doc.get(f.obj).as_dict().and_then(|d| d.reference(b"Parent")); + if child_named(doc, af, parent, n).is_some_and(|r| r != f.obj) { + return invalid(format!("a field named {new_name:?} already exists")); + } + doc.update_dict(f.obj, |d| d.set(b"T".to_vec(), PdfString::text(n)))?; + } + // Calculations read the field by its new name, unless another field keeps the old one. + if all.iter().filter(|x| x.name == f.name).count() == 1 { + rename_in_calculations(doc, all, &f.name, &new_name)?; + } + } + Ok(new_name) +} + +fn apply_widget_style_props(doc: &mut Document, f: &Field, name: &str, props: &FieldProps) -> Result<(), FormError> { + if let Some(l) = &props.look { + let arr = |c: [f64; 3]| Object::Array(c.iter().map(|v| Object::Real(v.clamp(0.0, 1.0))).collect()); + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its mark, rotation and other entries. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + doc.update_dict(w.obj, |d| { + match l.border { + Some(c) => mk.set(b"BC".to_vec(), arr(c)), + None => { + mk.remove(b"BC"); + } + } + match l.fill { + Some(c) => mk.set(b"BG".to_vec(), arr(c)), + None => { + mk.remove(b"BG"); + } + } + d.set(b"MK".to_vec(), Object::Dict(mk)); + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(l.width.clamp(0.0, 12.0))); + bs.set(b"S".to_vec(), Object::name(l.style.code())); + if l.style == BorderStyle::Dashed { + bs.set(b"D".to_vec(), Object::Array(vec![Object::Int(3)])); + } + d.set(b"BS".to_vec(), Object::Dict(bs)); + })?; + } + } + if let Some(style) = props.check_style { + for w in &f.widgets { + // Resolved first: an indirect /MK keeps its colours. + let mut mk = doc.get(w.obj).as_dict().and_then(|d| d.get(b"MK").map(|m| doc.resolve(m).as_dict().cloned())).flatten().unwrap_or_default(); + mk.set(b"CA".to_vec(), PdfString::text(style.code())); + doc.update_dict(w.obj, |d| d.set(b"MK".to_vec(), Object::Dict(mk)))?; + } + } + if let Some((wi, r)) = props.rect { + let w = f.widgets.get(wi).ok_or_else(|| FormError::Invalid(format!("{name} has no widget {}", wi + 1)))?; + let r = [r[0].min(r[2]), r[1].min(r[3]), r[0].max(r[2]), r[1].max(r[3])]; + if !r.iter().all(|v| v.is_finite()) || r[2] - r[0] < MIN_FIELD_SIDE || r[3] - r[1] < MIN_FIELD_SIDE { + return invalid("the field is too small"); + } + doc.update_dict(w.obj, |d| d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Real(*v)).collect())))?; + } + Ok(()) +} + +fn apply_late_widget_props( + doc: &mut Document, + f: &Field, + props: &FieldProps, + new_da: &str, + rotated_rect: Option>, +) -> Result, FormError> { // A complete look replaces widget /DA overrides. Partial changes were patched above // against each override, including size-only changes that keep its font and colour. if props.look.is_some() { @@ -348,7 +373,7 @@ pub fn set_props(doc: &mut Document, name: &str, props: &FieldProps) -> Result Result Dict { let mut d = Dict::new(); diff --git a/crates/forms/src/author/look.rs b/crates/forms/src/author/look.rs index 6044bdc70..23661ed70 100644 --- a/crates/forms/src/author/look.rs +++ b/crates/forms/src/author/look.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FieldKind, FormError, ObjRef, Object, Stream, Widget, appearance, fields, invalid, rgb}; /// The Appearance tab: borders, colours and text. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/forms/src/author/tree.rs b/crates/forms/src/author/tree.rs index 4f7db4d8e..d8ca67ffb 100644 --- a/crates/forms/src/author/tree.rs +++ b/crates/forms/src/author/tree.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Field, FormError, ObjRef, Object, PdfString, invalid}; /// The AcroForm dictionary's reference, creating the form (with `/Helv` in `/DR`) if needed. pub(crate) fn ensure_form(doc: &mut Document) -> Result { diff --git a/crates/forms/src/read.rs b/crates/forms/src/read.rs index a962af91b..e689c0e81 100644 --- a/crates/forms/src/read.rs +++ b/crates/forms/src/read.rs @@ -1,5 +1,5 @@ use super::tab_order::rank_tabs; -use super::*; +use super::{DecodeBudget, Dict, Document, Field, FieldKind, ObjRef, Object, PdfString, Widget, af, appearance, flags, name_text, text_of}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/forms/src/tab_order.rs b/crates/forms/src/tab_order.rs index 9c5c33b69..77234ee09 100644 --- a/crates/forms/src/tab_order.rs +++ b/crates/forms/src/tab_order.rs @@ -1,5 +1,5 @@ use super::read::{fields, page_refs}; -use super::*; +use super::{Document, Field, FormError, ObjRef, Object, invalid}; /// A page's tab order (`/Tabs`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/forms/src/tests.rs b/crates/forms/src/tests.rs index 61dab131e..d8199f15d 100644 --- a/crates/forms/src/tests.rs +++ b/crates/forms/src/tests.rs @@ -3601,13 +3601,17 @@ fn dotted_field_names_are_made_with_parent_fields() { }) .unwrap(); + invalid_dotted_field_names_are_rejected(&mut doc, &text); +} + +fn invalid_dotted_field_names_are_rejected(doc: &mut Document, text: &NewField) { for bad in ["MASTER", "MASTER.street1.x", "MASTER..x", ".x", "x.", "MASTER. "] { - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(bad)).is_err(), "{bad:?}"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(bad)).is_err(), "{bad:?}"); + assert!(set_props(doc, "name", &FieldProps { name: Some(bad.into()), ..FieldProps::default() }).is_err(), "{bad:?}"); } let deep = vec!["p"; 33].join("."); - assert!(add_field(&mut doc, 0, [300.0, 200.0, 450.0, 220.0], &text, Some(&deep)).is_err(), "too deep"); - assert!(set_props(&mut doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); + assert!(add_field(doc, 0, [300.0, 200.0, 450.0, 220.0], text, Some(&deep)).is_err(), "too deep"); + assert!(set_props(doc, "name", &FieldProps { name: Some("MASTER.street1".into()), ..FieldProps::default() }).is_err(), "taken"); } /// Review on #2957: a parent left empty by a move keeps its name from the next default name @@ -3801,90 +3805,17 @@ fn existing_form_fonts_are_kept_and_an_unchanged_resource_dictionary_is_not_rewr /// - Helv, TiRo and Cour are there. #[test] fn every_shape_of_form_fonts_ends_as_objects() { - let font = |base: &str| format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>"); let mut cases = 0; for dr_shape in ["missing", "inline", "object"] { for font_shape in ["missing", "inline", "object", "number"] { for helv in ["missing", "inline", "object", "dangling", "null", "number"] { for mine in ["missing", "inline", "object"] { - if dr_shape == "missing" && (font_shape != "missing" || helv != "missing" || mine != "missing") { - continue; - } - if matches!(font_shape, "missing" | "number") && (helv != "missing" || mine != "missing") { + let case = FontShapeCase::new(dr_shape, font_shape, helv, mine); + if !case.is_valid() { continue; } cases += 1; - let what = format!("/DR {dr_shape}, /Font {font_shape}, /Helv {helv}, /Mine {mine}"); - let mut entries = String::new(); - match helv { - "inline" => entries += &format!("/Helv {} ", font("Arial")), - "object" => entries += "/Helv 7 0 R ", - "dangling" => entries += "/Helv 99 0 R ", - "null" => entries += "/Helv null ", - "number" => entries += "/Helv 3 ", - _ => {} - } - match mine { - "inline" => entries += &format!("/Mine {} ", font("Symbol")), - "object" => entries += "/Mine 8 0 R ", - _ => {} - } - let fonts_value = match font_shape { - "inline" => format!("<< {entries}>>"), - "object" => "6 0 R".into(), - "number" => "7".into(), - _ => String::new(), - }; - let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; - let dr_value = match dr_shape { - "inline" => dr_dict.clone(), - "object" => "5 0 R".into(), - _ => String::new(), - }; - let af = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; - let mut doc = document(&[ - "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), - "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), - "<< /Type /Page /Parent 2 0 R >>".into(), - af, - if dr_shape == "object" { dr_dict } else { "null".into() }, - if font_shape == "object" { format!("<< {entries}>>") } else { "null".into() }, - font("Helvetica-Oblique"), - font("ZapfDingbats"), - ]); - add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) - .unwrap_or_else(|e| panic!("{what}: {e}")); - let doc = reopen(&doc); - let af = acroform(&doc).unwrap(); - let dr = doc.resolve(af.get(b"DR").unwrap_or_else(|| panic!("{what}: no /DR"))); - let fonts = dr_fonts(&doc); - for (name, value) in fonts.iter() { - assert!(value.as_dict().is_none(), "{what}: {} is inline", String::from_utf8_lossy(name)); - } - for name in ["Helv", "TiRo", "Cour"] { - assert!(fonts.contains(name.as_bytes()), "{what}: no {name}"); - } - let base = - |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); - match helv { - "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{what}"), - "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{what}"), - "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{what}"), - "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{what}"), - _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{what}"), - } - match mine { - "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{what}"), - "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{what}"), - _ => assert!(!fonts.contains(b"Mine"), "{what}"), - } - if dr_shape == "object" { - assert_eq!(af.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{what}: /DR stays its object"); - } - if font_shape == "object" { - assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{what}: /Font stays its object"); - } + assert_font_shape_case(&case); } } } @@ -3892,6 +3823,118 @@ fn every_shape_of_form_fonts_ends_as_objects() { assert_eq!(cases, 1 + 2 * (2 + 2 * 6 * 3), "every shape was tried"); } +struct FontShapeCase<'a> { + dr: &'a str, + fonts: &'a str, + helv: &'a str, + mine: &'a str, + description: String, +} + +impl<'a> FontShapeCase<'a> { + fn new(dr: &'a str, fonts: &'a str, helv: &'a str, mine: &'a str) -> Self { + Self { dr, fonts, helv, mine, description: format!("/DR {dr}, /Font {fonts}, /Helv {helv}, /Mine {mine}") } + } + + fn is_valid(&self) -> bool { + let only_default_dr = self.dr == "missing" && self.fonts == "missing" && self.helv == "missing" && self.mine == "missing"; + let invalid_fonts = matches!(self.fonts, "missing" | "number") && (self.helv != "missing" || self.mine != "missing"); + !(self.dr == "missing" && !only_default_dr || invalid_fonts) + } +} + +fn assert_font_shape_case(case: &FontShapeCase<'_>) { + let mut doc = font_shape_document(case); + add_field(&mut doc, 0, [40.0, 600.0, 300.0, 630.0], &NewField::Text { multiline: false }, Some("name")) + .unwrap_or_else(|e| panic!("{}: {e}", case.description)); + let doc = reopen(&doc); + let acro = acroform(&doc).unwrap(); + let dr = doc.resolve(acro.get(b"DR").unwrap_or_else(|| panic!("{}: no /DR", case.description))); + let fonts = dr_fonts(&doc); + assert_font_entries_are_references(&fonts, &case.description); + assert_required_form_fonts(&fonts, &case.description); + assert_requested_font_shapes(&doc, &acro, &dr, &fonts, case); +} + +fn font_shape_document(case: &FontShapeCase<'_>) -> Document { + let mut entries = String::new(); + match case.helv { + "inline" => entries += &format!("/Helv {} ", form_font("Arial")), + "object" => entries += "/Helv 7 0 R ", + "dangling" => entries += "/Helv 99 0 R ", + "null" => entries += "/Helv null ", + "number" => entries += "/Helv 3 ", + _ => {} + } + match case.mine { + "inline" => entries += &format!("/Mine {} ", form_font("Symbol")), + "object" => entries += "/Mine 8 0 R ", + _ => {} + } + let fonts_value = match case.fonts { + "inline" => format!("<< {entries}>>"), + "object" => "6 0 R".into(), + "number" => "7".into(), + _ => String::new(), + }; + let dr_dict = if fonts_value.is_empty() { "<< >>".to_string() } else { format!("<< /Font {fonts_value} >>") }; + let dr_value = match case.dr { + "inline" => dr_dict.clone(), + "object" => "5 0 R".into(), + _ => String::new(), + }; + let acroform = if dr_value.is_empty() { "<< /Fields [] >>".to_string() } else { format!("<< /Fields [] /DR {dr_value} >>") }; + document(&[ + "<< /Type /Catalog /Pages 2 0 R /AcroForm 4 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 600 800] >>".into(), + "<< /Type /Page /Parent 2 0 R >>".into(), + acroform, + if case.dr == "object" { dr_dict } else { "null".into() }, + if case.fonts == "object" { format!("<< {entries}>>") } else { "null".into() }, + form_font("Helvetica-Oblique"), + form_font("ZapfDingbats"), + ]) +} + +fn form_font(base: &str) -> String { + format!("<< /Type /Font /Subtype /Type1 /BaseFont /{base} >>") +} + +fn assert_font_entries_are_references(fonts: &Dict, description: &str) { + for (name, value) in fonts.iter() { + assert!(value.as_dict().is_none(), "{description}: {} is inline", String::from_utf8_lossy(name)); + } +} + +fn assert_required_form_fonts(fonts: &Dict, description: &str) { + for name in ["Helv", "TiRo", "Cour"] { + assert!(fonts.contains(name.as_bytes()), "{description}: no {name}"); + } +} + +fn assert_requested_font_shapes(doc: &Document, acro: &Dict, dr: &Object, fonts: &Dict, case: &FontShapeCase<'_>) { + let base = |name: &[u8]| fonts.reference(name).and_then(|r| doc.get(r).as_dict().and_then(|f| f.name(b"BaseFont").map(<[u8]>::to_vec))); + match case.helv { + "inline" => assert_eq!(base(b"Helv").as_deref(), Some(&b"Arial"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(7, 0)), "{}", case.description), + "dangling" => assert_eq!(fonts.reference(b"Helv"), Some(pdfcraft_cos::ObjRef::new(99, 0)), "{}", case.description), + "null" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Null), "{}", case.description), + "number" => assert_eq!(fonts.get(b"Helv"), Some(&Object::Int(3)), "{}", case.description), + _ => assert_eq!(base(b"Helv").as_deref(), Some(&b"Helvetica"[..]), "{}", case.description), + } + match case.mine { + "inline" => assert_eq!(base(b"Mine").as_deref(), Some(&b"Symbol"[..]), "{}", case.description), + "object" => assert_eq!(fonts.reference(b"Mine"), Some(pdfcraft_cos::ObjRef::new(8, 0)), "{}", case.description), + _ => assert!(!fonts.contains(b"Mine"), "{}", case.description), + } + if case.dr == "object" { + assert_eq!(acro.reference(b"DR"), Some(pdfcraft_cos::ObjRef::new(5, 0)), "{}: /DR stays its object", case.description); + } + if case.fonts == "object" { + assert_eq!(dr.as_dict().unwrap().reference(b"Font"), Some(pdfcraft_cos::ObjRef::new(6, 0)), "{}: /Font stays its object", case.description); + } +} + /// #3441 (review follow-up): filling a field converts the form's inline `/DR` fonts, so a form /// an earlier PdfCraft made works in Acrobat once it has been filled here. Nothing is added. A /// form a signature covers keeps `/DR` as the signer saw it, and a form whose fonts are objects diff --git a/crates/forms/src/values.rs b/crates/forms/src/values.rs index 9dd79f0a7..37df5502d 100644 --- a/crates/forms/src/values.rs +++ b/crates/forms/src/values.rs @@ -1,6 +1,9 @@ use super::appearances::{declares_pdfa, need_appearances}; use super::read::{acroform, field_flags, fields, page_refs}; -use super::*; +use super::{ + Dict, Document, Field, FieldEvent, FieldKind, FieldValue, FormError, NoScripts, ObjRef, Object, PdfString, Scripts, af, appearance, author, + calc_order, flags, invalid, name_obj, scripting, +}; // ── writing ───────────────────────────────────────────────────────────────────────────────── From a0598816d2c9fd043b9c3d8fec22e8f44bae720e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 08/33] Refactor js for strict Clippy checks --- crates/js/src/document.rs | 2 +- crates/js/src/format.rs | 2 +- crates/js/src/formcalc/access.rs | 5 +- crates/js/src/formcalc/ast.rs | 2 +- crates/js/src/formcalc/builtins.rs | 67 +++++++---- crates/js/src/formcalc/eval.rs | 5 +- crates/js/src/formcalc/lexer.rs | 2 +- crates/js/src/formcalc/parser.rs | 2 +- crates/js/src/globals.rs | 2 +- crates/js/src/host.rs | 5 +- crates/js/src/run.rs | 2 +- crates/js/src/tests.rs | 173 ++++++++++++++++++++--------- crates/js/src/xfa/globals.rs | 5 +- crates/js/src/xfa/host.rs | 2 +- crates/js/src/xfa/instances.rs | 2 +- crates/js/src/xfa/model.rs | 2 +- crates/js/src/xfa/nodes.rs | 5 +- crates/js/src/xfa/run.rs | 2 +- 18 files changed, 201 insertions(+), 86 deletions(-) diff --git a/crates/js/src/document.rs b/crates/js/src/document.rs index 3d0fe194e..e977457e2 100644 --- a/crates/js/src/document.rs +++ b/crates/js/src/document.rs @@ -1,6 +1,6 @@ use super::host::{arg, colour, error, function, host, read, s, set_value, strings, text, value_of, write}; use super::model::{DISPLAY_HIDDEN, DISPLAY_VISIBLE, FieldType, Request}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; macro_rules! getter { ($ctx:expr, $name:expr, |$f:ident, $c:ident| $body:expr) => {{ diff --git a/crates/js/src/format.rs b/crates/js/src/format.rs index 5f2eed102..7f207b191 100644 --- a/crates/js/src/format.rs +++ b/crates/js/src/format.rs @@ -1,6 +1,6 @@ use super::host::{arg, error, s, text}; use super::run::{MAX_PRINTF_OUTPUT_BYTES, MAX_PRINTF_WIDTH_PRECISION}; -use super::*; +use super::{Context, JsDate, JsResult, JsString, JsValue, formcalc}; /// `util.printf(cFormat, …)`: `%[,n][+ 0#][width][.prec](d|f|s|x)`, where `,0` groups /// thousands with commas (the default), `,1` doesn't, `,2` uses `.` and `,`, `,3` uses `.` diff --git a/crates/js/src/formcalc/access.rs b/crates/js/src/formcalc/access.rs index 3911a5223..bffbd8823 100644 --- a/crates/js/src/formcalc/access.rs +++ b/crates/js/src/formcalc/access.rs @@ -1,6 +1,9 @@ use super::ast::{Expr, Idx}; use super::value::{Val, num_text, parse_num}; -use super::*; +use super::{ + CLOCK_EVERY, HNode, HashSet, Interp, LIST_TOO_LONG, MAX_LIST, MAX_PARSE_DEPTH, MAX_STEPS, R, RAN_TOO_LONG, child_named, descendants_named, + instances, resolve_som, +}; impl Interp<'_> { pub(super) fn step(&mut self) -> R<()> { diff --git a/crates/js/src/formcalc/ast.rs b/crates/js/src/formcalc/ast.rs index f9a88be79..ab453a806 100644 --- a/crates/js/src/formcalc/ast.rs +++ b/crates/js/src/formcalc/ast.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Func, Rc}; // ── syntax ────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/builtins.rs b/crates/js/src/formcalc/builtins.rs index 1e7bb9424..c3b7c7f1f 100644 --- a/crates/js/src/formcalc/builtins.rs +++ b/crates/js/src/formcalc/builtins.rs @@ -3,7 +3,7 @@ use super::dates::{MAX_DAYS, civil_from_days, days_from_civil, format_date, pars use super::parser::parse; use super::value::{Val, fixed, is_numeric_text, parse_num}; use super::words::word_num; -use super::*; +use super::{Interp, MAX_STRING, R, STRING_TOO_LONG, XfaKind, is_limit}; impl Interp<'_> { /// A built-in function: its result is never a string past [`MAX_STRING`], and work on @@ -22,6 +22,15 @@ impl Interp<'_> { fn builtin_inner(&mut self, name: &str, args: &[Expr]) -> R { let lower = name.to_ascii_lowercase(); let n = |v: f64| Ok(Val::Num(v)); + if let Some(value) = self.date_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.financial_builtin(&lower, args)? { + return Ok(value); + } + if let Some(value) = self.unit_builtin(&lower, args)? { + return Ok(value); + } Ok(match lower.as_str() { // ── arithmetic "abs" => return n(self.arg_num(args, 0)?.abs()), @@ -271,7 +280,24 @@ impl Interp<'_> { let cleaned: String = s.chars().filter(|c| c.is_ascii_digit() || *c == '.' || *c == '-').collect(); parse_num(&cleaned).map_or(Val::Null, Val::Num) } - // ── dates and times (days since 1900-01-01 = 1; milliseconds since midnight) + "ref" => { + let Some(e) = args.first() else { return Ok(Val::Null) }; + self.eval(e)? + } + "eval" => { + let src = self.arg_str(args, 0)?; + self.charge((src.len() / 16) as u64)?; + let body = parse(&src)?; + let (_, v) = self.block(&body)?; + v + } + "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), + _ => return Err(format!("unknown function {name}")), + }) + } + + fn date_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "date" => { #[cfg(not(target_arch = "wasm32"))] { @@ -304,7 +330,7 @@ impl Interp<'_> { let days = self.arg_num(args, 0)?; let pic = if args.len() > 1 { self.arg_str(args, 1)? } else { "MMM D, YYYY".into() }; if !days.is_finite() || days.abs() >= MAX_DAYS { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let (y, m, d) = civil_from_days(days as i64); Val::Str(format_date(y, m, d, &pic)) @@ -326,7 +352,7 @@ impl Interp<'_> { "num2time" | "num2gmtime" => { let ms = self.arg_num(args, 0)?; if !ms.is_finite() || ms < 0.0 { - return Ok(Val::Null); + return Ok(Some(Val::Null)); } let s = (ms / 1000.0) as u64; Val::Str(format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)) @@ -344,7 +370,13 @@ impl Interp<'_> { parts.next().and_then(|p| p.trim().parse::().ok()).filter(|v| v.is_finite() && *v >= 0.0).unwrap_or(0.0).min(1_000_000.0); Val::Num(((h * 3600 + m * 60) * 1000) as f64 + (sec * 1000.0).round()) } - // ── financial + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn financial_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "fv" => { let (p, r, k) = (self.arg_num(args, 0)?, self.arg_num(args, 1)?, self.arg_num(args, 2)?); Val::Num(if r == 0.0 { p * k } else { p * (((1.0 + r).powf(k) - 1.0) / r) }) @@ -428,7 +460,13 @@ impl Interp<'_> { } Val::Num((lo + hi) / 2.0 * 12.0) } - // ── units and references + _ => return Ok(None), + }; + Ok(Some(value)) + } + + fn unit_builtin(&mut self, lower: &str, args: &[Expr]) -> R> { + let value = match lower { "unittype" => { let s = self.arg_str(args, 0)?; let unit: String = s.trim().chars().skip_while(|c| c.is_ascii_digit() || *c == '.' || *c == '-' || *c == '+').collect(); @@ -459,19 +497,8 @@ impl Interp<'_> { let target = if to.trim().is_empty() { unit.clone() } else { to }; Val::Num(v / per_in(&unit) * per_in(&target)) } - "ref" => { - let Some(e) = args.first() else { return Ok(Val::Null) }; - self.eval(e)? - } - "eval" => { - let src = self.arg_str(args, 0)?; - self.charge((src.len() / 16) as u64)?; - let body = parse(&src)?; - let (_, v) = self.block(&body)?; - v - } - "get" | "post" | "put" => return Err(format!("{name}: network access is not available to form scripts")), - _ => return Err(format!("unknown function {name}")), - }) + _ => return Ok(None), + }; + Ok(Some(value)) } } diff --git a/crates/js/src/formcalc/eval.rs b/crates/js/src/formcalc/eval.rs index 23ce3ec0c..a3218a78f 100644 --- a/crates/js/src/formcalc/eval.rs +++ b/crates/js/src/formcalc/eval.rs @@ -1,6 +1,9 @@ use super::ast::{Expr, Stmt}; use super::value::{Val, is_numeric_text, num_text}; -use super::*; +use super::{ + CALLS_TOO_DEEP, Flow, Func, HashMap, HashSet, Interp, LIST_TOO_LONG, LOOPED_TOO_MUCH, MAX_LIST, R, Rc, TOO_DEEP, XfaEffect, child_named, clip, + descendants_named, instances, reset_names, resolve_som, +}; impl Interp<'_> { // ── expressions diff --git a/crates/js/src/formcalc/lexer.rs b/crates/js/src/formcalc/lexer.rs index ba9ae4f16..46af19f76 100644 --- a/crates/js/src/formcalc/lexer.rs +++ b/crates/js/src/formcalc/lexer.rs @@ -1,4 +1,4 @@ -use super::*; +use super::MAX_STRING; // ── lexer ─────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/formcalc/parser.rs b/crates/js/src/formcalc/parser.rs index 84cf121cd..fceedadf5 100644 --- a/crates/js/src/formcalc/parser.rs +++ b/crates/js/src/formcalc/parser.rs @@ -1,6 +1,6 @@ use super::ast::{Expr, Idx, Stmt}; use super::lexer::{Tok, Token, lex}; -use super::*; +use super::{Func, MAX_CHAIN, MAX_PARSE_DEPTH, Rc}; struct Parser { toks: Vec, diff --git a/crates/js/src/globals.rs b/crates/js/src/globals.rs index dd487c248..a9ad4d2b8 100644 --- a/crates/js/src/globals.rs +++ b/crates/js/src/globals.rs @@ -2,7 +2,7 @@ use super::document::{field_object, get_field, get_nth_field_name, noop, num_fie use super::format::{printd, printf, printx, scand}; use super::host::{arg, function, host, s, text, text_line}; use super::model::{DISPLAY_HIDDEN, DISPLAY_NO_PRINT, DISPLAY_NO_VIEW, DISPLAY_VISIBLE, Event, Panel, Request}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, js_string}; // ── app, util, console ────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/host.rs b/crates/js/src/host.rs index 131b8851c..6e442d2a2 100644 --- a/crates/js/src/host.rs +++ b/crates/js/src/host.rs @@ -1,6 +1,9 @@ use super::model::{DocInfo, FieldState, FieldType, Request}; use super::run::MAX_ARRAY_LENGTH; -use super::*; +use super::{ + Arc, BTreeSet, Context, FunctionObjectBuilder, JsArray, JsError, JsFunction, JsNativeError, JsResult, JsString, JsValue, NativeFunction, RefCell, + xfa, +}; pub(crate) struct Host { pub(crate) fields: Arc>, diff --git a/crates/js/src/run.rs b/crates/js/src/run.rs index 17b5c252f..07c0e984b 100644 --- a/crates/js/src/run.rs +++ b/crates/js/src/run.rs @@ -1,7 +1,7 @@ use super::globals::{event_object, install}; use super::host::{Host, Shared, text}; use super::model::{DocInfo, Event, FieldState, Limits, Outcome}; -use super::*; +use super::{Arc, Attribute, BTreeSet, Context, HostHooks, JsNativeError, JsResult, JsString, Realm, RefCell, Source, js_string}; /// Longest script, in bytes. pub(crate) const MAX_SCRIPT_BYTES: usize = 256 * 1024; diff --git a/crates/js/src/tests.rs b/crates/js/src/tests.rs index 9e96dcd2e..38cebf54c 100644 --- a/crates/js/src/tests.rs +++ b/crates/js/src/tests.rs @@ -1017,6 +1017,14 @@ mod formcalc { #[test] fn built_in_functions_cover_the_common_set() { + assert_numeric_and_logical_builtins(); + assert_string_builtins(); + assert_format_and_date_builtins(); + assert_financial_and_unit_builtins(); + assert_builtin_errors(); + } + + fn assert_numeric_and_logical_builtins() { assert_eq!(calc("Abs(-2.5)"), "2.5"); assert_eq!(calc(r#"Decode("a%2Fb", "url")"#), "a/b"); assert_eq!(calc(r#"Decode("a%+fb", "url")"#), "a%+fb"); @@ -1034,6 +1042,9 @@ mod formcalc { assert_eq!(calc(r#"Within(5, 1, 10)"#), "1"); assert_eq!(calc(r#"HasValue("")"#), "0"); assert_eq!(calc(r#"HasValue("x")"#), "1"); + } + + fn assert_string_builtins() { assert_eq!(calc(r#"At("abcd", "c")"#), "3"); assert_eq!(calc(r#"Left("hello", 2)"#), "he"); assert_eq!(calc(r#"Right("hello", 2)"#), "lo"); @@ -1050,6 +1061,11 @@ mod formcalc { assert_eq!(calc(r#"WordNum(42)"#), "Forty-Two"); assert_eq!(calc(r#"WordNum(1.999, 2)"#), "Two Dollars And 00 Cents"); assert_eq!(calc(r#"WordNum(1.01, 2)"#), "One Dollar And 01 Cent"); + assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); + assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_format_and_date_builtins() { assert_eq!(calc(r#"Format("zzz,zz9.99", 1234.5)"#), "1,234.50"); assert_eq!(calc(r#"Format("$z,zz9.99", -5)"#), "-$5.00"); assert_eq!(calc(r#"Format("MM/DD/YYYY", "2024-02-29")"#), "02/29/2024"); @@ -1065,6 +1081,9 @@ mod formcalc { assert_eq!(calc(r#"IsoTime2Num("12:30:45.250")"#), "45045250"); assert_eq!(calc(r#"IsoTime2Num("T12:30:45")"#), "45045000"); assert_eq!(calc(r#"Num2Time(3661000)"#), "01:01:01"); + } + + fn assert_financial_and_unit_builtins() { assert_eq!(calc("Round(Pmt(10000, 0.01, 12), 2)"), "888.49"); assert_eq!(calc("Round(FV(100, 0.01, 12), 2)"), "1268.25"); assert_eq!(calc("Round(PV(100, 0.01, 12), 2)"), "1125.51"); @@ -1073,8 +1092,9 @@ mod formcalc { assert_eq!(calc(r#"UnitValue("72points", "in")"#), "1"); assert_eq!(calc(r#"UnitValue("25.4millimeters", "inches")"#), "1"); assert_eq!(calc(r#"UnitValue("1in", "points")"#), "72"); - assert_eq!(calc(r#"Encode("a b&c", "url")"#), "a%20b%26c"); - assert_eq!(calc(r#"Decode("a%20b", "url")"#), "a b"); + } + + fn assert_builtin_errors() { assert!(go(r#"Get("http://example.com")"#, "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("network")); assert!(go("Nope(1)", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown function")); } @@ -1147,6 +1167,13 @@ s"# #[test] fn form_objects_resolve_assign_and_record_effects() { + assert_formcalc_reads_fields_and_rows(); + assert_formcalc_reads_runtime_properties(); + assert_formcalc_records_primary_effects(); + assert_formcalc_updates_instances_and_records_requests(); + } + + fn assert_formcalc_reads_fields_and_rows() { // Fields deref to their values; `$` is the current field. assert_eq!(calc("qty * price"), "10"); assert_eq!(calc("$.name"), "total"); @@ -1170,7 +1197,6 @@ s"# assert_eq!(in_row("Sum(row[*].amount)"), "15"); assert_eq!(in_row("row[1].amount"), "5"); assert_eq!(in_row("row[-1].amount"), "10"); - // Past the ends there is no object, so a property of it is an error (as in Acrobat). let past = go("row[+1].amount", "form1[0].page1[0].table[0].row[1].amount[0]", "calculate"); assert!(past.error.unwrap().contains("no object")); assert_eq!(in_row("Exists(row[-5])"), "0"); @@ -1182,12 +1208,18 @@ s"# assert_eq!(calc("table._row.count"), "2"); assert_eq!(calc("Exists(qty) & not Exists(nothing)"), "1"); assert_eq!(calc("HasValue(total)"), "0"); + } + + fn assert_formcalc_reads_runtime_properties() { assert_eq!(calc("$host.numPages"), "3"); assert_eq!(calc("$layout.pageCount()"), "3"); assert_eq!(calc("$event.name"), "calculate"); assert_eq!(calc("xfa.resolveNode(\"page1.price\").rawValue"), "2.5"); assert_eq!(calc("$.border.fill.color.value"), ""); assert!(go("nothing + 1", "form1[0].page1[0].total[0]", "calculate").error.unwrap().contains("unknown name")); + } + + fn assert_formcalc_records_primary_effects() { // Assignment to a field (by name and by path), a property and a variable. let o = go( r#"page1.qty = 6 @@ -1215,7 +1247,9 @@ table._row.count"#, XfaEffect::AddInstance { som: "form1[0].page1[0].table[0].row[2]".into() }, ] ); - // Instance managers: remove, setInstances, count assignment, and the floor of one. + } + + fn assert_formcalc_updates_instances_and_records_requests() { let o = go( "table._row.removeInstance(0)\ntable._row.setInstances(1)\ntable._row.count = 0\ntable._row.count", "form1[0].page1[0].go[0]", @@ -1238,7 +1272,7 @@ $host.gotoURL("https://example.org")"#, ); assert_eq!( o.effects, - vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into())] + vec![XfaEffect::ResetData(vec!["qty".into(), "price".into()]), XfaEffect::Print, XfaEffect::LaunchUrl("https://example.org".into()),] ); } @@ -1279,58 +1313,79 @@ $host.gotoURL("https://example.org")"#, #[test] fn hostile_formcalc_scripts_end_with_an_error_and_bounded_output() { - let total = "form1[0].page1[0].total[0]"; - let fast = Limits { loop_iterations: 10_000, recursion: 32 }; - let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; - let doc = XfaDoc::default(); - let run = |s: &str| run_formcalc(s, &ev, &doc, &form(), fast); - assert!(run("while (1) do endwhile").error.unwrap().contains("looped")); - assert!(run("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); - assert!(run("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); - assert!(run(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); + assert_hostile_control_flow_is_bounded(); + assert_hostile_numeric_inputs_are_safe(); + assert_hostile_outputs_are_bounded(); + assert_hostile_nesting_is_bounded(); + assert_hostile_deadlines_and_parse_errors(); + assert_hostile_instance_growth_is_bounded(); + assert_hostile_message_count_is_bounded(); + assert_hostile_small_stack_is_safe(); + } + + fn assert_hostile_control_flow_is_bounded() { + assert!(run_hostile_formcalc("while (1) do endwhile").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("for i = 1 upto 1000000 do endfor").error.unwrap().contains("looped")); + assert!(run_hostile_formcalc("func f(n) do f(n + 1) endfunc\nf(1)").error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&format!("{}1{}", "(".repeat(500), ")".repeat(500))).error.is_some()); // A 120k-term chain is refused at parse time (its tree would be as deep as it is long). - assert!(run(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); - assert!(run("nothing.x = 1").error.unwrap().contains("unknown name")); - assert!(run("$form.nothing = 1").error.unwrap().contains("no nothing")); + assert!(run_hostile_formcalc(&format!("1{}", "+1".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc(&format!("$form{}", ".a".repeat(120_000))).error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("nothing.x = 1").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("$form.nothing = 1").error.unwrap().contains("no nothing")); // Values go into fields only: a subform or the form itself is refused, nothing is recorded. - let o = run("details = \"junk\""); + let o = run_hostile_formcalc("details = \"junk\""); assert!(o.error.unwrap().contains("not a field")); assert!(o.effects.is_empty()); - assert!(run("$form = \"junk\"").error.unwrap().contains("not a field")); + assert!(run_hostile_formcalc("$form = \"junk\"").error.unwrap().contains("not a field")); + } + + fn assert_hostile_numeric_inputs_are_safe() { // Hostile numbers into the string functions. - assert_eq!(run(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); - assert_eq!(run(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); - assert_eq!(run(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); - assert_eq!(run(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); - assert!(run(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Round(1e300, 1e300)"#).error.is_none()); - assert!(run(r#"Choose(1e300, 1)"#).error.is_none()); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", 2, 1e300, "X")"#).result.as_deref(), Some("aX")); + assert_eq!(run_hostile_formcalc(r#"Stuff("abc", -1e300, 1, "X")"#).result.as_deref(), Some("Xbc")); + assert_eq!(run_hostile_formcalc(r#"Substr("abc", 1e300, 1e300)"#).result.as_deref(), Some("")); + assert_eq!(run_hostile_formcalc(r#"Left("abc", -1e300)"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc(r#"Str(1e300, 1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Round(1e300, 1e300)"#).error.is_none()); + assert!(run_hostile_formcalc(r#"Choose(1e300, 1)"#).error.is_none()); + } + + fn assert_hostile_outputs_are_bounded() { // Strings can't grow past the cap through any function, and messages and values are clipped. assert!( - run("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") + run_hostile_formcalc("var s = Space(1000000)\nfor i = 1 upto 10 do s = Encode(Replace(s, \" \", \"&\"), \"html\") endfor\nLen(s)") .error .unwrap() .contains("too long") ); - assert!(run("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); - let o = run("$host.messageBox(Space(100000))"); + assert!(run_hostile_formcalc("Upper(Concat(Space(600000), Space(600000)))").error.unwrap().contains("too long")); + let o = run_hostile_formcalc("$host.messageBox(Space(100000))"); assert!(matches!(o.effects.as_slice(), [XfaEffect::MessageBox(m)] if m.chars().count() <= 4100), "{:?}", o.effects.len()); - let o = run("qty = Space(100000)"); + let o = run_hostile_formcalc("qty = Space(100000)"); assert!(matches!(o.effects.as_slice(), [XfaEffect::SetValue { value, .. }] if value.chars().count() == 65_536), "{:?}", o.effects.len()); assert!(o.notes.iter().any(|n| n.contains("cut")), "{:?}", o.notes); + } + + fn assert_hostile_nesting_is_bounded() { // A comment of dashes is not deep nesting. - assert_eq!(run(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); + assert_eq!(run_hostile_formcalc(&format!("; {}\n1 + 1", "-".repeat(70))).result.as_deref(), Some("2")); // Parentheses don't reset the chain count: a tree is never deeper than the cap. let mut nested = "1+1+1+1+1+1+1+1+1+1".to_string(); for _ in 0..30 { nested = format!("({nested}{})", "+1".repeat(40)); } - assert!(run(&nested).error.unwrap().contains("too long")); - assert_eq!(run("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); + assert!(run_hostile_formcalc(&nested).error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc("(1+2)*(3+4) + (5+6)").result.as_deref(), Some("32")); // Nested statements inside recursion count against the depth limit. let deep = format!("func f(n) do\n{}return f(n - 1)\n{}endfunc\nf(1000)", "if (n > 0) then\n".repeat(20), "endif\n".repeat(20)); - assert!(run(&deep).error.unwrap().contains("nested")); + assert!(run_hostile_formcalc(&deep).error.unwrap().contains("nested")); + } + + fn assert_hostile_deadlines_and_parse_errors() { + let total = "form1[0].page1[0].total[0]"; + let ev = XfaEvent { activity: "calculate".into(), target: total.into(), ..Default::default() }; + let doc = XfaDoc::default(); // A deadline already passed stops the script at its first look at the clock. let o = crate::formcalc::run_formcalc_at( "for i = 1 upto 100000 do Eval(\"1\") endfor", @@ -1341,29 +1396,40 @@ $host.gotoURL("https://example.org")"#, Some(std::time::Duration::ZERO), ); assert!(o.error.unwrap().contains("too long")); - assert!(run(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); - assert!(run("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); - assert!(run("Space(1e12)").error.is_none()); - assert!(run("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); - assert_eq!(run(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); - assert_eq!(run(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); - assert_eq!(run(r#"Time2Num("99999999999999999999:0:0")"#).error, None); - assert_eq!(run(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); - assert!(run("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); - assert!(run("\"unterminated").error.unwrap().contains("unterminated")); - assert!(run("1 ) 2").error.is_some()); - assert!(run("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + assert!(run_hostile_formcalc(&format!("{}1{}", "-".repeat(5000), "")).error.is_some()); + assert!(run_hostile_formcalc("var s = \"x\"\nfor i = 1 upto 100 do s = Concat(s, s) endfor\nLen(s)").error.unwrap().contains("too long")); + assert!(run_hostile_formcalc("Space(1e12)").error.is_none()); + assert!(run_hostile_formcalc("Replace(Space(100000), \" \", Space(100000))").error.unwrap().contains("too long")); + assert_eq!(run_hostile_formcalc(r#"Format("YYYY-MM-DD", 1e300)"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Date2Num("99999999999999999999-01-01", "YYYYYYYYYYYYYYYYYYYY-MM-DD")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Time2Num("99999999999999999999:0:0")"#).error, None); + assert_eq!(run_hostile_formcalc(r#"Num2Date(1e300, "YYYY")"#).result.as_deref(), Some("")); + assert!(run_hostile_formcalc("for i = 1 upto 1000 do nothing endfor").error.unwrap().contains("unknown name")); + assert!(run_hostile_formcalc("\"unterminated").error.unwrap().contains("unterminated")); + assert!(run_hostile_formcalc("1 ) 2").error.is_some()); + assert!(run_hostile_formcalc("Eval(\"Eval(\"\"Eval(\"\"\"\"1\"\"\"\")\"\")\")").error.is_none()); + } + + fn assert_hostile_instance_growth_is_bounded() { // Adding rows forever stops at the instance cap with the effects it managed. - let o = run("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 100000 do table._row.addInstance(1) endfor\ntable._row.count"); assert!(o.error.unwrap().contains("looped")); assert!(o.effects.iter().filter(|e| matches!(e, XfaEffect::AddInstance { .. })).count() <= 1000, "{}", o.effects.len()); - let o = run("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); + let o = run_hostile_formcalc("for i = 1 upto 5000 do table._row.addInstance(1) endfor\ntable._row.count"); assert_eq!(o.error, None); assert_eq!(o.result.as_deref(), Some("1002")); + } + + fn assert_hostile_message_count_is_bounded() { // Message boxes past the cap are dropped and noted. - let o = run("for i = 1 upto 500 do $host.messageBox(i) endfor"); + let o = run_hostile_formcalc("for i = 1 upto 500 do $host.messageBox(i) endfor"); assert_eq!(o.effects.iter().filter(|e| matches!(e, XfaEffect::MessageBox(_))).count(), 100); assert!(!o.notes.is_empty()); + } + + fn assert_hostile_small_stack_is_safe() { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); // On a small stack (the wasm build's), the limits stop the script before the stack does. let small = std::thread::Builder::new() .stack_size(1 << 20) @@ -1400,6 +1466,13 @@ $host.gotoURL("https://example.org")"#, ); assert!(o.abandoned || o.error.is_some(), "{o:?}"); } + + fn run_hostile_formcalc(script: &str) -> XfaOutcome { + let ev = XfaEvent { activity: "calculate".into(), target: "form1[0].page1[0].total[0]".into(), ..Default::default() }; + let doc = XfaDoc::default(); + let fast = Limits { loop_iterations: 10_000, recursion: 32 }; + run_formcalc(script, &ev, &doc, &form(), fast) + } } mod session { diff --git a/crates/js/src/xfa/globals.rs b/crates/js/src/xfa/globals.rs index 250794165..54508e6ca 100644 --- a/crates/js/src/xfa/globals.rs +++ b/crates/js/src/xfa/globals.rs @@ -2,7 +2,10 @@ use super::host::host; use super::instances::instance_manager; use super::model::{MAX_DEPTH, XfaDoc, XfaEffect, XfaEvent, clip, reset_names}; use super::nodes::{node_index, node_object, noop, resolve_som, resolver, sink}; -use super::*; +use super::{ + Attribute, Context, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, js_string, platform, printd, printf, printx, s, scand, + text, +}; // ── xfa.host, xfa.layout, xfa.event, app, console ─────────────────────────────────────────── diff --git a/crates/js/src/xfa/host.rs b/crates/js/src/xfa/host.rs index 419d2141f..ea2d4d433 100644 --- a/crates/js/src/xfa/host.rs +++ b/crates/js/src/xfa/host.rs @@ -2,7 +2,7 @@ use super::model::{ MAX_ALERTS, MAX_CONSOLE, MAX_DEPTH, MAX_EFFECTS, MAX_NODES, MAX_PAYLOAD, MAX_VALUE, XfaDoc, XfaEffect, XfaKind, XfaNode, clip, clip_value, effect_key, payload, }; -use super::*; +use super::{Context, HashMap, JsResult, RefCell, error}; /// One node of the flattened form, shared by the JavaScript and FormCalc engines. pub(crate) struct HNode { diff --git a/crates/js/src/xfa/instances.rs b/crates/js/src/xfa/instances.rs index b03e6aef0..c099c5abe 100644 --- a/crates/js/src/xfa/instances.rs +++ b/crates/js/src/xfa/instances.rs @@ -1,7 +1,7 @@ use super::host::{HNode, XHost, host}; use super::model::{MAX_ADDED, MAX_DEPTH, MAX_NODES, XfaEffect}; use super::nodes::{instances, node_object}; -use super::*; +use super::{Attribute, Context, JsArray, JsObject, JsResult, JsValue, NativeFunction, ObjectInitializer, arg, function, js_string, s, text}; pub(super) fn list_object(ctx: &mut Context, items: &[usize]) -> JsResult { let vals: Vec = items.iter().map(|i| JsValue::from(*i as f64)).collect(); diff --git a/crates/js/src/xfa/model.rs b/crates/js/src/xfa/model.rs index b9c6cedaa..83b2d7d14 100644 --- a/crates/js/src/xfa/model.rs +++ b/crates/js/src/xfa/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashSet; /// What kind of template object a node is. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/js/src/xfa/nodes.rs b/crates/js/src/xfa/nodes.rs index b9483f25d..75943352e 100644 --- a/crates/js/src/xfa/nodes.rs +++ b/crates/js/src/xfa/nodes.rs @@ -2,7 +2,10 @@ use super::globals::{recalculate, relayout}; use super::host::{HNode, XHost, host}; use super::instances::{instance_manager, list_object}; use super::model::{MAX_DEPTH, XfaEffect}; -use super::*; +use super::{ + Attribute, Context, JsObject, JsProxy, JsResult, JsString, JsValue, NativeFunction, ObjectInitializer, arg, as_number, error, function, + js_string, s, text, +}; // ── node objects ──────────────────────────────────────────────────────────────────────────── diff --git a/crates/js/src/xfa/run.rs b/crates/js/src/xfa/run.rs index 2db77d706..f951e02a5 100644 --- a/crates/js/src/xfa/run.rs +++ b/crates/js/src/xfa/run.rs @@ -1,7 +1,7 @@ use super::globals::install; use super::host::{Shared, XHost}; use super::model::{XfaDoc, XfaEvent, XfaNode, XfaOutcome, clip_result}; -use super::*; +use super::{Attribute, Context, JsResult, Limits, RefCell, Source, js_string, refuse, s, text}; /// Run `script` for `event` on the form `root` (the root subform), from node `event.target`. pub fn run_xfa(script: &str, event: &XfaEvent, doc: &XfaDoc, root: &XfaNode, limits: Limits) -> XfaOutcome { From f40a57878b993cea1bb5574c627af1bed921d225 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 09/33] Refactor organize for strict Clippy checks --- crates/organize/src/import/carry.rs | 2 +- crates/organize/src/import/combine.rs | 2 +- crates/organize/src/import/copy.rs | 2 +- crates/organize/src/import/fields.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/organize/src/import/carry.rs b/crates/organize/src/import/carry.rs index 21c21b9be..15a412e23 100644 --- a/crates/organize/src/import/carry.rs +++ b/crates/organize/src/import/carry.rs @@ -1,5 +1,5 @@ use super::copy::Copier; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, name_tree_entries}; /// Copy a source's document-level attachments (`/Names /EmbeddedFiles`) into `dst`. pub(super) fn collect_attachments(dst: &mut Document, src: &Document, out: &mut Vec<(Vec, Object)>) -> Result<(), OrganizeError> { diff --git a/crates/organize/src/import/combine.rs b/crates/organize/src/import/combine.rs index 09a1b0725..06972ae10 100644 --- a/crates/organize/src/import/combine.rs +++ b/crates/organize/src/import/combine.rs @@ -1,5 +1,5 @@ use super::carry::{Mark, add_outline, collect_attachments, kept_bookmarks, set_attachments, source_outline, write_outline_level}; -use super::*; +use super::{Dict, Document, FieldConflicts, HashMap, ObjRef, Object, OrganizeError, import_pages_mapped, rebuild}; /// A new document containing copies of `pages` from `src` (Extract Pages / Split). /// Document information (title, author…) is carried over, and so is the print standard the diff --git a/crates/organize/src/import/copy.rs b/crates/organize/src/import/copy.rs index f78411806..f4b549962 100644 --- a/crates/organize/src/import/copy.rs +++ b/crates/organize/src/import/copy.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashMap, ObjRef, Object, OrganizeError, named_dest}; /// Keys that must not be followed when copying: they point back up or across the source /// document, and following them would drag in unrelated objects. diff --git a/crates/organize/src/import/fields.rs b/crates/organize/src/import/fields.rs index 98f69c69e..44910d040 100644 --- a/crates/organize/src/import/fields.rs +++ b/crates/organize/src/import/fields.rs @@ -1,5 +1,5 @@ use super::copy::WIDGET_KEYS; -use super::*; +use super::{Dict, Document, HashMap, HashSet, ObjRef, Object, OrganizeError, walk}; /// Rename the top-level fields about to be registered in `dst` whose fully-qualified names /// (`parent.T.child`) are already taken, so each imported field stays its own field instead of From 530e2a26450541654b1e0a951e01e50eac180c6f Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 10/33] Refactor pdfcraft-cli for strict Clippy checks --- apps/pdfcraft-cli/src/check.rs | 4 +++- apps/pdfcraft-cli/src/commands.rs | 2 +- apps/pdfcraft-cli/src/options.rs | 2 +- apps/pdfcraft-cli/src/run.rs | 4 +++- apps/pdfcraft-cli/src/ui.rs | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/apps/pdfcraft-cli/src/check.rs b/apps/pdfcraft-cli/src/check.rs index 1821f8582..62993ad6d 100644 --- a/apps/pdfcraft-cli/src/check.rs +++ b/apps/pdfcraft-cli/src/check.rs @@ -1,6 +1,8 @@ use super::commands::{parse_dpi, read}; use super::options::{AUDIT_OPTIONS, CHECK_ONE_OPTIONS, CHECK_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{ + Arc, CliError, Command, Duration, Instant, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Stdio, inspect, stdout_line, +}; /// Audit a document for faux redactions. Prints the findings as JSON; fails when any are /// found so scripts and CI can gate on it, and when the audit itself cannot run. diff --git a/apps/pdfcraft-cli/src/commands.rs b/apps/pdfcraft-cli/src/commands.rs index 5efec8331..9d0b2057f 100644 --- a/apps/pdfcraft-cli/src/commands.rs +++ b/apps/pdfcraft-cli/src/commands.rs @@ -2,7 +2,7 @@ use super::options::{ COMBINE_OPTIONS, EXTRACT_OPTIONS, INFO_OPTIONS, RENDER_OPTIONS, SPLIT_OPTIONS, TEXT_OPTIONS, can_be_path_value, flag, needs_value, positional, validate_options, }; -use super::*; +use super::{Arc, CliError, ImageFormat, PageRenderer, Path, PathBuf, RenderConfig, RenderRequest, RequestKind, Write, inspect, stdout_line}; pub(crate) fn read(path: &str) -> Result>, String> { std::fs::read(path).map(Arc::new).map_err(|e| format!("{path}: {e}")) diff --git a/apps/pdfcraft-cli/src/options.rs b/apps/pdfcraft-cli/src/options.rs index ab1a3db34..1ddbfabcd 100644 --- a/apps/pdfcraft-cli/src/options.rs +++ b/apps/pdfcraft-cli/src/options.rs @@ -1,4 +1,4 @@ -use super::*; +use super::CliError; pub(crate) fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) diff --git a/apps/pdfcraft-cli/src/run.rs b/apps/pdfcraft-cli/src/run.rs index 19eed2187..0726087d9 100644 --- a/apps/pdfcraft-cli/src/run.rs +++ b/apps/pdfcraft-cli/src/run.rs @@ -1,7 +1,9 @@ #[cfg(feature = "mcp")] use super::options::MCP_OPTIONS; use super::options::{RUN_OPTIONS, flag, positional, validate_options}; -use super::*; +#[cfg(feature = "mcp")] +use super::{Arc, Path, Write, automation_args}; +use super::{CliError, stdout_line}; // ---- automation -------------------------------------------------------------------------------- diff --git a/apps/pdfcraft-cli/src/ui.rs b/apps/pdfcraft-cli/src/ui.rs index 9d1223352..dc419171b 100644 --- a/apps/pdfcraft-cli/src/ui.rs +++ b/apps/pdfcraft-cli/src/ui.rs @@ -1,5 +1,5 @@ use super::options::{UI_OPTIONS, flag, positional, validate_options}; -use super::*; +use super::{CliError, Duration, stdout_line}; // ---- UI control channel client ----------------------------------------------------------------- From c043c82531526d976762fb0ca9e0e16ea5b6e05e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 11/33] Refactor preflight for strict Clippy checks --- crates/preflight/src/lib.rs | 234 ++++++++++++++++++++++-------------- 1 file changed, 147 insertions(+), 87 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 13ca8997e..3fc642480 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -514,99 +514,159 @@ pub fn verify(doc: &Document, level: Level) -> Vec { issue("6.5.2", "Page {} has additional actions", Some((i + 1).to_string()), Some(i), true); } } - for (r, o) in &objs { - let Some(d) = dict_of(o) else { continue }; - let ty = d.name(b"Type"); - let sub = d.name(b"Subtype"); - // Actions anywhere (structure elements also have /S: their role), with the actions they - // chain to and those written directly in an /A or /OpenAction. - let own = (d.get(b"S").is_some() && is_action(d)).then_some(d); - let direct = [b"A".as_slice(), b"OpenAction"].map(|k| d.get(k).and_then(|a| a.as_dict())); - for a in [own].into_iter().chain(direct).flatten() { - if let Some(s) = chain_forbidden(doc, a) { - issue("6.5.1", "A {} action isn't allowed", Some(s), None, true); - } - } - // Annotations. - if ty == Some(b"Annot") || (sub.is_some() && d.get(b"Rect").is_some() && d.get(b"P").is_some()) { - let page = page_of(*r); - let name = sub.map(|s| String::from_utf8_lossy(s).into_owned()).unwrap_or_default(); - if sub.is_some_and(|s| FORBIDDEN_ANNOTS.contains(&s)) { - issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); - continue; - } - let f = d.int(b"F").unwrap_or(0); - if sub != Some(b"Popup") && (f & 4 == 0 || f & (1 | 2 | 32) != 0) { - issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); - } - let has_ap = d.get(b"AP").map(|a| doc.resolve(a)).and_then(|a| a.as_dict().cloned()).is_some_and(|a| a.get(b"N").is_some()); - if !has_ap && !matches!(sub, Some(b"Popup") | Some(b"Link")) { - let zero = d.get(b"Rect").map(|x| doc.resolve(x)).and_then(|x| x.as_array().cloned()).is_some_and(|a| { - let v: Vec = a.iter().filter_map(|n| doc.resolve(n).as_f64()).collect(); - v.len() == 4 && (v[2] - v[0]).abs() < 1e-6 && (v[3] - v[1]).abs() < 1e-6 - }); - if !zero { - issue("6.3.3", "A {} annotation has no appearance stream", Some(name.clone()), page, false); - } - } - if d.get(b"AA").is_some() { - issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); - } - } - // Form fields with additional actions (non-widget fields). - if d.get(b"FT").is_some() && d.get(b"AA").is_some() && ty != Some(b"Annot") { - issue("6.5.2", "A form field has additional actions", None, None, true); - } - // Fonts. - if ty == Some(b"Font") || d.get(b"BaseFont").is_some() && sub.is_some() { - check_font(doc, d, &mut issue); - } - // Streams. - if let Object::Stream(s) = &**o { - let filters: Vec> = match s.dict.get(b"Filter").map(|f| doc.resolve(f)) { - Some(f) => match &*f { - Object::Name(n) => vec![n.clone()], - Object::Array(a) => a.iter().filter_map(|x| x.as_name().map(<[u8]>::to_vec)).collect(), - _ => Vec::new(), - }, - None => Vec::new(), - }; - if filters.iter().any(|f| f == b"LZWDecode") { - issue("6.1.7.2", "A stream uses LZW compression", None, None, false); - } - if s.dict.get(b"F").is_some() || s.dict.get(b"FFilter").is_some() { - issue("6.1.7.1", "A stream refers to an external file", None, None, false); - } - if sub == Some(b"Image") { - if matches!(s.dict.get(b"Interpolate"), Some(Object::Bool(true))) { - issue("6.2.8", "An image asks to be interpolated", None, None, true); - } - if s.dict.get(b"Alternates").is_some() || s.dict.get(b"OPI").is_some() { - issue("6.2.8", "An image has alternates or OPI information", None, None, true); - } - } - if sub == Some(b"PS") { - issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); - } - } - // Embedded files. - if ty == Some(b"Filespec") && d.get(b"EF").is_some() { - match level { - Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), - Level::A3b => { - if d.get(b"AFRelationship").is_none() { - issue("6.8", "An embedded file has no AFRelationship", None, None, true); - } - } - } - } - } + check_objects(doc, level, &objs, &page_of, &mut issue); // One message per kind of problem is enough for repeated ones (as preflight summaries do). let mut seen = std::collections::HashSet::new(); out.retain(|i| seen.insert((i.clause, i.message.clone(), i.page))); out } +fn check_objects( + doc: &Document, + level: Level, + objects: &[(ObjRef, std::sync::Arc)], + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + for (r, o) in objects { + let Some(d) = dict_of(o) else { continue }; + check_one_object(doc, level, *r, d, o, page_of, issue); + } +} + +fn check_one_object( + doc: &Document, + level: Level, + reference: ObjRef, + dict: &Dict, + object: &Object, + page_of: &impl Fn(ObjRef) -> Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let ty = dict.name(b"Type"); + let subtype = dict.name(b"Subtype"); + check_actions(doc, dict, issue); + if is_annotation(dict, ty, subtype) { + check_annotation(doc, dict, subtype, page_of(reference), issue); + } + if dict.get(b"FT").is_some() && dict.get(b"AA").is_some() && ty != Some(b"Annot") { + issue("6.5.2", "A form field has additional actions", None, None, true); + } + if ty == Some(b"Font") || dict.get(b"BaseFont").is_some() && subtype.is_some() { + check_font(doc, dict, issue); + } + if let Object::Stream(stream) = object { + check_stream(doc, stream, subtype, issue); + } + if ty == Some(b"Filespec") && dict.get(b"EF").is_some() { + check_embedded_file(level, dict, issue); + } +} + +fn check_actions(doc: &Document, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + let own = (dict.get(b"S").is_some() && is_action(dict)).then_some(dict); + let direct = [b"A".as_slice(), b"OpenAction"].map(|key| dict.get(key).and_then(|action| action.as_dict())); + for action in [own].into_iter().chain(direct).flatten() { + if let Some(name) = chain_forbidden(doc, action) { + issue("6.5.1", "A {} action isn't allowed", Some(name), None, true); + } + } +} + +fn is_annotation(dict: &Dict, ty: Option<&[u8]>, subtype: Option<&[u8]>) -> bool { + ty == Some(b"Annot") || (subtype.is_some() && dict.get(b"Rect").is_some() && dict.get(b"P").is_some()) +} + +fn check_annotation( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let name = subtype.map(|bytes| String::from_utf8_lossy(bytes).into_owned()).unwrap_or_default(); + if subtype.is_some_and(|value| FORBIDDEN_ANNOTS.contains(&value)) { + issue("6.3.1", "{} annotations aren't allowed", Some(name), page, true); + return; + } + let flags = dict.int(b"F").unwrap_or(0); + if subtype != Some(b"Popup") && (flags & 4 == 0 || flags & (1 | 2 | 32) != 0) { + issue("6.3.2", "A {} annotation isn't set to print, or is hidden", Some(name.clone()), page, true); + } + check_annotation_appearance(doc, dict, subtype, &name, page, issue); + if dict.get(b"AA").is_some() { + issue("6.5.2", "A {} annotation has additional actions", Some(name), page, true); + } +} + +fn check_annotation_appearance( + doc: &Document, + dict: &Dict, + subtype: Option<&[u8]>, + name: &str, + page: Option, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let has_appearance = dict + .get(b"AP") + .map(|appearance| doc.resolve(appearance)) + .and_then(|appearance| appearance.as_dict().cloned()) + .is_some_and(|appearance| appearance.get(b"N").is_some()); + if has_appearance || matches!(subtype, Some(b"Popup") | Some(b"Link")) { + return; + } + let zero_area = dict.get(b"Rect").map(|rect| doc.resolve(rect)).and_then(|rect| rect.as_array().cloned()).is_some_and(|values| { + let values: Vec = values.iter().filter_map(|value| doc.resolve(value).as_f64()).collect(); + values.len() == 4 && (values[2] - values[0]).abs() < 1e-6 && (values[3] - values[1]).abs() < 1e-6 + }); + if !zero_area { + issue("6.3.3", "A {} annotation has no appearance stream", Some(name.to_owned()), page, false); + } +} + +fn check_stream( + doc: &Document, + stream: &pdfcraft_cos::Stream, + subtype: Option<&[u8]>, + issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool), +) { + let filters: Vec> = match stream.dict.get(b"Filter").map(|filter| doc.resolve(filter)) { + Some(filter) => match &*filter { + Object::Name(name) => vec![name.clone()], + Object::Array(filters) => filters.iter().filter_map(|filter| filter.as_name().map(<[u8]>::to_vec)).collect(), + _ => Vec::new(), + }, + None => Vec::new(), + }; + if filters.iter().any(|filter| filter == b"LZWDecode") { + issue("6.1.7.2", "A stream uses LZW compression", None, None, false); + } + if stream.dict.get(b"F").is_some() || stream.dict.get(b"FFilter").is_some() { + issue("6.1.7.1", "A stream refers to an external file", None, None, false); + } + if subtype == Some(b"Image") { + if matches!(stream.dict.get(b"Interpolate"), Some(Object::Bool(true))) { + issue("6.2.8", "An image asks to be interpolated", None, None, true); + } + if stream.dict.get(b"Alternates").is_some() || stream.dict.get(b"OPI").is_some() { + issue("6.2.8", "An image has alternates or OPI information", None, None, true); + } + } + if subtype == Some(b"PS") { + issue("6.2.9", "PostScript XObjects aren't allowed", None, None, false); + } +} + +fn check_embedded_file(level: Level, dict: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { + match level { + Level::A2b => issue("6.8", "Embedded files must be PDF/A files themselves (use PDF/A-3 for others)", None, None, false), + Level::A3b if dict.get(b"AFRelationship").is_none() => { + issue("6.8", "An embedded file has no AFRelationship", None, None, true); + } + Level::A3b => {} + } +} + fn check_font(doc: &Document, d: &Dict, issue: &mut impl FnMut(&'static str, &'static str, Option, Option, bool)) { let sub = d.name(b"Subtype"); if sub == Some(b"Type3") { From bc89733725c41f8373d28c1595370221362ea7ad Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 12/33] Refactor print for strict Clippy checks --- crates/print/src/imposition.rs | 2 +- crates/print/src/settings.rs | 2 +- crates/print/src/sheets.rs | 61 +++++++++++++++++++--------------- 3 files changed, 36 insertions(+), 29 deletions(-) diff --git a/crates/print/src/imposition.rs b/crates/print/src/imposition.rs index 1257bcf25..aa9a501f6 100644 --- a/crates/print/src/imposition.rs +++ b/crates/print/src/imposition.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::settings::{Content, Settings}; use super::sheets::{Sheet, layout}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, ObjRef, Object, SaveOptions, Stream, write_full}; // Model-derived transforms and clipping bounds need their full PDF decimal precision. fn geometry_number(value: f64) -> String { diff --git a/crates/print/src/settings.rs b/crates/print/src/settings.rs index 7c9554f25..0506eb5b1 100644 --- a/crates/print/src/settings.rs +++ b/crates/print/src/settings.rs @@ -1,4 +1,4 @@ -use super::*; +use super::spool; #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Orientation { diff --git a/crates/print/src/sheets.rs b/crates/print/src/sheets.rs index ce80adc3e..061a6ed5c 100644 --- a/crates/print/src/sheets.rs +++ b/crates/print/src/sheets.rs @@ -1,7 +1,7 @@ use super::error::PrintError; use super::imposition::decoded_contents; use super::settings::{Binding, BookletSubset, Layout, Orientation, PAPERS, PageOrder, Settings, SizeMode}; -use super::*; +use super::{DecodeBudget, Dict, Document, Matrix, Object}; /// Whether most of page `index`'s text runs bottom-to-top as the page displays (a label laid /// sideways on a letter or A4 sheet), counting each text-showing operator once; form XObjects @@ -162,32 +162,7 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } } Layout::Label { flip } => { - let size = oriented(paper, settings.orientation, false); - let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; - for &p in &pages { - let d = sizes[p]; - let crop = settings.crops.get(p).copied().flatten(); - let rect = crop - .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) - .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) - .unwrap_or([0.0, 0.0, d.0, d.1]); - let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); - let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); - let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { - (false, _) => 0, - (true, true) => 3, - (true, false) => 1, - }; - if flip { - quarter = (quarter + 2) % 4; - } - let s = if turn { - fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) - } else { - fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) - }; - sheets.push(Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() }); - } + sheets.extend(layout_labels(sizes, &pages, paper, settings.orientation, settings, flip)); } Layout::Multiple { cols, rows, per_sheet, order, border, auto_rotate } => { let cells = cols.checked_mul(rows).filter(|&n| n > 0 && n <= 256); @@ -348,3 +323,35 @@ pub fn layout(sizes: &[(f64, f64)], settings: &Settings) -> Result, P } Ok(sheets) } + +fn layout_labels(sizes: &[(f64, f64)], pages: &[usize], paper: (f64, f64), orientation: Orientation, settings: &Settings, flip: bool) -> Vec { + let size = oriented(paper, orientation, false); + let cell = [LABEL_MARGIN, LABEL_MARGIN, size.0 - LABEL_MARGIN, size.1 - LABEL_MARGIN]; + pages + .iter() + .map(|&p| { + let d = sizes[p]; + let crop = settings.crops.get(p).copied().flatten(); + let rect = crop + .map(|c| [c.rect[0].max(0.0), c.rect[1].max(0.0), c.rect[2].min(d.0), c.rect[3].min(d.1)]) + .filter(|r| r[2] - r[0] > 1.0 && r[3] - r[1] > 1.0) + .unwrap_or([0.0, 0.0, d.0, d.1]); + let (w, h) = (rect[2] - rect[0], rect[3] - rect[1]); + let turn = (w > h) != (cell[2] - cell[0] > cell[3] - cell[1]); + let mut quarter = match (turn, crop.is_some_and(|c| c.text_up)) { + (false, _) => 0, + (true, true) => 3, + (true, false) => 1, + }; + if flip { + quarter = (quarter + 2) % 4; + } + let s = if turn { + fit_scale((h, w), (cell[2] - cell[0], cell[3] - cell[1])) + } else { + fit_scale((w, h), (cell[2] - cell[0], cell[3] - cell[1])) + }; + Sheet { size, placed: vec![place_crop(p, rect, cell, s, quarter)], ..Sheet::default() } + }) + .collect() +} From 33e23da8a92b0d344bc93e2e6cef180520298b88 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 13/33] Refactor redact for strict Clippy checks --- crates/redact/src/interp.rs | 552 ++++++++++++++++------------- crates/redact/src/interp/layers.rs | 2 +- crates/redact/src/interp/prune.rs | 5 +- crates/redact/src/lib.rs | 398 +++++++++++---------- crates/redact/src/type3.rs | 64 ++-- 5 files changed, 568 insertions(+), 453 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 71b4684bc..3baec29c2 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -242,32 +242,21 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< /// [`process`] from graphics state `gs`: a form XObject starts with its caller's, text state /// included (ISO 32000-2 §8.10.1), so text it shows without setting a font of its own is placed /// with the caller's font, size and spacing, as the renderer places it. -fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, mut gs: Gs) -> Output { - let mut out = Output::default(); - let fallback = Rc::new(Metrics::fallback()); - let mut stack: Vec = Vec::new(); - let (mut tm, mut tlm) = (Matrix::IDENTITY, Matrix::IDENTITY); - let fonts_res = res_dict(doc, resources, b"Font"); - let ext_gstates = res_dict(doc, resources, b"ExtGState"); - let mut xobjects = res_dict(doc, resources, b"XObject"); - // The current path: its operators, its bounding box in user space, and whether it clips. - let mut path: Vec = Vec::new(); - let mut path_box: Option<[f64; 4]> = None; - let mut clip = false; - let mut forced_change = false; - +fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], resources: &Dict, gs: Gs) -> Output { + let mut walk = Walk::new(doc, resources, gs); // The streams are one content stream in pieces, which may be split between any two tokens // (ISO 32000-2 §7.8.2): an operator's operands can end one piece and the operator start the // next. Parse them joined, then give each operator back to the piece its keyword is in. let joined = Pieces::join(streams); let mut parsed = joined.parse(); - out.ambiguous = parsed.ambiguous_images > 0; + walk.out.ambiguous = parsed.ambiguous_images > 0; // A rewritten piece loses its comments, so keep where they began and ended: a comment that // ran on into the next piece still hides that piece's first line. let comment_edges = joined.comment_edges(&parsed); // Layer removal also changes enclosing replacement spans, so remember the operators before // any cleanup, not just the survivors of the layer pass. let original = parsed.ops.clone(); + let mut forced_change = false; if scope.layers_enabled { let props = res_dict(doc, resources, b"Properties"); let (kept, removed) = strip_hidden_layers(doc, scope, parsed.ops, &props); @@ -277,12 +266,32 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], forced_change = true; } } - let mut pieces: Vec> = streams.iter().map(|_| Vec::new()).collect(); - let mut rewrite: Vec = vec![forced_change; streams.len()]; - for op in parsed.ops { + let (pieces, rewrite) = split_into_pieces(&joined, parsed.ops, streams.len(), forced_change); + let mut processed = Vec::new(); + for (piece, changed) in pieces.into_iter().zip(rewrite) { + let mut done = Piece { ops: Vec::with_capacity(piece.len()), changed }; + for op in piece { + walk.step(doc, scope, resources, op, &mut done); + } + if !walk.path.is_empty() { + done.ops.append(&mut walk.path); + } + processed.push((done.ops, done.changed)); + } + if scope.mode == Mode::Apply { + clean_replacements(doc, resources, &original, &mut processed, scope.report); + } + walk.out.streams = processed.into_iter().zip(comment_edges).map(|((ops, changed), edges)| changed.then(|| piece_bytes(&ops, edges))).collect(); + walk.out +} + +/// Gives each operator to the piece its keyword is in. An operator split across pieces is written +/// whole into its keyword's piece, so every piece it spans is rewritten. +fn split_into_pieces(joined: &Pieces, ops: Vec, count: usize, forced_change: bool) -> (Vec>, Vec) { + let mut pieces: Vec> = (0..count).map(|_| Vec::new()).collect(); + let mut rewrite: Vec = vec![forced_change; count]; + for op in ops { let (first, last) = joined.pieces_of(&op); - // An operator split across pieces is written whole into its keyword's piece, so every - // piece it spans is rewritten. if first < last { for r in rewrite.iter_mut().take(last + 1).skip(first) { *r = true; @@ -292,228 +301,297 @@ fn process_from(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec], p.push(op); } } + (pieces, rewrite) +} - let mut processed = Vec::new(); - for (piece, mut changed) in pieces.into_iter().zip(rewrite) { - let mut ops: Vec = Vec::with_capacity(piece.len()); - for op in piece { - let o = op.op.as_slice(); - // Path construction. - if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { - let pts: Vec<(f64, f64)> = match o { - b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), - b"h" | b"W" | b"W*" => Vec::new(), - _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), - }; - for (x, y) in pts { - let (u, v) = gs.ctm.apply(x, y); - path_box = Some(match path_box { - None => [u, v, u, v], - Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], - }); - } - clip |= matches!(o, b"W" | b"W*"); - path.push(op); - continue; - } - if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { - let body = std::mem::take(&mut path); - let bbox = path_box.take(); - let clips = std::mem::replace(&mut clip, false); - let hit = scope.mode == Mode::Apply && o != b"n" && bbox.is_some_and(|b| scope.hits(b)); - if !hit { - ops.extend(body); - ops.push(op); - continue; - } - let b = bbox.unwrap_or_default(); - changed = true; - scope.report.paths_removed += 1; - if scope.covered(b) && clips { - // Keep the clipping, drop the painting. - ops.extend(body); - ops.push(Op::new("n", vec![])); - } - // Partial vector redaction must not retain underlying drawing commands - // behind clipping (F-008). Refuse partial clip retention and remove the path. - continue; - } - if !path.is_empty() { - // A path not ended by a painting operator: pass it through. - ops.append(&mut path); - path_box = None; - clip = false; - } - match o { - b"q" => stack.push(gs.clone()), - b"Q" => { - if let Some(g) = stack.pop() { - gs = g; - } - } - b"cm" => { - if let Some(m) = op.nums::<6>() { - gs.ctm = Matrix(m).then(&gs.ctm); - } - } - b"BT" => { - tm = Matrix::IDENTITY; - tlm = Matrix::IDENTITY; - } - b"Tf" => { - gs.size = op.num(1).unwrap_or(gs.size); - if let Some(name) = op.name(0) { - let font_dict = fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); - gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); - gs.font = match scope.fonts.get(name) { - Some(f) => f.clone(), - None => { - let m = font_dict.as_ref().map(|d| Rc::new(Metrics::from_dict(doc, d))).unwrap_or_else(|| fallback.clone()); - scope.fonts.insert(name.to_vec(), m.clone()); - m - } - }; - } - } - b"gs" => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &ext_gstates, n, &fallback)) { - (gs.font, gs.vertical, gs.size) = (font, vertical, size); - } - } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, - b"Td" | b"TD" => { - if let Some([x, y]) = op.nums::<2>() { - if o == b"TD" { - gs.leading = -y; - } - tlm = Matrix::translate(x, y).then(&tlm); - tm = tlm; - } - } - b"Tm" => { - if let Some(m) = op.nums::<6>() { - tlm = Matrix(m); - tm = tlm; - } - } - b"T*" => { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - b"Tj" | b"TJ" | b"'" | b"\"" => { - if o == b"\"" { - gs.word_spacing = op.num(0).unwrap_or(gs.word_spacing); - gs.char_spacing = op.num(1).unwrap_or(gs.char_spacing); - } - if matches!(o, b"'" | b"\"") { - tlm = Matrix::translate(0.0, -gs.leading).then(&tlm); - tm = tlm; - } - let items: Vec = match o { - // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still - // takes the one immediately before it, as the renderer and every other - // reader here do (#3569), so a redaction search matches what is drawn. - b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), - _ => op.operands.last().cloned().into_iter().collect(), - }; - // Vertical-writing text is laid out horizontally by `show`, so its glyphs are - // never matched against a region. Rather than ship a document whose vertical - // text was silently left under a mark, record it so apply can fail closed. - // (Full vertical-text removal is a future enhancement.) - if gs.vertical - && scope.mode == Mode::Apply - && scope.hidden_text.is_none() - && !scope.rects.is_empty() - && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) - { - out.vertical = true; - } - let (new_items, removed) = show(scope, &gs, &mut tm, &items); - if removed == 0 { - ops.push(op); - continue; - } - if scope.mode == Mode::Verify { - out.residue += removed; - ops.push(op); - continue; - } - changed = true; - scope.report.glyphs += removed; - match o { - b"\"" => { - ops.push(Op::new("Tw", vec![num(gs.word_spacing)])); - ops.push(Op::new("Tc", vec![num(gs.char_spacing)])); - ops.push(Op::new("T*", vec![])); - } - b"'" => ops.push(Op::new("T*", vec![])), - _ => {} - } - ops.push(Op::new("TJ", vec![Object::Array(new_items)])); - continue; - } - b"BI" => { - let b = gs.ctm.bbox([0.0, 0.0, 1.0, 1.0]); - if scope.hits(b) { - if scope.mode == Mode::Verify { - out.residue += 1; - } else { - scope.report.images_removed += 1; - changed = true; - continue; - } - } - } - b"sh" if scope.mode == Mode::Apply => { - // A shading fills the current clip: clip the regions out. - if let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) { - changed = true; - ops.push(Op::new("q", vec![])); - ops.extend(c); - ops.push(op); - ops.push(Op::new("Q", vec![])); - continue; - } - } - b"Do" => { - if let Some(new) = xobject(doc, scope, &op, &gs, resources, &mut xobjects, &mut out) { - changed = true; - if let Some(n) = new { - ops.push(n); - } - continue; - } - } - _ => {} - } - ops.push(op); +/// A rewritten piece's bytes. `(comment_in, comment_out)` say whether the piece before ended +/// inside a comment, and whether this one did. +fn piece_bytes(ops: &[Op], (comment_in, comment_out): (bool, bool)) -> Vec { + // The piece before still ends inside a comment: end it before the first operator. + let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; + bytes.extend(serialize_ops(ops)); + // The piece ended inside a comment that hid the start of the next one: keep it open. + if comment_out { + bytes.push(b'%'); + } + bytes +} + +/// The operators written for one piece of a scope's content, and whether they differ from the source. +struct Piece { + ops: Vec, + changed: bool, +} + +/// The interpreter state carried across the operators of a scope. +struct Walk { + gs: Gs, + stack: Vec, + tm: Matrix, + tlm: Matrix, + fonts_res: Dict, + ext_gstates: Dict, + xobjects: Dict, + fallback: Rc, + /// The current path: its operators, its bounding box in user space, and whether it clips. + path: Vec, + path_box: Option<[f64; 4]>, + clip: bool, + out: Output, +} + +impl Walk { + fn new(doc: &Document, resources: &Dict, gs: Gs) -> Self { + Self { + gs, + stack: Vec::new(), + tm: Matrix::IDENTITY, + tlm: Matrix::IDENTITY, + fonts_res: res_dict(doc, resources, b"Font"), + ext_gstates: res_dict(doc, resources, b"ExtGState"), + xobjects: res_dict(doc, resources, b"XObject"), + fallback: Rc::new(Metrics::fallback()), + path: Vec::new(), + path_box: None, + clip: false, + out: Output::default(), } - if !path.is_empty() { - ops.append(&mut path); + } + + /// Interprets one operator, writing what replaces it to `piece`. + fn step(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) { + let o = op.op.as_slice(); + if matches!(o, b"m" | b"l" | b"c" | b"v" | b"y" | b"h" | b"re" | b"W" | b"W*") { + self.construct_path(op); + return; } - processed.push((ops, changed)); - } - if scope.mode == Mode::Apply { - clean_replacements(doc, resources, &original, &mut processed, scope.report); - } - for ((ops, changed), (comment_in, comment_out)) in processed.into_iter().zip(comment_edges) { - out.streams.push(changed.then(|| { - // The piece before still ends inside a comment: end it before the first operator. - let mut bytes = if comment_in { b"\n".to_vec() } else { Vec::new() }; - bytes.extend(serialize_ops(&ops)); - // The piece ended inside a comment that hid the start of the next one: keep it open. - if comment_out { - bytes.push(b'%'); + if matches!(o, b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n") { + self.paint_path(scope, op, piece); + return; + } + if !self.path.is_empty() { + // A path not ended by a painting operator: pass it through. + piece.ops.append(&mut self.path); + self.path_box = None; + self.clip = false; + } + let kept = match o { + b"q" | b"Q" | b"cm" | b"Tf" | b"gs" => { + self.graphics_state(doc, scope, &op); + Some(op) } - bytes - })); + b"BT" | b"Tc" | b"Tw" | b"Tz" | b"TL" | b"Ts" | b"Tr" | b"Td" | b"TD" | b"Tm" | b"T*" => { + self.text_state(&op); + Some(op) + } + b"Tj" | b"TJ" | b"'" | b"\"" => self.show_text(scope, op, piece), + b"BI" => Self::inline_image(&self.gs, scope, &mut self.out, op, piece), + b"sh" if scope.mode == Mode::Apply => Self::shading(&self.gs, scope, op, piece), + b"Do" => self.draw_xobject(doc, scope, resources, op, piece), + _ => Some(op), + }; + piece.ops.extend(kept); + } + + /// Path construction: collects the operator and grows the path's bounding box. + fn construct_path(&mut self, op: Op) { + let o = op.op.as_slice(); + let pts: Vec<(f64, f64)> = match o { + b"re" => op.nums::<4>().map(|[x, y, w, h]| vec![(x, y), (x + w, y), (x, y + h), (x + w, y + h)]).unwrap_or_default(), + b"h" | b"W" | b"W*" => Vec::new(), + _ => op.operands.iter().filter_map(Object::as_f64).collect::>().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).collect(), + }; + for (x, y) in pts { + let (u, v) = self.gs.ctm.apply(x, y); + self.path_box = Some(match self.path_box { + None => [u, v, u, v], + Some(b) => [b[0].min(u), b[1].min(v), b[2].max(u), b[3].max(v)], + }); + } + self.clip |= matches!(o, b"W" | b"W*"); + self.path.push(op); + } + + /// A painting operator ends the path; one under a region is removed (or kept as a clip). + fn paint_path(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) { + let body = std::mem::take(&mut self.path); + let bbox = self.path_box.take(); + let clips = std::mem::replace(&mut self.clip, false); + let hit = scope.mode == Mode::Apply && op.op.as_slice() != b"n" && bbox.is_some_and(|b| scope.hits(b)); + if !hit { + piece.ops.extend(body); + piece.ops.push(op); + return; + } + let b = bbox.unwrap_or_default(); + piece.changed = true; + scope.report.paths_removed += 1; + if scope.covered(b) && clips { + // Keep the clipping, drop the painting. + piece.ops.extend(body); + piece.ops.push(Op::new("n", vec![])); + } + // Partial vector redaction must not retain underlying drawing commands + // behind clipping (F-008). Refuse partial clip retention and remove the path. + } + + fn graphics_state(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + match op.op.as_slice() { + b"q" => self.stack.push(self.gs.clone()), + b"Q" => { + if let Some(g) = self.stack.pop() { + self.gs = g; + } + } + b"cm" => { + if let Some(m) = op.nums::<6>() { + self.gs.ctm = Matrix(m).then(&self.gs.ctm); + } + } + b"Tf" => self.select_font(doc, scope, op), + _ => { + if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { + (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); + } + } + } + } + + fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { + self.gs.size = op.num(1).unwrap_or(self.gs.size); + let Some(name) = op.name(0) else { return }; + let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); + self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + self.gs.font = match scope.fonts.get(name) { + Some(f) => f.clone(), + None => { + let m = font_dict.as_ref().map_or_else(|| self.fallback.clone(), |d| Rc::new(Metrics::from_dict(doc, d))); + scope.fonts.insert(name.to_vec(), m.clone()); + m + } + }; + } + + /// Text state and text-positioning operators. + fn text_state(&mut self, op: &Op) { + let gs = &mut self.gs; + match op.op.as_slice() { + b"BT" => { + self.tm = Matrix::IDENTITY; + self.tlm = Matrix::IDENTITY; + } + b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), + b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), + b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = op.num(0).unwrap_or(0.0), + b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), + b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Td" | b"TD" => { + if let Some([x, y]) = op.nums::<2>() { + if op.is("TD") { + gs.leading = -y; + } + self.tlm = Matrix::translate(x, y).then(&self.tlm); + self.tm = self.tlm; + } + } + b"Tm" => { + if let Some(m) = op.nums::<6>() { + self.tlm = Matrix(m); + self.tm = self.tlm; + } + } + _ => { + self.tlm = Matrix::translate(0.0, -gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + } + } + + /// `Tj`, `TJ`, `'` and `"`: glyphs under a region are cut out (or counted, when verifying). + fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let o = op.op.as_slice(); + if o == b"\"" { + self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + } + if matches!(o, b"'" | b"\"") { + self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); + self.tm = self.tlm; + } + let items: Vec = match o { + // A surplus array (`[(A)] [(B)] TJ`) is malformed, but the operator still + // takes the one immediately before it, as the renderer and every other + // reader here do (#3569), so a redaction search matches what is drawn. + b"TJ" => op.operands.last().and_then(Object::as_array).cloned().unwrap_or_default(), + _ => op.operands.last().cloned().into_iter().collect(), + }; + // Vertical-writing text is laid out horizontally by `show`, so its glyphs are + // never matched against a region. Rather than ship a document whose vertical + // text was silently left under a mark, record it so apply can fail closed. + // (Full vertical-text removal is a future enhancement.) + if self.gs.vertical + && scope.mode == Mode::Apply + && scope.hidden_text.is_none() + && !scope.rects.is_empty() + && items.iter().any(|o| matches!(o, Object::String(s) if !s.bytes.is_empty())) + { + self.out.vertical = true; + } + let (new_items, removed) = show(scope, &self.gs, &mut self.tm, &items); + if removed == 0 { + return Some(op); + } + if scope.mode == Mode::Verify { + self.out.residue += removed; + return Some(op); + } + piece.changed = true; + scope.report.glyphs += removed; + match op.op.as_slice() { + b"\"" => { + piece.ops.push(Op::new("Tw", vec![num(self.gs.word_spacing)])); + piece.ops.push(Op::new("Tc", vec![num(self.gs.char_spacing)])); + piece.ops.push(Op::new("T*", vec![])); + } + b"'" => piece.ops.push(Op::new("T*", vec![])), + _ => {} + } + piece.ops.push(Op::new("TJ", vec![Object::Array(new_items)])); + None + } + + /// An inline image under a region is removed (or counted, when verifying). + fn inline_image(gs: &Gs, scope: &mut Scope<'_>, out: &mut Output, op: Op, piece: &mut Piece) -> Option { + if !scope.hits(gs.ctm.bbox([0.0, 0.0, 1.0, 1.0])) { + return Some(op); + } + if scope.mode == Mode::Verify { + out.residue += 1; + return Some(op); + } + scope.report.images_removed += 1; + piece.changed = true; + None + } + + /// A shading fills the current clip: the regions are clipped out. + fn shading(gs: &Gs, scope: &Scope<'_>, op: Op, piece: &mut Piece) -> Option { + let Some(c) = clip_out(scope.rects, &gs.ctm, [-1e6, -1e6, 1e6, 1e6]) else { return Some(op) }; + piece.changed = true; + piece.ops.push(Op::new("q", vec![])); + piece.ops.extend(c); + piece.ops.push(op); + piece.ops.push(Op::new("Q", vec![])); + None + } + + fn draw_xobject(&mut self, doc: &mut Document, scope: &mut Scope<'_>, resources: &Dict, op: Op, piece: &mut Piece) -> Option { + let Some(new) = xobject(doc, scope, &op, &self.gs, resources, &mut self.xobjects, &mut self.out) else { return Some(op) }; + piece.changed = true; + piece.ops.extend(new); + None } - out } fn clean_replacements(doc: &Document, resources: &Dict, original: &[Op], processed: &mut [(Vec, bool)], report: &mut Report) { diff --git a/crates/redact/src/interp/layers.rs b/crates/redact/src/interp/layers.rs index 32d7afdf1..88d1c4e67 100644 --- a/crates/redact/src/interp/layers.rs +++ b/crates/redact/src/interp/layers.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, HashSet, ObjRef, Object, Op, Scope}; impl<'a> Scope<'a> { /// Is this optional-content reference (an OCG or OCMD) hidden? diff --git a/crates/redact/src/interp/prune.rs b/crates/redact/src/interp/prune.rs index 2708a87a4..6ca5ea280 100644 --- a/crates/redact/src/interp/prune.rs +++ b/crates/redact/src/interp/prune.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num, + overlaps, page_streams, res_dict, +}; /// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`, /// so a full-rewrite save stops reaching the original image or form it named. A name that some diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6f69151d8..de9dafe7d 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -409,193 +409,24 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, _>>()?; report.pages = by_page.len(); localize_resources(doc, &by_page)?; - let mut doomed_fields: Vec = Vec::new(); - // The original image and form XObjects the rewrite drops or replaces, on any page. - let mut doomed_xobjects: Vec = Vec::new(); - let mut retired_properties = Vec::new(); let widget_owner: Vec<(ObjRef, ObjRef)> = pdfcraft_forms::fields(doc).into_iter().flat_map(|f| f.widgets.iter().map(|w| (w.obj, f.obj)).collect::>()).collect(); let listed_before = listed_annotations(doc); - let mut removed_everywhere: HashSet = HashSet::new(); - + let mut applying = Applying { + report, + doomed_fields: Vec::new(), + doomed_xobjects: Vec::new(), + retired_properties: Vec::new(), + widget_owner, + removed_everywhere: HashSet::new(), + }; for &(pi, page_marks) in &groups { let page = pdfcraft_model::pages(doc).swap_remove(pi); let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect(); - - // 1. Content. - let mut budget = pdfcraft_cos::DecodeBudget::page(); - let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; - let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); - let out = { - let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget); - process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) - }; - // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot - // tell whether those glyphs lie under a mark. Rather than report success and possibly - // leave the text in place, refuse the whole operation (the caller keeps the previous - // document). The error does not claim the text was under the mark. Full vertical-text - // removal is a future enhancement. - if let Some(error) = out.error { - return Err(error.into()); - } - if out.vertical { - return Err(RedactError::VerticalText(pi + 1)); - } - // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it - // can't read): where the page's operators resume after it is a guess, so neither removal - // nor the verify pass can be trusted on this page. - if out.ambiguous { - return Err(RedactError::Unreadable(pi + 1)); - } - doomed_xobjects.extend_from_slice(&out.doomed); - retired_properties.extend(out.retired_properties); - let mut new_list = list.clone(); - let mut changed = false; - for (i, new) in out.streams.into_iter().enumerate() { - let Some(bytes) = new else { continue }; - let mut dict = match &*doc.resolve(&list[i]) { - Object::Stream(s) => s.dict.clone(), - _ => Dict::new(), - }; - dict.remove(b"Length"); - // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs - // are redacted, keeping it would leave the redacted text in the file. The item stays - // as plain drawn content but is no longer editable. - dict.remove(b"PCAdded"); - // A new object: the original may be shared with other pages. - new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); - changed = true; - } - if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut res = resources.clone(); - if !out.xobjects.is_empty() || !out.replaced.is_empty() { - let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); - // A full save keeps whatever is still referenced: unlink the originals this page no - // longer draws so the removed image or form bytes cannot survive in the saved file. - let page_content: Vec> = new_list - .iter() - .map(|o| match &*doc.resolve(o) { - Object::Stream(s) => s.decoded().unwrap_or_default(), - _ => Vec::new(), - }) - .collect(); - interp::prune(&mut xo, &out.replaced, &page_content); - for (n, r) in &out.xobjects { - xo.set(n.clone(), Object::Ref(*r)); - } - res.set(b"XObject".to_vec(), Object::Dict(xo)); - } - let (_, cleaned_data) = page_streams( - doc, - &{ - let mut cleaned_page = page.dict.clone(); - cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); - cleaned_page - }, - pi, - )?; - retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; - doc.update_dict(page.obj, |d| { - d.set(b"Contents".to_vec(), Object::Array(new_list)); - d.set(b"Resources".to_vec(), Object::Dict(res)); - })?; - // Tags must not keep what the page no longer shows. - let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); - let (_, new_data) = page_streams(doc, &after, pi)?; - let (changed_ids, empty_ids) = - tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); - report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; - } - - for (old, new, changed, empty) in out.tag_changes { - report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; - } - - // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). - let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); - let mut removed: Vec = Vec::new(); - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - let obj = doc.get(r); - let Some(d) = obj.as_dict() else { continue }; - let subtype = d.name(b"Subtype").unwrap_or(b""); - if subtype == b"Redact" { - if page_marks.iter().any(|m| m.obj == r) { - removed.push(r); - } - continue; - } - if subtype == b"Popup" { - continue; - } - if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { - removed.push(r); - if subtype == b"Widget" { - if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) - && !doomed_fields.contains(field) - { - doomed_fields.push(*field); - } - } else { - report.annotations += 1; - } - } - } - // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to - // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints - // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at - // least one more annotation of this page or stops, so it ends. - let replies: Vec<(ObjRef, ObjRef)> = annots - .iter() - .filter_map(Object::as_ref) - .filter_map(|r| { - let obj = doc.get(r); - let d = obj.as_dict()?; - (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; - Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) - }) - .collect(); - loop { - let before = removed.len(); - for (r, parent) in &replies { - if !removed.contains(r) && removed.contains(parent) { - removed.push(*r); - report.annotations += 1; - } - } - if removed.len() == before { - break; - } - } - removed_everywhere.extend(removed.iter().copied()); - // Pop-ups dropped with their parent below count too: a reply on another page may answer one. - for a in &annots { - let Some(r) = a.as_ref() else { continue }; - if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { - removed_everywhere.insert(r); - } - } - let kept: Vec = annots - .into_iter() - .filter(|a| { - let Some(r) = a.as_ref() else { return true }; - if removed.contains(&r) { - return false; - } - // Pop-ups of removed annotations go too. - let obj = doc.get(r); - !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) - }) - .collect(); - doc.update_dict(page.obj, |d| { - if kept.is_empty() { - d.remove(b"Annots"); - } else { - d.set(b"Annots".to_vec(), Object::Array(kept)); - } - })?; + redact_page_content(doc, &mut applying, pi, &page, &rects)?; + remove_page_annotations(doc, &mut applying, &page, &rects, page_marks)?; } + let Applying { mut report, doomed_fields, doomed_xobjects, retired_properties, mut removed_everywhere, .. } = applying; // 2b. Replies and pop-ups of a removed annotation can be listed on another page than their // parent: their `/IRT` or `/Parent` would keep the removed comment reachable. @@ -702,6 +533,213 @@ fn apply_to(doc: &mut Document, pages: Option<&[usize]>) -> Result, + /// The original image and form XObjects the rewrite drops or replaces, on any page. + doomed_xobjects: Vec, + retired_properties: Vec, + widget_owner: Vec<(ObjRef, ObjRef)>, + removed_everywhere: HashSet, +} + +/// Step 1 for one page: rewrites its content so nothing readable stays under a mark, and cleans the tags it no longer shows. +fn redact_page_content( + doc: &mut Document, + applying: &mut Applying, + pi: usize, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], +) -> Result<(), RedactError> { + let Applying { report, doomed_xobjects, retired_properties, .. } = applying; + // 1. Content. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?; + let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default(); + let out = { + let mut scope = Scope::new(rects, Mode::Apply, report, budget); + process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY) + }; + // Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot + // tell whether those glyphs lie under a mark. Rather than report success and possibly + // leave the text in place, refuse the whole operation (the caller keeps the previous + // document). The error does not claim the text was under the mark. Full vertical-text + // removal is a future enhancement. + if let Some(error) = out.error { + return Err(error.into()); + } + if out.vertical { + return Err(RedactError::VerticalText(pi + 1)); + } + // An inline image the renderer reads no image for (no `EI` it accepts, or parameters it + // can't read): where the page's operators resume after it is a guess, so neither removal + // nor the verify pass can be trusted on this page. + if out.ambiguous { + return Err(RedactError::Unreadable(pi + 1)); + } + doomed_xobjects.extend_from_slice(&out.doomed); + retired_properties.extend(out.retired_properties); + let mut new_list = list.clone(); + let mut changed = false; + for (i, new) in out.streams.into_iter().enumerate() { + let Some(bytes) = new else { continue }; + let mut dict = match &*doc.resolve(&list[i]) { + Object::Stream(s) => s.dict.clone(), + _ => Dict::new(), + }; + dict.remove(b"Length"); + // Edit ▸ Add content keeps an added item's source text in `/PCAdded`; once its glyphs + // are redacted, keeping it would leave the redacted text in the file. The item stays + // as plain drawn content but is no longer editable. + dict.remove(b"PCAdded"); + // A new object: the original may be shared with other pages. + new_list[i] = Object::Ref(doc.add(Object::Stream(Stream::flate(dict, &bytes)))); + changed = true; + } + if changed || !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut res = resources.clone(); + if !out.xobjects.is_empty() || !out.replaced.is_empty() { + let mut xo = res.get(b"XObject").and_then(|x| doc.resolve(x).as_dict().cloned()).unwrap_or_default(); + // A full save keeps whatever is still referenced: unlink the originals this page no + // longer draws so the removed image or form bytes cannot survive in the saved file. + let page_content: Vec> = new_list + .iter() + .map(|o| match &*doc.resolve(o) { + Object::Stream(s) => s.decoded().unwrap_or_default(), + _ => Vec::new(), + }) + .collect(); + interp::prune(&mut xo, &out.replaced, &page_content); + for (n, r) in &out.xobjects { + xo.set(n.clone(), Object::Ref(*r)); + } + res.set(b"XObject".to_vec(), Object::Dict(xo)); + } + let (_, cleaned_data) = page_streams( + doc, + &{ + let mut cleaned_page = page.dict.clone(); + cleaned_page.set(b"Contents".to_vec(), Object::Array(new_list.clone())); + cleaned_page + }, + pi, + )?; + retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + doc.update_dict(page.obj, |d| { + d.set(b"Contents".to_vec(), Object::Array(new_list)); + d.set(b"Resources".to_vec(), Object::Dict(res)); + })?; + // Tags must not keep what the page no longer shows. + let after = doc.get(page.obj).as_dict().cloned().unwrap_or_default(); + let (_, new_data) = page_streams(doc, &after, pi)?; + let (changed_ids, empty_ids) = + tags::touched(doc, &resources, pdfcraft_content::Pieces::join(&data).data(), pdfcraft_content::Pieces::join(&new_data).data()); + report.tags += tags::clean(doc, page.obj, None, &changed_ids, &empty_ids)?; + } + + for (old, new, changed, empty) in out.tag_changes { + report.tags += tags::clean(doc, page.obj, Some((old, new)), &changed, &empty)?; + } + Ok(()) +} + +/// Step 2 for one page: the marks, and whatever lies under them (with their pop-ups). +fn remove_page_annotations( + doc: &mut Document, + applying: &mut Applying, + page: &pdfcraft_model::Page, + rects: &[[f64; 4]], + page_marks: &[&Mark], +) -> Result<(), RedactError> { + let Applying { report, doomed_fields, widget_owner, removed_everywhere, .. } = applying; + // 2. Annotations: the marks, and whatever lies under them (with their pop-ups). + let annots = annots_of(doc, &doc.get(page.obj).as_dict().cloned().unwrap_or_default()); + let mut removed: Vec = Vec::new(); + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + let obj = doc.get(r); + let Some(d) = obj.as_dict() else { continue }; + let subtype = d.name(b"Subtype").unwrap_or(b""); + if subtype == b"Redact" { + if page_marks.iter().any(|m| m.obj == r) { + removed.push(r); + } + continue; + } + if subtype == b"Popup" { + continue; + } + if rect_of(doc, d.get(b"Rect")).is_some_and(|b| rects.iter().any(|x| pdfcraft_content::overlaps(*x, b, 0.0))) { + removed.push(r); + if subtype == b"Widget" { + if let Some((_, field)) = widget_owner.iter().find(|(w, _)| *w == r) + && !doomed_fields.contains(field) + { + doomed_fields.push(*field); + } + } else { + report.annotations += 1; + } + } + } + // Replies (`/IRT`, ISO 32000-2 §12.5.6.2) to a removed annotation go too, and replies to + // those: a reply placed elsewhere would keep its parent — the comment and the QuadPoints + // of the redacted words — in the file through its `/IRT` (#2659). Each pass removes at + // least one more annotation of this page or stops, so it ends. + let replies: Vec<(ObjRef, ObjRef)> = annots + .iter() + .filter_map(Object::as_ref) + .filter_map(|r| { + let obj = doc.get(r); + let d = obj.as_dict()?; + (d.name(b"Subtype") != Some(b"Widget")).then_some(())?; + Some((r, d.get(b"IRT").and_then(Object::as_ref)?)) + }) + .collect(); + loop { + let before = removed.len(); + for (r, parent) in &replies { + if !removed.contains(r) && removed.contains(parent) { + removed.push(*r); + report.annotations += 1; + } + } + if removed.len() == before { + break; + } + } + removed_everywhere.extend(removed.iter().copied()); + // Pop-ups dropped with their parent below count too: a reply on another page may answer one. + for a in &annots { + let Some(r) = a.as_ref() else { continue }; + if doc.get(r).as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) { + removed_everywhere.insert(r); + } + } + let kept: Vec = annots + .into_iter() + .filter(|a| { + let Some(r) = a.as_ref() else { return true }; + if removed.contains(&r) { + return false; + } + // Pop-ups of removed annotations go too. + let obj = doc.get(r); + !obj.as_dict().and_then(|d| d.get(b"Parent")).and_then(Object::as_ref).is_some_and(|p| removed.contains(&p)) + }) + .collect(); + doc.update_dict(page.obj, |d| { + if kept.is_empty() { + d.remove(b"Annots"); + } else { + d.set(b"Annots".to_vec(), Object::Array(kept)); + } + })?; + Ok(()) +} + /// Remove redaction marks without applying them (`None` = all), with their replies and pop-ups as /// deleting a comment does: a reply left behind would keep its mark in the file through `/IRT`. /// Returns how many marks went. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab90..57971571a 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -178,42 +178,38 @@ fn collect_used_resources_in_stream(data: &[u8], used: &mut UsedInGlyphs) -> Res Ok(()) } -fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { - let mut changed = false; - - macro_rules! prune_category { - ($cat:expr, $used_set:expr) => { - if let Some(sub_obj) = font_res.get($cat) { - let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); - let names: Vec> = sub.iter().map(|(n, _)| n.to_vec()).collect(); - let mut cat_changed = false; - for n in names { - if !$used_set.contains(&n) { - sub.remove(&n); - cat_changed = true; - } - } - if cat_changed { - changed = true; - if sub.is_empty() { - font_res.remove($cat); - } else { - font_res.set($cat.to_vec(), Object::Dict(sub)); - } - } - } - }; +/// Removes the entries of `font_res`'s `category` sub-dictionary that `used` does not name, +/// dropping the sub-dictionary when nothing is left. Whether anything changed. +fn prune_category(doc: &Document, font_res: &mut Dict, category: &[u8], used: &HashSet>) -> bool { + let Some(sub_obj) = font_res.get(category) else { return false }; + let mut sub = doc.resolve(sub_obj).as_dict().cloned().unwrap_or_default(); + let unused: Vec> = sub.iter().map(|(n, _)| n.to_vec()).filter(|n| !used.contains(n)).collect(); + if unused.is_empty() { + return false; } + for name in &unused { + sub.remove(name); + } + if sub.is_empty() { + font_res.remove(category); + } else { + font_res.set(category.to_vec(), Object::Dict(sub)); + } + true +} - prune_category!(b"XObject", used.xobjects); - prune_category!(b"ColorSpace", used.colorspaces); - prune_category!(b"Pattern", used.patterns); - prune_category!(b"Shading", used.shadings); - prune_category!(b"ExtGState", used.extgstates); - prune_category!(b"Font", used.fonts); - prune_category!(b"Properties", used.properties); - - changed +fn prune_type3_font_resources(doc: &Document, font_res: &mut Dict, used: &UsedInGlyphs) -> bool { + let categories: [(&[u8], &HashSet>); 7] = [ + (b"XObject", &used.xobjects), + (b"ColorSpace", &used.colorspaces), + (b"Pattern", &used.patterns), + (b"Shading", &used.shadings), + (b"ExtGState", &used.extgstates), + (b"Font", &used.fonts), + (b"Properties", &used.properties), + ]; + // Every category is pruned, even after an earlier one changed. + categories.into_iter().fold(false, |changed, (category, names)| prune_category(doc, font_res, category, names) | changed) } struct ScanContext<'a> { From a11909551d5027cc6b509404a94cebc1e8f7f075 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 14/33] Refactor render for strict Clippy checks --- crates/render/src/inspect/annotations.rs | 2 +- crates/render/src/inspect/core.rs | 5 ++++- crates/render/src/inspect/model.rs | 2 +- crates/render/src/inspect/navigation.rs | 5 ++++- crates/render/src/inspect/resources.rs | 2 +- crates/render/src/raster/pool.rs | 6 +++++- crates/render/src/text/device.rs | 5 ++++- crates/render/src/text/layout.rs | 2 +- crates/render/src/text/order.rs | 2 +- 9 files changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/render/src/inspect/annotations.rs b/crates/render/src/inspect/annotations.rs index 07283c047..603aa8811 100644 --- a/crates/render/src/inspect/annotations.rs +++ b/crates/render/src/inspect/annotations.rs @@ -1,7 +1,7 @@ use super::core::{limited_pdf_string, pretty_date, spend}; use super::model::{Annotation, AttachmentSource, DestView, Field, FieldKind, LayerOp, Link, LinkTarget, RemoteDestination, link_replies}; use super::navigation::text_string; -use super::*; +use super::{Dictionary, DocInfo, Entry, HashMap, HashSet, Inspector, Object, ObjectId}; /// At most this many `/State` entries of a set-layer-visibility action are read. const MAX_LAYER_STATE: usize = 1024; diff --git a/crates/render/src/inspect/core.rs b/crates/render/src/inspect/core.rs index 875d87365..c190dc565 100644 --- a/crates/render/src/inspect/core.rs +++ b/crates/render/src/inspect/core.rs @@ -1,7 +1,10 @@ use super::annotations::MAX_LINK_ATTACHMENT_TEXT_BYTES; use super::model::{AttachmentSource, PageInfo, Xfa}; use super::navigation::text_string; -use super::*; +use super::{ + Arc, AssertUnwindSafe, DecryptionError, Dictionary, DocInfo, Document, HashMap, HashSet, LazyStructure, LoadOptions, LoadPdfError, Object, + ObjectId, OnceCell, OpenError, Pdf, Structure, catch_unwind, +}; /// lopdf decodes object and cross-reference streams while it loads, with no limit unless one is /// set: a few hundred bytes of nested FlateDecode then inflate to gigabytes. Real object and diff --git a/crates/render/src/inspect/model.rs b/crates/render/src/inspect/model.rs index e5f09c924..490897b60 100644 --- a/crates/render/src/inspect/model.rs +++ b/crates/render/src/inspect/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::HashMap; /// What kind of XFA form a document has (`/AcroForm /XFA`). #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/render/src/inspect/navigation.rs b/crates/render/src/inspect/navigation.rs index 173217c93..4000a7413 100644 --- a/crates/render/src/inspect/navigation.rs +++ b/crates/render/src/inspect/navigation.rs @@ -2,7 +2,10 @@ use super::core::inspect; use super::core::spend; use super::model::{DestView, OutlineItem, PageInfo}; -use super::*; +use super::{ + BTreeMap, Dictionary, HashMap, HashSet, Inspector, MAX_LABEL_BYTES, MAX_LABEL_TOTAL_BYTES, MAX_LABEL_TREE_DEPTH, MAX_LABEL_TREE_WORK, + MAX_PREFIX_BYTES, MAX_PREFIX_TOTAL_BYTES, Object, ObjectId, alpha, roman, +}; /// `DocInfo::outline` holds the first this many bookmarks in outline order. Opening a document /// reads only this window; the rest are listed by page through `pdfcraft_organize`. diff --git a/crates/render/src/inspect/resources.rs b/crates/render/src/inspect/resources.rs index 0386c5781..0895c64f8 100644 --- a/crates/render/src/inspect/resources.rs +++ b/crates/render/src/inspect/resources.rs @@ -3,7 +3,7 @@ use super::core::inspect; use super::core::{Inspector, load_options}; use super::model::{Attachment, AttachmentSource, FontInfo, Layer, PageTransition, TransitionStyle}; use super::navigation::NameTreeSeen; -use super::*; +use super::{AssertUnwindSafe, Dictionary, DocInfo, Document, HashSet, Object, ObjectId, catch_unwind}; /// Decoded bytes read from an XFA form's configuration packet (or a whole-XDP `/XFA` stream) to /// find `dynamicRender`. Designer's packets are a few kilobytes; a whole XDP can hold the template. diff --git a/crates/render/src/raster/pool.rs b/crates/render/src/raster/pool.rs index 81447263d..66a575eae 100644 --- a/crates/render/src/raster/pool.rs +++ b/crates/render/src/raster/pool.rs @@ -1,4 +1,8 @@ -use super::*; +use super::{ + Arc, AssertUnwindSafe, AtomicBool, AtomicRenderStats, InterpreterSettings, JoinHandle, Mutex, Ordering, PageRenderer, Pdf, Pixels, Receiver, + RenderCache, RenderConfig, RenderRequest, RenderStats, RenderedPage, RequestFailureKey, RequestKind, ResultReceiver, Stopwatch, SyncSender, + WorkQueue, catch_unwind, finish, panic_message, parse, render_page, sync_channel, take_warnings, warning_settings, +}; /// How long one page may render before the pool gives up on it (the watchdog). pub const STUCK_AFTER: std::time::Duration = std::time::Duration::from_secs(20); diff --git a/crates/render/src/text/device.rs b/crates/render/src/text/device.rs index eba4d9c1c..79bf05f7d 100644 --- a/crates/render/src/text/device.rs +++ b/crates/render/src/text/device.rs @@ -1,5 +1,8 @@ use super::layout::{Flow, along_baseline, arrange_flow, compatible, continues, from_upright, to_upright, unrolled}; -use super::*; +use super::{ + Affine, BezPath, BlendMode, ClipPath, Context, Device, Glyph, GlyphDrawMode, HashMap, Image, InterpreterCache, InterpreterSettings, PageText, + Paint, PathDrawMode, Pdf, Rect, Shape, SoftMask, TextFilter, TextGlyph, TransformExt, interpret_page, +}; /// Largest sine of the angle between a glyph's writing direction and a flow's for the glyph to be /// laid out in that flow's frame. Turning an em box by θ moves its edges along the line by up to diff --git a/crates/render/src/text/layout.rs b/crates/render/src/text/layout.rs index 302184aeb..00b45e266 100644 --- a/crates/render/src/text/layout.rs +++ b/crates/render/src/text/layout.rs @@ -1,6 +1,6 @@ use super::device::{DIRECTION_TOLERANCE, Drawn, WORD_GAP}; use super::order::{NONE, REDRAW_CANDIDATES, Run, Scratch, Seg, fingerprint, place_glyphs, redraws, restarts_run, reverse_rtl}; -use super::*; +use super::{HashMap, PageText, TextGlyph}; /// Glyphs in reading order, as indices into the page's glyphs, with their lines and word gaps. pub(super) struct Flow { diff --git a/crates/render/src/text/order.rs b/crates/render/src/text/order.rs index 6892d3f2a..d8cb65f32 100644 --- a/crates/render/src/text/order.rs +++ b/crates/render/src/text/order.rs @@ -1,5 +1,5 @@ +use super::TextGlyph; use super::layout::{is_rtl, union}; -use super::*; /// Glyphs `start..end` of the glyph list [`arrange`] lays out, drawn one after another. #[derive(Clone, Copy, Debug, PartialEq, Eq)] From 074d4f311198a241461808f691e90a1003dcf58d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:51 +0000 Subject: [PATCH 15/33] Refactor sign for strict Clippy checks --- crates/sign/src/pdf/changes.rs | 180 +++++++++++++++++++------------- crates/sign/src/pdf/listing.rs | 2 +- crates/sign/src/pdf/signing.rs | 2 +- crates/sign/src/pdf/types.rs | 2 +- crates/sign/src/pdf/validate.rs | 4 +- 5 files changed, 116 insertions(+), 74 deletions(-) diff --git a/crates/sign/src/pdf/changes.rs b/crates/sign/src/pdf/changes.rs index 2c66418a6..3a10a74e4 100644 --- a/crates/sign/src/pdf/changes.rs +++ b/crates/sign/src/pdf/changes.rs @@ -1,5 +1,5 @@ use super::types::{Modification, kind}; -use super::*; +use super::{Arc, Dict, Document, HashMap, HashSet, ObjRef, Object}; /// What later revisions changed, classified as Acrobat reports it, under DocMDP `p` (or none: /// an approval signature permits form fill, comments and further signatures). @@ -72,6 +72,18 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if doc.root() != old.root() { disallowed.push(kind::STRUCTURE); } + let classifier = ChangedObjectClassifier { + doc, + old: &old, + old_content: &old_content, + old_metadata, + old_xfa_data: &old_xfa_data, + signed_pages: &signed_pages, + signed_annots: &signed_annots, + signed_resources: &signed_resources, + old_info, + dss: &dss, + }; for num in doc.object_numbers() { if same_place(num) { continue; @@ -93,75 +105,7 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< // graphics state does not make that a form fill. A container (the resources dictionary, // a category dictionary) that kept every entry it had and only gained names changes // nothing the signed content draws, and is judged as before. - let kept_entries = |d: &Dict| before.as_deref().and_then(Object::as_dict).is_some_and(|b| b.iter().all(|(k, v)| d.get(k) == Some(v))); - let resource_kind = match (signed_resources.get(&r), &*new) { - (Some(false), _) => Some(kind::PAGE_CONTENT), - (Some(true), Object::Dict(d)) if kept_entries(d) => None, - (Some(true), _) => Some(kind::PAGE_CONTENT), - (None, _) => None, - }; - let what = match (resource_kind, &*new) { - (Some(k), _) => k, - // The Document Security Store's own arrays and dictionaries, when they only grew. - (_, _) if dss.contains(&num) => kind::SECURITY_STORE, - // A signed page's indirect /Annots array, still an array: what it lost, then what it - // gained. Dropping a signed annotation is never hidden behind an added signature. - (_, Object::Array(now)) if signed_annots.contains(&r) && before.as_deref().is_some_and(|b| b.as_array().is_some()) => { - let was: HashSet = - before.as_deref().and_then(Object::as_array).map(|a| a.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); - let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); - let removed: Vec = was.iter().filter(|r| !kept.contains(r)).copied().collect(); - if removed.is_empty() { - let added: Vec = now.iter().filter_map(Object::as_ref).filter(|r| !was.contains(r)).collect(); - added_annots(doc, &added) - } else if removed.iter().any(|r| old.get(*r).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Widget"))) { - // A form field or signature widget taken off the page: no permission covers it. - kind::OTHER - } else { - // Deleting comments is a comment change (permitted at P 3 only). - kind::COMMENTS - } - } - (_, Object::Dict(d)) => { - let was = before.as_deref().and_then(Object::as_dict); - if signed_pages.contains(&r) { - // A signed page is always judged by what the page shows, whatever it is now - // relabelled: a rewritten /Contents or /Resources is a page content change. - page_change_kind(doc, &old, d, was) - } else if was.is_some() && Some(r) == old_info { - // Only the signed revision's own /Info dictionary is a metadata change. - kind::METADATA - } else { - change_kind(doc, &old, d, was) - } - } - (_, Object::Stream(s)) => { - // File plumbing is what the stream was when signed: the /Type it has now is - // written by whoever changed it, and the renderer ignores /Type (a page's Form - // XObject relabelled /ObjStm still draws its new content). - let was_plumbing = matches!(before.as_deref(), Some(Object::Stream(b)) if matches!(b.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); - if old_content.contains(&r) { - kind::PAGE_CONTENT - } else if before.is_none() || was_plumbing { - continue; - } else if s.dict.name(b"Type") == Some(b"Metadata") && Some(r) == old_metadata { - // Only the signed catalog's XMP stream. A /Type /Metadata label on anything - // else, even one it already had when signed (a page's Form XObject: the - // renderer ignores /Type), does not make rewriting it a metadata change. - kind::METADATA - } else if old_xfa_data.contains(&r) { - // The form's XFA data (`datasets`) or state (`form`) as the signed revision - // named it: filling an XFA form rewrites these, as a Reader Extensions (UR3) - // save of a certified form does (#2759). The role comes from the signed - // revision, so a later one can't name page content as XFA data. - kind::FORM_FILL - } else { - kind::OTHER - } - } - _ if before.is_none() => continue, - _ => kind::OTHER, - }; + let Some(what) = classifier.classify(r, &new, before.as_deref()) else { continue }; let ok = match (what, p) { (kind::SIGNATURE | kind::SECURITY_STORE, _) => true, (kind::FORM_FILL, Some(2 | 3) | None) => true, @@ -195,6 +139,102 @@ pub(super) fn classify_changes(doc: &Document, old: Option, p: Option< if disallowed.is_empty() { Modification::Allowed(allowed) } else { Modification::Disallowed(disallowed) } } +struct ChangedObjectClassifier<'a> { + doc: &'a Document, + old: &'a Document, + old_content: &'a HashSet, + old_metadata: Option, + old_xfa_data: &'a HashSet, + signed_pages: &'a HashSet, + signed_annots: &'a HashSet, + signed_resources: &'a HashMap, + old_info: Option, + dss: &'a HashSet, +} + +impl ChangedObjectClassifier<'_> { + fn classify(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + if let Some(kind) = self.resource_kind(r, new, before) { + return Some(kind); + } + if self.dss.contains(&r.num) { + return Some(kind::SECURITY_STORE); + } + match new { + Object::Array(now) if self.is_signed_annotation_array(r, before) => Some(self.annotation_array_change(now, before)), + Object::Dict(dict) => Some(self.dictionary_change(r, dict, before)), + Object::Stream(stream) => self.stream_change(r, stream, before), + _ if before.is_none() => None, + _ => Some(kind::OTHER), + } + } + + fn resource_kind(&self, r: ObjRef, new: &Object, before: Option<&Object>) -> Option<&'static str> { + match (self.signed_resources.get(&r), new) { + (Some(false), _) => Some(kind::PAGE_CONTENT), + (Some(true), Object::Dict(dict)) if self.keeps_previous_entries(dict, before) => None, + (Some(true), _) => Some(kind::PAGE_CONTENT), + (None, _) => None, + } + } + + fn keeps_previous_entries(&self, dict: &Dict, before: Option<&Object>) -> bool { + before.and_then(Object::as_dict).is_some_and(|previous| previous.iter().all(|(key, value)| dict.get(key) == Some(value))) + } + + fn is_signed_annotation_array(&self, r: ObjRef, before: Option<&Object>) -> bool { + self.signed_annots.contains(&r) && before.is_some_and(|object| object.as_array().is_some()) + } + + fn annotation_array_change(&self, now: &[Object], before: Option<&Object>) -> &'static str { + let was: HashSet = + before.and_then(Object::as_array).map(|items| items.iter().filter_map(Object::as_ref).collect()).unwrap_or_default(); + let kept: HashSet = now.iter().filter_map(Object::as_ref).collect(); + let removed: Vec = was.iter().filter(|reference| !kept.contains(reference)).copied().collect(); + if removed.is_empty() { + let added: Vec = now.iter().filter_map(Object::as_ref).filter(|reference| !was.contains(reference)).collect(); + added_annots(self.doc, &added) + } else if removed.iter().any(|reference| self.old.get(*reference).as_dict().is_some_and(|dict| dict.name(b"Subtype") == Some(b"Widget"))) { + // A form field or signature widget taken off the page: no permission covers it. + kind::OTHER + } else { + // Deleting comments is a comment change (permitted at P 3 only). + kind::COMMENTS + } + } + + fn dictionary_change(&self, r: ObjRef, dict: &Dict, before: Option<&Object>) -> &'static str { + let previous = before.and_then(Object::as_dict); + if self.signed_pages.contains(&r) { + // A signed page is judged by what the page shows, whatever its new labels say. + page_change_kind(self.doc, self.old, dict, previous) + } else if previous.is_some() && Some(r) == self.old_info { + // Only the signed revision's own /Info dictionary is a metadata change. + kind::METADATA + } else { + change_kind(self.doc, self.old, dict, previous) + } + } + + fn stream_change(&self, r: ObjRef, stream: &pdfcraft_cos::Stream, before: Option<&Object>) -> Option<&'static str> { + // File plumbing is the stream's role when signed; a later /Type label cannot change it. + let was_plumbing = matches!(before, Some(Object::Stream(previous)) if matches!(previous.dict.name(b"Type"), Some(b"XRef" | b"ObjStm"))); + if self.old_content.contains(&r) { + Some(kind::PAGE_CONTENT) + } else if before.is_none() || was_plumbing { + None + } else if stream.dict.name(b"Type") == Some(b"Metadata") && Some(r) == self.old_metadata { + // Only the signed catalog's XMP stream is metadata. + Some(kind::METADATA) + } else if self.old_xfa_data.contains(&r) { + // Signed XFA data and state packets are changed by a form fill. + Some(kind::FORM_FILL) + } else { + Some(kind::OTHER) + } + } +} + /// The streams of `doc`'s XFA form that hold its data and state: the `datasets` and `form` /// packets of the AcroForm's `/XFA` array (name, stream, name, stream, …). A form given as one /// XFA stream holds its template too, and is not listed. diff --git a/crates/sign/src/pdf/listing.rs b/crates/sign/src/pdf/listing.rs index 88379b183..cbcf33971 100644 --- a/crates/sign/src/pdf/listing.rs +++ b/crates/sign/src/pdf/listing.rs @@ -1,6 +1,6 @@ use super::types::{Modification, Status, TrustStore}; use super::validate::{is_doc_timestamp, validate_doc_timestamp, validate_into}; -use super::*; +use super::{Arc, Dict, DigestAlg, Document, HashMap, HashSet, ObjRef, Object, SignatureInfo}; /// Digests of signed byte ranges (and parsed signed revisions), kept across revalidations of /// one document: incremental edits only append to the file, so the signed bytes and their diff --git a/crates/sign/src/pdf/signing.rs b/crates/sign/src/pdf/signing.rs index 0d00c68a6..71dd10b30 100644 --- a/crates/sign/src/pdf/signing.rs +++ b/crates/sign/src/pdf/signing.rs @@ -1,5 +1,5 @@ use super::listing::{mk_rotation, nums, sig_fields}; -use super::*; +use super::{Certificate, Dict, DigestAlg, DigitalId, Document, HashSet, Object, PdfString, SaveOptions, SignError, SignedData, Stream}; // ── signing ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/sign/src/pdf/types.rs b/crates/sign/src/pdf/types.rs index c061bc369..881f50357 100644 --- a/crates/sign/src/pdf/types.rs +++ b/crates/sign/src/pdf/types.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Certificate; /// Certificates the user trusts for signing (Acrobat: Trusted Certificates). #[derive(Clone, Debug, Default)] diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 1f0b8c342..668798c20 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -1,7 +1,9 @@ use super::changes::classify_changes; use super::listing::{DigestCache, list, nums, sig_fields, text}; use super::types::{Detail, Fill, Modification, Status, TrustStore}; -use super::*; +use super::{ + Certificate, Dict, DigestAlg, Document, HashSet, ObjRef, Object, SignError, SignatureInfo, SignedData, Time, build_chain, build_chain_noted, +}; /// Validate one signature dictionary. pub fn validate(doc: &Document, bytes: &[u8], trust: &TrustStore, field: &str) -> Option { From e7a60f2fd4871284c35a0b5737b0b7e5f7415325 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 16/33] Refactor ui canvas for strict Clippy checks --- crates/ui-egui/src/canvas.rs | 3515 ++++++++++++++--------- crates/ui-egui/src/canvas/area.rs | 2 +- crates/ui-egui/src/canvas/bars.rs | 5 +- crates/ui-egui/src/canvas/cache.rs | 5 +- crates/ui-egui/src/canvas/find.rs | 2 +- crates/ui-egui/src/canvas/navigation.rs | 71 +- crates/ui-egui/src/canvas/organize.rs | 95 +- crates/ui-egui/src/canvas/selection.rs | 2 +- crates/ui-egui/src/canvas/xform.rs | 2 +- crates/ui-egui/src/canvas/zoom.rs | 2 +- 10 files changed, 2202 insertions(+), 1499 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index edcd64569..adf22626b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -808,106 +808,1287 @@ impl DocView { } } -/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a -/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. -pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { - let t = Tokens::get(ui.ctx()); - // Keyboard scrolling applies to this frame's page view only, never later. - let key_scroll = std::mem::take(&mut app.views[index].key_scroll); - if key_scroll != 0.0 { - app.views[index].end_find_following(); +/// The renders a frame wants (page, scale, tag, tile), and the pages visible. +type PageRenders = (Vec<(usize, f32, u64, Option)>, Vec); + +/// What the page-scroll closure reads: this frame's zoom, scrolling, tool and document state. +#[derive(Clone, Copy)] +struct PageEnv<'a> { + added: &'a Vec, + allowed: bool, + author: &'a String, + auto_delta: Vec2, + avail: Rect, + boost_extra: Vec2, + by_line: &'a String, + calc_order: bool, + can_copy: bool, + can_crop: bool, + can_fill: bool, + can_modify: bool, + comments_hidden: bool, + content_h: f32, + content_w: f32, + custom_stamp: &'a Option, + date_text: &'a Result, + doc: &'a pdfcraft_engine::Document, + doc_links: &'a Vec, + edge_delta: Vec2, + editing_content: bool, + form: &'a Arc>, + generation: u64, + glide: Vec2, + hand: bool, + info: &'a DocInfo, + initials: &'a Option, + key_scroll: f32, + middle_gesture: bool, + modal: bool, + navigation_enabled: bool, + paper: Color32, + ppp: f32, + precise_scroll: Vec2, + prefs: &'a comments::CommentPrefs, + preparing: bool, + rects: &'a Vec, + redact_mark: ([f64; 3], f64), + scale: f32, + selection_wheel: bool, + selects_text: bool, + signature: &'a Option, + single_paging: bool, + t: &'a Tokens, + table_preview: &'a Option, + tag: u64, + text_style: &'a pdfcraft_engine::AddedText, + tool: QuickTool, + units: crate::units::Unit, + unobstructed: bool, + visible_pages: &'a Vec, + y_shift: f32, + fill_text_size: f64, + today: (i64, u32, u32), +} + +/// What the page-scroll closure produces for the rest of the frame. +#[derive(Default)] +struct PageOut { + badge_hover: Option<(Pos2, String)>, + canvas_action: Option, + clicked_link: Option, + copy_selected: bool, + field_menu: Option, + field_placed: bool, + field_props: bool, + hover_text: Option<(Pos2, String)>, + image_action: Option, + open_boxes: bool, + open_initials: bool, + open_signature: bool, + open_table: Option<(usize, [f64; 4])>, + refused: Option, + stamp_placed: bool, +} + +/// The page-scroll closure's body: lays out and draws the visible pages, their tools and overlays, and +/// returns the renders wanted and the pages visible. +/// The state of one pass over the visible pages: what is read, and what the pages add up to. +struct PageFrame<'a> { + env: PageEnv<'a>, + resp: &'a egui::Response, + ptr: &'a PagePointer, + origin: Pos2, + visible: Rect, + pressed: bool, + pointer: Option, + signature_preview: &'a mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, + out: PageOut, + wanted: Vec<(usize, f32, u64, Option)>, + visible_now: Vec, + current: usize, + best_overlap: f32, + current_overlap: f32, + current_height: f32, + pressed_page: Option, + calc_hover: Option, +} + +/// Visible heights (points) closer than this are equal: one page size can meet the viewport a +/// fraction of a point differently once the scroll offset is applied. +const PAGE_TIE: f32 = 0.5; + +/// Draws page `i` of the visible pages: its raster, the tools' input and overlays. +/// One visible page: its index, its rectangle on screen and the transform to and from the page. +#[derive(Clone, Copy)] +struct PageSlot { + i: usize, + r: Rect, + xf: PageXform, +} + +/// Paints the page's paper, shadow and raster (or why it can't be shown), and asks for the renders +/// it wants. +fn paint_page_surface(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) { + let PageEnv { paper, ppp, scale, t, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let info = frame.env.info; + let painter = ui.painter(); + // Shadows would darken the preceding page when pages touch. + if view.show_page_gaps { + painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); } - // The Search panel closed: its search moves to the find bar. - let search_open = app.right == Some(crate::RightPanel::Search); - if let Some(f) = app.views[index].find.as_mut() - && f.in_panel - && !search_open - { - f.in_panel = false; - } - // The snapshot notice bar's Close was clicked: close here, where no document borrow is - // held. The tab is looked up by id: indices may have shifted since the click, and the - // flag rides its own view, so a stale flag can never close the wrong tab. - let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); - if let Some(i) = pending { - app.views[i].close_requested = false; - if app.has_unsaved_work(i) { - app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + painter.rect_filled(r, CornerRadius::ZERO, paper); + if let Some(err) = view.errors.get(&i) { + painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); + icons::paint( + ui, + Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), + "triangle-alert", + 26.0, + Color32::from_rgb(0xC8, 0x3A, 0x3A), + ); + let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); + let msg = ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); + painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); + } else { + let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); + // A GPU may take smaller textures than these (OpenGL drivers report as little as + // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. + let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; + let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); + // Whole-page raster: sharp when small, a low-res backdrop when tiled. + let (want_scale, want_tag) = if tiled { + let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); + (bs, scale_tag(bs)) } else { - app.close_tab(i); + (scale, tag) + }; + // Under tiles, a whole-page raster sharper than the backdrop (from before the page + // grew past the tiling size) is a better backdrop: keep demanding it as it is. + match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { + Some(p) => frame.wanted.push((i, tag_scale(p.tag), p.tag, None)), + None => frame.wanted.push((i, want_scale, want_tag, None)), } - return; - } - let Some(doc) = app.session.get(app.views[index].id) else { return }; - let info = &doc.info; - let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { - let [r, g, b] = p.background; - Color32::from_rgb(r, g, b) - }); - if info.pages.is_empty() { - app.views[index].auto_scroll.cancel(); - ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); - return; - } - // The print dialog queues its own rasters later in the frame. Closing it drops them. - if app.dialog != Some(crate::Dialog::Print) { - app.views[index].print_pages.clear(); - } - // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. - let preparing = app.is_preparing(); - // Prepare a form ▸ Calculation order: the order's steps and links on the page. - let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; - // Edit a PDF: added text and images can be selected, moved and edited. - let editing_content = - (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); - let text_style = app.text_style.clone(); - let show_secured_notice = app.show_secured_notice; - let view = &mut app.views[index]; - // Opened without the owner password and something is restricted. The notice says so unless - // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ - // Security, stay as they are. - let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); - let repaired = !doc.repair_log().is_empty(); - match notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t) { - #[cfg(not(target_arch = "wasm32"))] - Some(Notice::Reload) => app.watch.reload_request = Some(view.id), - #[cfg(target_arch = "wasm32")] - Some(Notice::Reload) => {} - Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), - Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), - Some(Notice::Unlock) => app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)), - // Flags the view: the tab closes at the top of the next frame, where no document - // borrow is held. - Some(Notice::Close) => { - if let Some(view) = app.views.get_mut(index) { - view.close_requested = true; + match view.pages.get(&i) { + Some(p) => { + if !tiled && p.tag == want_tag { + xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } else { + // A backdrop, or a raster at an older scale until the new one arrives. + xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); + } + } + None => { + let now = ui.input(|inp| inp.time); + let since = *view.waiting_since.entry(i).or_insert(now); + let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; + painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); } } - Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), - Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, - Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), - // Back on in Preferences ▸ General. - Some(Notice::HideSecured) => app.show_secured_notice = false, - None => {} + if tiled && r.intersects(visible) { + paint_page_tiles(ui, view, frame, slot, (pw_pt, ph_pt, max_side)); + } } - // No dialog, close prompt or palette over the page: only then does page input count. - let modal = app.modal_open(); - let unobstructed = !modal && !app.palette_open; - let organize_zoom = app.organize_zoom; - let view = &mut app.views[index]; - if view.organize { - // One remembered grid size for every document, kept in the settings. - view.set_grid_zoom(organize_zoom); - organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); - app.organize_zoom = view.grid_zoom(); - return; +} + +/// Paints the tiles of a page too big to draw whole: the tiles of earlier zooms first, stretched, +/// then the visible tiles of this one, nearest the viewport centre first, asking for those missing. +fn paint_page_tiles(ui: &egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, (pw_pt, ph_pt, max_side): (f32, f32, f32)) { + let PageEnv { ppp, scale, tag, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let visible = frame.visible; + let painter = ui.painter(); + // Device-pixel geometry of the scaled page, and the visible part of it + // (found by mapping the visible screen corners back into the page). Sides round + // up as whole-page rasters do, so the last partial row and column get tiles. + let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); + // Adaptive tile side for this zoom and page, within the GPU texture limit: + // a zoomed viewport or a giant sheet is covered by a few large tiles + // instead of many small ones, and each tile costs one full page interpret. + // Never zero (u32 division below). + let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); + let vis = r.intersect(visible); + let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); + let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); + let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); + let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); + let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); + // Tiles of earlier zooms first, stretched, the nearest scale last (on top): + // until the current tiles arrive, a zoom shows them rather than the backdrop. + let mut older: Vec<(f32, u32, u32, &TextureHandle)> = + view.tiles.iter().filter(|((p, t, _, _), _)| *p == i && *t != tag).map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)).collect(); + older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); + for (s, ox, oy, tex) in older { + let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); + let [w, h] = tex.size(); + let (x, y) = (ox as f32, oy as f32); + xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); + } + // Visible tiles, nearest the viewport centre first: each tile costs a full + // page interpret, so the workers sharpen what is looked at before the edges. + let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); + let mut coords: Vec<(u32, u32)> = Vec::new(); + for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { + for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { + coords.push((tx, ty)); + } + } + coords.sort_by(|a, b| { + let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); + let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); + da.total_cmp(&db) + }); + for (tx, ty) in coords { + let (x, y) = (tx * ts, ty * ts); + let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); + if w == 0 || h == 0 { + continue; + } + frame.wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); + if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { + let (fw, fh) = (dw as f32, dh as f32); + xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( + painter, + tex.id(), + x as f32 / fw, + y as f32 / fh, + (x + w) as f32 / fw, + (y + h) as f32 / fh, + ); + } + } +} + +/// What the page's tools did with the pointer this frame. +struct PageInput { + /// A tool, a field or a comment took the pointer: text selection must leave it alone. + consumed: bool, + /// A form field is under the pointer and has set its cursor. + over_field: bool, + /// Redact or Highlight draws boxes off text. + area_tool: bool, +} + +/// Comments: tools, selection, moving and resizing come before text selection. Form fields take +/// clicks first with the Select tool (as Acrobat fills fields in every viewing mode); then +/// comments; then text selection. +fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot) -> PageInput { + let PageEnv { + added, + allowed, + author, + by_line, + can_crop, + can_fill, + can_modify, + comments_hidden, + custom_stamp, + date_text, + doc, + doc_links, + editing_content, + form, + info, + initials, + prefs, + preparing, + redact_mark, + signature, + table_preview, + text_style, + tool, + units, + fill_text_size, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if let QuickTool::Measure(measure_tool) = tool { + crate::measure_ui::page_input(ui, resp, doc, view, &pcx, measure_tool, units); } - let avail = ui.available_rect_before_wrap(); - view.viewport_w = avail.width(); - view.viewport_h = avail.height(); - let old_zoom = view.zoom; - view.fit_zoom(info); + // Form fields take clicks first with the Select tool (as Acrobat fills fields in + // every viewing mode); then comments; then text selection. + if let QuickTool::Stamp(kind) = tool + && allowed + && let Some(p) = ptr.within(xf.rect) + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() { + // Centred on the click, upright as the page is shown. + let (vx, vy) = xf.screen_to_view(p); + let (w, h) = kind.size(); + let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; + let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); + let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; + let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); + let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; + view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { + page: i, + style: pdfcraft_engine::Style::default_for(&shape), + shape, + contents: String::new(), + author: author.clone(), + })); + frame.out.stamp_placed = true; + } + } + if let Some(cs) = custom_stamp.as_ref() + && allowed + && let Some(p) = ptr.within(xf.rect) + { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + if resp.clicked() { + // Centred on the click at its natural size (the engine sizes it). + let (vx, vy) = xf.screen_to_view(p); + let u = info.pages[i].view_to_user(vx, vy); + let (x, y) = (u[0] as f64, u[1] as f64); + view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { + page: i, + rect: [x, y, x, y], + name: cs.name.clone(), + file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, + author: author.clone(), + }); + frame.out.stamp_placed = true; + } + } + // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). + if let QuickTool::Fill(ft) = tool + && allowed + && !comments::fill_grabs(&pcx, view) + { + match crate::fill_sign::page_input( + ui, + resp, + &xf, + ptr, + i, + info, + ft, + fill_text_size, + view, + signature.as_ref(), + initials.as_ref(), + frame.signature_preview, + author, + date_text, + ) { + Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), + Some(crate::fill_sign::FillAction::Signature(e)) => { + view.pending_edit = Some(*e); + view.fill_signature_page = Some(i); + } + Some(crate::fill_sign::FillAction::CreateSignature) => frame.out.open_signature = true, + Some(crate::fill_sign::FillAction::CreateInitials) => frame.out.open_initials = true, + Some(crate::fill_sign::FillAction::Refused(why)) => frame.out.refused = Some(why), + None => {} + } + } + if matches!(tool, QuickTool::SignArea { .. }) { + crate::sign_ui::page_input(ui, resp, &xf, ptr, i, info, view); + } + if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { + crate::zoom_snap::page_input(ui, resp, &xf, ptr, i, view); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::page_input(ui, resp, &xf, ptr, i, view, can_modify); + } + if tool == QuickTool::Crop && crate::crop::page_input(ui, resp, &xf, ptr, i, info, view, can_crop) { + view.current = i; + frame.out.open_boxes = true; + } + // `over_field`: a form field is under the pointer and has set its cursor. + let (on_field, over_field) = if preparing { + let field_tool = match tool { + QuickTool::Field(f) => Some(f), + _ => None, + }; + // While a table is being set up, nothing else is drawn or moved. + let allowed = can_modify && table_preview.is_none() && frame.out.open_table.is_none(); + let o = crate::prepare::page_input(ui, resp, &xf, ptr, i, info, form, field_tool, allowed, view); + frame.out.field_props |= o.properties; + frame.out.field_placed |= o.placed; + frame.out.open_table = frame.out.open_table.or(o.table); + (o.consumed || field_tool.is_some(), false) + } else { + use crate::forms_ui::FieldPointer; + let field = if tool == QuickTool::Select + && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, ptr, i, info, added, view)) + && !comments::select_grabs(&pcx, view) + { + crate::forms_ui::page_input(ui, resp, &xf, ptr, i, info, form, can_fill, view) + } else { + FieldPointer::Away + }; + (field == FieldPointer::Owned, field == FieldPointer::Over) + }; + // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). + let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; + let boxing = area_tool && { + let text = view.page_text(i); + let over_text = |p: Pos2| { + let (vx, vy) = xf.screen_to_view(p); + text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) + }; + crate::redact_ui::page_input(ui, resp, &xf, ptr, i, info, over_text, view) + }; + let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, ptr, i, info, view); + let on_objects = + !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, resp, &xf, ptr, i, doc, view); + let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { + let generation = doc.edit_generation(); + match view.edit_lines.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.text_blocks(i); + view.edit_lines.insert(i, (generation, l.clone())); + l + } + } + } else { + Vec::new() + }; + let over_source = tool == QuickTool::AddText + && pointer.is_some_and(|p| { + !crate::content_ui::hits_added(&xf, info, i, added, p) + && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) + }); + let source_blocked = over_source && view.content.hold_blocked(); + if over_source && resp.clicked() && !source_blocked { + if let Some(edit) = view.content.finish_draft(added) { + view.pending_edit = Some(edit); + } + view.content.selected = None; + } + let on_content = !on_objects && editing_content && can_modify && { + source_blocked + || crate::content_ui::page_input( + ui, + resp, + &xf, + ptr, + i, + info, + added, + tool == QuickTool::AddText && !over_source, + matches!(tool, QuickTool::AddText | QuickTool::EditText), + text_style, + view, + ) + || tool == QuickTool::AddText && !over_source + }; + // Added content owns its gesture; its source text must not queue a second edit. + // Edit text & images boxes every paragraph and image whenever the tool is on. Only the + // input waits while selected objects or added content own the pointer, or while page + // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn + // instead of vanishing until the pointer comes back. + let others_own_pointer = on_objects || on_content; + let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { + let generation = doc.edit_generation(); + let images = match view.edit_images.get(&i) { + Some((g, l)) if *g == generation => l.clone(), + _ => { + let l = doc.page_images(i); + view.edit_images.insert(i, (generation, l.clone())); + l + } + }; + // Editable text takes precedence over background/overlapping artwork. Both + // inventories are painted every frame, even when text owns the pointer. + let text_used = crate::edit_text_ui::page_input(ui, resp, &xf, ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); + let image_cx = crate::edit_text_ui::ImageCx { + ui, + resp, + xf: &xf, + ptr, + page: i, + info, + allow_interaction: !others_own_pointer && (image_owns_pointer || !text_used), + }; + let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view, &mut frame.out.image_action); + !others_own_pointer && (text_used || image_used) + }; + let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, resp, &xf, ptr, i, info, doc_links, view); + if tool == QuickTool::Select + && resp.secondary_clicked() + && can_modify + && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) + && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) + { + view.links.context = Some((i, l.index)); + } + let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, resp, &pcx, view); + PageInput { consumed, over_field, area_tool } +} + +/// Text layer: find matches, selection, I-beam and drag-to-select. +fn text_layer(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { info, selects_text, tool, .. } = frame.env; + let PageSlot { i, r, xf } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + if let Some(text) = view.texts.get(&i).cloned() { + paint_text_marks(ui, view, &text, i, xf, resp.dragged()); + if selects_text + && !input.consumed + && let Some(p) = pointer.filter(|p| r.contains(*p)) + { + text_pointer_input( + ui, + view, + &text, + TextPointer { page: i, xf, rect: r, info, tool, resp, ptr: ptr.clone(), pointer: p, over_field: input.over_field }, + ); + } + } +} + +fn paint_text_marks(ui: &egui::Ui, view: &DocView, text: &PageText, page: usize, xf: PageXform, dragging: bool) { + let painter = ui.painter(); + let to_screen = |g: [f32; 4]| xf.view_rect(g); + if let Some(find) = &view.find { + for (index, (match_page, range)) in find.matches.iter().enumerate() { + if *match_page != page { + continue; + } + let fill = if find.current == Some(index) { + Color32::from_rgba_unmultiplied(255, 140, 0, 110) + } else { + Color32::from_rgba_unmultiplied(255, 214, 0, 90) + }; + for line_rect in text.line_rects(range.clone()) { + painter.rect_filled(to_screen(line_rect).expand(1.0), CornerRadius::same(2), fill); + } + } + } + if view.all_text { + for line_rect in text.line_rects(0..text.glyphs.len()) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + } + if let Some(selection) = view.selection.filter(|selection| selection.page == page) { + for line_rect in selection.rects(text) { + painter.rect_filled(to_screen(line_rect), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); + } + if let Span::Column { rect } = selection.span + && dragging + { + let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); + painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); + } + } +} + +struct TextPointer<'a> { + page: usize, + xf: PageXform, + rect: Rect, + info: &'a DocInfo, + tool: QuickTool, + resp: &'a egui::Response, + ptr: PagePointer, + pointer: Pos2, + over_field: bool, +} + +struct TextGesture<'a> { + page: usize, + xf: PageXform, + rect: Rect, + tool: QuickTool, + ptr: PagePointer, + resp: &'a egui::Response, + pointer: Pos2, + vx: f32, + vy: f32, + over_text: bool, + over_link: bool, + column: bool, +} + +fn text_pointer_input(ui: &mut egui::Ui, view: &mut DocView, text: &PageText, pointer: TextPointer<'_>) { + let TextPointer { page, xf, rect, info, tool, resp, ptr, pointer: screen_pointer, over_field } = pointer; + let (vx, vy) = xf.screen_to_view(screen_pointer); + let over_text = text.glyphs.iter().any(|glyph| vx >= glyph.rect[0] && vx <= glyph.rect[2] && vy >= glyph.rect[1] && vy <= glyph.rect[3]); + let over_link = info.links.iter().any(|link| link.page == page && xf.user_rect(info, page, link.rect).contains(screen_pointer)); + let column = tool == QuickTool::ColumnSelect || ui.input(|input| input.modifiers.alt); + let gesture = TextGesture { page, xf, rect, tool, ptr, resp, pointer: screen_pointer, vx, vy, over_text, over_link, column }; + update_text_cursor(ui, resp, over_field, &gesture); + update_selection_on_press(ui, view, text, &gesture); + update_dragged_selection(view, text, &gesture); + finish_text_pointer_gesture(ui, view, text, &gesture); +} + +fn update_text_cursor(ui: &egui::Ui, resp: &egui::Response, over_field: bool, gesture: &TextGesture<'_>) { + let cursor_free = !gesture.over_link && !over_field && (resp.contains_pointer() || resp.dragged()); + if gesture.column && cursor_free { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } else if gesture.over_text && cursor_free { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } +} + +fn update_selection_on_press(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let press_here = gesture.ptr.origin.is_some_and(|origin| gesture.rect.contains(origin)); + if gesture.ptr.pressed().is_some() && press_here && !gesture.over_link { + let now = ui.input(|input| input.time); + let (max_delay, max_dist) = ui.ctx().options(|options| (options.input_options.max_double_click_delay, options.input_options.max_click_dist)); + let consecutive = now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(gesture.pointer) <= max_dist); + view.clicks = if consecutive { view.clicks.saturating_add(1) } else { 1 }; + view.last_press_time = now; + view.last_press_pos = Some(gesture.pointer); + let clicks = selection_click_count(view.clicks, gesture.tool); + if clicks >= 2 + && gesture.over_text + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } + } + if gesture.resp.drag_started() && press_here && !gesture.over_link { + view.actions_dismissed = None; + view.selection_drag = true; + let origin = gesture.ptr.origin.unwrap_or(gesture.pointer); + let (ox, oy) = gesture.xf.screen_to_view(origin); + if view.clicks < 2 { + view.selection = if gesture.column { + Some(Selection { page: gesture.page, span: Span::Column { rect: [ox, oy, gesture.vx, gesture.vy] } }) + } else { + text.nearest(ox, oy).map(|anchor| Selection::run(gesture.page, anchor, anchor)) + }; + } + } +} + +fn selection_click_count(clicks: u32, tool: QuickTool) -> u32 { + if clicks > 2 && tool != QuickTool::Select { 1 } else { clicks } +} + +fn update_dragged_selection(view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let clicks = selection_click_count(view.clicks, gesture.tool); + if gesture.resp.dragged() + && let Some(selection) = view.selection.as_mut().filter(|selection| selection.page == gesture.page) + { + match &mut selection.span { + Span::Run { anchor, head } if clicks >= 2 => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) + && let Some((first, last)) = click_unit(text, clicks, *anchor) + { + let (to_first, to_last) = click_unit(text, clicks, hit).unwrap_or((hit, hit)); + (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; + } + } + Span::Run { head, .. } => { + if let Some(hit) = text.nearest(gesture.vx, gesture.vy) { + *head = hit; + } + } + Span::Column { rect } => { + rect[2] = gesture.vx; + rect[3] = gesture.vy; + } + } + } + if gesture.resp.drag_stopped() + && view.selection.is_some_and(|selection| { + selection.page == gesture.page && matches!(selection.span, Span::Column { .. }) && selection.text(text).is_empty() + }) + { + view.selection = None; + } +} + +fn finish_text_pointer_gesture(ui: &egui::Ui, view: &mut DocView, text: &PageText, gesture: &TextGesture<'_>) { + let release_clicks = if gesture.resp.triple_clicked() { + view.clicks.max(2).saturating_add(1) + } else if gesture.resp.double_clicked() { + view.clicks.max(2) + } else { + 0 + }; + if release_clicks > 0 { + view.clicks = release_clicks; + } + let clicks = selection_click_count(view.clicks, gesture.tool); + if release_clicks > 0 + && clicks >= 2 + && gesture.over_text + && view.selection.is_none() + && let Some((first, last)) = text.nearest(gesture.vx, gesture.vy).and_then(|anchor| click_unit(text, clicks, anchor)) + { + view.selection = Some(Selection::run(gesture.page, first, last)); + } else if gesture.resp.clicked() && view.clicks < 2 && !gesture.over_link { + let shift = ui.input(|input| input.modifiers.shift); + match ( + view.selection.as_mut().filter(|selection| shift && selection.page == gesture.page).map(|selection| &mut selection.span), + text.nearest(gesture.vx, gesture.vy), + ) { + (Some(Span::Run { head, .. }), Some(hit)) => *head = hit, + _ => view.selection = None, + } + } +} + +/// Everything drawn over the page after its text: pictures, comments, content, links, fields, marks +/// and badges. +fn page_overlays(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, slot: &PageSlot, input: &PageInput) { + let PageEnv { + added, + allowed, + calc_order, + can_modify, + comments_hidden, + doc, + doc_links, + editing_content, + form, + generation, + info, + prefs, + preparing, + redact_mark, + table_preview, + tool, + unobstructed, + .. + } = frame.env; + let PageSlot { i, xf, .. } = *slot; + let PageFrame { resp, ptr, pointer, .. } = *frame; + let painter = ui.painter(); + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + if tool == QuickTool::Select && !input.consumed { + crate::image_pick::page_input(resp, &xf, i, info, doc, pointer, view); + } + crate::image_pick::drop_stale(doc, view); + crate::image_pick::paint(painter, &xf, i, info, view); + comments::page_after_text(resp, &pcx, view); + if tool == QuickTool::Redact && can_modify { + crate::redact_ui::after_text(resp, i, info, view); + } + if input.area_tool { + crate::redact_ui::paint(painter, i, view, pointer); + } + comments::paint_page(ui, painter, &pcx, view); + if editing_content { + crate::content_ui::paint_page(painter, &xf, i, info, added, view, pointer); + } + if tool == QuickTool::EditText { + crate::object_ui::paint(painter, &xf, i, info, &view.objects); + } + if tool == QuickTool::EnhanceCamera { + crate::camera_ui::paint(painter, &xf, i, view); + } + if tool == QuickTool::Link { + crate::link_ui::paint(painter, &xf, i, info, doc_links, view, pointer); + } + if preparing { + crate::prepare::paint_page(ui, painter, &xf, i, info, form, view, pointer); + if let Some(preview) = &table_preview { + crate::field_table_ui::paint_preview(painter, &xf, i, preview); + } + if calc_order { + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + if let Some(name) = crate::calc_order_ui::paint_page(painter, &xf, i, info, form, &view.calc_order, generation, selected, pointer) { + frame.calc_hover = Some(name); + } + } + } else { + crate::forms_ui::paint_page(painter, &xf, i, info, form, view, pointer); + } + + // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a + // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as + // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a + // radio button's own) is left as it is drawn. + paint_field_highlights(painter, &xf, info, form.as_slice(), i, view.highlight_fields); + // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips + // draw above the palette, so none shows while it is open, even beside it. + if !matches!(tool, QuickTool::Fill(_)) + && unobstructed + && let Some(p) = pointer + { + for l in info.links.iter().filter(|l| l.page == i) { + let sr = xf.user_rect(info, i, l.rect); + if sr.contains(p) { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + let label = match &l.target { + LinkTarget::Page(n, _) => { + crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) + } + LinkTarget::Uri(u) => u.clone(), + LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), + LinkTarget::RemoteGoTo { file, .. } => file.clone(), + LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), + }; + frame.out.hover_text = Some((p, label)); + if resp.clicked() && tool == QuickTool::Select && !input.consumed { + frame.out.clicked_link = Some(l.target.clone()); + } + } + } + // Annotation hover shows the comment, as Acrobat's popups do; not while drawing + // freehand, where it would cover the next stroke (#429). + let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); + let gesturing = view.comments.gesture.is_some() || freehand; + // Inspection sorts annotations spatially, but the renderer paints in /Annots + // order. Keep the shared list intact and choose the last eligible raw index. + if frame.out.hover_text.is_none() + && !gesturing + && let Some((_, text)) = info + .annotations + .iter() + .filter(|a| a.page == i) + .filter(|a| !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty))) + .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) + .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) + .max_by_key(|(index, _)| *index) + { + frame.out.hover_text = Some((p, text)); + } + } + for (mp, mr, mc) in &view.compare_marks { + if *mp == i { + let sr = xf.user_rect(info, i, *mr).expand(1.5); + painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); + } + } + if let Some((fp, fr, t0)) = view.flash + && fp == i + { + let now = ui.input(|inp| inp.time); + let t0 = if t0 == 0.0 { now } else { t0 }; + view.flash = Some((fp, fr, t0)); + let age = (now - t0) as f32; + if age < 1.6 { + let a = ((1.6 - age) / 1.6 * 255.0) as u8; + let sr = xf.user_rect(info, i, fr).expand(4.0); + painter.rect_stroke( + sr, + CornerRadius::same(3), + Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), + egui::StrokeKind::Outside, + ); + ui.ctx().request_repaint(); + } else { + view.flash = None; + } + } + // Validation is viewer chrome over the signer's original artwork, which + // may still say "Signature Not Verified". These badges never enter document + // streams, image/text exports, vector printing or Print as Image. + for badge in signature_badges(&doc.signatures, i) { + let [x0, y0, x1, y1] = badge.rect; + let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); + let (icon, color) = crate::sign_ui::signature_status_style(badge.status); + painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); + // A widget too small for the mark keeps its outline: the verdict still shows. + if sr.width() < 4.0 || sr.height() < 4.0 { + continue; + } + // Big enough to read at a glance, small enough to leave the signer's own + // text visible around it. + let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); + let mark = Rect::from_center_size(sr.center(), vec2(side, side)); + icons::paint(ui, mark, icon, side * 0.64, color); + if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) { + frame.out.badge_hover = Some((p, badge.summary.to_string())); + } + } + // Overlapping widgets paint in order, so the topmost badge owns the hover; an + // earlier link or annotation hover still wins over any badge. + if frame.out.hover_text.is_none() { + frame.out.hover_text = frame.out.badge_hover.take(); + } +} + +fn paint_field_highlights(painter: &egui::Painter, xf: &PageXform, info: &DocInfo, form: &[pdfcraft_engine::FormField], page: usize, enabled: bool) { + if !enabled { + return; + } + for field in form.iter() { + let required = field.has(pdfcraft_engine::field_flags::REQUIRED); + for widget in field.widgets.iter().filter(|widget| widget.page == Some(page) && !widget.hidden && !widget.read_only) { + let rect = widget.rect; + let screen_rect = xf.user_rect(info, page, [rect[0] as f32, rect[1] as f32, rect[2] as f32, rect[3] as f32]); + let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); + let (width, color) = if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; + if widget.round { + let radius = screen_rect.width().min(screen_rect.height()) / 2.0; + painter.circle_filled(screen_rect.center(), radius, tint); + painter.circle_stroke(screen_rect.center(), radius - width / 2.0, Stroke::new(width, color)); + } else { + painter.rect_filled(screen_rect, CornerRadius::same(1), tint); + painter.rect_stroke(screen_rect, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); + } + } + } +} + +fn draw_page(ui: &mut egui::Ui, view: &mut DocView, frame: &mut PageFrame<'_>, i: usize) { + let PageEnv { allowed, comments_hidden, doc, editing_content, info, ppp, prefs, rects, redact_mark, scale, t, tool, .. } = frame.env; + let PageFrame { ptr, origin, visible, pressed, pointer, .. } = *frame; + frame.out.badge_hover = None; + let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); + if !r.intersects(visible.expand(400.0)) { + return; + } + if pressed && frame.pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { + frame.pressed_page = Some(i); + } + if r.intersects(visible) { + frame.visible_now.push(i); + view.screen_rects.push((i, r)); + view.screen_xforms.push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); + } + let overlap = r.intersect(visible).height(); + if i == view.current { + frame.current_overlap = overlap; + frame.current_height = r.height(); + } + // Pages shown equally (two rows wholly on screen) differ only by rounding: the + // topmost one counts. + if overlap > frame.best_overlap + PAGE_TIE { + frame.best_overlap = overlap; + frame.current = i; + } + let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; + paint_page_surface(ui, view, frame, &PageSlot { i, r, xf }); + let painter = ui.painter(); + view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); + if editing_content { + view.move_background.paint(painter, &xf, i, info, &view.content, pointer); + } + if view.show_page_gaps { + painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); + } + + let slot = PageSlot { i, r, xf }; + let input = page_tool_input(ui, view, frame, &slot); + let painter = ui.painter(); + let pcx = comments::PageCx { page: i, xf: &xf, ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; + let preview_target = (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); + view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); + view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); + + // Any press on the page ends Select All of the document: text read or not, any tool, + // on a link too. + // A press that lands on something drawn over the page (a menu, a dialog) is not one. + if view.all_text && frame.pressed_page == Some(i) { + view.all_text = false; + } + text_layer(ui, view, frame, &slot, &input); + page_overlays(ui, view, frame, &slot, &input); +} + +fn draw_pages( + ui: &mut egui::Ui, + viewport: Rect, + view: &mut DocView, + env: &PageEnv<'_>, + out: &mut PageOut, + signature_preview: &mut Option<(crate::fill_sign::SavedSig, egui::TextureHandle)>, +) -> PageRenders { + let PageEnv { + allowed, + auto_delta, + avail, + boost_extra, + calc_order, + can_copy, + can_modify, + content_h, + content_w, + doc, + edge_delta, + form, + generation, + glide, + hand, + info, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + precise_scroll, + prefs, + preparing, + selection_wheel, + single_paging, + unobstructed, + visible_pages, + y_shift, + .. + } = *env; + // egui's drag responses accept every pointer button. Keep a wheel gesture from also + // selecting text, drawing a mark, or panning with the Hand tool. + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + // Only interactions pause; the document must keep its original colours. + ui.set_opacity(opacity); + } + // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide + // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y + // moves the content down. All scroll without easing, unlike egui's wheel default. + // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution + // until after the text widgets when a selection owns that drag. + let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; + let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); + view.page_widget = Some(resp.id); + // A drag lasts only while egui reports it, so the mark can't outlive one. + if !resp.dragged_by(egui::PointerButton::Primary) { + view.selection_drag = false; + } + // The Hand tool pans by this widget's drag, before the pages are laid out (above). + if hand { + if resp.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + } else if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } + } + let origin = resp_rect.min - vec2(0.0, y_shift); + let visible = viewport.translate(resp_rect.min.to_vec2()); + view.screen_rects.clear(); + view.screen_xforms.clear(); + // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), + // or a press they hold. The page modules read it rather than egui's raw pointer state. + let ptr = PagePointer::read(ui, &resp, modal); + let pointer = ptr.pos; + // A press on a page makes it the current one (as in Acrobat), so a page command with + // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page + // gone to earlier that still shows whole beside it. + let pressed = unobstructed && ptr.pressed().is_some(); + if resp.secondary_clicked() { + // Set again below when the right-click lands on a link. + view.links.context = None; + } + let mut frame = PageFrame { + env: *env, + resp: &resp, + ptr: &ptr, + origin, + visible, + pressed, + pointer, + signature_preview, + out: std::mem::take(out), + wanted: Vec::new(), + visible_now: Vec::new(), + current: view.current, + best_overlap: -1.0, + current_overlap: -1.0, + current_height: f32::INFINITY, + pressed_page: None, + calc_hover: None, + }; + for &i in visible_pages { + draw_page(ui, view, &mut frame, i); + } + let PageFrame { + wanted, visible_now, mut current, best_overlap, current_overlap, current_height, pressed_page, calc_hover, out: page_out, .. + } = frame; + let PageOut { + badge_hover, + mut canvas_action, + clicked_link, + mut copy_selected, + mut field_menu, + field_placed, + field_props, + hover_text, + image_action, + open_boxes, + open_initials, + open_signature, + open_table, + refused, + stamp_placed, + } = page_out; + let painter = ui.painter(); + if calc_order { + view.calc_order.set_hover_page(calc_hover); + let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); + crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); + } + // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag + // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the + // pointer leaves the selection's page for the gap or the next page; over the whole + // document area, like #759. Combine direct and smoothed motion in one request so mixed + // Point and Line events retain their full contribution. + if selection_wheel { + let smooth = if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; + let delta = precise_scroll + boost_extra + smooth; + if delta != Vec2::ZERO && delta.is_finite() { + ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); + } + } + // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view + // so it can reach text off screen (#1670); the selection follows the pointer on its page. + if view.selection_drag + && navigation_enabled + && unobstructed + && !single_paging + && let Some(p) = ptr.pos + { + let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); + if dy != 0.0 { + view.end_find_following(); + ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); + // Keep scrolling while the pointer rests there and sends no events. + ui.ctx().request_repaint(); + } + } + // The page navigated to stays current while no page shows more of itself, so the next + // page step starts from it rather than from a page further down the screen (#188). + // It also stays current while it is wholly on screen: a short page (a cheque) gone to + // from the Pages panel shows less of itself than the tall page below it, and must not + // hand the highlight to that page. + if current_overlap >= best_overlap - PAGE_TIE || current_overlap >= current_height - PAGE_TIE { + current = view.current; + } + if let Some(p) = pressed_page { + current = p; + } + if view.continuous { + view.current = current; + } + resp.context_menu(|ui| { + // Preparing a form: the selected field's menu. + if preparing && let Some((name, wi)) = view.prepare.selected.clone() { + // Several fields: Align, Center, Distribute, Set Fields to Same Size. + if !view.prepare.also.is_empty() { + use crate::prepare::Arrange as A; + let others = view.prepare.also.clone(); + let anchor = (name.clone(), wi); + let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { + if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { + view.pending_edit = crate::prepare::arrange(form, &anchor, &others, op); + ui.close(); + } + }; + ui.menu_button(tl!("Align"), |ui| { + pick(ui, A::AlignLeft, "Left"); + pick(ui, A::AlignRight, "Right"); + pick(ui, A::AlignTop, "Top"); + pick(ui, A::AlignBottom, "Bottom"); + pick(ui, A::AlignCenterV, "Center vertically"); + pick(ui, A::AlignCenterH, "Center horizontally"); + }); + ui.menu_button(tl!("Distribute"), |ui| { + pick(ui, A::DistributeH, "Horizontally"); + pick(ui, A::DistributeV, "Vertically"); + }); + ui.menu_button(tl!("Set Fields to Same Size"), |ui| { + pick(ui, A::SameHeight, "Height"); + pick(ui, A::SameWidth, "Width"); + pick(ui, A::SameSize, "Both"); + }); + ui.separator(); + } + if ui.button(tl!("Properties…")).clicked() { + field_menu = Some(FieldMenu::Properties); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { + field_menu = Some(FieldMenu::Duplicate(name.clone())); + ui.close(); + } + ui.separator(); + let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; + if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { + view.pending_edit = crate::prepare::delete_selected(view); + ui.close(); + } + return; + } + if let Some((page, index)) = view.links.context { + if ui.button(tl!("Properties…")).clicked() { + view.links.open_existing = Some((page, index)); + ui.close(); + } + if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { + view.pending_edit = Some(Edit::DeleteLink { page, index }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + ui.separator(); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { + view.pending_edit = Some(Edit::RemoveLinks { pages: None }); + view.links.selected = None; + view.links.context = None; + ui.close(); + } + return; + } + if view.comments.selected.is_none() { + let selected = view.selected_text().is_some_and(|t| !t.is_empty()); + let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); + if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { + copy_selected = true; + ui.close(); + } + ui.separator(); + } + // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. + let mut copy_image = None; + if view.comments.selected.is_none() + && let Some((page, at)) = view.comments.context_at + { + // The page's content is parsed once per right-click, not on every frame the menu is open. + let key = egui::Id::unique("copy-image-under-menu"); + let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); + let hit = match cached { + Some(c) => c.2, + None => { + let hit = doc.page_image_at(page, at); + ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); + hit + } + }; + if let Some(image) = hit { + if ui.button(tl!("Copy Image")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImage(page, image)); + ui.close(); + } + if ui.button(tl!("Copy Image in Original Format")).clicked() { + copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); + ui.close(); + } + ui.separator(); + } + } + canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); + }); + *out = PageOut { + badge_hover, + canvas_action, + clicked_link, + copy_selected, + field_menu, + field_placed, + field_props, + hover_text, + image_action, + open_boxes, + open_initials, + open_signature, + open_table, + refused, + stamp_placed, + }; + (wanted, visible_now) +} + +/// A zoom the fit mode changed keeps the page at the top of the view in place. +fn anchor_zoom_change(view: &mut DocView, avail_width: f32, old_zoom: f32) { if view.shown && view.zoom_anchor.is_none() && view.goto.is_none() && view.goto_point.is_none() && (view.zoom - old_zoom).abs() > 1e-4 { let prev_viewport = view.viewport_screen; let top_y = prev_viewport.min.y; @@ -922,86 +2103,28 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { .or_else(|| view.screen_rects.iter().find(|(_, r)| r.min.y >= top_y).map(|(p, r)| (*p, 0.0f32, (r.min.y - top_y).max(0.0)))) .or_else(|| view.screen_rects.last().map(|(p, r)| (*p, 1.0f32, (r.max.y - top_y).max(0.0)))); if let Some((page, fy, rel_y)) = anchor { - view.zoom_anchor = Some((page, 0.5, fy, vec2(avail.width() / 2.0, rel_y))); + view.zoom_anchor = Some((page, 0.5, fy, vec2(avail_width / 2.0, rel_y))); } else { - view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail.width() / 2.0, MARGIN))); + view.zoom_anchor = Some((view.current, 0.5, 0.0, vec2(avail_width / 2.0, MARGIN))); } } +} - // Pinch / ctrl+scroll zoom, anchored on the current page. - let zoom_delta = crate::motion::zoom_delta(ui.ctx()); - // Exclude the previous frame's scrollbar tracks from gesture activation. - let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; - let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); - // Full screen: a click on the document goes forward a page and a shift-click back, as in - // Acrobat, unless the click did something else (a link, a word selected). Decided below, - // once the pages have had the click. - let present_click = - view.presentation.is_some() && navigation_enabled && presentation_click(ui, navigation_rect) && app.quick_tool == QuickTool::Select; - // Shift as held now, or as the click's own event carried it (a synthetic click). - let present_back = present_click - && ui.input(|i| { - i.modifiers.shift - || i.events.iter().any(|e| { - matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) - }) - }); - if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && navigation_enabled && ui.rect_contains_pointer(navigation_rect) { - let z = view.zoom * zoom_delta; - match zoom_anchor(ui) { - Some(p) => view.zoom_at(z, p), - None => view.set_zoom(z), - } - } - view.viewport_screen = avail; - let auto_delta = if unobstructed { - view.auto_scroll.update(ui, avail, false) - } else { - view.auto_scroll.cancel(); - Vec2::ZERO - }; +/// What the pager needs to know about the page row on screen. +#[derive(Clone, Copy)] +struct PagingCx<'a> { + row: &'a [usize], + rects: &'a [Rect], + avail: Rect, + middle_gesture: bool, + unobstructed: bool, +} - // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view - // that way, so a point beyond what is shown can be reached without letting go of the tool. - let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { - let (pointer, dt) = drawing_edge_pointer(ui); - crate::autoscroll::edge_scroll(pointer, avail, dt) - } else { - Vec2::ZERO - }; - if edge_delta != Vec2::ZERO { - ui.ctx().request_repaint(); - } - - let content_w = view.content_width(info, avail.width()); - let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); - // When not scrolling continuously, only one row is shown at a time: the current page, or the - // current two-page spread (with the cover rule). These indices drive paging, which pages are - // drawn, and the content height (#752). Empty for a document with no pages. - let shown_row = |view: &DocView| -> Vec { - if rects.is_empty() { - return Vec::new(); - } - let last = rects.len() - 1; - let cur = view.current.min(last); - match view.layout { - PageLayout::Single => vec![cur], - PageLayout::TwoUp if view.cover && cur == 0 => vec![0], - PageLayout::TwoUp => { - let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; - if first < last { vec![first, first + 1] } else { vec![first] } - } - } - }; - let row = shown_row(view); - let middle_gesture = view.auto_scroll.blocks_input(); - // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns - // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on - // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with - // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow - // keys. Runs before `visible_pages` so the frame that turns the page draws it. - let mut single_paging = false; - let mut edge_turned = false; +/// Not scrolling: when the whole page or spread fits, the wheel turns pages instead. Returns whether +/// paging owns the wheel, and whether it turned the page at an edge. +fn single_page_paging(ui: &egui::Ui, view: &mut DocView, cx: &PagingCx<'_>) -> (bool, bool) { + let (mut single_paging, mut edge_turned) = (false, false); + let PagingCx { row, rects, avail, middle_gesture, unobstructed } = *cx; if !view.continuous { // The row's bounding box on screen (one page, or a two-page spread). let row_rect = row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union); @@ -1030,10 +2153,23 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } } - // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, - // smoothed base and adds only the accelerated surplus. While point suppression is active, - // reconstruct line and page input here so current coarse events survive without exposing an - // older eased tail. + (single_paging, edge_turned) +} + +/// This frame's wheel motion over the page view, after paging and point/line suppression. +struct Wheel { + precise_scroll: Vec2, + line_base: Vec2, + boost_extra: Vec2, + has_page: bool, + /// The pager owns the vertical axis (single-page view, or the rest of a gesture that turned the page). + paging_owns_vertical: bool, +} + +/// Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, smoothed +/// base and adds only the accelerated surplus. While point suppression is active, line and page +/// input are reconstructed so current coarse events survive without exposing an older eased tail. +fn read_wheel(ui: &mut egui::Ui, view: &mut DocView, avail: Rect, single_paging: bool, edge_turned: bool) -> Wheel { let now = ui.input(|i| i.time); let input = if ui.rect_contains_pointer(avail) { // Match the line distance used by egui's other scroll areas. @@ -1101,1206 +2237,25 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { // Page input belongs to egui on the free axis; the pager still owns vertical motion. ui.input_mut(|i| i.smooth_scroll_delta.y = 0.0); } - // Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a - // stop, through the same un-eased path. Never while the pager owns the wheel, a gesture or - // dialog has the input, or animations are off; any click, key, zoom or other scrolling ends it. - // Asked of the preference, not of egui's animation time, which test harnesses zero. - let animates = app.animation_effects.animates(app.motion.reduced); - let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; - let over_page = ui.rect_contains_pointer(avail); - ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); - let interrupted = glide_interrupted(ui); - if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { - view.fling.stop(); - } - let glide = view.fling.step(now); - if view.fling.gliding() { - ui.ctx().request_repaint(); - } - // Any reader-owned motion ends automatic search following, even when it stays on the same - // page: the wheel, keyboard, middle-button auto-scroll, a drawing's edge scroll, or the - // momentum left by a touchpad swipe. The automatic flash uses `goto`, not these deltas, so - // its programmatic scroll is never mistaken for a reader action. - let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; - if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { - view.end_find_following(); - } - // The wheel may have turned the page above: draw the row it turned to. Measured from the old - // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. - let row = shown_row(view); - let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; - let (y_shift, content_h) = if view.continuous { - (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) - } else { - // Only the current row scrolls into view; the content is its bounding box plus margins. - match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { - Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), - None => (0.0, 0.0), - } - }; + Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } +} - let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { - drag: if middle_gesture { - egui::scroll_area::DragScroll::Never - } else if app.quick_tool == QuickTool::Hand { - egui::scroll_area::DragScroll::Always - } else { - egui::scroll_area::DragScroll::OnTouch - }, - ..Default::default() - }); - // Each document keeps its own scroll position when switching tabs (#189). - scroll = scroll.id_salt(("page-view", view.id)); - // Every scroll here asks for no animation. An animated area would still queue a zero-length - // offset target for the next frame, which overwrites the jump a page turn just set. - scroll = scroll.animated(false); - // A newly opened document starts at the top. eframe saves egui's memory with the settings - // and document ids start again at 1 every launch, so the offset stored under this id can be - // where an earlier session left another document. - if !std::mem::replace(&mut view.shown, true) { - scroll = scroll.scroll_offset(Vec2::ZERO); - } - // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer - // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the - // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a - // click, the page catches up with all the pointer has moved since the press. A jump asked for - // below still wins. - if app.quick_tool == QuickTool::Hand - && !middle_gesture - && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) - { - let moved = if ui.ctx().drag_started_id() == Some(id) { - ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) - } else { - ui.input(|i| i.pointer.delta()) - }; - if moved != Vec2::ZERO { - view.end_find_following(); - } - // Within the content, as egui keeps it: past an end the page would show there for a - // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. - let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); - } - if let Some(v) = view.sync_scroll.take() { - scroll = scroll.scroll_offset(v); - } - if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { - let r = rects[page.min(rects.len() - 1)]; - let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); - scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); - } else if let Some((page, frac)) = view.goto.take() { - let page = page.min(rects.len() - 1); - let r = rects[page]; - // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() - // too high for a frame and then hopped into place. (`avail` is the viewport, as the - // scroll bars float.) - let max_y = (content_h - avail.height()).max(0.0); - scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); - // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off - // to one side. A page already shown whole, or one filling the window's width, keeps the - // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a - // page that fits is centred, and a wider one scrolled just far enough to fill the window. - // The page counts with its side gutters, which keep its edge clear of the floating - // quick-action bar as the layout does. - let (x, w) = (view.last_scroll.x, avail.width()); - let (left, right) = (r.left() - SIDE, r.right() + SIDE); - let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; - if (target - x).abs() > 0.5 { - scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); - } - } - let ppp = ui.ctx().pixels_per_point(); - let scale = view.render_scale(ppp); - let tag = scale_tag(scale); - // Hold sharp re-renders while the zoom is still moving: a new scale every frame would - // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD - // plot with ~1000 images renders for seconds) the sharp raster never arrives and the - // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the - // sharp request goes out once the zoom settles. - let now_scale = ui.input(|i| i.time); - if (scale - view.last_scale).abs() > f32::EPSILON { - view.last_scale = scale; - view.sharp_hold_until = now_scale + 0.35; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); - } - let sharp_held = now_scale < view.sharp_hold_until; - view.current_scale_tag = tag; - let hand = app.quick_tool == QuickTool::Hand; - let tool = app.quick_tool; - // Text selection runs for the Select tool and for the markup tools (highlight…). - let selects_text = match tool { - QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, - QuickTool::Select => !preparing, - QuickTool::Redact | QuickTool::ColumnSelect => true, - QuickTool::Hand - | QuickTool::Measure(_) - | QuickTool::Crop - | QuickTool::EnhanceCamera - | QuickTool::Fill(_) - | QuickTool::Field(_) - | QuickTool::AddText - | QuickTool::EditText - | QuickTool::Stamp(_) - | QuickTool::CustomStamp(_) - | QuickTool::Link - | QuickTool::SignArea { .. } - | QuickTool::MarqueeZoom - | QuickTool::Snapshot => false, - }; - let prefs = &app.comment_prefs; - let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); - let units = app.units; - let allowed = doc.allows_annotation(); - let can_copy = doc.allows_copying(); - let mut copy_selected = false; - let comments_hidden = doc.comments_hidden(); - let form = doc.form.clone(); - let generation = doc.edit_generation(); - let can_fill = doc.allows_form_filling(); - let can_crop = doc.allows_assembly(); - let mut open_boxes = false; - let signature = app.signature.clone(); - let initials = app.initials.clone(); - let mut open_initials = false; - let author = app.comment_prefs.author.clone(); - let today = app.session.today(); - // In Preferences ▸ Date format, or why it can't be stamped into the PDF. - let date_text = crate::date_text_for_pdf(&app.session); - let mut refused: Option = None; - let by_line = app.session.stamp_by_line(&author); - let mut stamp_placed = false; - let mut image_action: Option = None; - let custom_stamp = match app.quick_tool { - QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), - _ => None, - }; - let mut open_signature = false; - let mut hover_text: Option<(Pos2, String)> = None; - let mut badge_hover: Option<(Pos2, String)> = None; - let mut clicked_link: Option = None; - let mut remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> = None; - let mut canvas_action: Option = None; - let mut field_menu: Option = None; - let mut open_props: Option<(usize, usize)> = None; - let mut field_props = false; - let mut field_placed = false; - let mut open_table: Option<(usize, [f64; 4])> = None; - // The table being set up in its window, shown on its own document's page. - let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); - let can_modify = doc.allows_modification(); - if preparing { - crate::prepare::after_refresh(view, &form); - } else { - view.prepare.selected = None; - } - view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); - view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); - let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; - let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; - if editing_content { - crate::content_ui::after_refresh(view, &added); - } else { - view.content.selected = None; - } - view.move_background.prepare(ui.ctx(), doc, &view.content, scale); +/// What decides which renders a frame asks for. +#[derive(Clone, Copy)] +struct RenderPlan<'a> { + visible_pages: &'a [usize], + tag: u64, + scale: f32, + sharp_held: bool, + pan_held: bool, + hand: bool, +} - // A destination's position (bookmarks, links) refines `goto`: its point at the window's - // top-left, or the centre of a /FitR rectangle at the window's centre. - if let Some((page, fx, fy, rel)) = view.goto_point.take() { - let r = rects[page.min(rects.len() - 1)]; - // Within the content, so an unreachable point never shows a frame scrolled past the end. - let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); - if let Some(fx) = fx { - scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); - } - if let Some(fy) = fy { - scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); - } - } - let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); - let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { - // egui's drag responses accept every pointer button. Keep a wheel gesture from also - // selecting text, drawing a mark, or panning with the Hand tool. - if middle_gesture { - let opacity = ui.opacity(); - ui.disable(); - // Only interactions pause; the document must keep its original colours. - ui.set_opacity(opacity); - } - // Middle-button auto-scroll, the keyboard, precise touchpad input (#759) and its glide - // (#531), accelerated wheel surplus (#1072), and a drawing's edge scroll; positive y - // moves the content down. All scroll without easing, unlike egui's wheel default. - // ScrollArea ignores wheel input during a widget drag. Defer the wheel contribution - // until after the text widgets when a selection owns that drag. - let wheel_delta = if selection_wheel { Vec2::ZERO } else { precise_scroll + boost_extra }; - let delta = auto_delta + edge_delta - vec2(0.0, key_scroll) + wheel_delta + glide; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - let (resp_rect, resp) = ui.allocate_exact_size(vec2(content_w, content_h), Sense::click_and_drag()); - view.page_widget = Some(resp.id); - // A drag lasts only while egui reports it, so the mark can't outlive one. - if !resp.dragged_by(egui::PointerButton::Primary) { - view.selection_drag = false; - } - // The Hand tool pans by this widget's drag, before the pages are laid out (above). - if hand { - if resp.dragged() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - } else if resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); - } - } - let origin = resp_rect.min - vec2(0.0, y_shift); - let painter = ui.painter(); - let visible = viewport.translate(resp_rect.min.to_vec2()); - let mut wanted = Vec::new(); - let mut visible_now = Vec::new(); - view.screen_rects.clear(); - view.screen_xforms.clear(); - // Visible heights (points) closer than this are equal: one page size can meet the - // viewport a fraction of a point differently once the scroll offset is applied. - const TIE: f32 = 0.5; - let mut current = view.current; - let mut best_overlap = -1.0f32; - let mut current_overlap = -1.0f32; - let mut current_height = f32::INFINITY; - // The pointer as the pages see it: nothing over them (a dialog from the frame it opens), - // or a press they hold. The page modules read it rather than egui's raw pointer state. - let ptr = PagePointer::read(ui, &resp, modal); - let pointer = ptr.pos; - // A press on a page makes it the current one (as in Acrobat), so a page command with - // no selection (Rotate, Delete) acts on the page the reader pointed at, not on a page - // gone to earlier that still shows whole beside it. - let pressed = unobstructed && ptr.pressed().is_some(); - let mut pressed_page = None; - if resp.secondary_clicked() { - // Set again below when the right-click lands on a link. - view.links.context = None; - } - let mut calc_hover = None; - for &i in &visible_pages { - badge_hover = None; - let r = snap_to_pixels(rects[i].translate(origin.to_vec2()), ppp); - if !r.intersects(visible.expand(400.0)) { - continue; - } - if pressed && pressed_page.is_none() && pointer.is_some_and(|p| r.contains(p)) { - pressed_page = Some(i); - } - if r.intersects(visible) { - visible_now.push(i); - view.screen_rects.push((i, r)); - view.screen_xforms - .push((i, PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) })); - } - let overlap = r.intersect(visible).height(); - if i == view.current { - current_overlap = overlap; - current_height = r.height(); - } - // Pages shown equally (two rows wholly on screen) differ only by rounding: the - // topmost one counts. - if overlap > best_overlap + TIE { - best_overlap = overlap; - current = i; - } - let xf = PageXform { rect: r, rot: view.rotation, pw: info.pages[i].width.max(1.0), ph: info.pages[i].height.max(1.0) }; - // Shadows would darken the preceding page when pages touch. - if view.show_page_gaps { - painter.add(egui::epaint::Shadow { offset: [0, 3], blur: 14, spread: 0, color: t.page_shadow }.as_shape(r, CornerRadius::ZERO)); - } - painter.rect_filled(r, CornerRadius::ZERO, paper); - if let Some(err) = view.errors.get(&i) { - painter.rect_filled(r, CornerRadius::ZERO, Color32::from_rgb(0xFB, 0xF4, 0xF4)); - icons::paint( - ui, - Rect::from_center_size(r.center() - vec2(0.0, 26.0), vec2(28.0, 28.0)), - "triangle-alert", - 26.0, - Color32::from_rgb(0xC8, 0x3A, 0x3A), - ); - let message = crate::i18n::fmt(tl!("This page couldn't be displayed.\n{e}"), &[("e", err)]); - let msg = - ui.fonts_mut(|f| f.layout(message, theme::regular(12.5), Color32::from_rgb(0x6A, 0x2A, 0x2A), (r.width() - 40.0).max(80.0))); - painter.galley(pos2(r.center().x - msg.size().x / 2.0, r.center().y), msg, Color32::BLACK); - } else { - let (pw_pt, ph_pt) = (info.pages[i].width.max(1.0), info.pages[i].height.max(1.0)); - // A GPU may take smaller textures than these (OpenGL drivers report as little as - // 2048 pixels, and uploading a bigger one aborts), so both stay within its limit. - let max_side = ui.ctx().input(|inp| inp.max_texture_side) as f32; - let tiled = pw_pt.max(ph_pt) * scale > TILE_THRESHOLD.min(max_side); - // Whole-page raster: sharp when small, a low-res backdrop when tiled. - let (want_scale, want_tag) = if tiled { - let bs = BASE_SIDE.min(max_side) / pw_pt.max(ph_pt); - (bs, scale_tag(bs)) - } else { - (scale, tag) - }; - // Under tiles, a whole-page raster sharper than the backdrop (from before the page - // grew past the tiling size) is a better backdrop: keep demanding it as it is. - match view.pages.get(&i).filter(|p| tiled && p.tag != STALE_TAG && tag_scale(p.tag) >= want_scale) { - Some(p) => wanted.push((i, tag_scale(p.tag), p.tag, None)), - None => wanted.push((i, want_scale, want_tag, None)), - } - match view.pages.get(&i) { - Some(p) => { - if !tiled && p.tag == want_tag { - xf.texel_aligned(p.tex.size(), ppp).paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); - } else { - // A backdrop, or a raster at an older scale until the new one arrives. - xf.paint_image(painter, p.tex.id(), 0.0, 0.0, 1.0, 1.0); - } - } - None => { - let now = ui.input(|inp| inp.time); - let since = *view.waiting_since.entry(i).or_insert(now); - let msg = if now - since > 6.0 { "Still rendering — this page is unusually complex…" } else { "Rendering…" }; - painter.text(r.center(), Align2::CENTER_CENTER, tl!(msg), theme::regular(12.0), t.text_faint); - } - } - if tiled && r.intersects(visible) { - // Device-pixel geometry of the scaled page, and the visible part of it - // (found by mapping the visible screen corners back into the page). Sides round - // up as whole-page rasters do, so the last partial row and column get tiles. - let (dw, dh) = (device_pixels(pw_pt, scale), device_pixels(ph_pt, scale)); - // Adaptive tile side for this zoom and page, within the GPU texture limit: - // a zoomed viewport or a giant sheet is covered by a few large tiles - // instead of many small ones, and each tile costs one full page interpret. - // Never zero (u32 division below). - let ts = tile_side(scale, dw.max(dh)).min(max_side as u32).max(64); - let vis = r.intersect(visible); - let corners = [vis.left_top(), vis.right_top(), vis.right_bottom(), vis.left_bottom()].map(|c| xf.screen_to_norm(c)); - let (u0, u1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.0), b.max(c.0))); - let (v0, v1) = corners.iter().fold((1.0f32, 0.0f32), |(a, b), c| (a.min(c.1), b.max(c.1))); - let (vx0, vy0) = ((u0.max(0.0) * dw as f32) as u32, (v0.max(0.0) * dh as f32) as u32); - let (vx1, vy1) = (((u1.min(1.0) * dw as f32).ceil() as u32).min(dw), ((v1.min(1.0) * dh as f32).ceil() as u32).min(dh)); - // Tiles of earlier zooms first, stretched, the nearest scale last (on top): - // until the current tiles arrive, a zoom shows them rather than the backdrop. - let mut older: Vec<(f32, u32, u32, &TextureHandle)> = view - .tiles - .iter() - .filter(|((p, t, _, _), _)| *p == i && *t != tag) - .map(|((_, t, ox, oy), tex)| (tag_scale(*t), *ox, *oy, tex)) - .collect(); - older.sort_by(|a, b| (b.0 / scale).ln().abs().total_cmp(&(a.0 / scale).ln().abs())); - for (s, ox, oy, tex) in older { - let (ow, oh) = (device_pixels(pw_pt, s) as f32, device_pixels(ph_pt, s) as f32); - let [w, h] = tex.size(); - let (x, y) = (ox as f32, oy as f32); - xf.paint_image(painter, tex.id(), x / ow, y / oh, (x + w as f32) / ow, (y + h as f32) / oh); - } - // Visible tiles, nearest the viewport centre first: each tile costs a full - // page interpret, so the workers sharpen what is looked at before the edges. - let (vcx, vcy) = ((vx0 + vx1) as f32 / 2.0, (vy0 + vy1) as f32 / 2.0); - let mut coords: Vec<(u32, u32)> = Vec::new(); - for ty in vy0 / ts..=(vy1.saturating_sub(1)) / ts { - for tx in vx0 / ts..=(vx1.saturating_sub(1)) / ts { - coords.push((tx, ty)); - } - } - coords.sort_by(|a, b| { - let da = ((a.0 * ts + ts / 2) as f32 - vcx).abs() + ((a.1 * ts + ts / 2) as f32 - vcy).abs(); - let db = ((b.0 * ts + ts / 2) as f32 - vcx).abs() + ((b.1 * ts + ts / 2) as f32 - vcy).abs(); - da.total_cmp(&db) - }); - for (tx, ty) in coords { - let (x, y) = (tx * ts, ty * ts); - let (w, h) = (ts.min(dw.saturating_sub(x)), ts.min(dh.saturating_sub(y))); - if w == 0 || h == 0 { - continue; - } - wanted.push((i, scale, tag, Some(Tile { x, y, w, h }))); - if let Some(tex) = view.tiles.get(&(i, tag, x, y)) { - let (fw, fh) = (dw as f32, dh as f32); - xf.texel_aligned([dw as usize, dh as usize], ppp).paint_image( - painter, - tex.id(), - x as f32 / fw, - y as f32 / fh, - (x + w) as f32 / fw, - (y + h) as f32 / fh, - ); - } - } - } - } - view.text_background.paint(painter, &xf, i, view.line_editor.as_ref(), view.content.draft.as_ref()); - if editing_content { - view.move_background.paint(painter, &xf, i, info, &view.content, pointer); - } - if view.show_page_gaps { - painter.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, t.border.gamma_multiply(0.8)), egui::StrokeKind::Outside); - } - - // Comments: tools, selection, moving and resizing come before text selection. - let pcx = comments::PageCx { page: i, xf: &xf, ptr: &ptr, info, tool, prefs, allowed, hidden: comments_hidden, redact_mark }; - if let QuickTool::Measure(measure_tool) = tool { - crate::measure_ui::page_input(ui, &resp, doc, view, &pcx, measure_tool, units); - } - - // Form fields take clicks first with the Select tool (as Acrobat fills fields in - // every viewing mode); then comments; then text selection. - if let QuickTool::Stamp(kind) = tool - && allowed - && let Some(p) = ptr.within(xf.rect) - { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if resp.clicked() { - // Centred on the click, upright as the page is shown. - let (vx, vy) = xf.screen_to_view(p); - let (w, h) = kind.size(); - let corners = [(vx as f64 - w / 2.0, vy as f64 - h / 2.0), (vx as f64 + w / 2.0, vy as f64 + h / 2.0)]; - let u: Vec<[f32; 2]> = corners.iter().map(|(x, y)| info.pages[i].view_to_user(*x as f32, *y as f32)).collect(); - let rect = [u[0][0].min(u[1][0]) as f64, u[0][1].min(u[1][1]) as f64, u[0][0].max(u[1][0]) as f64, u[0][1].max(u[1][1]) as f64]; - let by = (kind.group() == pdfcraft_engine::StampGroup::Dynamic).then(|| by_line.clone()); - let shape = pdfcraft_engine::Shape::Stamp { rect, stamp: kind, by }; - view.pending_edit = Some(pdfcraft_engine::Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { - page: i, - style: pdfcraft_engine::Style::default_for(&shape), - shape, - contents: String::new(), - author: author.clone(), - })); - stamp_placed = true; - } - } - if let Some(cs) = custom_stamp.as_ref() - && allowed - && let Some(p) = ptr.within(xf.rect) - { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - if resp.clicked() { - // Centred on the click at its natural size (the engine sizes it). - let (vx, vy) = xf.screen_to_view(p); - let u = info.pages[i].view_to_user(vx, vy); - let (x, y) = (u[0] as f64, u[1] as f64); - view.pending_edit = Some(pdfcraft_engine::Edit::AddCustomStamp { - page: i, - rect: [x, y, x, y], - name: cs.name.clone(), - file: pdfcraft_engine::MarkFile { name: cs.file.clone(), bytes: cs.data.clone(), page: cs.page }, - author: author.clone(), - }); - stamp_placed = true; - } - } - // Over a mark already placed, the Fill & Sign tool picks it up (comments, below). - if let QuickTool::Fill(ft) = tool - && allowed - && !comments::fill_grabs(&pcx, view) - { - match crate::fill_sign::page_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - ft, - app.fill_text_size, - view, - signature.as_ref(), - initials.as_ref(), - &mut app.signature_preview, - &author, - &date_text, - ) { - Some(crate::fill_sign::FillAction::Edit(e)) => view.pending_edit = Some(*e), - Some(crate::fill_sign::FillAction::Signature(e)) => { - view.pending_edit = Some(*e); - view.fill_signature_page = Some(i); - } - Some(crate::fill_sign::FillAction::CreateSignature) => open_signature = true, - Some(crate::fill_sign::FillAction::CreateInitials) => open_initials = true, - Some(crate::fill_sign::FillAction::Refused(why)) => refused = Some(why), - None => {} - } - } - if matches!(tool, QuickTool::SignArea { .. }) { - crate::sign_ui::page_input(ui, &resp, &xf, &ptr, i, info, view); - } - if matches!(tool, QuickTool::MarqueeZoom | QuickTool::Snapshot) { - crate::zoom_snap::page_input(ui, &resp, &xf, &ptr, i, view); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::page_input(ui, &resp, &xf, &ptr, i, view, can_modify); - } - if tool == QuickTool::Crop && crate::crop::page_input(ui, &resp, &xf, &ptr, i, info, view, can_crop) { - view.current = i; - open_boxes = true; - } - // `over_field`: a form field is under the pointer and has set its cursor. - let (on_field, over_field) = if preparing { - let field_tool = match tool { - QuickTool::Field(f) => Some(f), - _ => None, - }; - // While a table is being set up, nothing else is drawn or moved. - let allowed = can_modify && table_preview.is_none() && open_table.is_none(); - let o = crate::prepare::page_input(ui, &resp, &xf, &ptr, i, info, &form, field_tool, allowed, view); - field_props |= o.properties; - field_placed |= o.placed; - open_table = open_table.or(o.table); - (o.consumed || field_tool.is_some(), false) - } else { - use crate::forms_ui::FieldPointer; - let field = if tool == QuickTool::Select - && !(editing_content && can_modify && crate::content_ui::select_grabs(&xf, &ptr, i, info, &added, view)) - && !comments::select_grabs(&pcx, view) - { - crate::forms_ui::page_input(ui, &resp, &xf, &ptr, i, info, &form, can_fill, view) - } else { - FieldPointer::Away - }; - (field == FieldPointer::Owned, field == FieldPointer::Over) - }; - // Redact draws boxes off text; so does Highlight (an area highlight, as in Acrobat). - let area_tool = tool == QuickTool::Redact && can_modify || tool == QuickTool::Comment(comments::CommentTool::Highlight) && allowed; - let boxing = area_tool && { - let text = view.page_text(i); - let over_text = |p: Pos2| { - let (vx, vy) = xf.screen_to_view(p); - text.as_ref().is_some_and(|t| t.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3])) - }; - crate::redact_ui::page_input(ui, &resp, &xf, &ptr, i, info, over_text, view) - }; - let image_owns_pointer = tool == QuickTool::EditText && crate::edit_text_ui::image_owns_pointer(ui, &xf, &ptr, i, info, view); - let on_objects = - !image_owns_pointer && tool == QuickTool::EditText && can_modify && crate::object_ui::page_input(ui, &resp, &xf, &ptr, i, doc, view); - let lines = if matches!(tool, QuickTool::AddText | QuickTool::EditText) && can_modify { - let generation = doc.edit_generation(); - match view.edit_lines.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.text_blocks(i); - view.edit_lines.insert(i, (generation, l.clone())); - l - } - } - } else { - Vec::new() - }; - let over_source = tool == QuickTool::AddText - && pointer.is_some_and(|p| { - !crate::content_ui::hits_added(&xf, info, i, &added, p) - && lines.iter().any(|line| xf.user_rect(info, i, line.rect.map(|v| v as f32)).expand(2.0).contains(p)) - }); - let source_blocked = over_source && view.content.hold_blocked(); - if over_source && resp.clicked() && !source_blocked { - if let Some(edit) = view.content.finish_draft(&added) { - view.pending_edit = Some(edit); - } - view.content.selected = None; - } - let on_content = !on_objects && editing_content && can_modify && { - source_blocked - || crate::content_ui::page_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - &added, - tool == QuickTool::AddText && !over_source, - matches!(tool, QuickTool::AddText | QuickTool::EditText), - &text_style, - view, - ) - || tool == QuickTool::AddText && !over_source - }; - // Added content owns its gesture; its source text must not queue a second edit. - // Edit text & images boxes every paragraph and image whenever the tool is on. Only the - // input waits while selected objects or added content own the pointer, or while page - // input is blocked (another tab, a dialog, the window unfocused): the boxes stay drawn - // instead of vanishing until the pointer comes back. - let others_own_pointer = on_objects || on_content; - let on_edit_text = (tool == QuickTool::EditText || over_source) && can_modify && { - let generation = doc.edit_generation(); - let images = match view.edit_images.get(&i) { - Some((g, l)) if *g == generation => l.clone(), - _ => { - let l = doc.page_images(i); - view.edit_images.insert(i, (generation, l.clone())); - l - } - }; - // Editable text takes precedence over background/overlapping artwork. Both - // inventories are painted every frame, even when text owns the pointer. - let text_used = - crate::edit_text_ui::page_input(ui, &resp, &xf, &ptr, i, doc, &lines, view, !others_own_pointer && !image_owns_pointer); - let image_used = crate::edit_text_ui::image_input( - ui, - &resp, - &xf, - &ptr, - i, - info, - &images, - view, - &mut image_action, - !others_own_pointer && (image_owns_pointer || !text_used), - ); - !others_own_pointer && (text_used || image_used) - }; - let on_link = tool == QuickTool::Link && can_modify && crate::link_ui::page_input(ui, &resp, &xf, &ptr, i, info, &doc_links, view); - if tool == QuickTool::Select - && resp.secondary_clicked() - && can_modify - && let Some(p) = pointer.filter(|p| xf.rect.contains(*p)) - && let Some(l) = doc.links().iter().find(|l| l.page == i && crate::link_ui::screen_rect(&xf, info, i, l.rect).expand(2.0).contains(p)) - { - view.links.context = Some((i, l.index)); - } - let consumed = on_objects || on_edit_text || on_link || on_content || boxing || on_field || comments::page_input(ui, &resp, &pcx, view); - - let preview_target = - (matches!(tool, QuickTool::Select | QuickTool::Fill(_)) && !comments_hidden).then_some(view.comments.selected).flatten(); - view.signature_drag.prepare(ui.ctx(), doc, preview_target, scale); - view.signature_drag.paint(painter, &pcx, &view.comments, view.pending_edit.as_ref()); - - // Any press on the page ends Select All of the document: text read or not, any tool, - // on a link too. - // A press that lands on something drawn over the page (a menu, a dialog) is not one. - if view.all_text && pressed_page == Some(i) { - view.all_text = false; - } - // Text layer: find matches, selection, I-beam and drag-to-select. - let to_screen = |g: [f32; 4]| xf.view_rect(g); - if let Some(text) = view.texts.get(&i).cloned() { - if let Some(f) = &view.find { - for (k, (mp, range)) in f.matches.iter().enumerate() { - if *mp != i { - continue; - } - let current = f.current == Some(k); - for lr in text.line_rects(range.clone()) { - let fill = if current { - Color32::from_rgba_unmultiplied(255, 140, 0, 110) - } else { - Color32::from_rgba_unmultiplied(255, 214, 0, 90) - }; - painter.rect_filled(to_screen(lr).expand(1.0), CornerRadius::same(2), fill); - } - } - } - if view.all_text { - for lr in text.line_rects(0..text.glyphs.len()) { - painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - } - if let Some(sel) = view.selection.filter(|s| s.page == i) { - for lr in sel.rects(&text) { - painter.rect_filled(to_screen(lr), CornerRadius::same(1), Color32::from_rgba_unmultiplied(0x3A, 0x7B, 0xF0, 70)); - } - // The rectangle being drawn, as Acrobat shows it while column-selecting. - if let Span::Column { rect } = sel.span - && resp.dragged() - { - let outline = Stroke::new(1.0, Color32::from_rgb(0x3A, 0x7B, 0xF0)); - painter.rect_stroke(to_screen(rect), CornerRadius::ZERO, outline, egui::StrokeKind::Middle); - } - } - if selects_text - && !consumed - && let Some(p) = pointer.filter(|p| r.contains(*p)) - { - let (vx, vy) = xf.screen_to_view(p); - let over_text = text.glyphs.iter().any(|g| vx >= g.rect[0] && vx <= g.rect[2] && vy >= g.rect[1] && vy <= g.rect[3]); - let over_link = info.links.iter().any(|l| l.page == i && xf.user_rect(info, i, l.rect).contains(p)); - // Column select (#740): the Column select tool, or Alt (Option on macOS) held - // while a drag starts, as in Acrobat. - let column = tool == QuickTool::ColumnSelect || ui.input(|inp| inp.modifiers.alt); - // A link or a form field under the pointer keeps its own cursor: a check box - // drawn with a glyph, or set on printed text, shows the hand, not the I-beam. - // So do the mode bar and side panels over a page scrolled under them (#2755), - // unless a selection is being dragged there. - let cursor_free = !over_link && !over_field && (resp.contains_pointer() || resp.dragged()); - if column && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } else if over_text && cursor_free { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } - // The pages' press, kept when its release came in the same frame (#2100): it - // still starts multi-click selection. - let press_here = ptr.origin.is_some_and(|o| r.contains(o)); - let pressed = ptr.pressed().is_some(); - if pressed && press_here && !over_link { - let now = ui.input(|inp| inp.time); - // The same limits egui uses to count clicks on release. - let (max_delay, max_dist) = ui.ctx().options(|o| (o.input_options.max_double_click_delay, o.input_options.max_click_dist)); - let is_consecutive = - now - view.last_press_time <= max_delay && view.last_press_pos.is_some_and(|pos| pos.distance(p) <= max_dist); - view.clicks = if is_consecutive { view.clicks.saturating_add(1) } else { 1 }; - view.last_press_time = now; - view.last_press_pos = Some(p); - - // Multi-click highlights immediately on pointer press instead of release (#1024). - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - if clicks >= 2 && over_text { - let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); - if let Some((first, last)) = span { - view.selection = Some(Selection::run(i, first, last)); - } - } - } - if resp.drag_started() && press_here && !over_link { - // A drag replaces the selection without passing through none, so a - // re-drag over text whose bar Copy closed would keep the bar hidden. - view.actions_dismissed = None; - view.selection_drag = true; - let origin = ptr.origin.unwrap_or(p); - let (ox, oy) = xf.screen_to_view(origin); - if view.clicks < 2 { - view.selection = if column { - Some(Selection { page: i, span: Span::Column { rect: [ox, oy, vx, vy] } }) - } else { - text.nearest(ox, oy).map(|a| Selection::run(i, a, a)) - }; - } - } - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - if resp.dragged() - && let Some(sel) = view.selection.as_mut().filter(|s| s.page == i) - { - match &mut sel.span { - // After a multi-click the drag extends by its unit (#1685), as in - // Acrobat and browsers: whole words or lines, always keeping the one - // clicked, which holds the anchor whichever way the drag went. - Span::Run { anchor, head } if clicks >= 2 => { - if let Some(h) = text.nearest(vx, vy) - && let Some((first, last)) = click_unit(&text, clicks, *anchor) - { - let (to_first, to_last) = click_unit(&text, clicks, h).unwrap_or((h, h)); - (*anchor, *head) = if to_first < first { (last, to_first) } else { (first, to_last.max(last)) }; - } - } - Span::Run { head, .. } => { - if let Some(h) = text.nearest(vx, vy) { - *head = h; - } - } - Span::Column { rect } => { - rect[2] = vx; - rect[3] = vy; - } - } - } - // A rectangle around no text selects nothing. - if resp.drag_stopped() - && view.selection.is_some_and(|s| s.page == i && matches!(s.span, Span::Column { .. }) && s.text(&text).is_empty()) - { - view.selection = None; - } - // Quick clicks widen the selection: two a word, three the line, four the page. - // Release-based fallback for platforms or synthetic events that report double_clicked. - let release_clicks = if resp.triple_clicked() { - view.clicks.max(2).saturating_add(1) - } else if resp.double_clicked() { - view.clicks.max(2) - } else { - 0 - }; - if release_clicks > 0 { - view.clicks = release_clicks; - } - let clicks = if view.clicks > 2 && tool != QuickTool::Select { 1 } else { view.clicks }; - // Only on the frame egui reports the multi-click: `view.clicks` outlives it, and - // acting on every frame re-selected the word as soon as a selection was cleared - // (a highlight made by the double-click left its word selected). - if release_clicks > 0 && clicks >= 2 && over_text { - if view.selection.is_none() { - let span = text.nearest(vx, vy).and_then(|a| click_unit(&text, clicks, a)); - if let Some((first, last)) = span { - view.selection = Some(Selection::run(i, first, last)); - } - } - } else if resp.clicked() && view.clicks < 2 && !over_link { - // ⇧-click extends a selection on this page to the click, keeping its - // anchor (#527). Otherwise (no shift, no selection on this page, or a - // column selection, which has no anchor glyph; a selection cannot yet - // span pages) a click clears the selection. - let shift = ui.input(|inp| inp.modifiers.shift); - match (view.selection.as_mut().filter(|s| shift && s.page == i).map(|s| &mut s.span), text.nearest(vx, vy)) { - (Some(Span::Run { head, .. }), Some(h)) => *head = h, - _ => view.selection = None, - } - } - } - } - - if tool == QuickTool::Select && !consumed { - crate::image_pick::page_input(&resp, &xf, i, info, doc, pointer, view); - } - crate::image_pick::drop_stale(doc, view); - crate::image_pick::paint(painter, &xf, i, info, view); - comments::page_after_text(&resp, &pcx, view); - if tool == QuickTool::Redact && can_modify { - crate::redact_ui::after_text(&resp, i, info, view); - } - if area_tool { - crate::redact_ui::paint(painter, i, view, pointer); - } - comments::paint_page(ui, painter, &pcx, view); - if editing_content { - crate::content_ui::paint_page(painter, &xf, i, info, &added, view, pointer); - } - if tool == QuickTool::EditText { - crate::object_ui::paint(painter, &xf, i, info, &view.objects); - } - if tool == QuickTool::EnhanceCamera { - crate::camera_ui::paint(painter, &xf, i, view); - } - if tool == QuickTool::Link { - crate::link_ui::paint(painter, &xf, i, info, &doc_links, view, pointer); - } - if preparing { - crate::prepare::paint_page(ui, painter, &xf, i, info, &form, view, pointer); - if let Some(preview) = &table_preview { - crate::field_table_ui::paint_preview(painter, &xf, i, preview); - } - if calc_order { - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - if let Some(name) = - crate::calc_order_ui::paint_page(painter, &xf, i, info, &form, &view.calc_order, generation, selected, pointer) - { - calc_hover = Some(name); - } - } - } else { - crate::forms_ui::paint_page(painter, &xf, i, info, &form, view, pointer); - } - - // Form-field highlight (Acrobat's "Highlight existing fields"); required fields get a - // red border. Radio buttons and round XFA check boxes are round, and so is theirs (as - // in Acrobat). Only what can be filled is marked: a read-only widget (its field's, or a - // radio button's own) is left as it is drawn. - if view.highlight_fields { - for f in form.iter() { - let required = f.has(pdfcraft_engine::field_flags::REQUIRED); - for w in f.widgets.iter().filter(|w| w.page == Some(i) && !w.hidden && !w.read_only) { - let r = w.rect; - let sr = xf.user_rect(info, i, [r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]); - let tint = Color32::from_rgba_unmultiplied(0x6E, 0x8E, 0xF5, 48); - let (width, color) = - if required { (2.0, Color32::from_rgb(0xE3, 0x22, 0x22)) } else { (1.0, Color32::from_rgb(0x6E, 0x8E, 0xF5)) }; - if w.round { - let radius = sr.width().min(sr.height()) / 2.0; - painter.circle_filled(sr.center(), radius, tint); - painter.circle_stroke(sr.center(), radius - width / 2.0, Stroke::new(width, color)); - } else { - painter.rect_filled(sr, CornerRadius::same(1), tint); - painter.rect_stroke(sr, CornerRadius::same(1), Stroke::new(width, color), egui::StrokeKind::Inside); - } - } - } - } - // Fill & Sign keeps its placement cursor clear of link/comment hover feedback. Tips - // draw above the palette, so none shows while it is open, even beside it. - if !matches!(tool, QuickTool::Fill(_)) - && unobstructed - && let Some(p) = pointer - { - for l in info.links.iter().filter(|l| l.page == i) { - let sr = xf.user_rect(info, i, l.rect); - if sr.contains(p) { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - let label = match &l.target { - LinkTarget::Page(n, _) => { - crate::i18n::fmt(tl!("Go to page {p}"), &[("p", info.pages.get(*n).map(|p| p.label.as_str()).unwrap_or("?"))]) - } - LinkTarget::Uri(u) => u.clone(), - LinkTarget::SetLayers { .. } => tl!("Set layer visibility").to_string(), - LinkTarget::RemoteGoTo { file, .. } => file.clone(), - LinkTarget::Other(s) => crate::i18n::fmt(tl!("{s} action"), &[("s", s)]), - }; - hover_text = Some((p, label)); - if resp.clicked() && tool == QuickTool::Select && !consumed { - clicked_link = Some(l.target.clone()); - } - } - } - // Annotation hover shows the comment, as Acrobat's popups do; not while drawing - // freehand, where it would cover the next stroke (#429). - let freehand = matches!(tool, QuickTool::Comment(comments::CommentTool::Ink | comments::CommentTool::Eraser)); - let gesturing = view.comments.gesture.is_some() || freehand; - // Inspection sorts annotations spatially, but the renderer paints in /Annots - // order. Keep the shared list intact and choose the last eligible raw index. - if hover_text.is_none() - && !gesturing - && let Some((_, text)) = info - .annotations - .iter() - .filter(|a| a.page == i) - .filter(|a| { - !(prefs.hide_empty_highlight_hover && a.subtype == "Highlight" && a.contents.as_deref().is_none_or(str::is_empty)) - }) - .filter(|a| pcx.screen_rects(a).any(|r| r.contains(p))) - .filter_map(|a| annotation_hover_text(a).map(|text| (a.index, text))) - .max_by_key(|(index, _)| *index) - { - hover_text = Some((p, text)); - } - } - for (mp, mr, mc) in &view.compare_marks { - if *mp == i { - let sr = xf.user_rect(info, i, *mr).expand(1.5); - painter.rect_filled(sr, CornerRadius::same(2), mc.gamma_multiply(0.28)); - } - } - if let Some((fp, fr, t0)) = view.flash - && fp == i - { - let now = ui.input(|inp| inp.time); - let t0 = if t0 == 0.0 { now } else { t0 }; - view.flash = Some((fp, fr, t0)); - let age = (now - t0) as f32; - if age < 1.6 { - let a = ((1.6 - age) / 1.6 * 255.0) as u8; - let sr = xf.user_rect(info, i, fr).expand(4.0); - painter.rect_stroke( - sr, - CornerRadius::same(3), - Stroke::new(2.5, Color32::from_rgba_unmultiplied(0x1B, 0x63, 0xE0, a)), - egui::StrokeKind::Outside, - ); - ui.ctx().request_repaint(); - } else { - view.flash = None; - } - } - // Validation is viewer chrome over the signer's original artwork, which - // may still say "Signature Not Verified". These badges never enter document - // streams, image/text exports, vector printing or Print as Image. - for badge in signature_badges(&doc.signatures, i) { - let [x0, y0, x1, y1] = badge.rect; - let sr = xf.user_rect(info, i, [x0 as f32, y0 as f32, x1 as f32, y1 as f32]); - let (icon, color) = crate::sign_ui::signature_status_style(badge.status); - painter.rect_stroke(sr, CornerRadius::same(2), Stroke::new(1.5, color), egui::StrokeKind::Outside); - // A widget too small for the mark keeps its outline: the verdict still shows. - if sr.width() < 4.0 || sr.height() < 4.0 { - continue; - } - // Big enough to read at a glance, small enough to leave the signer's own - // text visible around it. - let side = (sr.width().min(sr.height()) * 0.8).clamp(24.0, 192.0); - let mark = Rect::from_center_size(sr.center(), vec2(side, side)); - icons::paint(ui, mark, icon, side * 0.64, color); - if let Some(p) = pointer.filter(|p| mark.expand(2.0).contains(*p)) { - badge_hover = Some((p, badge.summary.to_string())); - } - } - // Overlapping widgets paint in order, so the topmost badge owns the hover; an - // earlier link or annotation hover still wins over any badge. - if hover_text.is_none() { - hover_text = badge_hover.take(); - } - } - if calc_order { - view.calc_order.set_hover_page(calc_hover); - let selected = view.prepare.selected.as_ref().map(|(name, _)| name.as_str()); - crate::calc_order_ui::paint_trace(ui, painter, visible, &view.calc_order, generation, selected); - } - // ScrollArea ignores the wheel while any widget is dragged, so a text-selection drag - // scrolls the page itself (#721). After the page loop, so it keeps scrolling once the - // pointer leaves the selection's page for the gap or the next page; over the whole - // document area, like #759. Combine direct and smoothed motion in one request so mixed - // Point and Line events retain their full contribution. - if selection_wheel { - let smooth = - if view.precise_scroll_until == 0.0 { ui.input_mut(|input| std::mem::take(&mut input.smooth_scroll_delta)) } else { Vec2::ZERO }; - let delta = precise_scroll + boost_extra + smooth; - if delta != Vec2::ZERO && delta.is_finite() { - ui.scroll_with_delta_animation(delta, egui::style::ScrollAnimation::none()); - } - } - // Held at the top or bottom edge, or dragged past it, a text selection scrolls the view - // so it can reach text off screen (#1670); the selection follows the pointer on its page. - if view.selection_drag - && navigation_enabled - && unobstructed - && !single_paging - && let Some(p) = ptr.pos - { - let dy = crate::autoscroll::edge_delta(p.y, avail, ui.input(|i| i.stable_dt)); - if dy != 0.0 { - view.end_find_following(); - ui.scroll_with_delta_animation(vec2(0.0, dy), egui::style::ScrollAnimation::none()); - // Keep scrolling while the pointer rests there and sends no events. - ui.ctx().request_repaint(); - } - } - // The page navigated to stays current while no page shows more of itself, so the next - // page step starts from it rather than from a page further down the screen (#188). - // It also stays current while it is wholly on screen: a short page (a cheque) gone to - // from the Pages panel shows less of itself than the tall page below it, and must not - // hand the highlight to that page. - if current_overlap >= best_overlap - TIE || current_overlap >= current_height - TIE { - current = view.current; - } - if let Some(p) = pressed_page { - current = p; - } - if view.continuous { - view.current = current; - } - resp.context_menu(|ui| { - // Preparing a form: the selected field's menu. - if preparing && let Some((name, wi)) = view.prepare.selected.clone() { - // Several fields: Align, Center, Distribute, Set Fields to Same Size. - if !view.prepare.also.is_empty() { - use crate::prepare::Arrange as A; - let others = view.prepare.also.clone(); - let anchor = (name.clone(), wi); - let mut pick = |ui: &mut egui::Ui, op: A, label: &str| { - if ui.add_enabled(can_modify, egui::Button::new(tl!(label))).clicked() { - view.pending_edit = crate::prepare::arrange(&form, &anchor, &others, op); - ui.close(); - } - }; - ui.menu_button(tl!("Align"), |ui| { - pick(ui, A::AlignLeft, "Left"); - pick(ui, A::AlignRight, "Right"); - pick(ui, A::AlignTop, "Top"); - pick(ui, A::AlignBottom, "Bottom"); - pick(ui, A::AlignCenterV, "Center vertically"); - pick(ui, A::AlignCenterH, "Center horizontally"); - }); - ui.menu_button(tl!("Distribute"), |ui| { - pick(ui, A::DistributeH, "Horizontally"); - pick(ui, A::DistributeV, "Vertically"); - }); - ui.menu_button(tl!("Set Fields to Same Size"), |ui| { - pick(ui, A::SameHeight, "Height"); - pick(ui, A::SameWidth, "Width"); - pick(ui, A::SameSize, "Both"); - }); - ui.separator(); - } - if ui.button(tl!("Properties…")).clicked() { - field_menu = Some(FieldMenu::Properties); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Duplicate…"))).clicked() { - field_menu = Some(FieldMenu::Duplicate(name.clone())); - ui.close(); - } - ui.separator(); - let delete = if view.prepare.also.is_empty() { "Delete" } else { "Delete selected fields" }; - if ui.add_enabled(can_modify, egui::Button::new(tl!(delete))).clicked() { - view.pending_edit = crate::prepare::delete_selected(view); - ui.close(); - } - return; - } - if let Some((page, index)) = view.links.context { - if ui.button(tl!("Properties…")).clicked() { - view.links.open_existing = Some((page, index)); - ui.close(); - } - if ui.add_enabled(can_modify, egui::Button::new(tl!("Delete"))).clicked() { - view.pending_edit = Some(Edit::DeleteLink { page, index }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - ui.separator(); - if ui.add_enabled(can_modify, egui::Button::new(tl!("Remove all links"))).clicked() { - view.pending_edit = Some(Edit::RemoveLinks { pages: None }); - view.links.selected = None; - view.links.context = None; - ui.close(); - } - return; - } - if view.comments.selected.is_none() { - let selected = view.selected_text().is_some_and(|t| !t.is_empty()); - let key = crate::commands::shortcut_label(ui.ctx(), crate::commands::COPY); - if ui.add_enabled(can_copy && selected, egui::Button::new(tl!("Copy")).shortcut_text(key)).clicked() { - copy_selected = true; - ui.close(); - } - ui.separator(); - } - // Over a picture (and no comment): Copy Image, as in Acrobat's page menu. - let mut copy_image = None; - if view.comments.selected.is_none() - && let Some((page, at)) = view.comments.context_at - { - // The page's content is parsed once per right-click, not on every frame the menu is open. - let key = egui::Id::unique("copy-image-under-menu"); - let cached = ui.data(|d| d.get_temp::<(usize, [f64; 2], Option)>(key)).filter(|c| c.0 == page && c.1 == at); - let hit = match cached { - Some(c) => c.2, - None => { - let hit = doc.page_image_at(page, at); - ui.data_mut(|d| d.insert_temp(key, (page, at, hit))); - hit - } - }; - if let Some(image) = hit { - if ui.button(tl!("Copy Image")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImage(page, image)); - ui.close(); - } - if ui.button(tl!("Copy Image in Original Format")).clicked() { - copy_image = Some(comments::CanvasAction::CopyImageFile(page, image)); - ui.close(); - } - ui.separator(); - } - } - canvas_action = comments::context_menu(ui, view, info, prefs, allowed, can_modify).or(copy_image); - }); - (wanted, visible_now) - }); - view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { - // At the top once the page's top edge shows where going to a page puts it (a gap - // below the window's top). Within a point, as for wheel paging, so layout rounding - // can't hide an edge. - let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); - (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) - }); - - view.viewport_screen = out.inner_rect; - view.auto_scroll.paint(ui, avail); - if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { - copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); - } - - // Collect all main-page demand, including cached surfaces, for byte admission later. - // Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding - // fresh tiles back while the scroll offset is still moving keeps a drag on the stretched - // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that - // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; - // pages with nothing on screen yet still queue. - let scroll_offset = out.state.clamped_offset(); - // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas - // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag - // background. The output gives us the area id, and egui's pinned ScrollArea registers those - // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. - // Ask for their exact responses instead of treating any held drag as scrolling: a text - // selection, a panel splitter or another widget's drag must not excuse the programmatic jump - // a search flash makes, while a track click (which moves the offset with no pointer motion) - // must still count as the reader scrolling. - let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); - let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); - if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { - view.end_find_following(); - } - let now_settle = ui.input(|i| i.time); - if (scroll_offset - view.last_scroll).length() > 2.0 { - view.pan_hold_until = now_settle + 0.25; - ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); - } - if glide != Vec2::ZERO { - view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); - } - view.last_scroll = scroll_offset; - let pan_held = now_settle < view.pan_hold_until; - let (mut wanted, visible_now) = out.inner; +/// Queues the renders for the pages and tiles `renders` wants (nearest the view first), prefetches the +/// rows either side, frees tiles of far pages and old zooms, and asks for the text layers. +fn plan_renders(ui: &egui::Ui, view: &mut DocView, info: &DocInfo, renders: PageRenders, plan: &RenderPlan<'_>) { + let (mut wanted, visible_now) = renders; + let RenderPlan { visible_pages, tag, scale, sharp_held, pan_held, hand } = *plan; // Single-page and two-page views draw only the current row, so the next one was first asked // for on the turn and showed blank paper until it rendered. Prefetch the rows either side, // as continuous scrolling does for the pages just off screen. @@ -2419,7 +2374,176 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { })); view.frame_queue = queue; view.frame_visible = visible_now.iter().copied().collect(); +} +/// Acts on the notice bar button that was clicked. +fn apply_notice(app: &mut PdfCraftApp, index: usize, notice: Option) { + match notice { + #[cfg(not(target_arch = "wasm32"))] + Some(Notice::Reload) => app.watch.reload_request = app.views.get(index).map(|view| view.id), + #[cfg(target_arch = "wasm32")] + Some(Notice::Reload) => {} + Some(Notice::Repairs) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Advanced)), + Some(Notice::Security) => app.dialog = Some(crate::Dialog::Properties(crate::PropsTab::Security)), + Some(Notice::Unlock) => { + if let Some(doc) = app.views.get(index).and_then(|view| app.session.get(view.id)) { + app.unlock_prompt = Some(crate::UnlockPrompt::new(doc, None)); + } + } + // Flags the view: the tab closes at the top of the next frame, where no document + // borrow is held. + Some(Notice::Close) => { + if let Some(view) = app.views.get_mut(index) { + view.close_requested = true; + } + } + Some(Notice::Signatures) => app.right = Some(RightPanel::Signatures), + Some(Notice::FieldHighlights(on)) => app.view_defaults.highlight_fields = on, + Some(Notice::Console) => app.dialog = Some(crate::Dialog::JsConsole), + // Back on in Preferences ▸ General. + Some(Notice::HideSecured) => app.show_secured_notice = false, + None => {} + } +} + +/// The snapshot notice bar's Close was clicked: close here, where no document borrow is held. The +/// tab is looked up by id: indices may have shifted since the click, and the flag rides its own +/// view, so a stale flag can never close the wrong tab. Whether a tab was closed (or asked about). +fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool { + let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id)); + let Some(i) = pending else { return false }; + app.views[i].close_requested = false; + if app.has_unsaved_work(i) { + app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id)); + } else { + app.close_tab(i); + } + true +} + +/// Where the pages are laid out, for placing the scroll area. +#[derive(Clone, Copy)] +struct ScrollGeometry<'a> { + rects: &'a [Rect], + y_shift: f32, + content_w: f32, + content_h: f32, + avail: Rect, + middle_gesture: bool, + hand: bool, +} + +/// The page view's scroll area, with the offset this frame's jumps, drags and zoom anchor ask for. +fn page_scroll_area(ui: &egui::Ui, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, middle_gesture, hand } = *geometry; + let mut scroll = egui::ScrollArea::both().auto_shrink([false, false]).scroll_source(egui::scroll_area::ScrollSource { + drag: if middle_gesture { + egui::scroll_area::DragScroll::Never + } else if hand { + egui::scroll_area::DragScroll::Always + } else { + egui::scroll_area::DragScroll::OnTouch + }, + ..Default::default() + }); + // Each document keeps its own scroll position when switching tabs (#189). + scroll = scroll.id_salt(("page-view", view.id)); + // Every scroll here asks for no animation. An animated area would still queue a zero-length + // offset target for the next frame, which overwrites the jump a page turn just set. + scroll = scroll.animated(false); + // A newly opened document starts at the top. eframe saves egui's memory with the settings + // and document ids start again at 1 every launch, so the offset stored under this id can be + // where an earlier session left another document. + if !std::mem::replace(&mut view.shown, true) { + scroll = scroll.scroll_offset(Vec2::ZERO); + } + // The Hand tool pans here, before the pages are laid out, so the page moves with the pointer + // in the frame the pointer moves (#924). Scrolling by the drag from inside the area moved the + // page only after that frame was drawn, a frame behind. The frame egui tells the drag from a + // click, the page catches up with all the pointer has moved since the press. A jump asked for + // below still wins. + if hand + && !middle_gesture + && let Some(id) = view.page_widget.filter(|id| ui.ctx().is_being_dragged(*id)) + { + let moved = if ui.ctx().drag_started_id() == Some(id) { + ui.input(|i| i.pointer.total_drag_delta()).unwrap_or(Vec2::ZERO) + } else { + ui.input(|i| i.pointer.delta()) + }; + if moved != Vec2::ZERO { + view.end_find_following(); + } + // Within the content, as egui keeps it: past an end the page would show there for a + // frame and spring back. `max` is never NaN or below zero, so the clamp can't panic. + let max = vec2((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + scroll = scroll.scroll_offset((view.last_scroll - moved).clamp(Vec2::ZERO, max)); + } + if let Some(v) = view.sync_scroll.take() { + scroll = scroll.scroll_offset(v); + } + if let Some((page, fx, fy, rel)) = view.zoom_anchor.take() { + let r = rects[page.min(rects.len() - 1)]; + let point = pos2(r.left() + fx * r.width(), r.top() - y_shift + fy * r.height()); + scroll = scroll.scroll_offset(anchored_scroll(point, rel, vec2(content_w, content_h), avail.size())); + } else if let Some((page, frac)) = view.goto.take() { + let page = page.min(rects.len() - 1); + let r = rects[page]; + // Clamped here: egui clamps only after drawing, so a page that fits was drawn MARGIN - page_gap() + // too high for a frame and then hopped into place. (`avail` is the viewport, as the + // scroll bars float.) + let max_y = (content_h - avail.height()).max(0.0); + scroll = scroll.vertical_scroll_offset((r.top() - y_shift - view.page_gap() + frac * r.height()).max(0.0).min(max_y)); + // Horizontally too, as Acrobat does (#2665): beside a much wider page the others sit off + // to one side. A page already shown whole, or one filling the window's width, keeps the + // scroll, so paging through a zoomed-in document stays on the same column. Otherwise a + // page that fits is centred, and a wider one scrolled just far enough to fill the window. + // The page counts with its side gutters, which keep its edge clear of the floating + // quick-action bar as the layout does. + let (x, w) = (view.last_scroll.x, avail.width()); + let (left, right) = (r.left() - SIDE, r.right() + SIDE); + let target = if right - left <= w { if left >= x && right <= x + w { x } else { r.center().x - w / 2.0 } } else { x.clamp(left, right - w) }; + if (target - x).abs() > 0.5 { + scroll = scroll.horizontal_scroll_offset(target.clamp(0.0, (content_w - w).max(0.0))); + } + } + scroll +} + +/// Notes how far the page view scrolled this frame: reader-owned scrolling ends search following, +/// a pan holds back fresh tile renders until it settles, and a glide keeps going or stops. Whether +/// a pan is still settling. +fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::scroll_area::ScrollAreaOutput, glide: Vec2) -> bool { + let scroll_offset = out.state.clamped_offset(); + // The document ScrollArea's own scroll surfaces move the offset without the wheel deltas + // above: its two scroll-bar tracks/handles and, for touch or the Hand tool, its drag + // background. The output gives us the area id, and egui's pinned ScrollArea registers those + // widgets under `id.with(0)` (horizontal), `id.with(1)` (vertical) and `id.with("area")`. + // Ask for their exact responses instead of treating any held drag as scrolling: a text + // selection, a panel splitter or another widget's drag must not excuse the programmatic jump + // a search flash makes, while a track click (which moves the offset with no pointer motion) + // must still count as the reader scrolling. + let scroll_surface = |id: egui::Id| ui.ctx().read_response(id).is_some_and(|r| r.is_pointer_button_down_on() || r.dragged() || r.clicked()); + let pointer_scrolling = scroll_surface(out.id.with(0usize)) || scroll_surface(out.id.with(1usize)) || scroll_surface(out.id.with("area")); + if pointer_scrolling && (scroll_offset - view.last_scroll).length() > 2.0 { + view.end_find_following(); + } + let now_settle = ui.input(|i| i.time); + if (scroll_offset - view.last_scroll).length() > 2.0 { + view.pan_hold_until = now_settle + 0.25; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(280)); + } + if glide != Vec2::ZERO { + view.fling.moved((scroll_offset - view.last_scroll).length() > 0.01); + } + view.last_scroll = scroll_offset; + now_settle < view.pan_hold_until +} + +/// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the +/// text editor was finished. +fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hover_text: Option<(Pos2, String)>) -> bool { + let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. let tip = egui::Area::new(egui::Id::unique("canvas-hover")).order(egui::Order::Tooltip).interactable(false); @@ -2430,10 +2554,10 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { }); }); } - find_bar(view, info.pages.len(), avail, ui, &t); - page_size_hint(view, info, units, avail, unobstructed, ui, &t); + find_bar(view, info.pages.len(), avail, ui, t); + page_size_hint(view, info, units, avail, unobstructed, ui, t); if tool == QuickTool::EnhanceCamera { - crate::camera_ui::bar(ui.ctx(), view, avail, &t, can_modify); + crate::camera_ui::bar(ui.ctx(), view, avail, t, can_modify); } if let Some(e) = comments::composer(ui.ctx(), view, info, prefs) { view.pending_edit = Some(e); @@ -2441,17 +2565,545 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { if let Some(e) = crate::edit_text_ui::overlay(ui.ctx(), view, info) { view.pending_edit = Some(e); } - let text_box = crate::forms_ui::text_box(doc, view, &form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, &form, text_box.as_ref(), today) { + let text_box = crate::forms_ui::text_box(doc, view, form); + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, text_box.as_ref(), today) { view.pending_edit = Some(e); } - if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, &author) { + if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { view.pending_edit = Some(e); } - let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, &added); + let (typed, text_done) = crate::content_ui::editor(ui.ctx(), view, info, added); if typed.is_some() { view.pending_edit = typed; } + text_done +} + +/// Full screen: the click turns the page unless it did something else first. It waits out the +/// double-click time: turning on the first click's release sent a double-click's second click, +/// meant to select a word, to the next page (review on #2070). A selection cancels it, and so do a +/// link followed meanwhile and leaving full screen; a double-click elsewhere still turns once. +/// `click` is `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn schedule_presentation_turn(ui: &egui::Ui, view: &mut DocView, click: Option, link_followed: bool) { + let now = ui.input(|i| i.time); + if let Some(back) = click + && !link_followed + && view.selection.is_none() + && view.clicks < 2 + { + let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); + view.present_turn = Some((now + delay, !back)); + } + if let Some((due, forward)) = view.present_turn { + if link_followed || view.selection.is_some() || view.presentation.is_none() { + view.present_turn = None; + } else if now >= due { + view.present_turn = None; + view.step_page(forward); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); + } + } +} + +/// Follows the link that was clicked. A link to another file comes back, to be opened once the +/// document is no longer borrowed. +fn follow_link( + app: &mut PdfCraftApp, + index: usize, + target: Option, +) -> Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)> { + let view_id = app.views[index].id; + match target? { + LinkTarget::Page(p, dest) => { + let view = &mut app.views[index]; + if let Some(doc) = app.session.get(view.id) { + view.go_to_dest(p, dest, &doc.info); + } + } + LinkTarget::Uri(u) => app.request_document_url(&u, crate::LinkOrigin::Link), + LinkTarget::SetLayers { changes, preserve_rb } => set_layers(app, index, &changes, preserve_rb), + LinkTarget::RemoteGoTo { file, destination, .. } => return Some((view_id, file, destination)), + LinkTarget::Other(s) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), + } + None +} + +/// A finished marquee zoom, a signature rectangle drawn, or an empty signature field clicked. +/// Returns the tool to go on with. +fn finish_sign_requests(app: &mut PdfCraftApp, index: usize, mut tool: QuickTool) -> QuickTool { + if let Some(done) = app.views[index].marquee_done.take() { + app.finish_marquee(index, done); + } + // A signature rectangle was drawn, or an empty signature field clicked. + let drawn = app.views[index].sign.drawn.take(); + if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { + tool = QuickTool::Select; + app.quick_tool = tool; + app.start_signing(page, Some(rect), None, certify.then_some(2)); + } + if let Some(field) = app.views[index].sign.field.take() { + let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); + if signed { + app.right = Some(RightPanel::Signatures); + if !app.sig_expanded.contains(&field) { + app.sig_expanded.push(field); + } + } else { + let page = app.views[index].current; + app.start_signing(page, None, Some(field), None); + } + } + tool +} + +/// When not scrolling continuously, only one row is shown at a time: the current page, or the +/// current two-page spread (with the cover rule). These indices drive paging, which pages are +/// drawn, and the content height (#752). Empty for a document with no pages. +fn shown_row(view: &DocView, rects: &[Rect]) -> Vec { + if rects.is_empty() { + return Vec::new(); + } + let last = rects.len() - 1; + let cur = view.current.min(last); + match view.layout { + PageLayout::Single => vec![cur], + PageLayout::TwoUp if view.cover && cur == 0 => vec![0], + PageLayout::TwoUp => { + let first = if view.cover { cur - (cur + 1) % 2 } else { cur - cur % 2 }; + if first < last { vec![first, first + 1] } else { vec![first] } + } + } +} + +/// Full screen: a click on the document goes forward a page and a shift-click back, as in +/// Acrobat, unless the click did something else (a link, a word selected). Decided once the pages +/// have had the click. `Some(back)` on the frame of a click, `back` when it was a shift-click. +fn presentation_click_state(ui: &egui::Ui, view: &DocView, navigation_rect: Rect, enabled: bool) -> Option { + let clicked = view.presentation.is_some() && enabled && presentation_click(ui, navigation_rect); + // Shift as held now, or as the click's own event carried it (a synthetic click). + clicked.then(|| { + ui.input(|i| { + i.modifiers.shift + || i.events.iter().any(|e| { + matches!(e, egui::Event::PointerButton { button: egui::PointerButton::Primary, pressed: false, modifiers, .. } if modifiers.shift) + }) + }) + }) +} + +/// Pinch / ctrl+scroll zoom, anchored on the pointer or the current page. +fn pinch_zoom(ui: &egui::Ui, view: &mut DocView, zoom_delta: f32, navigation_rect: Rect, enabled: bool) { + if zoom_delta.is_finite() && zoom_delta > 0.0 && zoom_delta != 1.0 && enabled && ui.rect_contains_pointer(navigation_rect) { + let z = view.zoom * zoom_delta; + match zoom_anchor(ui) { + Some(p) => view.zoom_at(z, p), + None => view.set_zoom(z), + } + } +} + +/// The middle-button auto-scroll's delta, and the edge scroll a drawing in progress asks for: the +/// pointer in a band along any edge (or just past it) scrolls the view that way, so a point +/// beyond what is shown can be reached without letting go of the tool. +fn auto_scroll_deltas(ui: &egui::Ui, view: &mut DocView, avail: Rect, unobstructed: bool) -> (Vec2, Vec2) { + let auto_delta = if unobstructed { + view.auto_scroll.update(ui, avail, false) + } else { + view.auto_scroll.cancel(); + Vec2::ZERO + }; + + // Drawing a comment: the pointer in a band along any edge (or just past it) scrolls the view + // that way, so a point beyond what is shown can be reached without letting go of the tool. + let edge_delta = if unobstructed && matches!(view.comments.gesture, Some(comments::Gesture::Draw { .. })) { + let (pointer, dt) = drawing_edge_pointer(ui); + crate::autoscroll::edge_scroll(pointer, avail, dt) + } else { + Vec2::ZERO + }; + if edge_delta != Vec2::ZERO { + ui.ctx().request_repaint(); + } + (auto_delta, edge_delta) +} + +/// What decides whether touchpad momentum goes on. +#[derive(Clone, Copy)] +struct GlideCx { + now: f64, + may_glide: bool, + key_scroll: f32, + auto_delta: Vec2, + zoom_delta: f32, + edge_delta: Vec2, + wheel_scrolled: bool, +} + +/// Touchpad momentum (#531): a swipe over the page lifted at speed glides on and slows to a stop, +/// through the same un-eased path. Never while the pager owns the wheel, a gesture or dialog has +/// the input, or animations are off; any click, key, zoom or other scrolling ends it. Any +/// reader-owned motion ends automatic search following, even when it stays on the same page; the +/// automatic flash uses `goto`, not these deltas, so its programmatic scroll is never mistaken for +/// a reader action. Returns this frame's glide. +fn step_glide(ui: &egui::Ui, view: &mut DocView, avail: Rect, cx: &GlideCx) -> Vec2 { + let GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled } = *cx; + let over_page = ui.rect_contains_pointer(avail); + ui.input(|i| view.fling.read(&i.events, now, may_glide && over_page)); + let interrupted = glide_interrupted(ui); + if !may_glide || interrupted || key_scroll != 0.0 || auto_delta != Vec2::ZERO || zoom_delta != 1.0 { + view.fling.stop(); + } + let glide = view.fling.step(now); + if view.fling.gliding() { + ui.ctx().request_repaint(); + } + if wheel_scrolled || auto_delta != Vec2::ZERO || edge_delta != Vec2::ZERO || glide != Vec2::ZERO { + view.end_find_following(); + } + glide +} + +/// Text selection runs for the Select tool and for the markup tools (highlight…). +fn tool_selects_text(tool: QuickTool, preparing: bool) -> bool { + match tool { + QuickTool::Comment(t) => t.markup().is_some() || t == comments::CommentTool::ReplaceText, + QuickTool::Select => !preparing, + QuickTool::Redact | QuickTool::ColumnSelect => true, + QuickTool::Hand + | QuickTool::Measure(_) + | QuickTool::Crop + | QuickTool::EnhanceCamera + | QuickTool::Fill(_) + | QuickTool::Field(_) + | QuickTool::AddText + | QuickTool::EditText + | QuickTool::Stamp(_) + | QuickTool::CustomStamp(_) + | QuickTool::Link + | QuickTool::SignArea { .. } + | QuickTool::MarqueeZoom + | QuickTool::Snapshot => false, + } +} + +/// Holds sharp re-renders while the zoom is still moving: a new scale every frame would cancel the +/// in-flight full-page interpret and restart it, so on a heavy page (a CAD plot with ~1000 images +/// renders for seconds) the sharp raster never arrives and the view looks stuck on a soft image. +/// The stale raster stays stretched meanwhile; the sharp request goes out once the zoom settles. +fn hold_sharp_renders(ui: &egui::Ui, view: &mut DocView, scale: f32) -> bool { + let now_scale = ui.input(|i| i.time); + if (scale - view.last_scale).abs() > f32::EPSILON { + view.last_scale = scale; + view.sharp_hold_until = now_scale + 0.35; + ui.ctx().request_repaint_after(std::time::Duration::from_millis(380)); + } + now_scale < view.sharp_hold_until +} + +/// A destination's position (bookmarks, links) refines `goto`: its point at the window's top-left, +/// or the centre of a /FitR rectangle at the window's centre. +fn refine_goto_point(mut scroll: egui::ScrollArea, view: &mut DocView, geometry: &ScrollGeometry<'_>) -> egui::ScrollArea { + let ScrollGeometry { rects, y_shift, content_w, content_h, avail, .. } = *geometry; + if let Some((page, fx, fy, rel)) = view.goto_point.take() { + let r = rects[page.min(rects.len() - 1)]; + // Within the content, so an unreachable point never shows a frame scrolled past the end. + let (max_x, max_y) = ((content_w - avail.width()).max(0.0), (content_h - avail.height()).max(0.0)); + if let Some(fx) = fx { + scroll = scroll.horizontal_scroll_offset((r.left() + fx * r.width() - rel.x).clamp(0.0, max_x)); + } + if let Some(fy) = fy { + scroll = scroll.vertical_scroll_offset((r.top() - y_shift + fy * r.height() - rel.y).clamp(0.0, max_y)); + } + } + scroll +} + +/// Draws the document tab at `index`: its notice bars, then its pages (or the Organize grid) in a +/// scroll area with this frame's zoom, scrolling, tool input, overlays and render requests. +pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + // Keyboard scrolling applies to this frame's page view only, never later. + let key_scroll = std::mem::take(&mut app.views[index].key_scroll); + if key_scroll != 0.0 { + app.views[index].end_find_following(); + } + // The Search panel closed: its search moves to the find bar. + let search_open = app.right == Some(crate::RightPanel::Search); + if let Some(f) = app.views[index].find.as_mut() + && f.in_panel + && !search_open + { + f.in_panel = false; + } + if close_requested_tab(app, index) { + return; + } + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + let paper = app.session.reading_colors(app.views[index].id).and_then(|c| c.params).map_or(Color32::WHITE, |p| { + let [r, g, b] = p.background; + Color32::from_rgb(r, g, b) + }); + if info.pages.is_empty() { + app.views[index].auto_scroll.cancel(); + ui.centered_and_justified(|ui| ui.label(tl!("This document has no pages."))); + return; + } + // The print dialog queues its own rasters later in the frame. Closing it drops them. + if app.dialog != Some(crate::Dialog::Print) { + app.views[index].print_pages.clear(); + } + // The Prepare a form panel is open (or a field tool is picked): fields are edited, not filled. + let preparing = app.is_preparing(); + // Prepare a form ▸ Calculation order: the order's steps and links on the page. + let calc_order = preparing && app.left_open && app.left == crate::LeftPanel::CalculationOrder; + // Edit a PDF: added text and images can be selected, moved and edited. + let editing_content = + (app.left_open && app.left == crate::LeftPanel::Tool("edit")) || matches!(app.quick_tool, QuickTool::AddText | QuickTool::EditText); + let text_style = app.text_style.clone(); + let show_secured_notice = app.show_secured_notice; + let view = &mut app.views[index]; + // Opened without the owner password and something is restricted. The notice says so unless + // the user turned it off (#4035): the restrictions themselves, and Document Properties ▸ + // Security, stay as they are. + let secured = show_secured_notice && doc.security_summary().is_some_and(|s| !(s.owner || (s.permissions.modify() && s.permissions.assemble()))); + let repaired = !doc.repair_log().is_empty(); + let notice = notices(view, doc, secured, repaired, crate::sign_ui::banner(&doc.signatures), ui, &t); + apply_notice(app, index, notice); + let Some(doc) = app.session.get(app.views[index].id) else { return }; + let info = &doc.info; + // No dialog, close prompt or palette over the page: only then does page input count. + let modal = app.modal_open(); + let unobstructed = !modal && !app.palette_open; + let organize_zoom = app.organize_zoom; + let view = &mut app.views[index]; + if view.organize { + // One remembered grid size for every document, kept in the settings. + view.set_grid_zoom(organize_zoom); + organize_grid(view, info, doc.allows_assembly(), doc.dirty, unobstructed, ui, &t); + app.organize_zoom = view.grid_zoom(); + return; + } + + let avail = ui.available_rect_before_wrap(); + view.viewport_w = avail.width(); + view.viewport_h = avail.height(); + let old_zoom = view.zoom; + view.fit_zoom(info); + anchor_zoom_change(view, avail.width(), old_zoom); + + // Pinch / ctrl+scroll zoom, anchored on the current page. + let zoom_delta = crate::motion::zoom_delta(ui.ctx()); + // Exclude the previous frame's scrollbar tracks from gesture activation. + let navigation_rect = if view.viewport_screen.is_positive() { avail.intersect(view.viewport_screen) } else { avail }; + let navigation_enabled = app.dialog.is_none() && app.close_request.is_none() && !app.palette_open && ui.is_enabled() && ui.input(|i| i.focused); + let present = presentation_click_state(ui, view, navigation_rect, navigation_enabled && app.quick_tool == QuickTool::Select); + pinch_zoom(ui, view, zoom_delta, navigation_rect, navigation_enabled); + view.viewport_screen = avail; + let (auto_delta, edge_delta) = auto_scroll_deltas(ui, view, avail, unobstructed); + + let content_w = view.content_width(info, avail.width()); + let rects = view.layout(info, content_w, ui.ctx().pixels_per_point()); + // When not scrolling continuously, only one row is shown at a time: the current page, or the + // current two-page spread (with the cover rule). These indices drive paging, which pages are + // drawn, and the content height (#752). Empty for a document with no pages. + let row = shown_row(view, &rects); + let middle_gesture = view.auto_scroll.blocks_input(); + // Not scrolling: when the whole page or spread fits, the wheel would do nothing, so it turns + // pages instead; zoomed in far enough to pan, it pans, and at the edge it turns the page on + // (#1924, `single_page_wheel_edge`). Touch drags are untouched: with + // nothing to pan, touch users turn pages with the rail buttons, the page box and the arrow + // keys. Runs before `visible_pages` so the frame that turns the page draws it. + let (single_paging, edge_turned) = single_page_paging(ui, view, &PagingCx { row: &row, rects: &rects, avail, middle_gesture, unobstructed }); + // Point input bypasses egui smoothing for 1:1 motion. Line input keeps egui's configured, + // smoothed base and adds only the accelerated surplus. While point suppression is active, + // reconstruct line and page input here so current coarse events survive without exposing an + // older eased tail. + let now = ui.input(|i| i.time); + let Wheel { precise_scroll, line_base, boost_extra, has_page, paging_owns_vertical } = read_wheel(ui, view, avail, single_paging, edge_turned); + let animates = app.animation_effects.animates(app.motion.reduced); + let may_glide = !paging_owns_vertical && !middle_gesture && navigation_enabled && unobstructed && animates; + let wheel_scrolled = precise_scroll != Vec2::ZERO || line_base != Vec2::ZERO || boost_extra != Vec2::ZERO || has_page; + let motion = GlideCx { now, may_glide, key_scroll, auto_delta, zoom_delta, edge_delta, wheel_scrolled }; + let glide = step_glide(ui, view, avail, &motion); + // The wheel may have turned the page above: draw the row it turned to. Measured from the old + // row, the jump to the new page scrolled both out of view, a blank frame on every turn forward. + let row = shown_row(view, &rects); + let visible_pages: Vec = if view.continuous { (0..rects.len()).collect() } else { row.clone() }; + let (y_shift, content_h) = if view.continuous { + (0.0, rects.iter().map(|r| r.bottom()).reduce(f32::max).map(|bottom| bottom + MARGIN).unwrap_or(0.0)) + } else { + // Only the current row scrolls into view; the content is its bounding box plus margins. + match row.iter().filter_map(|&i| rects.get(i)).copied().reduce(Rect::union) { + Some(r) => (r.top() - MARGIN, r.height() + 2.0 * MARGIN), + None => (0.0, 0.0), + } + }; + + let mut scroll = page_scroll_area( + ui, + view, + &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand: app.quick_tool == QuickTool::Hand }, + ); + let ppp = ui.ctx().pixels_per_point(); + let scale = view.render_scale(ppp); + let tag = scale_tag(scale); + // Hold sharp re-renders while the zoom is still moving: a new scale every frame would + // cancel the in-flight full-page interpret and restart it, so on a heavy page (a CAD + // plot with ~1000 images renders for seconds) the sharp raster never arrives and the + // view looks stuck on a soft image. The stale raster stays stretched meanwhile; the + // sharp request goes out once the zoom settles. + let sharp_held = hold_sharp_renders(ui, view, scale); + view.current_scale_tag = tag; + let hand = app.quick_tool == QuickTool::Hand; + let tool = app.quick_tool; + // Text selection runs for the Select tool and for the markup tools (highlight…). + let selects_text = tool_selects_text(tool, preparing); + let prefs = &app.comment_prefs; + let redact_mark = (app.redact_prefs.hover_fill, app.redact_prefs.opacity); + let units = app.units; + let allowed = doc.allows_annotation(); + let can_copy = doc.allows_copying(); + let comments_hidden = doc.comments_hidden(); + let form = doc.form.clone(); + let generation = doc.edit_generation(); + let can_fill = doc.allows_form_filling(); + let can_crop = doc.allows_assembly(); + let signature = app.signature.clone(); + let initials = app.initials.clone(); + let author = app.comment_prefs.author.clone(); + let today = app.session.today(); + // In Preferences ▸ Date format, or why it can't be stamped into the PDF. + let date_text = crate::date_text_for_pdf(&app.session); + let by_line = app.session.stamp_by_line(&author); + let custom_stamp = match app.quick_tool { + QuickTool::CustomStamp(i) => app.custom_stamps.get(i).cloned(), + _ => None, + }; + let mut open_props: Option<(usize, usize)> = None; + // The table being set up in its window, shown on its own document's page. + let table_preview = app.field_table_draft.as_mut().filter(|d| d.doc == view.id).and_then(|d| Some(d.preview(info.pages.get(d.page)?, &form))); + let can_modify = doc.allows_modification(); + if preparing { + crate::prepare::after_refresh(view, &form); + } else { + view.prepare.selected = None; + } + view.objects.prepare(doc, tool == QuickTool::EditText && can_modify, ui.ctx()); + view.text_background.prepare(ui.ctx(), doc, view.line_editor.as_ref(), view.content.draft.as_ref(), scale); + let added = if editing_content { doc.added().to_vec() } else { Vec::new() }; + let doc_links = if tool == QuickTool::Link { doc.links().to_vec() } else { Vec::new() }; + if editing_content { + crate::content_ui::after_refresh(view, &added); + } else { + view.content.selected = None; + } + view.move_background.prepare(ui.ctx(), doc, &view.content, scale); + + scroll = refine_goto_point(scroll, view, &ScrollGeometry { rects: &rects, y_shift, content_w, content_h, avail, middle_gesture, hand }); + let selection_wheel = view.selection_drag && navigation_enabled && unobstructed && !single_paging && ui.rect_contains_pointer(avail); + let env = PageEnv { + added: &added, + allowed, + author: &author, + auto_delta, + avail, + boost_extra, + by_line: &by_line, + calc_order, + can_copy, + can_crop, + can_fill, + can_modify, + comments_hidden, + content_h, + content_w, + custom_stamp: &custom_stamp, + date_text: &date_text, + doc, + doc_links: &doc_links, + edge_delta, + editing_content, + form: &form, + generation, + glide, + hand, + info, + initials: &initials, + key_scroll, + middle_gesture, + modal, + navigation_enabled, + paper, + ppp, + precise_scroll, + prefs, + preparing, + rects: &rects, + redact_mark, + scale, + selection_wheel, + selects_text, + signature: &signature, + single_paging, + t: &t, + table_preview: &table_preview, + tag, + text_style: &text_style, + tool, + units, + unobstructed, + visible_pages: &visible_pages, + y_shift, + fill_text_size: app.fill_text_size, + today, + }; + let mut page_out = PageOut::default(); + let out = page_scroll(ui, scroll, egui::Id::unique(("page-view-scope", view.id)), |ui, viewport| { + draw_pages(ui, viewport, view, &env, &mut page_out, &mut app.signature_preview) + }); + let PageOut { + badge_hover: _, + canvas_action, + clicked_link, + mut copy_selected, + field_menu, + field_placed, + mut field_props, + hover_text, + mut image_action, + open_boxes, + open_initials, + open_signature, + open_table, + mut refused, + stamp_placed, + } = page_out; + view.single_edges = visible_pages.first().copied().filter(|_| !view.continuous).map(|page| { + // At the top once the page's top edge shows where going to a page puts it (a gap + // below the window's top). Within a point, as for wheel paging, so layout rounding + // can't hide an edge. + let max_y = (out.content_size.y - out.inner_rect.height()).max(0.0); + (page, out.state.clamped_offset().y <= MARGIN - view.page_gap() + 1.0, out.state.clamped_offset().y >= max_y - 1.0) + }); + + view.viewport_screen = out.inner_rect; + view.auto_scroll.paint(ui, avail); + if unobstructed && matches!(tool, QuickTool::Select | QuickTool::ColumnSelect) { + copy_selected |= selection_actions(ui, avail, view, info, prefs, allowed && !comments_hidden, can_copy); + } + + // Collect all main-page demand, including cached surfaces, for byte admission later. + // Pan-settle debounce for tiled pages: each tile costs a full page interpret, so holding + // fresh tiles back while the scroll offset is still moving keeps a drag on the stretched + // backdrop/older tiles instead of queueing a storm of interprets (one per frame) that + // the workers cannot cancel mid-operator. Fresh tiles go out once the pan settles; + // pages with nothing on screen yet still queue. + let pan_held = track_scroll(ui, view, &out, glide); + plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); + + let text_done = frame_overlays(ui, view, &env, hover_text); if let Some(why) = view.objects.notice.take() { refused = Some(why); } @@ -2498,60 +3150,71 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { Some(comments::CanvasAction::CopyImageFile(p, i)) => image_action = Some(crate::edit_text_ui::ImageAction::CopyFile(p, i)), None => {} } - // Full screen: the click turns the page unless it did something else first. It waits out the - // double-click time: turning on the first click's release sent a double-click's second click, - // meant to select a word, to the next page (review on #2070). A selection cancels it, and so - // do a link followed meanwhile and leaving full screen; a double-click elsewhere still turns - // once. - let now = ui.input(|i| i.time); - if present_click && clicked_link.is_none() && view.selection.is_none() && view.clicks < 2 { - let delay = ui.ctx().options(|o| o.input_options.max_double_click_delay); - view.present_turn = Some((now + delay, !present_back)); - } - if let Some((due, forward)) = view.present_turn { - if clicked_link.is_some() || view.selection.is_some() || view.presentation.is_none() { - view.present_turn = None; - } else if now >= due { - view.present_turn = None; - view.step_page(forward); - } else { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((due - now).max(0.0))); - } - } - match clicked_link { - Some(LinkTarget::Page(p, dest)) => view.go_to_dest(p, dest, info), - Some(LinkTarget::Uri(u)) => app.request_document_url(&u, crate::LinkOrigin::Link), - Some(LinkTarget::SetLayers { changes, preserve_rb }) => set_layers(app, index, &changes, preserve_rb), - Some(LinkTarget::RemoteGoTo { file, destination, .. }) => remote_open = Some((view.id, file, destination)), - Some(LinkTarget::Other(s)) => app.notify_fmt("This PDF uses the unsupported {s} action.", &[("s", &s)]), - None => {} - } + schedule_presentation_turn(ui, view, present, clicked_link.is_some()); + let remote_open = follow_link(app, index, clicked_link); if tool == QuickTool::Crop && cropped { tool = QuickTool::Select; } - if let Some(done) = app.views[index].marquee_done.take() { - app.finish_marquee(index, done); - } - // A signature rectangle was drawn, or an empty signature field clicked. - let drawn = app.views[index].sign.drawn.take(); - if let (Some((page, rect)), QuickTool::SignArea { certify }) = (drawn, tool) { - tool = QuickTool::Select; - app.quick_tool = tool; - app.start_signing(page, Some(rect), None, certify.then_some(2)); - } - if let Some(field) = app.views[index].sign.field.take() { - let signed = app.session.get(app.views[index].id).is_some_and(|d| d.signatures.iter().any(|s| s.field == field && s.signed)); - if signed { - app.right = Some(RightPanel::Signatures); - if !app.sig_expanded.contains(&field) { - app.sig_expanded.push(field); - } - } else { - let page = app.views[index].current; - app.start_signing(page, None, Some(field), None); - } - } + let tool = finish_sign_requests(app, index, tool); app.quick_tool = tool; + let requests = FrameRequests { + open_props, + field_props, + field_menu, + image_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + }; + apply_frame_requests(app, index, ui, requests); +} + +/// What the frame's input asked the app to do, once the document is no longer borrowed. +struct FrameRequests { + open_props: Option<(usize, usize)>, + field_props: Option<(String, usize)>, + field_menu: Option, + image_action: Option, + refused: Option, + open_signature: bool, + open_initials: bool, + open_boxes: bool, + open_table: Option<(usize, [f64; 4])>, + form_notice: Option, + copy_selected: bool, + remote_open: Option<(pdfcraft_engine::DocId, String, pdfcraft_render::RemoteDestination)>, + form: Arc>, + generation: u64, + avail: Rect, +} + +/// Opens the dialogs and runs the app commands the frame asked for. +fn apply_frame_requests(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui, requests: FrameRequests) { + let FrameRequests { + open_props, + field_props, + field_menu, + image_action, + refused, + open_signature, + open_initials, + open_boxes, + open_table, + form_notice, + copy_selected, + remote_open, + form, + generation, + avail, + } = requests; if let Some((page, at)) = app.views[index].comments.attach_at.take() { app.attach_file_comment(page, at); } diff --git a/crates/ui-egui/src/canvas/area.rs b/crates/ui-egui/src/canvas/area.rs index d7997a79a..ea3b74170 100644 --- a/crates/ui-egui/src/canvas/area.rs +++ b/crates/ui-egui/src/canvas/area.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{LayerOp, PdfCraftApp, Pos2, Rect}; /// A validation badge drawn over a signature widget: the widget rectangle (user space), the verdict it shows, and its hover text. pub(crate) struct SignatureBadge { diff --git a/crates/ui-egui/src/canvas/bars.rs b/crates/ui-egui/src/canvas/bars.rs index 4d916a1b8..b48027ae3 100644 --- a/crates/ui-egui/src/canvas/bars.rs +++ b/crates/ui-egui/src/canvas/bars.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color32, CornerRadius, DocInfo, DocView, Edit, PdfCraftApp, Pos2, QuickTool, Rect, Sense, SnapshotKind, Stroke, Tokens, comments, icons, theme, + vec2, +}; /// Whether `p` lies in the bottom-left quadrant of `area`, where the page size shows (#1855). pub(super) fn in_size_hint_zone(area: Rect, p: Pos2) -> bool { diff --git a/crates/ui-egui/src/canvas/cache.rs b/crates/ui-egui/src/canvas/cache.rs index d8a1d1d44..a0ad8fb33 100644 --- a/crates/ui-egui/src/canvas/cache.rs +++ b/crates/ui-egui/src/canvas/cache.rs @@ -1,5 +1,8 @@ use super::find::Follow; -use super::*; +use super::{ + Arc, Color32, DocInfo, DocView, HashSet, PRINT_TAG, PT, PageText, Range, RenderPool, RenderRequest, RequestKind, TextureHandle, TextureOptions, + device_pixels, +}; pub(super) const THUMB_TAG: u64 = 1 << 63; diff --git a/crates/ui-egui/src/canvas/find.rs b/crates/ui-egui/src/canvas/find.rs index d44ad9d5e..bcb4b42a2 100644 --- a/crates/ui-egui/src/canvas/find.rs +++ b/crates/ui-egui/src/canvas/find.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align2, Color32, CornerRadius, DocView, Range, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; /// The find bar (⌘F): query, matches across the document, current match. #[derive(Default)] diff --git a/crates/ui-egui/src/canvas/navigation.rs b/crates/ui-egui/src/canvas/navigation.rs index 1cf3be91e..46468a112 100644 --- a/crates/ui-egui/src/canvas/navigation.rs +++ b/crates/ui-egui/src/canvas/navigation.rs @@ -1,5 +1,5 @@ use super::zoom::GRID_ZOOM_STEP; -use super::*; +use super::{DestView, DocInfo, DocView, Fit, PT, PageLayout, SIDE, ViewAction, pos2, vec2}; impl DocView { /// Where a bookmark made now should go: the page and the `/XYZ` position `[left, top]` in user @@ -299,6 +299,43 @@ impl DocView { } } +/// ⌘+ / ⌘− / ⌘0 / ⌘1 / ⌘2: zoom the page, or size the pages in the page grid. +fn zoom_shortcuts(view: &mut DocView, pressed: &impl Fn(egui::KeyboardShortcut) -> bool) { + use egui::{Key, KeyboardShortcut, Modifiers}; + let cmd = |k| KeyboardShortcut::new(Modifiers::COMMAND, k); + // In the page grid ⌘+ / ⌘− / ⌘0 size its pages instead (looking is always allowed). + let (zoom_in, zoom_out, zoom_reset) = (pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)), pressed(cmd(Key::Minus)), pressed(cmd(Key::Num0))); + if view.organize { + if zoom_in { + view.grid_zoom_request = Some(view.grid_zoom * GRID_ZOOM_STEP); + } + if zoom_out { + view.grid_zoom_request = Some(view.grid_zoom / GRID_ZOOM_STEP); + } + if zoom_reset { + view.grid_zoom_request = Some(1.0); + } + } else { + if zoom_in { + view.zoom_step(true); + } + if zoom_out { + view.zoom_step(false); + } + if zoom_reset { + view.fit = Fit::Page; + view.goto = Some((view.current, 0.0)); + } + } + if pressed(cmd(Key::Num1)) { + view.set_zoom(1.0); + } + if pressed(cmd(Key::Num2)) { + view.fit = Fit::Width; + view.goto = Some((view.current, 0.0)); + } +} + pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_speed::ScrollSpeed) { use egui::{Key, KeyboardShortcut, Modifiers}; if ctx.input_mut(|i| i.consume_shortcut(&KeyboardShortcut::new(Modifiers::COMMAND, Key::F))) { @@ -331,37 +368,7 @@ pub fn shortcuts(view: &mut DocView, ctx: &egui::Context, speed: crate::scroll_s if pressed(KeyboardShortcut::new(Modifiers::COMMAND | Modifiers::SHIFT, Key::Minus)) { view.rotate_view(false); } - // In the page grid ⌘+ / ⌘− / ⌘0 size its pages instead (looking is always allowed). - let (zoom_in, zoom_out, zoom_reset) = (pressed(cmd(Key::Plus)) || pressed(cmd(Key::Equals)), pressed(cmd(Key::Minus)), pressed(cmd(Key::Num0))); - if view.organize { - if zoom_in { - view.grid_zoom_request = Some(view.grid_zoom * GRID_ZOOM_STEP); - } - if zoom_out { - view.grid_zoom_request = Some(view.grid_zoom / GRID_ZOOM_STEP); - } - if zoom_reset { - view.grid_zoom_request = Some(1.0); - } - } else { - if zoom_in { - view.zoom_step(true); - } - if zoom_out { - view.zoom_step(false); - } - if zoom_reset { - view.fit = Fit::Page; - view.goto = Some((view.current, 0.0)); - } - } - if pressed(cmd(Key::Num1)) { - view.set_zoom(1.0); - } - if pressed(cmd(Key::Num2)) { - view.fit = Fit::Width; - view.goto = Some((view.current, 0.0)); - } + zoom_shortcuts(view, &pressed); if pressed(cmd(Key::OpenBracket)) { view.view_history(false); } diff --git a/crates/ui-egui/src/canvas/organize.rs b/crates/ui-egui/src/canvas/organize.rs index 3eae2cc37..7f333b3e4 100644 --- a/crates/ui-egui/src/canvas/organize.rs +++ b/crates/ui-egui/src/canvas/organize.rs @@ -1,6 +1,9 @@ use super::cache::{GRID_TAG, thumbnail_density, thumbnail_rows, thumbnail_scale}; use super::zoom::{GRID_ZOOM_RANGE, GRID_ZOOM_STEP}; -use super::*; +use super::{ + Align2, Color32, CornerRadius, DocInfo, DocView, Edit, Pos2, Rect, RenderRequest, RequestKind, Sense, Stroke, Tokens, Vec2, ViewAction, + device_pixels, icons, pos2, theme, vec2, widgets, +}; /// How far a sharp grid render may be from the size drawn before it is redone: a pinch changes /// the size on every frame, and a slightly soft or slightly large image is fine meanwhile. @@ -210,6 +213,60 @@ fn gap_button(ui: &mut egui::Ui, gap: usize, at: Pos2, height: f32, label: Strin resp.on_hover_text(label).clicked() } +/// Middle-button auto-scroll: its delta scrolls the grid, and the page controls under it are +/// disabled (but not dimmed) while the gesture lasts. +fn apply_auto_scroll(ui: &mut egui::Ui, middle_gesture: bool, auto_delta: Vec2) { + if middle_gesture { + let opacity = ui.opacity(); + ui.disable(); + ui.set_opacity(opacity); + } + if auto_delta != Vec2::ZERO { + ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); + } +} + +/// Drag pages to move them (the selection, or the page grabbed); `drop` is set when the drag ends. +fn drag_pages(view: &mut DocView, resp: &egui::Response, page: usize, drop: &mut bool) { + if resp.drag_started() { + if !view.selected.contains(&page) { + view.selected = [page].into(); + view.select_anchor = Some(page); + } + let mut pages: Vec = view.selected.iter().copied().collect(); + pages.sort_unstable(); + view.org_drag = Some(pages); + } + if resp.drag_stopped() { + *drop = true; + } +} + +/// A page drawn larger than its thumbnail gets a render at the size drawn, while it is in view; +/// until that arrives the thumbnail stands in. +fn request_sharper( + view: &DocView, + page: usize, + want: f32, + page_width: f32, + drawn_width: Option, + in_view: &mut Vec<(usize, f32)>, + sharper: &mut Vec, +) { + in_view.push((page, want)); + if !drawn_width.is_some_and(|w| sharp_enough(w, want)) && !view.errors.contains_key(&page) { + sharper.push(RenderRequest { + page, + kind: RequestKind::Pixels, + tile: None, + scale: want / page_width.max(1.0), + tag: GRID_TAG, + // Grid cells are previews: tiny images are skipped for them. + preview: true, + }); + } +} + pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, dirty: bool, unobstructed: bool, ui: &mut egui::Ui, t: &Tokens) { let ppp = ui.ctx().pixels_per_point(); // The page image scales with the zoom; the padding and the page number don't. @@ -234,14 +291,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, // The source cell can scroll out of the virtualized rows during a drag. let mut drop = view.org_drag.is_some() && ui.input(|i| i.pointer.primary_released()); egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, clip| { - if middle_gesture { - let opacity = ui.opacity(); - ui.disable(); - ui.set_opacity(opacity); - } - if auto_delta != Vec2::ZERO { - ui.scroll_with_delta_animation(auto_delta, egui::style::ScrollAnimation::none()); - } + apply_auto_scroll(ui, middle_gesture, auto_delta); ui.add_space(20.0); let cols = ((ui.available_width() - 40.0) / cell.x).floor().max(1.0) as usize; let rows = info.pages.len().div_ceil(cols); @@ -267,19 +317,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, if let Some((_, above)) = anchor.filter(|(page, _)| *page == i) { ui.scroll_to_rect_animation(c.translate(vec2(0.0, -above)), Some(egui::Align::Min), egui::style::ScrollAnimation::none()); } - // Drag pages to move them (the selection, or the page grabbed). - if resp.drag_started() { - if !view.selected.contains(&i) { - view.selected = [i].into(); - view.select_anchor = Some(i); - } - let mut pages: Vec = view.selected.iter().copied().collect(); - pages.sort_unstable(); - view.org_drag = Some(pages); - } - if resp.drag_stopped() { - drop = true; - } + drag_pages(view, &resp, i, &mut drop); let page_label = crate::i18n::fmt(tl!("Page {label}"), &[("label", &p.label)]); resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::CheckBox, true, view.selected.contains(&i), page_label.clone())); let pr = grid_page_rect(c, vec2(p.width, p.height)); @@ -300,18 +338,7 @@ pub(super) fn organize_grid(view: &mut DocView, info: &DocInfo, editable: bool, let thumb_ok = thumb.is_some_and(|t| t.size()[0] as f32 >= want * GRID_SHARP.start()); let sharp = view.grid_pages.get(&i).filter(|_| !thumb_ok); if !thumb_ok && ui.is_rect_visible(c) { - in_view.push((i, want)); - if !sharp.is_some_and(|(w, _)| sharp_enough(*w, want)) && !view.errors.contains_key(&i) { - sharper.push(RenderRequest { - page: i, - kind: RequestKind::Pixels, - tile: None, - scale: want / p.width.max(1.0), - tag: GRID_TAG, - // Grid cells are previews: tiny images are skipped for them. - preview: true, - }); - } + request_sharper(view, i, want, p.width, sharp.map(|(w, _)| *w), &mut in_view, &mut sharper); } if let Some(tex) = sharp.map(|(_, t)| t).or(thumb) { ui.painter().image(tex.id(), pr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); diff --git a/crates/ui-egui/src/canvas/selection.rs b/crates/ui-egui/src/canvas/selection.rs index 43d5990c5..cfc5967bc 100644 --- a/crates/ui-egui/src/canvas/selection.rs +++ b/crates/ui-egui/src/canvas/selection.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, DocInfo, DocView, Edit, HashMap, PageText, Pos2, Range, Rect, comments, pos2}; /// A text selection on one page. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/crates/ui-egui/src/canvas/xform.rs b/crates/ui-egui/src/canvas/xform.rs index 24ff25f7d..91a3739d6 100644 --- a/crates/ui-egui/src/canvas/xform.rs +++ b/crates/ui-egui/src/canvas/xform.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Color32, DocInfo, DocView, Pos2, Rect, pos2, vec2}; impl DocView { /// Where `page` is drawn on screen this frame (`None` when it is not on screen). diff --git a/crates/ui-egui/src/canvas/zoom.rs b/crates/ui-egui/src/canvas/zoom.rs index b6a9669fd..91bc4feac 100644 --- a/crates/ui-egui/src/canvas/zoom.rs +++ b/crates/ui-egui/src/canvas/zoom.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocInfo, DocView, Fit, GAP, MARGIN, PT, PageLayout, Pos2, Rect, SIDE, SavedDisplay, Vec2, device_pixels, pos2, vec2}; /// The page grid's zoom range and the step of its buttons and keys. pub const GRID_ZOOM_RANGE: std::ops::RangeInclusive = 0.5..=3.0; From f796c71abe2634a7ac0cc3157c9797a76c1659ef Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 17/33] Refactor ui commands for strict Clippy checks --- crates/ui-egui/src/commands.rs | 678 ++++++++++++++++++--------------- 1 file changed, 364 insertions(+), 314 deletions(-) diff --git a/crates/ui-egui/src/commands.rs b/crates/ui-egui/src/commands.rs index 26fad6f77..acccbcc55 100644 --- a/crates/ui-egui/src/commands.rs +++ b/crates/ui-egui/src/commands.rs @@ -112,56 +112,64 @@ impl PdfCraftApp { } } + /// Why the command can't run right now, for the user. + fn disabled_reason(&self, spec: &CommandSpec) -> String { + match spec.needs { + commands::Needs::Undo => tl!("Nothing to undo").to_string(), + commands::Needs::Redo => tl!("Nothing to redo").to_string(), + commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(), + commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(), + commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(), + commands::Needs::HasRedactions if self.active.is_some() => { + tl!("There are no redaction marks (mark text, areas or pages first)").to_string() + } + commands::Needs::Marks(k) if self.active.is_some() => { + let kind = match k { + pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), + pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), + pdfcraft_engine::MarkKind::Background => tl!("background"), + }; + crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) + } + // Restricted documents ask for their permissions password instead, so only an + // unprotected or a read-only document gets here. + commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { + match self.active_ids().and_then(|(_, id)| self.session.get(id)) { + Some(d) if !d.allows_security_change() => crate::i18n::fmt(tl!("{name} is read-only"), &[("name", &d.name)]), + _ => tl!("This document isn't password-protected").to_string(), + } + } + commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(), + commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { + tl!("The document's security settings don't allow this change").to_string() + } + commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), + commands::Needs::Printing if self.active.is_some() => crate::print_refused().to_string(), + commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), + _ => tl!("Open a document first").to_string(), + } + } + fn execute_unguarded(&mut self, id: &str) -> bool { let Some(spec) = commands::command(id) else { return false }; if !self.command_enabled(spec) { - let why = match spec.needs { - commands::Needs::Undo => tl!("Nothing to undo").to_string(), - commands::Needs::Redo => tl!("Nothing to redo").to_string(), - commands::Needs::FillForms if self.active.is_some() => tl!("This document has no form fields you can fill in").to_string(), - commands::Needs::HasComments if self.active.is_some() => tl!("This document has no comments to flatten").to_string(), - commands::Needs::HasFields if self.active.is_some() => tl!("This document has no form fields to flatten").to_string(), - commands::Needs::HasRedactions if self.active.is_some() => { - tl!("There are no redaction marks (mark text, areas or pages first)").to_string() - } - commands::Needs::Marks(k) if self.active.is_some() => { - let kind = match k { - pdfcraft_engine::MarkKind::HeaderFooter => tl!("header or footer"), - pdfcraft_engine::MarkKind::Watermark => tl!("watermark"), - pdfcraft_engine::MarkKind::Background => tl!("background"), - }; - crate::i18n::fmt(tl!("This document has no {kind} to change"), &[("kind", kind)]) - } - // Restricted documents ask for their permissions password instead, so only an - // unprotected or a read-only document gets here. - commands::Needs::Security | commands::Needs::ProtectedSecurity if self.active.is_some() => { - match self.active_ids().and_then(|(_, id)| self.session.get(id)) { - Some(d) if !d.allows_security_change() => crate::i18n::fmt(tl!("{name} is read-only"), &[("name", &d.name)]), - _ => tl!("This document isn't password-protected").to_string(), - } - } - commands::Needs::Unlock if self.active.is_some() => tl!("This document has no restrictions to lift").to_string(), - commands::Needs::Assembly | commands::Needs::Modification | commands::Needs::Annotate if self.active.is_some() => { - tl!("The document's security settings don't allow this change").to_string() - } - commands::Needs::Copying if self.active.is_some() => crate::copy_refused().to_string(), - commands::Needs::Printing if self.active.is_some() => crate::print_refused().to_string(), - commands::Needs::TwoPageView if self.active.is_some() => tl!("Switch to two-page view first to show the cover page").to_string(), - _ => tl!("Open a document first").to_string(), - }; + let why = self.disabled_reason(spec); self.notify(why); return false; } let active = self.active; let targets = active.map(|i| self.views[i].target_pages()).unwrap_or_default(); + self.run_file_command(id, active) + .or_else(|| self.run_view_command(id, active)) + .or_else(|| self.run_page_command(id, active, targets)) + .or_else(|| self.run_edit_command(id, active)) + .or_else(|| self.run_form_command(id, active)) + .or_else(|| self.run_secure_command(id, active)) + .unwrap_or(false) + } + + fn run_file_command(&mut self, id: &str, active: Option) -> Option { match id { - "ui.dock.reset" => { - if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { - self.notify(error); - } - } - "view.side_panel" => self.toggle_side_panel(), - "view.tools_pane" => self.left_open = !self.left_open, "file.open" => self.open_dialog(), "file.open_recent" => match self.recent.first().map(|r| r.path.clone()) { // The palette runs commands without a submenu: open the most recent file. @@ -171,7 +179,6 @@ impl PdfCraftApp { "file.clear_recent" if self.recent.is_empty() => self.notify_tr("No recent files"), "file.clear_recent" => self.recent.clear(), "file.pin_folder" => self.pin_folder_dialog(), - "page.combine" => self.open_combine_tab(), "file.save" => { self.save_active(SaveTarget::InPlace); } @@ -198,35 +205,58 @@ impl PdfCraftApp { self.protect_draft = Default::default(); self.dialog = Some(Dialog::Protect); } - "edit.attach" => self.attach_file_dialog(), "protect.remove" => { if self.apply_edit(Edit::RemoveProtection) { self.notify_tr("Security will be removed when you save"); } } - "page.number" => { - if let Some(i) = active { - let v = &self.views[i]; - let pages: Vec = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() }; - let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0)); - self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() }; - } - self.dialog = Some(Dialog::NumberPages); - } link if pdfcraft_engine::links::for_command(link).is_some() => { if let Some(l) = pdfcraft_engine::links::for_command(link) { self.open_url(l.url); } } - "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), - "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), - "edit.undo" => self.undo(), - "edit.redo" => self.redo(), - "edit.find" => { - if let Some(i) = active { - self.views[i].open_find(); + "app.preferences" => self.dialog = Some(Dialog::Preferences), + "protect.remove_hidden" => self.open_remove_hidden(), + "print.dialog" => self.open_print(), + "optimize.advanced" => self.dialog = Some(Dialog::Optimize), + "doc.compare" => self.dialog = Some(Dialog::CompareFiles), + "standards.pdfa" => { + self.pdfa.issues = None; + self.dialog = Some(Dialog::PdfA); + } + "actions.wizard" | "actions.distribution" | "actions.optimize_scans" => { + self.wizard.editing = None; + match id { + "actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()), + "actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()), + _ => {} + } + self.dialog = Some(Dialog::ActionWizard); + } + "create.blank" => self.create_blank(), + "create.file" => self.open_dialog(), + "create.multiple" => self.create_multiple_dialog(), + "create.multiple_separate" => self.create_multiple_separate_dialog(), + "create.images" => self.create_from_images_dialog(), + "create.clipboard" => self.create_from_clipboard(), + "optimize.reduce" => self.reduce_file_size(), + "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), + "help.about" => self.dialog = Some(Dialog::About), + "help.check_updates" => self.check_for_updates(), + _ => return None, + } + Some(true) + } + + fn run_view_command(&mut self, id: &str, active: Option) -> Option { + match id { + "ui.dock.reset" => { + if let Err(error) = crate::side_panels::command(self, true, &serde_json::Value::Null) { + self.notify(error); } } + "view.side_panel" => self.toggle_side_panel(), + "view.tools_pane" => self.left_open = !self.left_open, "view.focus_page_input" => { if let (Some(ctx), Some(view)) = (self.ctx.clone(), active.and_then(|i| self.views.get_mut(i))) { // The page box in the toolbar (chrome.rs); select its label so typing replaces it. @@ -287,7 +317,7 @@ impl PdfCraftApp { "view.read_mode" => self.switch_mode(if self.mode == Mode::Read { Mode::AllTools } else { Mode::Read }, false), "view.previous_view" | "view.next_view" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - return v.view_history(id == "view.next_view"); + return Some(v.view_history(id == "view.next_view")); } } "view.next_tab" => self.step_tab(true), @@ -300,54 +330,33 @@ impl PdfCraftApp { "view.theme.system" => self.set_theme_preference(ThemePreference::System), "view.theme.light" => self.set_theme_preference(ThemePreference::Light), "view.theme.dark" => self.set_theme_preference(ThemePreference::Dark), - "comment.list" => self.choose_right_panel(Some(RightPanel::Comments)), - tool if crate::comments::CommentTool::from_command(tool).is_some() => { - let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return false }; - self.comment_prefs.group_tool[tool.group()] = tool; - self.quick_tool = crate::QuickTool::Comment(tool); - // Acrobat opens the Comments panel with the commenting tools, unless the user closed it. - if self.right.is_none() && !self.comments_panel_closed { - self.choose_right_panel(Some(RightPanel::Comments)); - } - // A text selection made before picking a markup tool is marked right away. - if let (Some(kind), Some(i)) = (tool.markup(), active) - && let Some(doc) = self.session.get(self.views[i].id) + "view.fit_visible" => { + if let Some(i) = self.active + && let Err(e) = self.fit_visible(i) { - let info = &doc.info; - if let Some((page, quads)) = self.views[i].selection_quads(info) { - self.views[i].clear_selection(); - let style = self.comment_prefs.style(tool); - let author = self.comment_prefs.author.clone(); - let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads }; - self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author })); - } + self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]); } } - "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)), - "form.calculation_order" => { - self.left = crate::LeftPanel::CalculationOrder; - // The page edits the fields, so Preview ends; the page grid closes, as for a tool. - self.form_preview = false; - if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - v.organize = false; - } - self.reveal_tools(); - } - "comment.flatten" => { - self.apply_edit(Edit::Flatten { comments: true, fields: false }); - } - "form.flatten" => { - // Flatten what's typed in a field too (#166); a refused value flattens nothing. - if self.commit_form_typing() { - self.apply_edit(Edit::Flatten { comments: false, fields: true }); - } - } - "form.clear" => { + "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, + _ => return None, + } + Some(true) + } + + fn run_page_command(&mut self, id: &str, active: Option, targets: Vec) -> Option { + match id { + "page.combine" => self.open_combine_tab(), + "page.number" => { if let Some(i) = active { - self.views[i].forms.focus = None; + let v = &self.views[i]; + let pages: Vec = if v.selected.is_empty() { vec![v.current] } else { v.selected.iter().copied().collect() }; + let (lo, hi) = (pages.iter().min().copied().unwrap_or(0), pages.iter().max().copied().unwrap_or(0)); + self.number_draft = crate::NumberDraft { from: lo + 1, to: hi + 1, ..self.number_draft.clone() }; } - self.apply_edit(Edit::ResetForm { names: None }); + self.dialog = Some(Dialog::NumberPages); } + "bookmark.add" => self.bookmark_action(crate::panels::BmAction::New), + "bookmark.from_structure" => self.bookmark_action(crate::panels::BmAction::FromStructure), "page.organize" => { if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { if v.organize { @@ -376,6 +385,80 @@ impl PdfCraftApp { } "page.insert" => self.insert_from_file_dialog(), "page.replace" => self.replace_pages_dialog(), + "page.copy" => self.copy_pages(false), + "page.cut" => self.copy_pages(true), + "page.paste" => self.paste_pages(), + "page.duplicate" => { + self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); + } + // Two or more selected pages reverse among themselves; otherwise the whole document. + "page.reverse" => { + let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); + let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; + self.apply_edit(Edit::ReversePages { pages }); + } + "page.crop" => { + self.quick_tool = crate::QuickTool::Crop; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } + self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); + } + "page.boxes" => { + self.boxes_draft.seeded = None; + self.dialog = Some(Dialog::PageBoxes); + } + "page.extract" => self.open_extract_dialog(), + "page.rotate_dialog" => { + if let Some(i) = active { + let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); + self.rotate_draft.to = n; + self.rotate_draft.which = if self.views[i].selected.is_empty() { 0 } else { 1 }; + } + self.dialog = Some(Dialog::RotatePages); + } + "page.split" => self.dialog = Some(Dialog::Split), + _ => return None, + } + Some(true) + } + + fn run_edit_command(&mut self, id: &str, active: Option) -> Option { + match id { + "edit.attach" => self.attach_file_dialog(), + "edit.undo" => self.undo(), + "edit.redo" => self.redo(), + "edit.find" => { + if let Some(i) = active { + self.views[i].open_find(); + } + } + "comment.list" => self.choose_right_panel(Some(RightPanel::Comments)), + tool if crate::comments::CommentTool::from_command(tool).is_some() => { + let Some(tool) = crate::comments::CommentTool::from_command(tool) else { return Some(false) }; + self.comment_prefs.group_tool[tool.group()] = tool; + self.quick_tool = crate::QuickTool::Comment(tool); + // Acrobat opens the Comments panel with the commenting tools, unless the user closed it. + if self.right.is_none() && !self.comments_panel_closed { + self.choose_right_panel(Some(RightPanel::Comments)); + } + // A text selection made before picking a markup tool is marked right away. + if let (Some(kind), Some(i)) = (tool.markup(), active) + && let Some(doc) = self.session.get(self.views[i].id) + { + let info = &doc.info; + if let Some((page, quads)) = self.views[i].selection_quads(info) { + self.views[i].clear_selection(); + let style = self.comment_prefs.style(tool); + let author = self.comment_prefs.author.clone(); + let shape = pdfcraft_engine::Shape::TextMarkup { kind, quads }; + self.apply_edit(Edit::AddAnnotation(pdfcraft_engine::NewAnnotation { page, shape, style, contents: String::new(), author })); + } + } + } + "comment.flatten" => { + self.apply_edit(Edit::Flatten { comments: true, fields: false }); + } "edit.header_footer" | "edit.watermark" | "edit.background" @@ -416,15 +499,7 @@ impl PdfCraftApp { } "export.image" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Image)), "export.text" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::Text)), - "a11y.check" => self.start_accessibility_check(), - "a11y.find_untagged" => self.find_untagged_content(), "ocr.recognize" => self.dialog = Some(Dialog::RecognizeText), - "tools.js_console" => self.dialog = Some(Dialog::JsConsole), - "tools.document_js" => { - self.doc_js = Default::default(); - self.dialog = Some(Dialog::DocumentJs); - } - "app.preferences" => self.dialog = Some(Dialog::Preferences), "ocr.recognize_batch" => self.ocr_files_dialog(), "edit.edit_text" => { self.quick_tool = crate::QuickTool::EditText; @@ -440,16 +515,6 @@ impl PdfCraftApp { self.choose_right_panel(Some(crate::RightPanel::Search)); } } - "a11y.report" => self.show_accessibility_report(), - "a11y.autotag" => { - if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) { - self.choose_right_panel(Some(RightPanel::Tags)); - } - } - "a11y.alt_text" => self.start_alt_text(), - "a11y.autotag_fields" => self.autotag_form_fields(), - "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), - "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), "export.all_images" => self.dialog = Some(Dialog::Export(crate::export_ui::ExportKind::AllImages)), "edit.text" => { self.quick_tool = crate::QuickTool::AddText; @@ -463,6 +528,183 @@ impl PdfCraftApp { } "edit.links_from_urls" => self.links_from_urls(), "edit.remove_links" => self.remove_all_links(), + "comment.import" | "form.import_data" => self.import_data_dialog(), + "comment.stamp" => { + self.left = crate::LeftPanel::Tool("stamp"); + self.reveal_tools(); + } + "comment.export" => self.export_data_dialog(true, false), + "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), + "edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect, + "edit.snapshot" => { + self.quick_tool = crate::QuickTool::Snapshot; + self.notify_tr("Drag a rectangle around the area to copy"); + } + "edit.copy" => { + if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { + return Some(self.copy_selected_text(i, &ctx)); + } + return Some(false); + } + "comment.hide_all" => { + if let Some(i) = active { + let id = self.views[i].id; + let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden()); + if self.session.set_hide_comments(id, hide) { + self.views[i].invalidate_content(); + } + } + } + "export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx), + "export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx), + "export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html), + "export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf), + "ocr.enhance_camera" => { + // The corners start on the document's edges when they can be found, else + // inset on the current page (camera_ui::page_input). + self.quick_tool = crate::QuickTool::EnhanceCamera; + if let Some(i) = self.active + && let Some(view) = self.views.get_mut(i) + && let Some(page) = self.session.get(view.id).and_then(|d| d.info.pages.get(view.current).cloned()) + { + view.camera_done = None; + view.camera = self.session.detect_camera_corners(view.id, view.current).map(|found| crate::camera_ui::CameraQuad { + page: view.current, + corners: found.map(|[x, y]| page.user_to_view(x as f32, y as f32)), + grab: None, + }); + } + self.notify_tr("Drag the corners onto the document's edges, then choose Enhance page."); + } + _ => return None, + } + Some(true) + } + + fn run_form_command(&mut self, id: &str, active: Option) -> Option { + match id { + "form.fields" => self.choose_right_panel(Some(RightPanel::Fields)), + "form.calculation_order" => { + self.left = crate::LeftPanel::CalculationOrder; + // The page edits the fields, so Preview ends; the page grid closes, as for a tool. + self.form_preview = false; + if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { + v.organize = false; + } + self.reveal_tools(); + } + "form.flatten" => { + // Flatten what's typed in a field too (#166); a refused value flattens nothing. + if self.commit_form_typing() { + self.apply_edit(Edit::Flatten { comments: false, fields: true }); + } + } + "form.clear" => { + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.apply_edit(Edit::ResetForm { names: None }); + } + "form.tab_order.row" | "form.tab_order.column" | "form.tab_order.structure" => { + let order = match id { + "form.tab_order.row" => pdfcraft_engine::TabOrder::Row, + "form.tab_order.column" => pdfcraft_engine::TabOrder::Column, + _ => pdfcraft_engine::TabOrder::Structure, + }; + let n = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.info.pages.len()); + self.apply_edit(Edit::SetTabOrder { pages: (0..n).collect(), order }); + } + "sign.digital" | "sign.certify" => { + let certify = id == "sign.certify"; + self.quick_tool = crate::QuickTool::SignArea { certify }; + self.notify_tr("Drag to draw the area where the signature should appear."); + } + "sign.certify_invisible" => { + let page = active.map_or(0, |i| self.views[i].current); + self.start_signing(page, None, None, Some(2)); + } + "sign.validate" => { + let certs = self.session.trusted_certificates().to_vec(); + self.apply_trusted_certificates(certs); + self.choose_right_panel(Some(RightPanel::Signatures)); + } + "sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)), + "form.export_data" => self.export_data_dialog(false, true), + "form.merge_data" => self.merge_data_dialog(), + "form.prepare" => { + self.left = crate::LeftPanel::Tool("form"); + self.reveal_tools(); + self.choose_right_panel(Some(RightPanel::Fields)); + // Like Acrobat, a document without fields gets them detected on the way in. + if let Some((_, id)) = self.active_ids() + && self.session.get(id).is_some_and(|d| d.form.is_empty() && d.editable()) + { + self.detect_fields(); + } + } + "form.detect" => self.detect_fields(), + "form.field.properties" => { + if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) { + self.open_field_props(&name, w); + } + } + field if crate::prepare::FieldTool::from_command(field).is_some() => { + let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return Some(false) }; + self.quick_tool = crate::QuickTool::Field(tool); + self.left = crate::LeftPanel::Tool("form"); + self.reveal_tools(); + if let Some(i) = active { + self.views[i].forms.focus = None; + } + self.notify_fmt( + "Click on the page to add a {tool}, or drag to set its size", + &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))], + ); + } + "sign.fill.signature.remove" => self.signature = None, + "sign.fill.initials.remove" => self.initials = None, + "sign.fill.signature.change" | "sign.fill.initials.change" => { + let initials = id == "sign.fill.initials.change"; + let saved = if initials { self.initials.as_ref() } else { self.signature.as_ref() }; + self.signature_draft = saved.map_or_else( + || crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author), + |s| crate::fill_sign::SigDraft::from_saved(initials, s), + ); + self.dialog = Some(Dialog::Signature); + } + fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { + let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return Some(false) }; + self.quick_tool = crate::QuickTool::Fill(tool); + let initials = tool == crate::fill_sign::FillTool::Initials; + if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { + self.signature_draft = crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author); + self.dialog = Some(Dialog::Signature); + } + } + _ => return None, + } + Some(true) + } + + fn run_secure_command(&mut self, id: &str, active: Option) -> Option { + match id { + "a11y.check" => self.start_accessibility_check(), + "a11y.find_untagged" => self.find_untagged_content(), + "tools.js_console" => self.dialog = Some(Dialog::JsConsole), + "tools.document_js" => { + self.doc_js = Default::default(); + self.dialog = Some(Dialog::DocumentJs); + } + "a11y.report" => self.show_accessibility_report(), + "a11y.autotag" => { + if self.apply_edit(pdfcraft_engine::Edit::AutoTagText) { + self.choose_right_panel(Some(RightPanel::Tags)); + } + } + "a11y.alt_text" => self.start_alt_text(), + "a11y.autotag_fields" => self.autotag_form_fields(), + "a11y.tags" => self.choose_right_panel(Some(RightPanel::Tags)), + "a11y.reading_options" => self.dialog = Some(Dialog::Properties(crate::PropsTab::Advanced)), "redact.mark" => { self.quick_tool = crate::QuickTool::Redact; self.left = crate::LeftPanel::Tool("redact"); @@ -502,205 +744,13 @@ impl PdfCraftApp { self.dialog = Some(Dialog::RedactApply); } } - "protect.remove_hidden" => self.open_remove_hidden(), - "print.dialog" => self.open_print(), "redact.sanitize" => self.dialog = Some(Dialog::Sanitize), "redact.clear" => { self.apply_edit(Edit::ClearRedactions); } - "form.tab_order.row" | "form.tab_order.column" | "form.tab_order.structure" => { - let order = match id { - "form.tab_order.row" => pdfcraft_engine::TabOrder::Row, - "form.tab_order.column" => pdfcraft_engine::TabOrder::Column, - _ => pdfcraft_engine::TabOrder::Structure, - }; - let n = active.and_then(|i| self.session.get(self.views[i].id)).map_or(0, |d| d.info.pages.len()); - self.apply_edit(Edit::SetTabOrder { pages: (0..n).collect(), order }); - } - "comment.import" | "form.import_data" => self.import_data_dialog(), - "comment.stamp" => { - self.left = crate::LeftPanel::Tool("stamp"); - self.reveal_tools(); - } - "comment.export" => self.export_data_dialog(true, false), - "comment.summarize" => self.dialog = Some(Dialog::SummarizeComments), - "sign.digital" | "sign.certify" => { - let certify = id == "sign.certify"; - self.quick_tool = crate::QuickTool::SignArea { certify }; - self.notify_tr("Drag to draw the area where the signature should appear."); - } - "sign.certify_invisible" => { - let page = active.map_or(0, |i| self.views[i].current); - self.start_signing(page, None, None, Some(2)); - } - "sign.validate" => { - let certs = self.session.trusted_certificates().to_vec(); - self.apply_trusted_certificates(certs); - self.choose_right_panel(Some(RightPanel::Signatures)); - } - "sign.panel" => self.choose_right_panel(Some(RightPanel::Signatures)), - "optimize.advanced" => self.dialog = Some(Dialog::Optimize), - "view.fit_visible" => { - if let Some(i) = self.active - && let Err(e) = self.fit_visible(i) - { - self.notify_fmt("Couldn't fit the visible content: {e}", &[("e", &e.to_string())]); - } - } - "view.marquee_zoom" => self.quick_tool = crate::QuickTool::MarqueeZoom, - "edit.column_select" => self.quick_tool = crate::QuickTool::ColumnSelect, - "edit.snapshot" => { - self.quick_tool = crate::QuickTool::Snapshot; - self.notify_tr("Drag a rectangle around the area to copy"); - } - "edit.copy" => { - if let (Some(i), Some(ctx)) = (active, self.ctx.clone()) { - return self.copy_selected_text(i, &ctx); - } - return false; - } - "page.copy" => self.copy_pages(false), - "page.cut" => self.copy_pages(true), - "page.paste" => self.paste_pages(), - "comment.hide_all" => { - if let Some(i) = active { - let id = self.views[i].id; - let hide = !self.session.get(id).is_some_and(|d| d.comments_hidden()); - if self.session.set_hide_comments(id, hide) { - self.views[i].invalidate_content(); - } - } - } - "form.export_data" => self.export_data_dialog(false, true), - "form.merge_data" => self.merge_data_dialog(), - "export.docx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Docx), - "export.pptx" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Pptx), - "export.html" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Html), - "export.rtf" => self.export_office_dialog(pdfcraft_engine::compare::OfficeFormat::Rtf), - "form.prepare" => { - self.left = crate::LeftPanel::Tool("form"); - self.reveal_tools(); - self.choose_right_panel(Some(RightPanel::Fields)); - // Like Acrobat, a document without fields gets them detected on the way in. - if let Some((_, id)) = self.active_ids() - && self.session.get(id).is_some_and(|d| d.form.is_empty() && d.editable()) - { - self.detect_fields(); - } - } - "form.detect" => self.detect_fields(), - "doc.compare" => self.dialog = Some(Dialog::CompareFiles), - "standards.pdfa" => { - self.pdfa.issues = None; - self.dialog = Some(Dialog::PdfA); - } - "actions.wizard" | "actions.distribution" | "actions.optimize_scans" => { - self.wizard.editing = None; - match id { - "actions.distribution" => self.wizard.selected = Some("Prepare for Distribution".into()), - "actions.optimize_scans" => self.wizard.selected = Some("Optimize Scanned Documents".into()), - _ => {} - } - self.dialog = Some(Dialog::ActionWizard); - } - "form.field.properties" => { - if let Some((name, w)) = active.and_then(|i| self.views[i].prepare.selected.clone()) { - self.open_field_props(&name, w); - } - } - field if crate::prepare::FieldTool::from_command(field).is_some() => { - let Some(tool) = crate::prepare::FieldTool::from_command(field) else { return false }; - self.quick_tool = crate::QuickTool::Field(tool); - self.left = crate::LeftPanel::Tool("form"); - self.reveal_tools(); - if let Some(i) = active { - self.views[i].forms.focus = None; - } - self.notify_fmt( - "Click on the page to add a {tool}, or drag to set its size", - &[("tool", &crate::i18n::in_sentence(tl!(tool.label())))], - ); - } - "sign.fill.signature.remove" => self.signature = None, - "sign.fill.initials.remove" => self.initials = None, - "sign.fill.signature.change" | "sign.fill.initials.change" => { - let initials = id == "sign.fill.initials.change"; - let saved = if initials { self.initials.as_ref() } else { self.signature.as_ref() }; - self.signature_draft = saved.map_or_else( - || crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author), - |s| crate::fill_sign::SigDraft::from_saved(initials, s), - ); - self.dialog = Some(Dialog::Signature); - } - fill if crate::fill_sign::FillTool::from_command(fill).is_some() => { - let Some(tool) = crate::fill_sign::FillTool::from_command(fill) else { return false }; - self.quick_tool = crate::QuickTool::Fill(tool); - let initials = tool == crate::fill_sign::FillTool::Initials; - if (tool == crate::fill_sign::FillTool::Signature && self.signature.is_none()) || (initials && self.initials.is_none()) { - self.signature_draft = crate::fill_sign::SigDraft::new(initials, &self.comment_prefs.author); - self.dialog = Some(Dialog::Signature); - } - } - "create.blank" => self.create_blank(), - "create.file" => self.open_dialog(), - "create.multiple" => self.create_multiple_dialog(), - "create.multiple_separate" => self.create_multiple_separate_dialog(), - "create.images" => self.create_from_images_dialog(), - "create.clipboard" => self.create_from_clipboard(), - "optimize.reduce" => self.reduce_file_size(), - "page.duplicate" => { - self.apply_edit(Edit::DuplicatePages { pages: targets, at: None }); - } - // Two or more selected pages reverse among themselves; otherwise the whole document. - "page.reverse" => { - let all = active.and_then(|i| self.views.get(i)).map_or(0, |v| v.page_count()); - let pages = if targets.len() >= 2 { targets } else { (0..all).collect() }; - self.apply_edit(Edit::ReversePages { pages }); - } - "ocr.enhance_camera" => { - // The corners start on the document's edges when they can be found, else - // inset on the current page (camera_ui::page_input). - self.quick_tool = crate::QuickTool::EnhanceCamera; - if let Some(i) = self.active - && let Some(view) = self.views.get_mut(i) - && let Some(page) = self.session.get(view.id).and_then(|d| d.info.pages.get(view.current).cloned()) - { - view.camera_done = None; - view.camera = self.session.detect_camera_corners(view.id, view.current).map(|found| crate::camera_ui::CameraQuad { - page: view.current, - corners: found.map(|[x, y]| page.user_to_view(x as f32, y as f32)), - grab: None, - }); - } - self.notify_tr("Drag the corners onto the document's edges, then choose Enhance page."); - } - "page.crop" => { - self.quick_tool = crate::QuickTool::Crop; - if let Some(v) = active.and_then(|i| self.views.get_mut(i)) { - v.organize = false; - } - self.notify_tr("Drag a rectangle on a page to crop it; double-click a page for Set Page Boxes"); - } - "page.boxes" => { - self.boxes_draft.seeded = None; - self.dialog = Some(Dialog::PageBoxes); - } - "page.extract" => self.open_extract_dialog(), - "page.rotate_dialog" => { - if let Some(i) = active { - let n = self.session.get(self.views[i].id).map_or(1, |d| d.info.pages.len()); - self.rotate_draft.to = n; - self.rotate_draft.which = if self.views[i].selected.is_empty() { 0 } else { 1 }; - } - self.dialog = Some(Dialog::RotatePages); - } - "page.split" => self.dialog = Some(Dialog::Split), - "help.shortcuts" => self.dialog = Some(Dialog::Shortcuts), - "help.about" => self.dialog = Some(Dialog::About), - "help.check_updates" => self.check_for_updates(), - _ => return false, + _ => return None, } - true + Some(true) } /// Shows the next tab (or the previous one) in the strip's order, wrapping around at either From bf89bf918c0c5589f6018e828ae511c7b7909586 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 18/33] Refactor ui dialogs for strict Clippy checks --- crates/ui-egui/src/dialogs.rs | 2921 +++++++++++++------------ crates/ui-egui/src/dialogs/prompts.rs | 2 +- 2 files changed, 1559 insertions(+), 1364 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a0c1ca880..a2cbe902f 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -23,6 +23,48 @@ use prompts::{full_screen_prompt, link_prompt, password, remote_link_prompt, sav const INFO_KEYS: [&str; 4] = ["Title", "Author", "Subject", "Keywords"]; +/// What the dialog's widgets asked for this frame; acted on after the modal is drawn. +#[derive(Default)] +struct Pending { + apply: bool, + split_now: Option, + split_ready: Option, + extract_now: bool, + rotate_now: bool, + link_now: Option, + recover: Option, + number_now: Option, + apply_number: bool, + link_command: Option<&'static str>, + protect_now: bool, + boxes_now: bool, + marks_now: bool, + export_now: bool, + props_now: bool, + field_props_now: bool, + bulk_field_props_now: bool, + redact_now: Option, + import_words: bool, + print_go: bool, + revert_now: bool, + overwrite_now: bool, + overwrite_elsewhere: bool, + summarize_now: bool, + optimize_now: bool, + duplicate_now: Option, + replace_now: bool, + open_revision: Option, + a11y_now: bool, + ocr_now: bool, + compare_now: bool, + images_now: bool, + stamp_now: bool, + alt_now: bool, + close: bool, + /// The dialog to switch to, when a widget asked for another one. + next: Option, +} + pub fn show(app: &mut PdfCraftApp, ctx: &egui::Context) { // The page sets hover cursors from the raw pointer position (form fields, links, comments) // before any dialog is drawn, and nothing under a modal's backdrop takes clicks. Start from @@ -77,43 +119,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.view_draft = Some((id, doc.initial_view())); app.custom_draft = Some((id, crate::CustomDraft { props: doc.custom_info(), ..Default::default() })); } - let mut apply = false; - let mut split_now: Option = None; - let mut split_ready: Option = None; - let mut extract_now = false; - let mut rotate_now = false; - let mut link_now: Option = None; - let mut recover: Option = None; - let mut number_now: Option = None; - let mut apply_number = false; - let mut link_command: Option<&'static str> = None; - let mut protect_now = false; - let mut boxes_now = false; - let mut marks_now = false; - let mut export_now = false; - let mut props_now = false; - let mut field_props_now = false; - let mut bulk_field_props_now = false; - let mut redact_now: Option = None; - let mut import_words = false; - let mut print_go = false; - let mut revert_now = false; - let mut overwrite_now = false; - let mut overwrite_elsewhere = false; - let mut summarize_now = false; - let mut optimize_now = false; - let mut duplicate_now: Option = None; - let mut replace_now = false; - let mut open_revision: Option = None; - let mut a11y_now = false; - let mut ocr_now = false; - let mut compare_now = false; - let mut images_now = false; - let mut stamp_now = false; - let mut alt_now = false; - let t = Tokens::get(ctx); - let mut close = false; - let mut next = dialog; + let mut req = Pending::default(); let modal = widgets::show_modal(ctx, "dialog", |ui| { ui.set_width(match dialog { Dialog::Properties(_) => 640.0, @@ -122,1302 +128,1463 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::About => 780.0, _ => 520.0, }); - match dialog { - Dialog::WordCount => { - close = crate::word_count_ui::body(ui, app); - } - Dialog::CreateImages => { - let (go, cancel) = crate::create_ui::image_import_body(ui, app); - images_now = go; - close = go || cancel; - return; - } - Dialog::Properties(tab) => { - ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.horizontal_wrapped(|ui| { - for (tb, label) in [ - (PropsTab::Description, tl!("Description")), - (PropsTab::InitialView, tl!("Initial View")), - (PropsTab::Security, tl!("Security")), - (PropsTab::Fonts, tl!("Fonts")), - (PropsTab::Custom, tl!("Custom")), - (PropsTab::Advanced, tl!("Advanced")), - (PropsTab::SpaceAudit, tl!("Space Audit")), - ] { - if widgets::mode_tab(ui, label, tab == tb).clicked() { - next = Dialog::Properties(tb); - } - } - }); - ui.separator(); - let units = app.units; - let Some((_, id)) = app.active_ids() else { return }; - let Some(doc) = app.session.get(id) else { return }; - let i = &doc.info; - // Description shows plain values among text fields; they start where the fields' text does. - let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); - let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; - let row = |ui: &mut egui::Ui, k: &str, v: String| { - // Row labels are UI chrome; values are document data and stay as they are. - ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.horizontal(|ui| { - ui.add_space(inset); - ui.add( - egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap(), - ); - }); - ui.end_row(); - }; - // A file name or path is read left to right even in an RTL UI: the extension - // stays at its visual end, as in the tab strip. - let path_row = |ui: &mut egui::Ui, k: &str, v: String| { - ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - ui.scope(|ui| { - ui.set_max_width(420.0); - // Align path text with editable fields while keeping its LTR text order. - ui.add_space(inset); - if v.is_empty() { - ui.label(egui::RichText::new("—").color(t.text_faint)); - } else { - crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); - } - }); - ui.end_row(); - }; - egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { - if tab == PropsTab::SpaceAudit { - ui.add_space(4.0); - crate::optimize_ui::audit_table(ui, &app.space_audit, &t); - return; - } - egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { - PropsTab::Description => { - path_row(ui, "File", doc.name.clone()); - match app.props_draft.as_mut() { - Some((_, draft)) if editable_info => { - for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { - let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); - // Subject and Keywords are often a sentence or a list: they wrap and grow - // instead of hiding most of the value past the field's edge. - let field = - if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; - ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); - ui.end_row(); - } - } - _ => { - row(ui, "Title", i.title.clone().unwrap_or_default()); - row(ui, "Author", i.author.clone().unwrap_or_default()); - row(ui, "Subject", i.subject.clone().unwrap_or_default()); - row(ui, "Keywords", i.keywords.clone().unwrap_or_default()); - } - } - row(ui, "Application", i.creator.clone().unwrap_or_default()); - row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); - } - PropsTab::InitialView => { - use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; - let editable = doc.allows_modification(); - let pages = i.pages.len(); - let Some((_, v)) = app.view_draft.as_mut() else { return }; - ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); - ui.end_row(); - let label = ui.label(tl!("Navigation tab")); - ui.add_enabled_ui(editable, |ui| { - craft_ui::ComboBox::from_id_salt("iv-nav") - .selected_text(tl!(format!("{:?}", v.navigation) - .replace("PageOnly", "Page Only") - .replace("Pages", "Pages Panel and Page") - .as_str())) - .show_ui(ui, |ui| { - for (n, l) in [ - (N::PageOnly, tl!("Page Only")), - (N::Bookmarks, tl!("Bookmarks Panel and Page")), - (N::Pages, tl!("Pages Panel and Page")), - (N::Attachments, tl!("Attachments Panel and Page")), - (N::Layers, tl!("Layers Panel and Page")), - ] { - ui.selectable_value(&mut v.navigation, n, l); - } - }) - .response - .labelled_by(label.id); - }); - ui.end_row(); - let label = ui.label(tl!("Page layout")); - ui.add_enabled_ui(editable, |ui| { - let names = [ - (L::Default, tl!("Default")), - (L::SinglePage, tl!("Single Page")), - (L::SinglePageContinuous, tl!("Single Page Continuous")), - (L::TwoUp, tl!("Two-Up (Facing)")), - (L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")), - (L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")), - (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), - ]; - let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - craft_ui::ComboBox::from_id_salt("iv-layout") - .selected_text(shown) - .show_ui(ui, |ui| { - for (l, n) in names { - ui.selectable_value(&mut v.layout, l, n); - } - }) - .response - .labelled_by(label.id); - }); - ui.end_row(); - let label = ui.label(tl!("Magnification")); - ui.add_enabled_ui(editable, |ui| { - ui.horizontal(|ui| { - let names = [ - (M::Default, tl!("Default")), - (M::ActualSize, tl!("Actual Size")), - (M::FitPage, tl!("Fit Page")), - (M::FitWidth, tl!("Fit Width")), - (M::FitHeight, tl!("Fit Height")), - (M::FitVisible, tl!("Fit Visible")), - ]; - let shown = match v.magnification { - M::Percent(p) => format!("{p:.0}%"), - m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), - }; - craft_ui::ComboBox::from_id_salt("iv-mag") - .selected_text(shown) - .show_ui(ui, |ui| { - for (m, n) in names { - ui.selectable_value(&mut v.magnification, m, n); - } - for p in [50.0, 75.0, 125.0, 150.0, 200.0] { - ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); - } - }) - .response - .labelled_by(label.id); - }); - }); - ui.end_row(); - let label = ui.label(tl!("Open to page")); - ui.add_enabled_ui(editable, |ui| { - let mut p = v.page + 1; - if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { - v.page = p - 1; - } - ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); - }); - ui.end_row(); - ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5))); - ui.end_row(); - ui.label(""); - ui.add_enabled_ui(editable, |ui| { - ui.vertical(|ui| { - ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page"))); - ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen"))); - ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"))); - ui.horizontal(|ui| { - ui.label(tl!("Show:")); - widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name")); - widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title")); - }); - }); - }); - ui.end_row(); - ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5))); - ui.end_row(); - ui.label(""); - ui.add_enabled_ui(editable, |ui| { - ui.vertical(|ui| { - ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar"))); - ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"))); - ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls"))); - }); - }); - ui.end_row(); - } - PropsTab::Security => match doc.security_summary() { - None => { - row(ui, "Security method", tl!("No security").to_string()); - row(ui, "Restrictions", tl!("None — everything is allowed").to_string()); - if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() { - link_command = Some("protect.password"); - } - } - Some(sec) => { - row(ui, "Security method", tl!("Password security").to_string()); - row(ui, "Encryption", sec.method.clone()); - row( - ui, - "Opened with", - if sec.pending { - tl!("— (protection is applied when you save)").to_string() - } else if sec.owner { - tl!("Owner password (no restrictions apply)").to_string() - } else { - tl!("User password").to_string() - }, - ); - // A restricted document asks for its permissions password first. - if doc.allows_security_change() || doc.can_unlock() { - ui.horizontal(|ui| { - if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() { - link_command = Some("protect.unlock"); - } - if ui.button(tl!("Change settings…")).clicked() { - link_command = Some("protect.password"); - } - if ui.button(tl!("Remove security")).clicked() { - link_command = Some("protect.remove"); - } - }); - } - let p = sec.permissions; - let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string(); - row( - ui, - "Printing", - if !p.print() { - tl!("Not allowed").to_string() - } else if p.print_high_quality() { - tl!("High resolution").to_string() - } else { - tl!("Low resolution").to_string() - }, - ); - row(ui, "Changing the document", yes(p.modify())); - row(ui, "Document assembly", yes(p.assemble())); - row(ui, "Content copying", yes(p.copy())); - row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility())); - row(ui, "Commenting", yes(p.annotate())); - row(ui, "Filling of form fields", yes(p.fill_forms())); - } - }, - PropsTab::Fonts => { - if i.fonts.is_empty() { - row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string()); - } - for f in &i.fonts { - let mut detail = f.kind.clone(); - if let Some(e) = &f.encoding { - detail.push_str(&format!(" · {e}")); - } - detail.push_str(if f.subset { - " · Embedded subset" - } else if f.embedded { - " · Embedded" - } else { - " · Not embedded (substituted)" - }); - row(ui, &f.name, detail); - } - } - PropsTab::Custom => { - // Add takes a new name, Update changes the value of the listed property the - // Name field names, Delete removes it; OK writes the changes as one step. - let editable = doc.allows_modification(); - let Some((_, d)) = app.custom_draft.as_mut() else { return }; - let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted)); - ui.add_enabled( - editable, - widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name"), - ) - .labelled_by(l.id); - ui.end_row(); - let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted)); - ui.add_enabled( - editable, - widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value"), - ) - .labelled_by(l.id); - ui.end_row(); - // Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused. - let name = d.name.clone(); - let value = d.value.trim().to_owned(); - let listed = d.props.iter().position(|(k, _)| *k == name); - // An entry the list leaves out (not text, only padding, or past its 256) keeps its value. - let file_props = doc.custom_info(); - let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name); - let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string()); - ui.label(""); - // A file can hold a custom name that `check_custom_key` refuses, such as one - // with a space: the tab lists it, and Add, Update and Delete stay off for it. - let why = |b: egui::Response| match &refused { - Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)), - None => b, - }; - // A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is. - let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER); - // `custom_info` lists at most 256 properties, and a new name goes after the file's - // entries, so Add is off once the draft or the file's own list holds 256. - let room = d.props.len() < 256 && file_props.len() < 256; - ui.horizontal(|ui| { - let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty(); - if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() { - d.props.push((name.clone(), value.clone())); - d.name.clear(); - d.value.clear(); - } - let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value); - if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked() - && let Some(p) = listed.and_then(|n| d.props.get_mut(n)) - { - p.1 = value.clone(); - } - if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked() - && let Some(n) = listed.filter(|n| *n < d.props.len()) - { - d.props.remove(n); - d.name.clear(); - d.value.clear(); - } - }); - ui.end_row(); - if d.props.is_empty() { - ui.label(""); - ui.label(egui::RichText::new(tl!("None")).color(t.text_faint)); - ui.end_row(); - } - // A click on a property fills the fields with it; values are document data. - let mut picked = None; - for (k, v) in &d.props { - if ui.selectable_label(*k == name, k.as_str()).clicked() { - picked = Some((k.clone(), v.clone())); - } - ui.scope(|ui| { - ui.set_max_width(420.0); - ui.add(egui::Label::new(v.as_str()).wrap()); - }); - ui.end_row(); - } - if let Some((k, v)) = picked { - d.name = k; - d.value = v; - } - } - PropsTab::Advanced => { - row(ui, "PDF version", i.pdf_version.clone()); - path_row(ui, "Location", doc.path.clone().unwrap_or_default()); - row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); - let p = &i.pages[0]; - row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); - row(ui, "Number of pages", i.pages.len().to_string()); - row(ui, "Tagged PDF", yes(i.tagged)); - row(ui, "Form fields", i.fields.len().to_string()); - row(ui, "Comments", i.annotations.len().to_string()); - row(ui, "Layers", i.layers.len().to_string()); - row(ui, "Attachments", i.attachments.len().to_string()); - row(ui, "JavaScript", yes(i.has_javascript)); - // Each incremental update is a revision; earlier ones open as their own document. - let ends = doc.revision_ends(); - if ends.len() > 1 { - ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); - // Reserve the full wrapped height before the grid starts the next row. - ui.vertical(|ui| { - ui.horizontal_wrapped(|ui| { - ui.label(ends.len().to_string()); - for n in (1..ends.len()).rev().take(12) { - if ui - .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) - .on_hover_text(tl!("Open the file as it was saved then")) - .clicked() - { - open_revision = Some(n); - } - } - }); - }); - ui.end_row(); - } - // Reading Options: binding and language. - if let Some((_, v)) = app.view_draft.as_mut() { - let editable = doc.allows_modification(); - ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); - ui.add_enabled_ui(editable, |ui| { - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge")); - widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge")); - }); - }); - ui.end_row(); - let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted)); - let mut lang = v.language.clone().unwrap_or_default(); - if ui - .add_enabled(editable, widgets::line(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0)) - .labelled_by(l.id) - .changed() - { - v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string()); - } - ui.end_row(); - } - // What was repaired while reading a damaged file (fidelity: never silent). - let repairs = doc.repair_log(); - row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() }); - if !repairs.is_empty() { - ui.label(""); - egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| { - for r in &repairs { - ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); - } - }); - ui.end_row(); - } - } - // Laid out above, outside the two-column grid. - PropsTab::SpaceAudit => {} - }); - }); - } - Dialog::Split => { - ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0))); - ui.add_space(8.0); - let Some((vi, id)) = app.active_ids() else { return }; - let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); - let selected: Vec = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect(); - let marks = app.session.bookmark_splits(id); - let draft = &mut app.split_draft; - use crate::SplitMode as M; - if selected.is_empty() && draft.mode == M::Selection { - draft.mode = M::Pages; - } - widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages")); - ui.add_enabled_ui(draft.mode == M::Pages, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - ui.label(tl!("Pages per file")); - ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); - }); - }); - widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size")); - ui.add_enabled_ui(draft.mode == M::Size, |ui| { - ui.horizontal(|ui| { - ui.add_space(24.0); - ui.label(tl!("At most")); - ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB")); - }); - }); - ui.add_enabled_ui(!marks.is_empty(), |ui| { - widgets::radio_value( - ui, - &mut draft.mode, - M::Bookmarks, - crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), - ) - }); - ui.add_enabled_ui(!selected.is_empty(), |ui| { - widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) - }); - let plan = match draft.mode { - M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), - M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)), - M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize), - M::Bookmarks => crate::SplitPlan::Bookmarks, - }; - let files = match &plan { - crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()), - crate::SplitPlan::Bookmarks => { - Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len()) - } - crate::SplitPlan::Size(_) => None, - }; - ui.add_space(8.0); - let text = match files { - Some(f) => { - if f == 1 { - crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())]) - } else { - crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())]) - } - } - None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]), - }; - ui.label(egui::RichText::new(text).color(t.text_muted)); - if files.is_none_or(|f| f > 1) { - split_ready = Some(plan); - } - } - Dialog::ReplacePages => { - let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1); - let Some(d) = app.replace_draft.as_mut() else { - close = true; - return; - }; - ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - d.to = d.to.clamp(1, count); - d.from = d.from.clamp(1, d.to); - ui.horizontal(|ui| { - let from_label = ui.label(tl!("Original: replace pages")); - let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); - let to_label = ui.label(tl!("to")); - let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); - if from.changed() || to.changed() { - d.range_edited = true; - } - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); - }); - let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) }; - let max_start = d.src_pages.saturating_sub(n) + 1; - d.src_from = d.src_from.clamp(1, max_start.max(1)); - ui.horizontal(|ui| { - let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); - ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); - ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); - }); - let fits = n <= d.src_pages; - ui.add_space(6.0); - ui.label( - egui::RichText::new(if fits { - tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.") - } else { - tl!("The replacement file doesn't have that many pages.") - }) - .small() - .color(t.text_faint), - ); - ui.add_space(10.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { - replace_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::RedactPages => { - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::RedactSearch => { - let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t); - if go { - redact_now = Some(dialog); - } - import_words = import; - close = cancel; - return; - } - Dialog::RedactProps => { - let mut prefs = app.redact_prefs.clone(); - let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t); - app.redact_prefs = prefs; - if ok && app.redact_prefs.remember { - app.redact_defaults = app.redact_prefs.clone(); - } - if cancel && let Some(before) = app.redact_before.take() { - app.redact_prefs = before; - } - if ok { - app.redact_before = None; - } - close = ok || cancel; - return; - } - Dialog::RedactApply => { - let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); - let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::LinkProps => { - let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let Some(d) = app.link_draft.as_mut() else { - close = true; - return; - }; - let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t); - if ok { - link_now = Some(crate::link_ui::edit_for(d)); - } - close = ok || cancel; - return; - } - Dialog::Extract => { - let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); - ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - // Page ranges to take instead of the selection (#3593); the hint is the selection. - let hint = match crate::combine_ui::range_of(&selected, total) { - all if all.is_empty() && total > 1 => format!("1-{total}"), - all if all.is_empty() => "1".to_string(), - some => some, - }; - ui.horizontal(|ui| { - let l = ui.label(tl!("Pages")); - ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); - }); - let chosen = app.extract_draft.pages_to_extract(selected, total); - match &chosen { - Ok(pages) if pages.len() == 1 => { - ui.label(tl!("1 page selected.")); - } - Ok(pages) => { - ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); - } - Err(e) => { - ui.colored_label(ui.visuals().error_fg_color, e); - } - } - ui.add_space(4.0); - // The name the pages are saved under (#737); separate files add " (page N)". - ui.horizontal(|ui| { - let l = ui.label(tl!("File name")); - ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id); - }); - ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"))); - ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { - extract_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::RotatePages => { - use pdfcraft_engine::{PageOrientation as O, PageParity as P}; - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let d = &mut app.rotate_draft; - ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Direction:")); - craft_ui::ComboBox::from_id_salt("rotate-dir") - .selected_text(match d.degrees { - 270 => tl!("Counterclockwise 90 degrees"), - 180 => tl!("180 degrees"), - _ => tl!("Clockwise 90 degrees"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees")); - ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees")); - ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees")); - }); - ui.end_row(); - ui.label(tl!("Pages:")); - ui.vertical(|ui| { - widgets::radio_value(ui, &mut d.which, 0, tl!("All")); - widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.which, 2, tl!("From")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); - }); - }); - ui.end_row(); - ui.label(tl!("Rotate:")); - craft_ui::ComboBox::from_id_salt("rotate-parity") - .selected_text(match d.parity { - P::Both => tl!("Even and Odd Pages"), - P::Even => tl!("Even Pages Only"), - P::Odd => tl!("Odd Pages Only"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages")); - ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only")); - ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only")); - }); - ui.end_row(); - ui.label(""); - craft_ui::ComboBox::from_id_salt("rotate-orient") - .selected_text(match d.orientation { - O::Both => tl!("Landscape and Portrait Pages"), - O::Landscape => tl!("Landscape Pages"), - O::Portrait => tl!("Portrait Pages"), - }) - .show_ui(ui, |ui| { - ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages")); - ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages")); - ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages")); - }); - ui.end_row(); - }); - d.to = d.to.max(d.from); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - rotate_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::DuplicateField => { - let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); - let Some(d) = app.duplicate_draft.as_mut() else { - close = true; - return; - }; - ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); - widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); - ui.horizontal(|ui| { - widgets::radio_value(ui, &mut d.all, false, tl!("From")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); - }); - d.to = d.to.max(d.from); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; - duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - // Never reached: `dialogs` shows this movable window above and returns before the modal. - // The arm keeps the match exhaustive. - Dialog::FieldTable => close = true, - Dialog::Optimize => { - let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); - optimize_now = ok; - close = ok || cancel; - if std::mem::take(&mut app.optimize_draft.audit) { - app.refresh_space_audit(); - next = Dialog::AuditSpace; - } - return; - } - Dialog::CertificateViewer => { - ui.set_width(700.0); - let trusted = app.session.trusted_certificates().to_vec(); - let Some(v) = app.cert_viewer.as_mut() else { - close = true; - return; - }; - let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t); - close = done; - match action { - Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), - Some(crate::sign_ui::CertAction::Export(c)) => { - app.export_certificate(&c); - } - None => {} - } - return; - } - Dialog::AuditSpace => { - if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { - next = Dialog::Optimize; - } - return; - } - Dialog::Sign => { - if crate::sign_ui::dialog(ui, app, &t) { - app.sign_draft = None; - close = true; - } - return; - } - Dialog::SummarizeComments => { - ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(tl!("Choose a layout:")); - let _ = ui.add(widgets::radio(true, tl!("Comments only"))); - ui.add_space(6.0); - ui.horizontal(|ui| { - ui.label(tl!("Sort comments by:")); - craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { - for s in pdfcraft_engine::SummarySort::ALL { - ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); - } - }); - }); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() { - summarize_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::Revert => { - let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); - ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt( - tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."), - &[("name", &name)], - )); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Revert"), true).clicked() { - revert_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::OverwriteChanged => { - #[cfg(not(target_arch = "wasm32"))] - let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default(); - #[cfg(target_arch = "wasm32")] - let name = String::new(); - ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0))); - ui.add_space(8.0); - ui.label(crate::i18n::fmt( - tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."), - &[("name", &name)], - )); - ui.add_space(12.0); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() { - overwrite_now = true; - close = true; - } - if widgets::pill_button(ui, tl!("Save as…"), false).clicked() { - overwrite_elsewhere = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - }); - return; - } - Dialog::Print => { - let Some((i, id)) = app.active_ids() else { - close = true; - return; - }; - if app.print_draft.handling == crate::print_ui::Handling::Label { - app.read_label_crops(id); - } - let Some(doc) = app.session.get(id) else { return }; - let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); - let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); - let rasters = crate::print_ui::preview_rasters( - &app.print_draft, - &sizes, - &labels, - ui.ctx().pixels_per_point(), - ui.ctx().input(|i| i.max_texture_side) as f32, - ); - let view = &mut app.views[i]; - view.set_print_grayscale(app.print_draft.grayscale); - view.queue_print_previews(&rasters); - let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { - view.need_thumbnail(p, true); - view.page_preview(p) - }); - print_go = go; - close = go || cancel; - return; - } - Dialog::RemoveHidden => { - let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::Sanitize => { - let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t); - if ok { - redact_now = Some(dialog); - } - close = ok || cancel; - return; - } - Dialog::FieldProps => { - let Some(d) = app.field_props.as_mut() else { - close = true; - return; - }; - let (apply, cancel) = crate::prepare::body(ui, d, &t); - field_props_now = apply; - close = apply || cancel; - return; - } - Dialog::BulkFieldProps => { - let Some(d) = app.bulk_field_props.as_mut() else { - close = true; - return; - }; - let (apply, cancel) = crate::bulk_fields::body(ui, d, &t); - bulk_field_props_now = apply; - close = cancel; - return; - } - Dialog::CommentProps => { - let (apply, cancel) = crate::comment_props::body(ui, app, &t); - props_now = apply; - close = apply || cancel; - return; - } - Dialog::Signature => { - let (apply, cancel, browse) = - crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo); - if browse { - app.pick_signature_image(); - } - if apply { - let d = std::mem::take(&mut app.signature_draft); - let tool = if d.initials { - app.initials = d.saved(); - crate::fill_sign::FillTool::Initials - } else { - app.signature = d.saved(); - crate::fill_sign::FillTool::Signature - }; - app.quick_tool = crate::QuickTool::Fill(tool); - app.toast = None; - } - close = apply || cancel; - return; - } - Dialog::AltText => { - let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t); - alt_now = save; - close = save || cancel; - return; - } - Dialog::CreateStamp => { - let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t); - stamp_now = save; - close = save || cancel; - return; - } - Dialog::PdfA => { - close = crate::standards_ui::body(ui, app, &t); - return; - } - Dialog::ActionWizard => { - ui.set_width(620.0); - close = crate::actions_ui::body(ui, app, &t); - return; - } - Dialog::CompareFiles => { - let (go, cancel) = crate::compare_ui::body(ui, app, &t); - compare_now = go; - close = go || cancel; - return; - } - Dialog::JsConsole => { - ui.set_width(640.0); - close = crate::js_ui::console_body(ui, app, &t); - return; - } - Dialog::DocumentJs => { - ui.set_width(640.0); - close = crate::js_ui::document_js_body(ui, app, &t); - return; - } - Dialog::Preferences => { - // The settings outgrow a short window: scroll them rather than run off the screen. - // The OK row stays below the scroll, in reach however far the settings are scrolled. - let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); - // A scroll area starts from the room the dialog offers it, and a modal offers more - // each frame for the first few, so with OK outside the scroll the dialog would grow - // over those frames. Asking for the full height as the least it may scroll in - // (which only applies when the settings overflow it) opens the dialog at full size. - // A scrollbar of its own: the floating one lies over the right edge of the theme - // cards and other full-width rows (#3557). - ui.scope(|ui| { - ui.spacing_mut().scroll.floating = false; - egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); - }); - close = crate::js_ui::preferences_buttons(ui); - return; - } - Dialog::RecognizeText => { - let (go, cancel) = crate::ocr_ui::body(ui, app, &t); - ocr_now = go; - close = go || cancel; - return; - } - Dialog::AccessibilityOptions => { - let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); - a11y_now = start; - close = start || cancel; - return; - } - Dialog::Export(kind) => { - let (apply, cancel) = crate::export_ui::body(ui, app, &t, kind); - export_now = apply; - close = apply || cancel; - return; - } - Dialog::Marks(kind) => { - ui.set_width(720.0); - let (apply, cancel) = crate::marks_ui::body(ui, app, &t, kind); - marks_now = apply; - close = apply || cancel; - return; - } - Dialog::PageBoxes => { - let (apply, cancel) = crate::pageboxes::body(ui, app, &t); - boxes_now = apply; - close = apply || cancel; - return; - } - Dialog::Protect => { - let (apply, cancel) = crate::protect::body(ui, app, &t); - protect_now = apply; - close = apply || cancel; - return; - } - Dialog::NumberPages => { - use pdfcraft_engine::LabelStyle as L; - ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); - ui.add_space(8.0); - let Some((_, id)) = app.active_ids() else { return }; - let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); - let d = &mut app.number_draft; - d.to = d.to.clamp(1, n); - d.from = d.from.clamp(1, d.to); - egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { - ui.label(tl!("Pages")); - ui.horizontal(|ui| { - ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); - ui.label(tl!("to")); - ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); - ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); - }); - ui.end_row(); - ui.label(tl!("Style")); - let styles = [ - (L::Decimal, "1, 2, 3"), - (L::LowerRoman, "i, ii, iii"), - (L::UpperRoman, "I, II, III"), - (L::LowerAlpha, "a, b, c"), - (L::UpperAlpha, "A, B, C"), - (L::None, tl!("None (prefix only)")), - ]; - let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); - craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { - for (s, l) in styles { - ui.selectable_value(&mut d.style, s, l); - } - }); - ui.end_row(); - let l = ui.label(tl!("Prefix")); - ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).labelled_by(l.id); - ui.end_row(); - ui.label(tl!("Start")); - ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); - ui.end_row(); - }); - d.to = d.to.max(d.from); - let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); - ui.add_space(8.0); - let preview = if d.from == d.to { - label(d.start) - } else { - format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) - }; - ui.label( - egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])) - .color(t.text_muted), - ); - number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); - } - Dialog::Recovery => { - ui.horizontal(|ui| { - ui.add(crate::icons::image("clock-3", 22.0, t.accent)); - ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0))); - }); - ui.add_space(6.0); - ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:")); - ui.add_space(8.0); - let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); - egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { - for m in &app.recoverable { - ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0))); - let mins = now.saturating_sub(m.saved_at) / 60; - let when = match mins { - 0 => tl!("just now").to_string(), - 1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), - _ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]), - }; - let lock = if m.encrypted { tl!(" · password-protected") } else { "" }; - ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted)); - ui.end_row(); - } - }); - } - Dialog::Shortcuts => { - ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); - ui.add_space(8.0); - use crate::commands::{ - ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, - ZOOM_OUT, - }; - // Registered commands first (always in sync with the real bindings), then the - // keys the document view handles itself. - let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS - .iter() - .filter_map(|c| { - c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string())) - }) - .collect(); - let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s); - let pair = |a, b| format!("{} / {}", key(a), key(b)); - // Key names, translated where a catalog has them (not scanned as UI literals). - let named = |k: &str| tl!(k).to_string(); - let scroll = crate::i18n::fmt( - tl!("Zoom in / out (also pinch or {key}-scroll)"), - &[("key", crate::commands::command_modifier_label(ui.ctx()))], - ); - for (k, v) in [ - (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), - (key(COPY), tl!("Copy selected text").to_string()), - (named("Double-click"), tl!("Select a word").to_string()), - (crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / close find").to_string()), - (key(ACTUAL_SIZE), tl!("Actual size").to_string()), - (key(PAGE_LEVEL), tl!("Zoom to page level").to_string()), - (key(FIT_WIDTH), tl!("Fit to width").to_string()), - (pair(ZOOM_IN, ZOOM_OUT), scroll), - (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), - (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), - (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), - (named("V"), tl!("Select (V)").to_string()), - (named("H / Space (hold)"), tl!("Hand (H)").to_string()), - (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), - (key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()), - ] { - rows.push((k, v)); - } - egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { - egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { - for (k, v) in &rows { - ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5))); - ui.label(v); - ui.end_row(); - } - }); - }); - } - Dialog::About => { - // Tabs About · Contributors · Models (craftrules standards/contributors.md). - let tab_id = egui::Id::unique("about_tab"); - let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); - ui.horizontal_wrapped(|ui| { - for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { - let i = i as u8; - if widgets::mode_tab(ui, tl!(label), tab == i).clicked() { - tab = i; - } - } - }); - ui.data_mut(|d| d.insert_temp(tab_id, tab)); - ui.separator(); - match tab { - 1 => crate::credits::contributors_ui(ui), - 2 => crate::credits::models_ui(ui), - _ => { - ui.horizontal(|ui| { - widgets::artcraft_mark(ui, 40.0); - ui.vertical(|ui| { - ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); - ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())])); - }); - }); - ui.add_space(6.0); - ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.")); - ui.label( - egui::RichText::new( - "Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)", - ) - .color(t.text_muted) - .small(), - ); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted)); - widgets::artcraft_logo(ui, 16.0); - }); - ui.add_space(6.0); - if let Some(cmd) = widgets::community_links(ui) { - link_command = Some(cmd); - } - } - } + if dialog_body(ui, app, dialog, &mut req) { + dialog_footer(ui, app, dialog, &mut req); + } + }); + finish_dialog(app, dialog, modal.should_close(), &mut req); +} + +/// The dialog's content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + match dialog { + Dialog::WordCount => { + req.close = crate::word_count_ui::body(ui, app); + } + Dialog::CreateImages => { + let (go, cancel) = crate::create_ui::image_import_body(ui, app); + req.images_now = go; + req.close = go || cancel; + return false; + } + Dialog::Properties(tab) => { + if !dialog_properties(ui, app, req, tab) { + return false; } } - ui.add_space(16.0); - let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); - // One row high: on its own, a right-to-left layout centres the buttons in all the height - // left, which after a taller Properties tab is the room that tab left behind. + Dialog::Split => { + if !dialog_split(ui, app, req) { + return false; + } + } + Dialog::ReplacePages => { + if !dialog_replace_pages(ui, app, req) { + return false; + } + } + Dialog::RedactPages => { + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let (ok, cancel) = crate::redact_ui::pages_body(ui, &mut app.redact_pages_draft, n, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::RedactSearch => { + let (go, cancel, import) = crate::redact_ui::search_body(ui, &mut app.redact_search, &t); + if go { + req.redact_now = Some(dialog); + } + req.import_words = import; + req.close = cancel; + return false; + } + Dialog::RedactProps => { + if !dialog_redact_props(ui, app, req) { + return false; + } + } + Dialog::RedactApply => { + let marks = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(0, |d| d.redaction_marks()); + let (ok, cancel) = crate::redact_ui::apply_body(ui, marks, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::LinkProps => { + let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let Some(d) = app.link_draft.as_mut() else { + req.close = true; + return false; + }; + let (ok, cancel) = crate::link_ui::body(ui, d, pages, &t); + if ok { + req.link_now = Some(crate::link_ui::edit_for(d)); + } + req.close = ok || cancel; + return false; + } + Dialog::Extract => { + if !dialog_extract(ui, app, req) { + return false; + } + } + Dialog::RotatePages => { + if !dialog_rotate_pages(ui, app, req) { + return false; + } + } + Dialog::DuplicateField => { + if !dialog_duplicate_field(ui, app, req) { + return false; + } + } + // Never reached: `dialogs` shows this movable window above and returns before the modal. + // The arm keeps the match exhaustive. + Dialog::FieldTable => req.close = true, + Dialog::Optimize => { + let (ok, cancel) = crate::optimize_ui::body(ui, &mut app.optimize_draft, &t); + req.optimize_now = ok; + req.close = ok || cancel; + if std::mem::take(&mut app.optimize_draft.audit) { + app.refresh_space_audit(); + req.next = Some(Dialog::AuditSpace); + } + return false; + } + Dialog::CertificateViewer => { + if !dialog_certificate_viewer(ui, app, req) { + return false; + } + } + Dialog::AuditSpace => { + if crate::optimize_ui::audit_body(ui, &app.space_audit, &t) { + req.next = Some(Dialog::Optimize); + } + return false; + } + Dialog::Sign => { + if crate::sign_ui::dialog(ui, app, &t) { + app.sign_draft = None; + req.close = true; + } + return false; + } + Dialog::SummarizeComments => { + if !dialog_summarize_comments(ui, app, req) { + return false; + } + } + Dialog::Revert => { + if !dialog_revert(ui, app, req) { + return false; + } + } + Dialog::OverwriteChanged => { + if !dialog_overwrite_changed(ui, app, req) { + return false; + } + } + Dialog::Print => { + if !dialog_print(ui, app, req) { + return false; + } + } + Dialog::RemoveHidden => { + let (ok, cancel) = crate::redact_ui::hidden_body(ui, &mut app.hidden_draft, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::Sanitize => { + let (ok, cancel) = crate::redact_ui::sanitize_body(ui, &t); + if ok { + req.redact_now = Some(dialog); + } + req.close = ok || cancel; + return false; + } + Dialog::FieldProps => { + let Some(d) = app.field_props.as_mut() else { + req.close = true; + return false; + }; + let (go, cancel) = crate::prepare::body(ui, d, &t); + req.apply = go; + req.field_props_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::BulkFieldProps => { + let Some(d) = app.bulk_field_props.as_mut() else { + req.close = true; + return false; + }; + let (go, cancel) = crate::bulk_fields::body(ui, d, &t); + req.apply = go; + req.bulk_field_props_now = req.apply; + req.close = cancel; + return false; + } + Dialog::CommentProps => { + let (go, cancel) = crate::comment_props::body(ui, app, &t); + req.apply = go; + req.props_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Signature => { + if !dialog_signature(ui, app, req) { + return false; + } + } + Dialog::AltText => { + let (save, cancel) = crate::a11y_ui::alt_body(ui, app, &t); + req.alt_now = save; + req.close = save || cancel; + return false; + } + Dialog::CreateStamp => { + let (save, cancel) = crate::stamps_ui::create_body(ui, app, &t); + req.stamp_now = save; + req.close = save || cancel; + return false; + } + Dialog::PdfA => { + req.close = crate::standards_ui::body(ui, app, &t); + return false; + } + Dialog::ActionWizard => { + ui.set_width(620.0); + req.close = crate::actions_ui::body(ui, app, &t); + return false; + } + Dialog::CompareFiles => { + let (go, cancel) = crate::compare_ui::body(ui, app, &t); + req.compare_now = go; + req.close = go || cancel; + return false; + } + Dialog::JsConsole => { + ui.set_width(640.0); + req.close = crate::js_ui::console_body(ui, app, &t); + return false; + } + Dialog::DocumentJs => { + ui.set_width(640.0); + req.close = crate::js_ui::document_js_body(ui, app, &t); + return false; + } + Dialog::Preferences => { + if !dialog_preferences(ui, app, req) { + return false; + } + } + Dialog::RecognizeText => { + let (go, cancel) = crate::ocr_ui::body(ui, app, &t); + req.ocr_now = go; + req.close = go || cancel; + return false; + } + Dialog::AccessibilityOptions => { + let (start, cancel) = crate::a11y_ui::options_body(ui, app, &t); + req.a11y_now = start; + req.close = start || cancel; + return false; + } + Dialog::Export(kind) => { + let (go, cancel) = crate::export_ui::body(ui, app, &t, kind); + req.apply = go; + req.export_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Marks(kind) => { + ui.set_width(720.0); + let (go, cancel) = crate::marks_ui::body(ui, app, &t, kind); + req.apply = go; + req.marks_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::PageBoxes => { + let (go, cancel) = crate::pageboxes::body(ui, app, &t); + req.apply = go; + req.boxes_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::Protect => { + let (go, cancel) = crate::protect::body(ui, app, &t); + req.apply = go; + req.protect_now = req.apply; + req.close = req.apply || cancel; + return false; + } + Dialog::NumberPages => { + if !dialog_number_pages(ui, app, req) { + return false; + } + } + Dialog::Recovery => { + if !dialog_recovery(ui, app) { + return false; + } + } + Dialog::Shortcuts => { + if !dialog_shortcuts(ui) { + return false; + } + } + Dialog::About => { + if !dialog_about(ui, req) { + return false; + } + } + } + true +} + +/// `Dialog::Properties(tab)` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_properties(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending, tab: PropsTab) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Document Properties")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + for (tb, label) in [ + (PropsTab::Description, tl!("Description")), + (PropsTab::InitialView, tl!("Initial View")), + (PropsTab::Security, tl!("Security")), + (PropsTab::Fonts, tl!("Fonts")), + (PropsTab::Custom, tl!("Custom")), + (PropsTab::Advanced, tl!("Advanced")), + (PropsTab::SpaceAudit, tl!("Space Audit")), + ] { + if widgets::mode_tab(ui, label, tab == tb).clicked() { + req.next = Some(Dialog::Properties(tb)); + } + } + }); + ui.separator(); + let units = app.units; + let Some((_, id)) = app.active_ids() else { return false }; + let Some(doc) = app.session.get(id) else { return false }; + let i = &doc.info; + // Description shows plain values among text fields; they start where the fields' text does. + let editable_info = tab == PropsTab::Description && doc.allows_modification() && app.props_draft.is_some(); + let inset = if editable_info { widgets::FIELD_TEXT_INSET } else { 0.0 }; + let row = |ui: &mut egui::Ui, k: &str, v: String| { + // Row labels are UI chrome; values are document data and stay as they are. + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); ui.horizontal(|ui| { - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - if dialog == Dialog::Recovery { - if widgets::pill_button(ui, tl!("Recover"), true).clicked() { - recover = Some(true); - close = true; + ui.add_space(inset); + ui.add(egui::Label::new(if v.is_empty() { egui::RichText::new("—").color(t.text_faint) } else { egui::RichText::new(v) }).wrap()); + }); + ui.end_row(); + }; + // A file name or path is read left to right even in an RTL UI: the extension + // stays at its visual end, as in the tab strip. + let path_row = |ui: &mut egui::Ui, k: &str, v: String| { + ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + ui.scope(|ui| { + ui.set_max_width(420.0); + // Align path text with editable fields while keeping its LTR text order. + ui.add_space(inset); + if v.is_empty() { + ui.label(egui::RichText::new("—").color(t.text_faint)); + } else { + crate::widgets::label_ltr(ui, v, crate::theme::regular(13.0), t.text); + } + }); + ui.end_row(); + }; + egui::ScrollArea::vertical().max_height(460.0).auto_shrink([false, true]).show(ui, |ui| { + if tab == PropsTab::SpaceAudit { + ui.add_space(4.0); + crate::optimize_ui::audit_table(ui, &app.space_audit, &t); + return; + } + egui::Grid::new("props").num_columns(2).spacing([18.0, 8.0]).min_col_width(140.0).show(ui, |ui| match tab { + PropsTab::Description => { + path_row(ui, "File", doc.name.clone()); + match app.props_draft.as_mut() { + Some((_, draft)) if editable_info => { + for (k, v) in INFO_KEYS.iter().zip(draft.iter_mut()) { + let l = ui.label(egui::RichText::new(tl!(k)).color(t.text_muted)); + // Subject and Keywords are often a sentence or a list: they wrap and grow + // instead of hiding most of the value past the field's edge. + let field = if matches!(*k, "Subject" | "Keywords") { widgets::block(v).desired_rows(1) } else { widgets::line(v) }; + ui.add(field.desired_width(f32::INFINITY).background_color(t.field).id_salt(("info", *k))).labelled_by(l.id); + ui.end_row(); + } } - if widgets::pill_button(ui, tl!("Discard"), false).clicked() { - recover = Some(false); - close = true; + _ => { + row(ui, "Title", i.title.clone().unwrap_or_default()); + row(ui, "Author", i.author.clone().unwrap_or_default()); + row(ui, "Subject", i.subject.clone().unwrap_or_default()); + row(ui, "Keywords", i.keywords.clone().unwrap_or_default()); } - } else if dialog == Dialog::NumberPages { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply_number = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if dialog == Dialog::Split { - if ui.add_enabled_ui(split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { - split_now = split_ready.clone(); - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if changed { - if widgets::pill_button(ui, tl!("OK"), true).clicked() { - apply = true; - close = true; - } - if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { - close = true; - } - } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { - close = true; } - }) + row(ui, "Application", i.creator.clone().unwrap_or_default()); + row(ui, "PDF producer", i.producer.clone().unwrap_or_default()); + } + PropsTab::InitialView => { + use pdfcraft_engine::{InitialLayout as L, Magnification as M, Navigation as N}; + let editable = doc.allows_modification(); + let pages = i.pages.len(); + let Some((_, v)) = app.view_draft.as_mut() else { return }; + ui.label(egui::RichText::new(tl!("Layout and Magnification")).font(theme::semibold(12.5))); + ui.end_row(); + let label = ui.label(tl!("Navigation tab")); + ui.add_enabled_ui(editable, |ui| { + craft_ui::ComboBox::from_id_salt("iv-nav") + .selected_text(tl!(format!("{:?}", v.navigation) + .replace("PageOnly", "Page Only") + .replace("Pages", "Pages Panel and Page") + .as_str())) + .show_ui(ui, |ui| { + for (n, l) in [ + (N::PageOnly, tl!("Page Only")), + (N::Bookmarks, tl!("Bookmarks Panel and Page")), + (N::Pages, tl!("Pages Panel and Page")), + (N::Attachments, tl!("Attachments Panel and Page")), + (N::Layers, tl!("Layers Panel and Page")), + ] { + ui.selectable_value(&mut v.navigation, n, l); + } + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + let label = ui.label(tl!("Page layout")); + ui.add_enabled_ui(editable, |ui| { + let names = [ + (L::Default, tl!("Default")), + (L::SinglePage, tl!("Single Page")), + (L::SinglePageContinuous, tl!("Single Page Continuous")), + (L::TwoUp, tl!("Two-Up (Facing)")), + (L::TwoUpContinuous, tl!("Two-Up Continuous (Facing)")), + (L::TwoUpCoverPage, tl!("Two-Up (Cover Page)")), + (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), + ]; + let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); + craft_ui::ComboBox::from_id_salt("iv-layout") + .selected_text(shown) + .show_ui(ui, |ui| { + for (l, n) in names { + ui.selectable_value(&mut v.layout, l, n); + } + }) + .response + .labelled_by(label.id); + }); + ui.end_row(); + let label = ui.label(tl!("Magnification")); + ui.add_enabled_ui(editable, |ui| { + ui.horizontal(|ui| { + let names = [ + (M::Default, tl!("Default")), + (M::ActualSize, tl!("Actual Size")), + (M::FitPage, tl!("Fit Page")), + (M::FitWidth, tl!("Fit Width")), + (M::FitHeight, tl!("Fit Height")), + (M::FitVisible, tl!("Fit Visible")), + ]; + let shown = match v.magnification { + M::Percent(p) => format!("{p:.0}%"), + m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), + }; + craft_ui::ComboBox::from_id_salt("iv-mag") + .selected_text(shown) + .show_ui(ui, |ui| { + for (m, n) in names { + ui.selectable_value(&mut v.magnification, m, n); + } + for p in [50.0, 75.0, 125.0, 150.0, 200.0] { + ui.selectable_value(&mut v.magnification, M::Percent(p), format!("{p:.0}%")); + } + }) + .response + .labelled_by(label.id); + }); + }); + ui.end_row(); + let label = ui.label(tl!("Open to page")); + ui.add_enabled_ui(editable, |ui| { + let mut p = v.page + 1; + if ui.add(egui::DragValue::new(&mut p).range(1..=pages.max(1))).labelled_by(label.id).changed() { + v.page = p - 1; + } + ui.label(crate::i18n::fmt(tl!("of {pages}"), &[("pages", &pages.to_string())])); + }); + ui.end_row(); + ui.label(egui::RichText::new(tl!("Window Options")).font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.add(widgets::checkbox(&mut v.fit_window, tl!("Resize window to initial page"))); + ui.add(widgets::checkbox(&mut v.center_window, tl!("Center window on screen"))); + ui.add(widgets::checkbox(&mut v.full_screen, tl!("Open in Full Screen mode"))); + ui.horizontal(|ui| { + ui.label(tl!("Show:")); + widgets::radio_value(ui, &mut v.display_title, false, tl!("File Name")); + widgets::radio_value(ui, &mut v.display_title, true, tl!("Document Title")); + }); + }); + }); + ui.end_row(); + ui.label(egui::RichText::new(tl!("User Interface Options")).font(theme::semibold(12.5))); + ui.end_row(); + ui.label(""); + ui.add_enabled_ui(editable, |ui| { + ui.vertical(|ui| { + ui.add(widgets::checkbox(&mut v.hide_menubar, tl!("Hide menu bar"))); + ui.add(widgets::checkbox(&mut v.hide_toolbar, tl!("Hide toolbars"))); + ui.add(widgets::checkbox(&mut v.hide_window_ui, tl!("Hide window controls"))); + }); + }); + ui.end_row(); + } + PropsTab::Security => match doc.security_summary() { + None => { + row(ui, "Security method", tl!("No security").to_string()); + row(ui, "Restrictions", tl!("None — everything is allowed").to_string()); + if doc.allows_security_change() && ui.button(tl!("Protect using password…")).clicked() { + req.link_command = Some("protect.password"); + } + } + Some(sec) => { + row(ui, "Security method", tl!("Password security").to_string()); + row(ui, "Encryption", sec.method.clone()); + row( + ui, + "Opened with", + if sec.pending { + tl!("— (protection is applied when you save)").to_string() + } else if sec.owner { + tl!("Owner password (no restrictions req.apply)").to_string() + } else { + tl!("User password").to_string() + }, + ); + // A restricted document asks for its permissions password first. + if doc.allows_security_change() || doc.can_unlock() { + ui.horizontal(|ui| { + if doc.can_unlock() && ui.button(tl!("Unlock…")).clicked() { + req.link_command = Some("protect.unlock"); + } + if ui.button(tl!("Change settings…")).clicked() { + req.link_command = Some("protect.password"); + } + if ui.button(tl!("Remove security")).clicked() { + req.link_command = Some("protect.remove"); + } + }); + } + let p = sec.permissions; + let yes = |b: bool| tl!(if b { "Allowed" } else { "Not allowed" }).to_string(); + row( + ui, + "Printing", + if !p.print() { + tl!("Not allowed").to_string() + } else if p.print_high_quality() { + tl!("High resolution").to_string() + } else { + tl!("Low resolution").to_string() + }, + ); + row(ui, "Changing the document", yes(p.modify())); + row(ui, "Document assembly", yes(p.assemble())); + row(ui, "Content copying", yes(p.copy())); + row(ui, "Content copying for accessibility", yes(p.extract_for_accessibility())); + row(ui, "Commenting", yes(p.annotate())); + row(ui, "Filling of form fields", yes(p.fill_forms())); + } + }, + PropsTab::Fonts => { + if i.fonts.is_empty() { + row(ui, "Fonts", tl!("No fonts are referenced by the pages.").to_string()); + } + for f in &i.fonts { + let mut detail = f.kind.clone(); + if let Some(e) = &f.encoding { + detail.push_str(&format!(" · {e}")); + } + detail.push_str(if f.subset { + " · Embedded subset" + } else if f.embedded { + " · Embedded" + } else { + " · Not embedded (substituted)" + }); + row(ui, &f.name, detail); + } + } + PropsTab::Custom => { + // Add takes a new name, Update changes the value of the listed property the + // Name field names, Delete removes it; OK writes the changes as one step. + let editable = doc.allows_modification(); + let Some((_, d)) = app.custom_draft.as_mut() else { return }; + let l = ui.label(egui::RichText::new(tl!("Name:")).color(t.text_muted)); + ui.add_enabled(editable, widgets::line(&mut d.name).desired_width(420.0).background_color(t.field).id_salt("custom-name")) + .labelled_by(l.id); + ui.end_row(); + let l = ui.label(egui::RichText::new(tl!("Value:")).color(t.text_muted)); + ui.add_enabled(editable, widgets::line(&mut d.value).desired_width(420.0).background_color(t.field).id_salt("custom-value")) + .labelled_by(l.id); + ui.end_row(); + // Untrimmed: a file's `/Project#20` is not `/Project`, and a typed space is refused. + let name = d.name.clone(); + let value = d.value.trim().to_owned(); + let listed = d.props.iter().position(|(k, _)| *k == name); + // An entry the list leaves out (not text, only padding, or past its 256) keeps its value. + let file_props = doc.custom_info(); + let unlisted = doc.has_info(&name) && !file_props.iter().any(|(k, _)| *k == name); + let refused = pdfcraft_engine::check_custom_key(&name).err().filter(|_| !name.is_empty()).map(|e| e.to_string()); + ui.label(""); + // A file can hold a custom name that `check_custom_key` refuses, such as one + // with a space: the tab lists it, and Add, Update and Delete stay off for it. + let why = |b: egui::Response| match &refused { + Some(why) => b.on_disabled_hover_text(crate::i18n::words(why)), + None => b, + }; + // A name that isn't UTF-8 is listed with U+FFFD and can't be written back as it is. + let editable = editable && refused.is_none() && !name.contains(char::REPLACEMENT_CHARACTER); + // `custom_info` lists at most 256 properties, and a new name goes after the file's + // entries, so Add is off once the draft or the file's own list holds 256. + let room = d.props.len() < 256 && file_props.len() < 256; + ui.horizontal(|ui| { + let add = editable && room && listed.is_none() && !unlisted && !name.is_empty() && !value.is_empty(); + if why(ui.add_enabled(add, egui::Button::new(tl!("Add")))).clicked() { + d.props.push((name.clone(), value.clone())); + d.name.clear(); + d.value.clear(); + } + let update = editable && !value.is_empty() && listed.and_then(|n| d.props.get(n)).is_some_and(|(_, v)| *v != value); + if why(ui.add_enabled(update, egui::Button::new(tl!("Update")))).clicked() + && let Some(p) = listed.and_then(|n| d.props.get_mut(n)) + { + p.1 = value.clone(); + } + if why(ui.add_enabled(editable && listed.is_some(), egui::Button::new(tl!("Delete")))).clicked() + && let Some(n) = listed.filter(|n| *n < d.props.len()) + { + d.props.remove(n); + d.name.clear(); + d.value.clear(); + } + }); + ui.end_row(); + if d.props.is_empty() { + ui.label(""); + ui.label(egui::RichText::new(tl!("None")).color(t.text_faint)); + ui.end_row(); + } + // A click on a property fills the fields with it; values are document data. + let mut picked = None; + for (k, v) in &d.props { + if ui.selectable_label(*k == name, k.as_str()).clicked() { + picked = Some((k.clone(), v.clone())); + } + ui.scope(|ui| { + ui.set_max_width(420.0); + ui.add(egui::Label::new(v.as_str()).wrap()); + }); + ui.end_row(); + } + if let Some((k, v)) = picked { + d.name = k; + d.value = v; + } + } + PropsTab::Advanced => { + row(ui, "PDF version", i.pdf_version.clone()); + path_row(ui, "Location", doc.path.clone().unwrap_or_default()); + row(ui, "File size", format!("{} ({})", human_size(i.file_size), crate::panels::byte_count(i.file_size))); + let p = &i.pages[0]; + row(ui, "Page size", units.size(f64::from(p.width), f64::from(p.height))); + row(ui, "Number of pages", i.pages.len().to_string()); + row(ui, "Tagged PDF", yes(i.tagged)); + row(ui, "Form fields", i.fields.len().to_string()); + row(ui, "Comments", i.annotations.len().to_string()); + row(ui, "Layers", i.layers.len().to_string()); + row(ui, "Attachments", i.attachments.len().to_string()); + row(ui, "JavaScript", yes(i.has_javascript)); + // Each incremental update is a revision; earlier ones open as their own document. + let ends = doc.revision_ends(); + if ends.len() > 1 { + ui.label(egui::RichText::new(tl!("Revisions")).color(t.text_muted)); + // Reserve the full wrapped height before the grid starts the next row. + ui.vertical(|ui| { + ui.horizontal_wrapped(|ui| { + ui.label(ends.len().to_string()); + for n in (1..ends.len()).rev().take(12) { + if ui + .small_button(crate::i18n::fmt(tl!("View revision {n}"), &[("n", &n.to_string())])) + .on_hover_text(tl!("Open the file as it was saved then")) + .clicked() + { + req.open_revision = Some(n); + } + } + }); + }); + ui.end_row(); + } + // Reading Options: binding and language. + if let Some((_, v)) = app.view_draft.as_mut() { + let editable = doc.allows_modification(); + ui.label(egui::RichText::new(tl!("Binding")).color(t.text_muted)); + ui.add_enabled_ui(editable, |ui| { + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut v.right_to_left, false, tl!("Left Edge")); + widgets::radio_value(ui, &mut v.right_to_left, true, tl!("Right Edge")); + }); + }); + ui.end_row(); + let l = ui.label(egui::RichText::new(tl!("Language")).color(t.text_muted)); + let mut lang = v.language.clone().unwrap_or_default(); + if ui + .add_enabled(editable, widgets::line(&mut lang).hint_text(tl!("e.g. en-US")).desired_width(160.0)) + .labelled_by(l.id) + .changed() + { + v.language = (!lang.trim().is_empty()).then(|| lang.trim().to_string()); + } + ui.end_row(); + } + // What was repaired while reading a damaged file (fidelity: never silent). + let repairs = doc.repair_log(); + row(ui, "Repairs", if repairs.is_empty() { tl!("None").to_string() } else { repairs.len().to_string() }); + if !repairs.is_empty() { + ui.label(""); + egui::CollapsingHeader::new(tl!("Repair log")).show(ui, |ui| { + for r in &repairs { + ui.add(egui::Label::new(egui::RichText::new(r).small()).wrap()); + } + }); + ui.end_row(); + } + } + // Laid out above, outside the two-column grid. + PropsTab::SpaceAudit => {} }); }); - if let Some(yes) = recover { + true +} + +/// `Dialog::Split` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_split(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + ui.label(egui::RichText::new(tl!("Split document")).font(theme::semibold(18.0))); + ui.add_space(8.0); + let Some((vi, id)) = app.active_ids() else { return false }; + let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); + let selected: Vec = app.views[vi].selected.iter().copied().filter(|p| *p > 0).collect(); + let marks = app.session.bookmark_splits(id); + let draft = &mut app.split_draft; + use crate::SplitMode as M; + if selected.is_empty() && draft.mode == M::Selection { + draft.mode = M::Pages; + } + widgets::radio_value(ui, &mut draft.mode, M::Pages, tl!("Number of pages")); + ui.add_enabled_ui(draft.mode == M::Pages, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label(tl!("Pages per file")); + ui.add(egui::DragValue::new(&mut draft.every).range(1..=n.max(1))); + }); + }); + widgets::radio_value(ui, &mut draft.mode, M::Size, tl!("File size")); + ui.add_enabled_ui(draft.mode == M::Size, |ui| { + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label(tl!("At most")); + ui.add(egui::DragValue::new(&mut draft.size_mb).range(0.05..=2000.0).speed(0.1).suffix(" MB")); + }); + }); + ui.add_enabled_ui(!marks.is_empty(), |ui| { + widgets::radio_value( + ui, + &mut draft.mode, + M::Bookmarks, + crate::i18n::fmt(tl!("Top-level bookmarks ({n})"), &[("n", &marks.len().to_string())]), + ) + }); + ui.add_enabled_ui(!selected.is_empty(), |ui| { + widgets::radio_value(ui, &mut draft.mode, M::Selection, tl!("Before each selected page (select pages in Organize)")) + }); + let plan = match draft.mode { + M::Pages => crate::SplitPlan::By(pdfcraft_engine::SplitBy::PageCount(draft.every)), + M::Selection => crate::SplitPlan::By(pdfcraft_engine::SplitBy::Before(selected)), + M::Size => crate::SplitPlan::Size((draft.size_mb * 1_048_576.0) as usize), + M::Bookmarks => crate::SplitPlan::Bookmarks, + }; + let files = match &plan { + crate::SplitPlan::By(by) => Some(pdfcraft_engine::split_ranges(n, by).len()), + crate::SplitPlan::Bookmarks => { + Some(pdfcraft_engine::split_ranges(n, &pdfcraft_engine::SplitBy::Before(marks.iter().map(|m| m.0).collect())).len()) + } + crate::SplitPlan::Size(_) => None, + }; + ui.add_space(8.0); + let text = match files { + Some(f) => { + if f == 1 { + crate::i18n::fmt(tl!("Creates 1 file from {n} pages."), &[("n", &n.to_string())]) + } else { + crate::i18n::fmt(tl!("Creates {f} files from {n} pages."), &[("f", &f.to_string()), ("n", &n.to_string())]) + } + } + None => crate::i18n::fmt(tl!("Each file holds as many of the {n} pages as fit."), &[("n", &n.to_string())]), + }; + ui.label(egui::RichText::new(text).color(t.text_muted)); + if files.is_none_or(|f| f > 1) { + req.split_ready = Some(plan); + } + true +} + +/// `Dialog::ReplacePages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_replace_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let count = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let Some(d) = app.replace_draft.as_mut() else { + req.close = true; + return false; + }; + ui.label(egui::RichText::new(tl!("Replace Pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + d.to = d.to.clamp(1, count); + d.from = d.from.clamp(1, d.to); + ui.horizontal(|ui| { + let from_label = ui.label(tl!("Original: replace pages")); + let from = ui.add(egui::DragValue::new(&mut d.from).range(1..=count)).labelled_by(from_label.id); + let to_label = ui.label(tl!("to")); + let to = ui.add(egui::DragValue::new(&mut d.to).range(1..=count)).labelled_by(to_label.id); + if from.changed() || to.changed() { + d.range_edited = true; + } + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &count.to_string())])).color(t.text_muted)); + }); + let n = if d.range_edited { d.to - d.from + 1 } else { d.pages.len().max(1) }; + let max_start = d.src_pages.saturating_sub(n) + 1; + d.src_from = d.src_from.clamp(1, max_start.max(1)); + ui.horizontal(|ui| { + let replacement_label = ui.label(crate::i18n::fmt(tl!("Replacement: pages of {name}"), &[("name", &d.name)])); + ui.add(egui::DragValue::new(&mut d.src_from).range(1..=max_start.max(1))).labelled_by(replacement_label.id); + ui.label(crate::i18n::fmt(tl!("to {n}"), &[("n", &(d.src_from + n - 1).to_string())])); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &d.src_pages.to_string())])).color(t.text_muted)); + }); + let fits = n <= d.src_pages; + ui.add_space(6.0); + ui.label( + egui::RichText::new(if fits { + tl!("Only the page content changes: links, comments, form fields and bookmarks on the original pages stay.") + } else { + tl!("The replacement file doesn't have that many pages.") + }) + .small() + .color(t.text_faint), + ); + ui.add_space(10.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(fits, |ui| widgets::pill_button(ui, tl!("OK"), true)).inner.clicked() { + req.replace_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::RedactProps` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_redact_props(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let mut prefs = app.redact_prefs.clone(); + let (ok, cancel) = crate::redact_ui::props_body(ui, &mut prefs, &t); + app.redact_prefs = prefs; + if ok && app.redact_prefs.remember { + app.redact_defaults = app.redact_prefs.clone(); + } + if cancel && let Some(before) = app.redact_before.take() { + app.redact_prefs = before; + } + if ok { + app.redact_before = None; + } + req.close = ok || cancel; + false +} + +/// `Dialog::Extract` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_extract(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let (selected, total) = app.active_ids().map_or((Vec::new(), 0), |(i, _)| (app.views[i].target_pages(), app.views[i].page_count())); + ui.label(egui::RichText::new(tl!("Extract pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + // Page ranges to take instead of the selection (#3593); the hint is the selection. + let hint = match crate::combine_ui::range_of(&selected, total) { + all if all.is_empty() && total > 1 => format!("1-{total}"), + all if all.is_empty() => "1".to_string(), + some => some, + }; + ui.horizontal(|ui| { + let l = ui.label(tl!("Pages")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.pages).hint_text(hint).desired_width(240.0)).labelled_by(l.id); + }); + let chosen = app.extract_draft.pages_to_extract(selected, total); + match &chosen { + Ok(pages) if pages.len() == 1 => { + ui.label(tl!("1 page selected.")); + } + Ok(pages) => { + ui.label(crate::i18n::fmt(tl!("{n} pages selected."), &[("n", &pages.len().to_string())])); + } + Err(e) => { + ui.colored_label(ui.visuals().error_fg_color, e); + } + } + ui.add_space(4.0); + // The name the pages are saved under (#737); separate files add " (page N)". + ui.horizontal(|ui| { + let l = ui.label(tl!("File name")); + ui.add(egui::TextEdit::singleline(&mut app.extract_draft.name).desired_width(240.0)).labelled_by(l.id); + }); + ui.add(widgets::checkbox(&mut app.extract_draft.delete, tl!("Delete pages after extracting"))); + ui.add(widgets::checkbox(&mut app.extract_draft.separate, tl!("Extract pages as separate files"))); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if ui.add_enabled_ui(chosen.is_ok(), |ui| widgets::pill_button(ui, tl!("Extract"), true)).inner.clicked() { + req.extract_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::RotatePages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_rotate_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + use pdfcraft_engine::{PageOrientation as O, PageParity as P}; + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let d = &mut app.rotate_draft; + ui.label(egui::RichText::new(tl!("Rotate Pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Direction:")); + craft_ui::ComboBox::from_id_salt("rotate-dir") + .selected_text(match d.degrees { + 270 => tl!("Counterclockwise 90 degrees"), + 180 => tl!("180 degrees"), + _ => tl!("Clockwise 90 degrees"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.degrees, 90, tl!("Clockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 270, tl!("Counterclockwise 90 degrees")); + ui.selectable_value(&mut d.degrees, 180, tl!("180 degrees")); + }); + ui.end_row(); + ui.label(tl!("Pages:")); + ui.vertical(|ui| { + widgets::radio_value(ui, &mut d.which, 0, tl!("All")); + widgets::radio_value(ui, &mut d.which, 1, tl!("Selection")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.which, 2, tl!("From")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add_enabled(d.which == 2, egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + }); + }); + ui.end_row(); + ui.label(tl!("Rotate:")); + craft_ui::ComboBox::from_id_salt("rotate-parity") + .selected_text(match d.parity { + P::Both => tl!("Even and Odd Pages"), + P::Even => tl!("Even Pages Only"), + P::Odd => tl!("Odd Pages Only"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.parity, P::Both, tl!("Even and Odd Pages")); + ui.selectable_value(&mut d.parity, P::Even, tl!("Even Pages Only")); + ui.selectable_value(&mut d.parity, P::Odd, tl!("Odd Pages Only")); + }); + ui.end_row(); + ui.label(""); + craft_ui::ComboBox::from_id_salt("rotate-orient") + .selected_text(match d.orientation { + O::Both => tl!("Landscape and Portrait Pages"), + O::Landscape => tl!("Landscape Pages"), + O::Portrait => tl!("Portrait Pages"), + }) + .show_ui(ui, |ui| { + ui.selectable_value(&mut d.orientation, O::Both, tl!("Landscape and Portrait Pages")); + ui.selectable_value(&mut d.orientation, O::Landscape, tl!("Landscape Pages")); + ui.selectable_value(&mut d.orientation, O::Portrait, tl!("Portrait Pages")); + }); + ui.end_row(); + }); + d.to = d.to.max(d.from); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.rotate_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::DuplicateField` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_duplicate_field(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let n = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len()); + let Some(d) = app.duplicate_draft.as_mut() else { + req.close = true; + return false; + }; + ui.label(egui::RichText::new(tl!("Duplicate Field")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt(tl!("Duplicate \"{name}\" onto:"), &[("name", &d.name)])); + widgets::radio_value(ui, &mut d.all, true, tl!("All pages")); + ui.horizontal(|ui| { + widgets::radio_value(ui, &mut d.all, false, tl!("From")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add_enabled(!d.all, egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])); + }); + d.to = d.to.max(d.from); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + let pages: Vec = if d.all { (0..n).collect() } else { (d.from - 1..d.to.min(n)).collect() }; + req.duplicate_now = Some(Edit::DuplicateField { name: d.name.clone(), pages }); + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::CertificateViewer` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_certificate_viewer(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + ui.set_width(700.0); + let trusted = app.session.trusted_certificates().to_vec(); + let Some(v) = app.cert_viewer.as_mut() else { + req.close = true; + return false; + }; + let (done, action) = crate::sign_ui::cert_viewer(ui, v, &trusted, &t); + req.close = done; + match action { + Some(crate::sign_ui::CertAction::Trust(c)) => app.trust_certificate(*c), + Some(crate::sign_ui::CertAction::Export(c)) => { + app.export_certificate(&c); + } + None => {} + } + false +} + +/// `Dialog::SummarizeComments` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_summarize_comments(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + ui.label(egui::RichText::new(tl!("Summarize Options")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(tl!("Choose a layout:")); + let _ = ui.add(widgets::radio(true, tl!("Comments only"))); + ui.add_space(6.0); + ui.horizontal(|ui| { + ui.label(tl!("Sort comments by:")); + craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { + for s in pdfcraft_engine::SummarySort::ALL { + ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); + } + }); + }); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Create PDF Comment Summary"), true).clicked() { + req.summarize_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::Revert` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_revert(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let name = app.active_ids().and_then(|(_, id)| app.session.get(id)).map(|d| d.name.clone()).unwrap_or_default(); + ui.label(egui::RichText::new(tl!("Revert")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt( + tl!("Revert to the previously saved version of “{name}”? Changes since then can't be undone afterwards."), + &[("name", &name)], + )); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Revert"), true).clicked() { + req.revert_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::OverwriteChanged` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_overwrite_changed(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + #[cfg(not(target_arch = "wasm32"))] + let name = app.pending_overwrite.as_ref().and_then(|p| app.session.get(p.id)).map(|d| d.name.clone()).unwrap_or_default(); + #[cfg(target_arch = "wasm32")] + let name = String::new(); + ui.label(egui::RichText::new(tl!("Save over the changed file?")).font(theme::semibold(18.0))); + ui.add_space(8.0); + ui.label(crate::i18n::fmt( + tl!("“{name}” changed on disk after it was opened or last saved. Saving now replaces that newer version with this one."), + &[("name", &name)], + )); + ui.add_space(12.0); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if widgets::pill_button(ui, tl!("Save anyway"), true).clicked() { + req.overwrite_now = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Save as…"), false).clicked() { + req.overwrite_elsewhere = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + }); + false +} + +/// `Dialog::Print` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_print(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let Some((i, id)) = app.active_ids() else { + req.close = true; + return false; + }; + if app.print_draft.handling == crate::print_ui::Handling::Label { + app.read_label_crops(id); + } + let Some(doc) = app.session.get(id) else { return false }; + let sizes: Vec<(f64, f64)> = doc.info.pages.iter().map(|p| (p.width as f64, p.height as f64)).collect(); + let labels: Vec = doc.info.pages.iter().map(|p| p.label.clone()).collect(); + let rasters = crate::print_ui::preview_rasters( + &app.print_draft, + &sizes, + &labels, + ui.ctx().pixels_per_point(), + ui.ctx().input(|i| i.max_texture_side) as f32, + ); + let view = &mut app.views[i]; + view.set_print_grayscale(app.print_draft.grayscale); + view.queue_print_previews(&rasters); + let (go, cancel) = crate::print_ui::body(ui, &mut app.print_draft, &t, &sizes, &labels, &mut |p| { + view.need_thumbnail(p, true); + view.page_preview(p) + }); + req.print_go = go; + req.close = go || cancel; + false +} + +/// `Dialog::Signature` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_signature(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + let (go, cancel, browse) = + crate::fill_sign::signature_pad(ui, &t, &mut app.signature_draft, &mut app.signature_preview, &mut app.signature_photo); + req.apply = go; + if browse { + app.pick_signature_image(); + } + if req.apply { + let d = std::mem::take(&mut app.signature_draft); + let tool = if d.initials { + app.initials = d.saved(); + crate::fill_sign::FillTool::Initials + } else { + app.signature = d.saved(); + crate::fill_sign::FillTool::Signature + }; + app.quick_tool = crate::QuickTool::Fill(tool); + app.toast = None; + } + req.close = req.apply || cancel; + false +} + +/// `Dialog::Preferences` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_preferences(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + // The settings outgrow a short window: scroll them rather than run off the screen. + // The OK row stays below the scroll, in reach however far the settings are scrolled. + let max = (ui.ctx().content_rect().height() - 120.0 - crate::js_ui::PREFERENCES_BUTTON_ROW).max(crate::js_ui::PREFERENCES_MIN_SCROLL); + // A scroll area starts from the room the dialog offers it, and a modal offers more + // each frame for the first few, so with OK outside the scroll the dialog would grow + // over those frames. Asking for the full height as the least it may scroll in + // (which only applies when the settings overflow it) opens the dialog at full size. + // A scrollbar of its own: the floating one lies over the right edge of the theme + // cards and other full-width rows (#3557). + ui.scope(|ui| { + ui.spacing_mut().scroll.floating = false; + egui::ScrollArea::vertical().max_height(max).min_scrolled_height(max).show(ui, |ui| crate::js_ui::preferences_body(ui, app, &t)); + }); + req.close = crate::js_ui::preferences_buttons(ui); + false +} + +/// `Dialog::NumberPages` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_number_pages(ui: &mut egui::Ui, app: &mut PdfCraftApp, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + use pdfcraft_engine::LabelStyle as L; + ui.label(egui::RichText::new(tl!("Number pages")).font(theme::semibold(18.0))); + ui.add_space(8.0); + let Some((_, id)) = app.active_ids() else { return false }; + let n = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(1).max(1); + let d = &mut app.number_draft; + d.to = d.to.clamp(1, n); + d.from = d.from.clamp(1, d.to); + egui::Grid::new("number_pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { + ui.label(tl!("Pages")); + ui.horizontal(|ui| { + ui.add(egui::DragValue::new(&mut d.from).range(1..=n)); + ui.label(tl!("to")); + ui.add(egui::DragValue::new(&mut d.to).range(1..=n)); + ui.label(egui::RichText::new(crate::i18n::fmt(tl!("of {name}"), &[("name", &n.to_string())])).color(t.text_muted)); + }); + ui.end_row(); + ui.label(tl!("Style")); + let styles = [ + (L::Decimal, "1, 2, 3"), + (L::LowerRoman, "i, ii, iii"), + (L::UpperRoman, "I, II, III"), + (L::LowerAlpha, "a, b, c"), + (L::UpperAlpha, "A, B, C"), + (L::None, tl!("None (prefix only)")), + ]; + let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); + craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { + for (s, l) in styles { + ui.selectable_value(&mut d.style, s, l); + } + }); + ui.end_row(); + let l = ui.label(tl!("Prefix")); + ui.add(widgets::line(&mut d.prefix).desired_width(160.0)).labelled_by(l.id); + ui.end_row(); + ui.label(tl!("Start")); + ui.add(egui::DragValue::new(&mut d.start).range(1..=99_999)); + ui.end_row(); + }); + d.to = d.to.max(d.from); + let label = |k: u32| format!("{}{}", d.prefix, d.style.format(k)); + ui.add_space(8.0); + let preview = if d.from == d.to { + label(d.start) + } else { + format!("{}, {} … {}", label(d.start), label(d.start + 1), label(d.start + (d.to - d.from) as u32)) + }; + ui.label( + egui::RichText::new(crate::i18n::fmt(tl!("Labels: {preview}. Later pages keep their labels."), &[("preview", &preview)])).color(t.text_muted), + ); + req.number_now = Some(Edit::NumberPages { from: d.from - 1, to: d.to - 1, style: d.style, prefix: d.prefix.clone(), first: d.start }); + true +} + +/// `Dialog::Recovery` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_recovery(ui: &mut egui::Ui, app: &mut PdfCraftApp) -> bool { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.add(crate::icons::image("clock-3", 22.0, t.accent)); + ui.label(egui::RichText::new(tl!("Recover unsaved documents?")).font(theme::semibold(18.0))); + }); + ui.add_space(6.0); + ui.label(tl!("PdfCraft didn't shut down normally. These documents had changes that were autosaved:")); + ui.add_space(8.0); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + egui::Grid::new("recoverable").num_columns(2).spacing([18.0, 6.0]).show(ui, |ui| { + for m in &app.recoverable { + ui.label(egui::RichText::new(&m.name).font(theme::medium(13.0))); + let mins = now.saturating_sub(m.saved_at) / 60; + let when = match mins { + 0 => tl!("just now").to_string(), + 1..=59 => crate::i18n::fmt(tl!("{n} min ago"), &[("n", &mins.to_string())]), + _ => crate::i18n::fmt(tl!("{n} h ago"), &[("n", &(mins / 60).to_string())]), + }; + let lock = if m.encrypted { tl!(" · password-protected") } else { "" }; + ui.label(egui::RichText::new(format!("{when}{lock}")).color(t.text_muted)); + ui.end_row(); + } + }); + true +} + +/// `Dialog::Shortcuts` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_shortcuts(ui: &mut egui::Ui) -> bool { + ui.label(egui::RichText::new(tl!("Keyboard shortcuts")).font(theme::semibold(18.0))); + ui.add_space(8.0); + use crate::commands::{ + ACTUAL_SIZE, COPY, FIND_NEXT, FIND_PREV, FIT_WIDTH, PAGE_LEVEL, PAGE_NEXT, PAGE_PREV, ROTATE_CCW, ROTATE_CW, SELECT_ALL, ZOOM_IN, ZOOM_OUT, + }; + // Registered commands first (always in sync with the real bindings), then the + // keys the document view handles itself. + let mut rows: Vec<(String, String)> = pdfcraft_engine::commands::COMMANDS + .iter() + .filter_map(|c| c.shortcut.map(|k| (crate::commands::shortcut_label(ui.ctx(), k), tl!(c.label).trim_end_matches('…').to_string()))) + .collect(); + let key = |s: pdfcraft_engine::commands::Shortcut| crate::commands::shortcut_label(ui.ctx(), s); + let pair = |a, b| format!("{} / {}", key(a), key(b)); + // Key names, translated where a catalog has them (not scanned as UI literals). + let named = |k: &str| tl!(k).to_string(); + let scroll = crate::i18n::fmt(tl!("Zoom in / out (also pinch or {key}-scroll)"), &[("key", crate::commands::command_modifier_label(ui.ctx()))]); + for (k, v) in [ + (pair(FIND_NEXT, FIND_PREV), tl!("Next / previous match").to_string()), + (key(COPY), tl!("Copy selected text").to_string()), + (named("Double-click"), tl!("Select a word").to_string()), + (crate::i18n::key_name("Esc").to_string(), tl!("Clear selection / req.close find").to_string()), + (key(ACTUAL_SIZE), tl!("Actual size").to_string()), + (key(PAGE_LEVEL), tl!("Zoom to page level").to_string()), + (key(FIT_WIDTH), tl!("Fit to width").to_string()), + (pair(ZOOM_IN, ZOOM_OUT), scroll), + (pair(ROTATE_CW, ROTATE_CCW), tl!("Rotate view").to_string()), + (format!("{} / {}", crate::i18n::key_name("Home"), crate::i18n::key_name("End")), tl!("First / last page").to_string()), + (format!("← / →, {}", pair(PAGE_PREV, PAGE_NEXT)), tl!("Previous / next page").to_string()), + (named("V"), tl!("Select (V)").to_string()), + (named("H / Space (hold)"), tl!("Hand (H)").to_string()), + (crate::i18n::key_name("Delete").to_string(), tl!("Delete selected pages (Organize)").to_string()), + (key(SELECT_ALL), tl!("Select all pages (Organize)").to_string()), + ] { + rows.push((k, v)); + } + egui::ScrollArea::vertical().max_height(460.0).show(ui, |ui| { + egui::Grid::new("keys").num_columns(2).spacing([24.0, 6.0]).show(ui, |ui| { + for (k, v) in &rows { + ui.label(egui::RichText::new(k).font(egui::FontId::monospace(12.5))); + ui.label(v); + ui.end_row(); + } + }); + }); + true +} + +/// `Dialog::About` content. Returns `false` when it has its own buttons and no footer is wanted. +fn dialog_about(ui: &mut egui::Ui, req: &mut Pending) -> bool { + let t = Tokens::get(ui.ctx()); + // Tabs About · Contributors · Models (craftrules standards/contributors.md). + let tab_id = egui::Id::unique("about_tab"); + let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); + ui.horizontal_wrapped(|ui| { + for (i, label) in ["About", "Contributors", "Models"].into_iter().enumerate() { + let i = i as u8; + if widgets::mode_tab(ui, tl!(label), tab == i).clicked() { + tab = i; + } + } + }); + ui.data_mut(|d| d.insert_temp(tab_id, tab)); + ui.separator(); + match tab { + 1 => crate::credits::contributors_ui(ui), + 2 => crate::credits::models_ui(ui), + _ => { + ui.horizontal(|ui| { + widgets::artcraft_mark(ui, 40.0); + ui.vertical(|ui| { + ui.label(egui::RichText::new("PdfCraft").font(theme::semibold(20.0))); + ui.label(crate::i18n::fmt(tl!("Version {v}"), &[("v", &pdfcraft_engine::build_info::version())])); + }); + }); + ui.add_space(6.0); + ui.label(tl!("A clean-room, open-source PDF application written in Rust. MIT OR Apache-2.0.")); + ui.label( + egui::RichText::new( + "Rendering: hayro (bootstrap) · UI: egui · Icons: Lucide (ISC) · Fonts: Inter, JetBrains Mono, Dancing Script (OFL)", + ) + .color(t.text_muted) + .small(), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(tl!("Part of")).color(t.text_muted)); + widgets::artcraft_logo(ui, 16.0); + }); + ui.add_space(6.0); + if let Some(cmd) = widgets::community_links(ui) { + req.link_command = Some(cmd); + } + } + } + true +} + +fn dialog_footer(ui: &mut egui::Ui, app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { + ui.add_space(16.0); + let changed = draft_changes(app).is_some_and(|c| !c.is_empty()); + // One row high: on its own, a right-to-left layout centres the buttons in all the height + // left, which after a taller Properties tab is the room that tab left behind. + ui.horizontal(|ui| { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if dialog == Dialog::Recovery { + if widgets::pill_button(ui, tl!("Recover"), true).clicked() { + req.recover = Some(true); + req.close = true; + } + if widgets::pill_button(ui, tl!("Discard"), false).clicked() { + req.recover = Some(false); + req.close = true; + } + } else if dialog == Dialog::NumberPages { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.apply_number = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + } else if dialog == Dialog::Split { + if ui.add_enabled_ui(req.split_ready.is_some(), |ui| widgets::pill_button(ui, tl!("Split"), true)).inner.clicked() { + req.split_now = req.split_ready.clone(); + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + } else if changed { + if widgets::pill_button(ui, tl!("OK"), true).clicked() { + req.apply = true; + req.close = true; + } + if widgets::pill_button(ui, tl!("Cancel"), false).clicked() { + req.close = true; + } + } else if widgets::pill_button(ui, tl!("Close"), true).clicked() { + req.close = true; + } + }) + }); +} + +/// Acts on what the dialog asked for, once the modal is drawn. +fn finish_dialog(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { + finish_requests(app, dialog, modal_closed, req); + finish_actions(app, req); + finish_redactions_and_fields(app, req); + finish_edits(app, dialog, req); + finish_close(app, dialog, modal_closed, req); +} + +/// Runs the one-shot requests the dialog made. +fn finish_requests(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { + if let Some(yes) = req.recover { let keys: Vec = app.recoverable.iter().map(|m| m.key.clone()).collect(); if yes { app.recover(&keys); @@ -1426,7 +1593,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } } // The pages to replace belong to the document the dialog was opened on (#167). - if replace_now + if req.replace_now && let Some(d) = app.replace_draft.take() && app.still_pick_target(d.target) { @@ -1440,52 +1607,60 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); } // A print or save that fails keeps the dialog open, with the reason in a notice. - if print_go && !app.print_now() { - close = false; + if req.print_go && !app.print_now() { + req.close = false; } - if revert_now { + if req.revert_now { app.revert_active(); } #[cfg(not(target_arch = "wasm32"))] { - if overwrite_now { + if req.overwrite_now { app.overwrite_changed_file(); } - if overwrite_elsewhere { + if req.overwrite_elsewhere { app.save_changed_file_elsewhere(); } } #[cfg(target_arch = "wasm32")] - let _ = (overwrite_now, overwrite_elsewhere); + let _ = (req.overwrite_now, req.overwrite_elsewhere); // Cancelled or dismissed: the save is dropped. #[cfg(not(target_arch = "wasm32"))] - if dialog == Dialog::OverwriteChanged && (modal.should_close() || close) { + if dialog == Dialog::OverwriteChanged && (modal_closed || req.close) { app.pending_overwrite = None; } - if summarize_now { +} + +/// Runs the document actions the dialog asked for. +fn finish_actions(app: &mut PdfCraftApp, req: &mut Pending) { + if req.summarize_now { app.summarize_comments(); } - if optimize_now { + if req.optimize_now { app.optimize_with_draft(); } - if let Some(e) = duplicate_now { + if let Some(e) = req.duplicate_now.take() { app.duplicate_draft = None; app.apply_edit(e); } - if extract_now { + if req.extract_now { app.extract_selection(); } - if let Some(e) = link_now { + if let Some(e) = req.link_now.take() { app.apply_edit(e); app.link_draft = None; } - if rotate_now { + if req.rotate_now { app.rotate_with_draft(); } - if import_words { + if req.import_words { app.pick_files(crate::files::FilePurpose::RedactWords, false); } - match redact_now { +} + +/// Redaction, hidden-data and field-properties requests. +fn finish_redactions_and_fields(app: &mut PdfCraftApp, req: &mut Pending) { + match req.redact_now.take() { Some(Dialog::RedactPages) => app.redact_pages(), Some(Dialog::RedactSearch) => { let n = app.redact_search(); @@ -1517,10 +1692,10 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { } _ => {} } - if bulk_field_props_now { - close = app.apply_bulk_field_props(); + if req.bulk_field_props_now { + req.close = app.apply_bulk_field_props(); } - if field_props_now + if req.field_props_now && let Some(d) = app.field_props.take() && let Some(props) = d.props() { @@ -1549,7 +1724,13 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.views[i].prepare.selected = Some((name, d.widget)); } } - if props_now && let Some(d) = app.comment_props.take() { +} + +/// Edits built from the dialog's drafts. +fn finish_edits(app: &mut PdfCraftApp, dialog: Dialog, req: &mut Pending) { + if req.props_now + && let Some(d) = app.comment_props.take() + { let mut edits = crate::comment_props::edits(&d); match edits.len() { 0 => {} @@ -1565,68 +1746,82 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { app.make_props_default(d.page, d.index, &d.edited); } } - if export_now && let Dialog::Export(kind) = dialog { + if req.export_now + && let Dialog::Export(kind) = dialog + { app.start_export(kind); } - if marks_now && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) { + if req.marks_now + && let (Dialog::Marks(kind), Some((_, id))) = (dialog, app.active_ids()) + { let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); let edit = crate::marks_ui::edit(&app.marks_draft, kind, count); app.apply_edit(edit); } - if boxes_now && let Some((i, id)) = app.active_ids() { + if req.boxes_now + && let Some((i, id)) = app.active_ids() + { let count = app.session.get(id).map(|d| d.info.pages.len()).unwrap_or(0); let edit = app.boxes_draft.edit(app.views[i].current, count); app.apply_edit(edit); app.boxes_draft.seeded = None; } - if protect_now && app.apply_edit(app.protect_draft.edit()) { + if req.protect_now && app.apply_edit(app.protect_draft.edit()) { app.protect_draft = Default::default(); app.notify_tr("Password protection will be applied when you save"); } - if apply_number && let Some(edit) = number_now { + if req.apply_number + && let Some(edit) = req.number_now.take() + { app.apply_edit(edit); } - if let Some(by) = split_now { + if let Some(by) = req.split_now.take() { app.split_active(&by); } - if apply && let Some(edits) = draft_changes(app) { + if req.apply + && let Some(edits) = draft_changes(app) + { app.apply_edit(Edit::Batch { label: "Change document properties".into(), edits }); } +} + +/// Closes (or switches) the dialog, then runs what closing it asked for. +fn finish_close(app: &mut PdfCraftApp, dialog: Dialog, modal_closed: bool, req: &mut Pending) { // Protect / Remove security replace the Properties dialog. - let replaces = link_command.is_some_and(|c| c.starts_with("protect.")) || open_revision.is_some(); - if modal.should_close() || close || replaces { + let replaces = req.link_command.is_some_and(|c| c.starts_with("protect.")) || req.open_revision.is_some(); + if modal_closed || req.close || replaces { app.dialog = None; app.props_draft = None; app.view_draft = None; app.custom_draft = None; app.bulk_field_props = None; } else { - app.dialog = Some(next); + app.dialog = Some(req.next.unwrap_or(dialog)); } - if let Some(cmd) = link_command { + if let Some(cmd) = req.link_command { app.execute(cmd); } - if let Some(n) = open_revision { + if let Some(n) = req.open_revision { app.open_revision(n); } - if alt_now { + if req.alt_now { app.save_alt_text(); } - if stamp_now { + if req.stamp_now { app.save_custom_stamp(); } - if images_now { + if req.images_now { app.finish_image_import(); } else if dialog == Dialog::CreateImages && app.dialog != Some(Dialog::CreateImages) { app.image_import = None; } - if ocr_now { + if req.ocr_now { app.start_ocr(); } - if compare_now { + if req.compare_now { app.run_compare(); } - if a11y_now { + if req.a11y_now { if let Some((_, id)) = app.active_ids() { app.a11y_skipped.retain(|(doc, _)| *doc != id); } diff --git a/crates/ui-egui/src/dialogs/prompts.rs b/crates/ui-egui/src/dialogs/prompts.rs index 557ddd3b8..fff7eea9a 100644 --- a/crates/ui-egui/src/dialogs/prompts.rs +++ b/crates/ui-egui/src/dialogs/prompts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align, CloseRequest, Layout, PdfCraftApp, Tokens, theme, widgets}; /// The buttons of the "Save changes?" prompt, in the order they sit on screen, left to right. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] From 2d27306a8d3a655e0e89c444a19199d8722d8872 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 19/33] Refactor ui support for strict Clippy checks --- crates/ui-egui/src/app/input.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 25 +++ crates/ui-egui/src/app/window.rs | 2 +- crates/ui-egui/src/control/host.rs | 2 +- crates/ui-egui/src/control/pending.rs | 2 +- crates/ui-egui/src/control/plugin.rs | 2 +- crates/ui-egui/src/control/requests.rs | 2 +- crates/ui-egui/src/control/screenshot.rs | 2 +- crates/ui-egui/src/control/transport.rs | 18 +- crates/ui-egui/src/editing.rs | 157 +++++++++--------- crates/ui-egui/src/editing/close.rs | 2 +- crates/ui-egui/src/editing/save.rs | 2 +- crates/ui-egui/src/panels/bookmarks.rs | 2 +- crates/ui-egui/src/panels/edit.rs | 5 +- crates/ui-egui/src/panels/pages.rs | 2 +- crates/ui-egui/src/panels/right.rs | 4 +- crates/ui-egui/src/panels/tools.rs | 5 +- .../ui-egui/src/platform/open_worker/tests.rs | 140 +++++++++++----- crates/ui-egui/tests/it/signing.rs | 89 +++++++--- 19 files changed, 296 insertions(+), 169 deletions(-) diff --git a/crates/ui-egui/src/app/input.rs b/crates/ui-egui/src/app/input.rs index ad554c1df..953eb4a6e 100644 --- a/crates/ui-egui/src/app/input.rs +++ b/crates/ui-egui/src/app/input.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{GridDrop, GridFile, OpenRequest, PdfCraftApp, open_worker}; /// Read a file dropped on a page grid (see [`GridFile`]). fn read_grid_file((name, source): GridFile) -> Result<(String, Vec), (String, String)> { diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index ac8ac644e..f64ba6d89 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -178,6 +178,15 @@ impl PdfCraftApp { let r: Vec = r.into_iter().filter(|f| std::path::Path::new(&f.path).exists()).collect(); self.recent = r; } + self.restore_layout_prefs(&v); + self.restore_behavior_prefs(&v); + self.restore_view_prefs(&v); + self.restore_comment_prefs(&v); + self.restore_signature_prefs(&v); + } + + /// The panels, combine view, zoom and recent files. + fn restore_layout_prefs(&mut self, v: &serde_json::Value) { if let Some(on) = v["reopen_last_session"].as_bool() { self.reopen_last_session = on; } @@ -211,6 +220,10 @@ impl PdfCraftApp { if let Some((layout, continuous)) = v["default_layout"].as_str().and_then(canvas::parse_display_token) { (self.view_defaults.layout, self.view_defaults.continuous) = (layout, continuous); } + } + + /// Switches for reopening, window chrome and file watching. + fn restore_behavior_prefs(&mut self, v: &serde_json::Value) { if let Some(show) = v["default_page_gaps"].as_bool() { self.view_defaults.show_page_gaps = show; } @@ -252,6 +265,10 @@ impl PdfCraftApp { self.redact_prefs = prefs.clone(); self.redact_defaults = prefs; } + } + + /// Display defaults, units, language and sign-in details. + fn restore_view_prefs(&mut self, v: &serde_json::Value) { // Settings are untrusted: an unreadable value (a colour that isn't three bytes, say) // fails to deserialize and keeps Off. if let Ok(colors) = serde_json::from_value::(v["reading_colors"].clone()) { @@ -277,6 +294,10 @@ impl PdfCraftApp { if let Some(pinned) = v["comment_tool_pinned"].as_bool() { self.comment_prefs.pinned = pinned; } + } + + /// The author and per-tool comment styles. + fn restore_comment_prefs(&mut self, v: &serde_json::Value) { // Per-tool comment styles (#340): each field goes through its clamping setter, so a // malformed or hostile entry is clamped or ignored rather than trusted. if let Some(styles) = v["comment_styles"].as_array() { @@ -305,6 +326,10 @@ impl PdfCraftApp { } } } + } + + /// Saved signatures, stamps, actions and trusted certificates. + fn restore_signature_prefs(&mut self, v: &serde_json::Value) { if let Ok(s) = serde_json::from_value::>>(v["signature"].clone()) && s.iter().all(|st| st.iter().all(|p| p.iter().all(|x| x.is_finite()))) { diff --git a/crates/ui-egui/src/app/window.rs b/crates/ui-egui/src/app/window.rs index c950b54a5..e7035b500 100644 --- a/crates/ui-egui/src/app/window.rs +++ b/crates/ui-egui/src/app/window.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{PdfCraftApp, QuickTool}; impl PdfCraftApp { /// Whether the app draws the window's title bar, buttons and resize edges: the desktop leaves diff --git a/crates/ui-egui/src/control/host.rs b/crates/ui-egui/src/control/host.rs index 62693cda2..1166c0e7e 100644 --- a/crates/ui-egui/src/control/host.rs +++ b/crates/ui-egui/src/control/host.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Host, Reply, Value, json}; impl Host for crate::PdfCraftApp { /// Cancel any in-progress pointer gesture in every open view. diff --git a/crates/ui-egui/src/control/pending.rs b/crates/ui-egui/src/control/pending.rs index 9e63849cf..d87ae037a 100644 --- a/crates/ui-egui/src/control/pending.rs +++ b/crates/ui-egui/src/control/pending.rs @@ -1,5 +1,5 @@ use super::screenshot::{Geometry, MAX_IMAGE_BYTES, retried_reservation, screenshot_png}; -use super::*; +use super::{Arc, Control, Host, Pending, Reply, Sender, Value, not_drawn, timeout}; /// A screenshot pending in the `finish_pending` pass, with ownership of its reply. struct PendingCapture { diff --git a/crates/ui-egui/src/control/plugin.rs b/crates/ui-egui/src/control/plugin.rs index be0dff826..a6def94c4 100644 --- a/crates/ui-egui/src/control/plugin.rs +++ b/crates/ui-egui/src/control/plugin.rs @@ -1,5 +1,5 @@ use super::requests::Handled; -use super::*; +use super::{Arc, Control, Mutex, RequestBatches, Shared, Value, timeout}; /// Track only presses that reached egui and have not yet been released. A pointer release is /// retained as an ownership marker, never as a timeout event. diff --git a/crates/ui-egui/src/control/requests.rs b/crates/ui-egui/src/control/requests.rs index 1e2a24b97..0c926ab2a 100644 --- a/crates/ui-egui/src/control/requests.rs +++ b/crates/ui-egui/src/control/requests.rs @@ -1,5 +1,5 @@ use super::screenshot::Geometry; -use super::*; +use super::{Action, Control, Host, NodeId, Reply, Value, accesskit, json}; impl Control { /// Validate and dispatch one control method, rejecting unknown methods and malformed parameters with diff --git a/crates/ui-egui/src/control/screenshot.rs b/crates/ui-egui/src/control/screenshot.rs index 9eb3e50bb..c6fbb0ed8 100644 --- a/crates/ui-egui/src/control/screenshot.rs +++ b/crates/ui-egui/src/control/screenshot.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Control, Host, Pending, Reply, Sender, json}; /// Screenshot admission limits: bounded requests, per-image bytes and aggregate retained bytes. const MAX_PENDING_SCREENSHOTS: usize = 4; diff --git a/crates/ui-egui/src/control/transport.rs b/crates/ui-egui/src/control/transport.rs index 81e30ff10..23a5181de 100644 --- a/crates/ui-egui/src/control/transport.rs +++ b/crates/ui-egui/src/control/transport.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ControlClient, Value, json}; // ---- transport (native only) ----------------------------------------------------------------- @@ -331,7 +331,7 @@ mod transport_tests { #[test] fn malformed_input_before_auth_closes_the_connection() { use std::io::{BufRead, BufReader, ErrorKind, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); @@ -375,7 +375,7 @@ mod transport_tests { #[test] fn an_overlong_line_before_auth_is_refused() { use std::io::{BufRead, BufReader, ErrorKind, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); @@ -429,7 +429,7 @@ mod transport_tests { #[test] fn an_unauthenticated_connection_is_closed_at_the_deadline() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); // Idle. let started = std::time::Instant::now(); @@ -468,7 +468,7 @@ mod transport_tests { #[test] fn a_client_refused_with_input_unread_gets_the_reason() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); let s = connect(ep.port); let mut w = s.try_clone().unwrap(); @@ -488,7 +488,7 @@ mod transport_tests { use std::io::{BufReader, Write}; // A linger far longer than the checks below take, even on a loaded machine. let limits = super::Limits { linger: std::time::Duration::from_secs(5), ..TEST_LIMITS }; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, limits).unwrap(); let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }); // Refused with most of its line unread, and left open: it lingers for `linger`. @@ -517,7 +517,7 @@ mod transport_tests { use std::io::{BufReader, Write}; // A linger far longer than the checks below take, even on a loaded machine. let limits = super::Limits { linger: std::time::Duration::from_secs(5), max_lingering: 1, ..TEST_LIMITS }; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, limits).unwrap(); let bad = r#"{"jsonrpc": "2.0", "id": 1, "method": "page_count"}"#; // The first refused connection takes the only lingering place and keeps it. @@ -553,7 +553,7 @@ mod transport_tests { #[test] fn only_so_many_connections_may_wait_to_authenticate() { use std::io::{BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve_with(client, TEST_LIMITS).unwrap(); let auth = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "auth", "params": { "token": ep.token } }); let first = connect(ep.port); @@ -593,7 +593,7 @@ mod transport_tests { #[test] fn long_lines_after_auth_are_still_read() { use std::io::{BufRead, BufReader, Write}; - let (_control, client) = super::attach(&egui::Context::default()); + let (_control, client) = crate::control::attach(&egui::Context::default()); let ep = super::serve(client).unwrap(); let s = std::net::TcpStream::connect(("127.0.0.1", ep.port)).unwrap(); s.set_read_timeout(Some(std::time::Duration::from_secs(5))).unwrap(); diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index ba2d3cbf9..4d09b5cf4 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -27,6 +27,79 @@ fn new_text_typed(v: &crate::DocView) -> bool { v.content.draft.as_ref().is_some_and(|d| d.index.is_none() && !d.text.trim().is_empty()) } +/// Keeps the pages (or comment) the user acted on selected, where they now are. +fn select_after_edit(view: &mut crate::DocView, edit: Edit, info: &pdfcraft_render::DocInfo, quick_tool: crate::QuickTool) { + match edit { + Edit::MovePages { pages, to } => { + let n = pages.iter().collect::>().len(); + let to = to.min(info.pages.len() - n); + view.select_pages(&(to..to + n).collect::>()); + } + Edit::DuplicatePages { pages, at: Some(at) } => { + let n = pages.iter().collect::>().len(); + let at = at.min(info.pages.len().saturating_sub(n)); + view.select_pages(&(at..at + n).collect::>()); + } + Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), + Edit::DeletePages { .. } => view.select_pages(&[]), + // A stroke drawn with the pen stays unselected, so the selection box and + // author popup don't sit over the next stroke (#429). + Edit::AddAnnotation(a) + if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) + && quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => + { + view.comments.selected = None; + } + Edit::AddAnnotation(a) => { + // Select the new comment (appended last among the page's comments). + let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); + view.comments.selected = newest.map(|n| (a.page, n)); + view.comments.reveal = true; + // A new highlight opens its note for typing straight away, as in Acrobat, + // unless a note typed into another card is still unsaved. + if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) + && a.contents.is_empty() + && view.comments.editing.is_none() + { + view.comments.open_editor(a.page, n, String::new()); + } + } + Edit::DeleteAnnotation { .. } => view.comments.selected = None, + _ => {} + } +} + +/// The pages whose widgets differ between two form states, and the (field, page) pairs whose +/// value changed and so must not be drawn from the baked page. +fn form_edit_pages( + before: &[pdfcraft_engine::FormField], + after: &[pdfcraft_engine::FormField], +) -> (std::collections::BTreeSet, Vec<(String, usize)>) { + let mut changed_pages = std::collections::BTreeSet::new(); + let mut unbaked = Vec::new(); + for f in after { + let pages = |field: &pdfcraft_engine::FormField| field.widgets.iter().filter_map(|w| w.page).collect::>(); + match before.iter().find(|old| old.name == f.name) { + Some(old) if old == f => {} + Some(old) => { + changed_pages.extend(pages(f)); + changed_pages.extend(pages(old)); + if old.value != f.value { + unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p))); + } + } + None => { + changed_pages.extend(pages(f)); + unbaked.extend(pages(f).into_iter().map(|p| (f.name.clone(), p))); + } + } + } + for old in before.iter().filter(|old| !after.iter().any(|f| f.name == old.name)) { + changed_pages.extend(old.widgets.iter().filter_map(|w| w.page)); + } + (changed_pages, unbaked) +} + impl PdfCraftApp { /// Apply an edit to the active document. Returns `true` on success; failures are shown. pub fn apply_edit(&mut self, edit: Edit) -> bool { @@ -68,49 +141,8 @@ impl PdfCraftApp { && doc.xfa.is_none() && is_form_edit(&edit) { - let mut changed_pages = std::collections::BTreeSet::new(); - for f in doc.form.iter() { - match form_before.iter().find(|old| old.name == f.name) { - Some(old) => { - if old != f { - for w in &f.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - for w in &old.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - if old.value != f.value { - for w in &f.widgets { - if let Some(p) = w.page { - view.forms.unbaked.insert((f.name.clone(), p)); - } - } - } - } - } - None => { - for w in &f.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - view.forms.unbaked.insert((f.name.clone(), p)); - } - } - } - } - } - for old in form_before.iter() { - if !doc.form.iter().any(|f| f.name == old.name) { - for w in &old.widgets { - if let Some(p) = w.page { - changed_pages.insert(p); - } - } - } - } + let (changed_pages, unbaked) = form_edit_pages(&form_before, &doc.form); + view.forms.unbaked.extend(unbaked); for page in changed_pages { view.page_changed(page); } @@ -121,44 +153,7 @@ impl PdfCraftApp { view.comments.comment_added(); } // Keep the pages the user acted on selected, where they now are. - match edit { - Edit::MovePages { pages, to } => { - let n = pages.iter().collect::>().len(); - let to = to.min(info.pages.len() - n); - view.select_pages(&(to..to + n).collect::>()); - } - Edit::DuplicatePages { pages, at: Some(at) } => { - let n = pages.iter().collect::>().len(); - let at = at.min(info.pages.len().saturating_sub(n)); - view.select_pages(&(at..at + n).collect::>()); - } - Edit::InsertBlankPage { at, .. } => view.select_pages(&[at.min(info.pages.len() - 1)]), - Edit::DeletePages { .. } => view.select_pages(&[]), - // A stroke drawn with the pen stays unselected, so the selection box and - // author popup don't sit over the next stroke (#429). - Edit::AddAnnotation(a) - if matches!(a.shape, pdfcraft_engine::Shape::Ink { .. }) - && self.quick_tool == crate::QuickTool::Comment(crate::comments::CommentTool::Ink) => - { - view.comments.selected = None; - } - Edit::AddAnnotation(a) => { - // Select the new comment (appended last among the page's comments). - let newest = info.annotations.iter().filter(|x| x.page == a.page && !x.is_reply()).map(|x| x.index).max(); - view.comments.selected = newest.map(|n| (a.page, n)); - view.comments.reveal = true; - // A new highlight opens its note for typing straight away, as in Acrobat, - // unless a note typed into another card is still unsaved. - if let (pdfcraft_engine::Shape::TextMarkup { kind: pdfcraft_engine::Markup::Highlight, .. }, Some(n)) = (&a.shape, newest) - && a.contents.is_empty() - && view.comments.editing.is_none() - { - view.comments.open_editor(a.page, n, String::new()); - } - } - Edit::DeleteAnnotation { .. } => view.comments.selected = None, - _ => {} - } + select_after_edit(view, edit, info, self.quick_tool); if std::mem::take(&mut view.comments.tool_done) && !self.comment_prefs.pinned { self.quick_tool = crate::QuickTool::Select; } diff --git a/crates/ui-egui/src/editing/close.rs b/crates/ui-egui/src/editing/close.rs index 5317c0ccd..55d884cd9 100644 --- a/crates/ui-egui/src/editing/close.rs +++ b/crates/ui-egui/src/editing/close.rs @@ -1,5 +1,5 @@ +use super::PdfCraftApp; use super::save::SaveTarget; -use super::*; /// What the user was doing when we asked whether to save. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/ui-egui/src/editing/save.rs b/crates/ui-egui/src/editing/save.rs index 59eba1fbc..f685247c9 100644 --- a/crates/ui-egui/src/editing/save.rs +++ b/crates/ui-egui/src/editing/save.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{PdfCraftApp, StagingName, staging_suffixes}; /// A save held back by [`Dialog::OverwriteChanged`](crate::Dialog::OverwriteChanged): the file /// on disk is another version than the document came from (#2166). diff --git a/crates/ui-egui/src/panels/bookmarks.rs b/crates/ui-egui/src/panels/bookmarks.rs index 3753c8494..ec61bf2fe 100644 --- a/crates/ui-egui/src/panels/bookmarks.rs +++ b/crates/ui-egui/src/panels/bookmarks.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DocInfo, Nav, OutlineItem, Rect, Sense, Tokens, icons, pos2, theme, vec2}; /// What the user asked to do with a bookmark (applied after the panel is drawn). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index e10d0d01e..fa4cc2536 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -1,5 +1,8 @@ use super::tools::row_label; -use super::*; +use super::{ + Align2, Availability, Color32, CornerRadius, LeftPanel, PdfCraftApp, Rect, Sense, Stroke, Tokens, ToolGroup, ToolItem, catalog, icons, theme, + vec2, widgets, +}; /// The page operations Edit shows as a row of icon buttons, as Acrobat's Edit panel does. const EDIT_PAGE_BUTTONS: [&str; 4] = ["page.rotate", "page.insert", "page.delete", "page.extract"]; diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index e7afe45e8..1157b64a4 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Align, Align2, Color32, CornerRadius, DocInfo, Layout, Nav, Rect, Sense, Stroke, Tokens, icons, pos2, theme, vec2}; /// Page tools in the Pages panel's header (#4415): a menu, as Bookmark options is in its panel, /// to insert a blank page or pages from a file, rotate, extract and delete. Each runs its diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 4f6c150c4..7b6502b9c 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -1,6 +1,8 @@ use super::bookmarks::{BmAction, OutlineCtx, outline_item, outline_matches}; use super::pages::{page_tools, pages, thumbnail_size}; -use super::*; +use super::{ + Align, Align2, Color32, DocInfo, FieldKind, Layout, Nav, PdfCraftApp, Rect, RightPanel, Sense, Stroke, Tokens, human_size, icons, theme, vec2, +}; /// Draw the selected right-hand panel and apply its navigation, layer, bookmark or edit actions. pub fn right_panel(app: &mut PdfCraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 3a1cccd71..4ff76a7c4 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -1,5 +1,8 @@ use super::edit::{actions_menu, edit_panel, stamp_palette}; -use super::*; +use super::{ + Align2, Availability, Color32, CornerRadius, LeftPanel, PdfCraftApp, Rect, Sense, Stroke, TOOL_GROUPS, Tokens, ToolGroup, catalog, icons, theme, + vec2, widgets, +}; const COLLAPSED_TOOLS: usize = 14; diff --git a/crates/ui-egui/src/platform/open_worker/tests.rs b/crates/ui-egui/src/platform/open_worker/tests.rs index af847b9a2..fe2a0ae41 100644 --- a/crates/ui-egui/src/platform/open_worker/tests.rs +++ b/crates/ui-egui/src/platform/open_worker/tests.rs @@ -556,55 +556,117 @@ fn tool_picker_keeps_picked_recipient_when_other_open_is_ready() { held.release.send(()).expect("release ordinary B"); held.finished.recv_timeout(Duration::from_secs(5)).expect("ordinary B reply is also ready"); assert_eq!(harness.state().pending_opens.len(), 2); + let files = PickerFiles { + picked: &picked, + other: &other, + insert: &insert, + original_a: &original_a, + original_b: &original_b, + original_c: &original_c, + }; + let a_id = assert_picker_keeps_a_visible(&mut harness, &files); + assert_picker_completion_releases_b(&mut harness, a_id, cancel, &files); + } +} - // Poll admission separately from consuming the picker callback: this reproduces - // the same ordering as a UI frame, with both worker replies already available. - harness.state_mut().poll_pending_opens(); - assert_eq!(harness.state().views.len(), 1, "ready B must wait for A's Insert pages picker"); - assert_eq!(harness.state().active, Some(0)); - assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none()); - let a_id = harness.state().views[0].id; - let doc = harness.state().session.get(a_id).expect("picked A before picker completion"); - assert_eq!(doc.path.as_deref(), Some(picked.0.to_string_lossy().as_ref())); +struct PickerFiles<'a> { + picked: &'a ToolOpenFile, + other: &'a ToolOpenFile, + insert: &'a ToolOpenFile, + original_a: &'a [u8], + original_b: &'a [u8], + original_c: &'a [u8], +} + +fn assert_picker_keeps_a_visible(harness: &mut Harness<'static, PdfCraftApp>, files: &PickerFiles<'_>) -> pdfcraft_engine::DocId { + // Poll admission separately from consuming the picker callback: this reproduces + // the same ordering as a UI frame, with both worker replies already available. + harness.state_mut().poll_pending_opens(); + assert_eq!(harness.state().views.len(), 1, "ready B must wait for A's Insert pages picker"); + assert_eq!(harness.state().active, Some(0)); + assert!(harness.state().dialog.is_none() && harness.state().password_prompt.is_none()); + let a_id = harness.state().views[0].id; + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "picked A before picker completion"); + if let Some(doc) = doc { + assert_eq!(doc.path.as_deref(), Some(files.picked.0.to_string_lossy().as_ref())); assert_eq!(doc.info.pages.len(), 2); assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none()); - assert_eq!(harness.state().pending_opens.len(), 1); - assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(other.0.to_string_lossy().as_ref())); - harness.state_mut().poll_pending_opens(); - assert_eq!(harness.state().views.len(), 1, "B remains held until the queued picker result is consumed"); - assert_eq!(harness.state().active, Some(0)); + } + assert_eq!(harness.state().pending_opens.len(), 1); + assert_eq!(harness.state().pending_opens[0].path.as_deref(), Some(files.other.0.to_string_lossy().as_ref())); + harness.state_mut().poll_pending_opens(); + assert_eq!(harness.state().views.len(), 1, "B remains held until the queued picker result is consumed"); + assert_eq!(harness.state().active, Some(0)); + a_id +} - // Consume the actual native-picker callback (or its empty-path cancellation). - harness.state_mut().process_picked(); - let doc = harness.state().session.get(a_id).expect("A after picker completion"); - assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 }); +fn assert_picker_completion_releases_b( + harness: &mut Harness<'static, PdfCraftApp>, + a_id: pdfcraft_engine::DocId, + cancel: bool, + files: &PickerFiles<'_>, +) { + // Consume the actual native-picker callback (or its empty-path cancellation). + harness.state_mut().process_picked(); + assert_document_a_after_picker(harness, a_id, cancel); + assert_eq!(harness.state().views.len(), 1); + assert_eq!(harness.state().pending_opens.len(), 1); + assert_eq!(harness.state().active, Some(0)); + + harness.step(); + assert!(harness.state().pending_opens.is_empty()); + assert_eq!(harness.state().views.len(), 2, "picker completion releases B exactly once"); + let active = harness.state().active; + assert!(active.is_some(), "B activates after picker completion"); + if let Some(active) = active { + assert_document_b_after_picker(harness, active, files); + } + harness.run_steps(2); + assert!(harness.state().pending_opens.is_empty() && harness.state().dialog.is_none()); + assert_eq!(harness.state().views.len(), 2); + assert_eq!(harness.state().active, active); + assert_document_a_after_picker(harness, a_id, cancel); + assert_a_saved_source(harness, a_id, files.original_a); + assert_picker_files_unchanged(files); +} + +fn assert_document_a_after_picker(harness: &Harness<'static, PdfCraftApp>, a_id: pdfcraft_engine::DocId, cancel: bool) { + let expected_pages = if cancel { 2 } else { 3 }; + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "A after picker completion"); + if let Some(doc) = doc { + assert_eq!(doc.info.pages.len(), expected_pages); assert_eq!(doc.dirty, !cancel); assert_eq!(doc.can_undo().is_some(), !cancel); assert!(doc.can_redo().is_none()); - assert_eq!(harness.state().views.len(), 1); - assert_eq!(harness.state().pending_opens.len(), 1); - assert_eq!(harness.state().active, Some(0)); + } +} - harness.step(); - assert!(harness.state().pending_opens.is_empty()); - assert_eq!(harness.state().views.len(), 2, "picker completion releases B exactly once"); - let active = harness.state().active.expect("B activates after picker completion"); - let doc = harness.state().session.get(harness.state().views[active].id).expect("ordinary B document"); - assert_eq!(doc.path.as_deref(), Some(other.0.to_string_lossy().as_ref())); +fn assert_document_b_after_picker(harness: &Harness<'static, PdfCraftApp>, active: usize, files: &PickerFiles<'_>) { + let doc = harness.state().session.get(harness.state().views[active].id); + assert!(doc.is_some(), "ordinary B document"); + if let Some(doc) = doc { + assert_eq!(doc.path.as_deref(), Some(files.other.0.to_string_lossy().as_ref())); assert_eq!(doc.info.pages.len(), 1); assert!(!doc.dirty && doc.can_undo().is_none() && doc.can_redo().is_none()); - harness.run_steps(2); - assert!(harness.state().pending_opens.is_empty() && harness.state().dialog.is_none()); - assert_eq!(harness.state().views.len(), 2); - assert_eq!(harness.state().active, Some(active)); - let doc = harness.state().session.get(a_id).expect("A retains exactly one insertion or cancellation"); - assert_eq!(doc.info.pages.len(), if cancel { 2 } else { 3 }); - assert_eq!(doc.dirty, !cancel); - assert_eq!(doc.can_undo().is_some(), !cancel); - assert!(doc.can_redo().is_none()); - assert_eq!(doc.saved_bytes().as_slice(), original_a.as_slice(), "insertion preserves A's saved source"); - for (file, original) in [(&picked, &original_a), (&other, &original_b), (&insert, &original_c)] { - assert_eq!(std::fs::read(&file.0).expect("read unchanged original picker fixture"), *original); + } +} + +fn assert_a_saved_source(harness: &Harness<'static, PdfCraftApp>, a_id: pdfcraft_engine::DocId, original: &[u8]) { + let doc = harness.state().session.get(a_id); + assert!(doc.is_some(), "A retains exactly one insertion or cancellation"); + if let Some(doc) = doc { + assert_eq!(doc.saved_bytes().as_slice(), original, "insertion preserves A's saved source"); + } +} + +fn assert_picker_files_unchanged(files: &PickerFiles<'_>) { + for (file, original) in [(files.picked, files.original_a), (files.other, files.original_b), (files.insert, files.original_c)] { + let bytes = std::fs::read(&file.0); + assert!(bytes.is_ok(), "read unchanged original picker fixture: {bytes:?}"); + if let Ok(bytes) = bytes { + assert_eq!(bytes, original); } } } diff --git a/crates/ui-egui/tests/it/signing.rs b/crates/ui-egui/tests/it/signing.rs index 62d97d4e5..0d8bc72de 100644 --- a/crates/ui-egui/tests/it/signing.rs +++ b/crates/ui-egui/tests/it/signing.rs @@ -97,23 +97,32 @@ fn ctrl_alt_q_types_an_email_without_quitting() { fn drawing_a_signature_creating_an_id_signing_and_trusting() { let dir = dir(); let mut h = harness(dir.clone()); + create_signing_id(&mut h); + sign_with_created_id(&mut h, &dir); + trust_signer_and_inspect_certificate(&mut h); + verify_signed_snapshot_reuse(&mut h); + verify_dirty_snapshot_close(&mut h); + assert_trust_and_id_persist(&h); +} + +fn create_signing_id(h: &mut Harness<'static, PdfCraftApp>) { assert!(h.state_mut().execute("sign.digital")); assert_eq!(h.state().quick_tool, QuickTool::SignArea { certify: false }); - let (a, b) = (at(&h, 40.0, 90.0), at(&h, 220.0, 40.0)); - drag(&mut h, a, b); + let (a, b) = (at(h, 40.0, 90.0), at(h, 220.0, 40.0)); + drag(h, a, b); assert_eq!(h.state().dialog, Some(Dialog::Sign)); assert_eq!(h.state().sign_draft.as_ref().map(|d| d.step), Some(SignStep::Configure), "no IDs yet: configure one"); // Create a new digital ID (P-256 keeps the test fast). h.get_by_label("Configure a Digital ID for Signing"); - field(&h, "Name").click(); + field(h, "Name").click(); h.run_steps(1); - field(&h, "Name").type_text("Grace Hopper"); + field(h, "Name").type_text("Grace Hopper"); h.state_mut().sign_draft.as_mut().unwrap().new_id.key = 3; for (label, text) in [("Password", "secret12"), ("Confirm Password", "secret12")] { - field(&h, label).click(); + field(h, label).click(); h.run_steps(1); - field(&h, label).type_text(text); + field(h, label).type_text(text); h.run_steps(1); } h.get_by_label("Save").click(); @@ -124,31 +133,41 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("Continue").click(); h.run_steps(2); h.get_by_label("Sign as \"Grace Hopper\""); +} +fn sign_with_created_id(h: &mut Harness<'static, PdfCraftApp>, dir: &std::path::Path) { // A wrong password says so. - field(&h, "Digital ID password").click(); + field(h, "Digital ID password").click(); h.run_steps(1); - field(&h, "Digital ID password").type_text("nope"); - support::outside_menus(&h, "Sign").click(); + field(h, "Digital ID password").type_text("nope"); + support::outside_menus(h, "Sign").click(); h.run_steps(3); h.get_by_label("The password is incorrect."); h.state_mut().sign_draft.as_mut().unwrap().password = "secret12".into(); - field(&h, "Reason").click(); + field(h, "Reason").click(); h.run_steps(1); - field(&h, "Reason").type_text("I approve"); - support::outside_menus(&h, "Sign").click(); + field(h, "Reason").type_text("I approve"); + support::outside_menus(h, "Sign").click(); h.run_steps(6); let s = h.state(); assert_eq!(s.dialog, None); assert!(dir.join("contract_signed.pdf").exists()); - let doc = s.session.get(s.views[0].id).unwrap(); - assert_eq!(doc.name, "contract_signed.pdf"); - let sig = doc.signatures.iter().find(|x| x.signed).expect("signed"); - assert_eq!((sig.status, sig.reason.as_deref(), sig.page), (SignatureStatus::Unknown, Some("I approve"), Some(0))); + let doc = s.session.get(s.views[0].id); + assert!(doc.is_some(), "signed document remains open"); + if let Some(doc) = doc { + assert_eq!(doc.name, "contract_signed.pdf"); + let signature = doc.signatures.iter().find(|signature| signature.signed); + assert!(signature.is_some(), "the saved document contains a signature"); + if let Some(signature) = signature { + assert_eq!((signature.status, signature.reason.as_deref(), signature.page), (SignatureStatus::Unknown, Some("I approve"), Some(0))); + } + } assert_eq!(s.right, Some(RightPanel::Signatures)); h.get_by_label("At least one signature has problems."); +} +fn trust_signer_and_inspect_certificate(h: &mut Harness<'static, PdfCraftApp>) { // Signatures panel: expand, trust the signer → valid. h.get_by_label_contains("Signed by Grace Hopper").click(); h.run_steps(2); @@ -160,9 +179,13 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.run_steps(3); h.get_by_label("Signed and all signatures are valid."); let s = h.state(); - assert_eq!(s.session.get(s.views[0].id).unwrap().signatures[0].status, SignatureStatus::Valid); + let doc = s.session.get(s.views[0].id); + assert!(doc.is_some(), "the original signed document remains open"); + if let Some(doc) = doc { + assert_eq!(doc.signatures[0].status, SignatureStatus::Valid); + assert!(!doc.dirty, "trust alone is not an edit"); + } assert!(s.views[0].find.as_ref().is_some_and(|f| f.matches.is_empty()), "trust invalidates the view without zooming"); - assert!(!s.session.get(s.views[0].id).unwrap().dirty, "trust alone is not an edit"); // Show certificate: the Certificate Viewer. h.get_by_label("Show certificate…").click(); h.run_steps(3); @@ -181,13 +204,19 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("OK").click(); h.run_steps(2); assert_eq!(h.state().dialog, None); +} + +fn verify_signed_snapshot_reuse(h: &mut Harness<'static, PdfCraftApp>) { // View signed version opens the bytes the signature covers as a new document. h.get_by_label("View signed version").click(); h.run_steps(3); let s = h.state(); assert_eq!(s.views.len(), 2); - let v = s.session.get(s.views[1].id).unwrap(); - assert!(v.name.contains("signed version") && v.signatures.iter().any(|x| x.signed)); + let signed_version = s.session.get(s.views[1].id); + assert!(signed_version.is_some(), "the signed version opens in its own tab"); + if let Some(version) = signed_version { + assert!(version.name.contains("signed version") && version.signatures.iter().any(|signature| signature.signed)); + } assert!(s.views[0].snapshot.is_none()); assert!(s.views[1].snapshot.as_ref().is_some_and(|snap| snap.kind == pdfcraft_ui_egui::canvas::SnapshotKind::SignedVersion)); // A repeat click reuses the snapshot tab instead of opening another identical one. @@ -219,15 +248,20 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { let Some(up) = node.parent() else { break }; node = up; } - let close = close.expect("the notice bar holds its own Close"); + assert!(close.is_some(), "the notice bar holds its own Close"); // The bar's Close sits in the bar's row and the tab chrome's sits above it. Without // this the walk could reach the chrome's button, which closes the same tab, and the // test would pass with the bar's own button never pressed. - assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own"); - close.click(); - h.run_steps(2); - assert_eq!(h.state().views.len(), 1); - assert!(h.state().close_request.is_none()); + if let Some(close) = close { + assert!(label_row.contains(close.rect().center().y), "the Close found is the bar's own"); + close.click(); + h.run_steps(2); + assert_eq!(h.state().views.len(), 1); + assert!(h.state().close_request.is_none()); + } +} + +fn verify_dirty_snapshot_close(h: &mut Harness<'static, PdfCraftApp>) { // Closing a dirty snapshot asks about saving instead of closing at once. h.get_by_label("View signed version").click(); h.run_steps(3); @@ -240,6 +274,9 @@ fn drawing_a_signature_creating_an_id_signing_and_trusting() { h.get_by_label("Don't save").click(); h.run_steps(2); assert_eq!(h.state().views.len(), 1); +} + +fn assert_trust_and_id_persist(h: &Harness<'static, PdfCraftApp>) { // Trusted certificates and the ID list persist. let saved = h.state().persist(); let mut fresh = PdfCraftApp::new(); From 21cae663d155442b76b4c3f16c9028a7ea215104 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 20/33] Refactor ui tools for strict Clippy checks --- crates/ui-egui/src/tools/comments/composer.rs | 5 +- crates/ui-egui/src/tools/comments/geometry.rs | 2 +- crates/ui-egui/src/tools/comments/input.rs | 516 ++++++++++-------- crates/ui-egui/src/tools/comments/menu.rs | 5 +- crates/ui-egui/src/tools/comments/paint.rs | 5 +- crates/ui-egui/src/tools/comments/state.rs | 2 +- crates/ui-egui/src/tools/edit_text_ui.rs | 2 +- .../src/tools/edit_text_ui/background.rs | 2 +- .../ui-egui/src/tools/edit_text_ui/image.rs | 338 +++++++----- .../ui-egui/src/tools/edit_text_ui/layout.rs | 2 +- crates/ui-egui/src/tools/fill_sign/scan.rs | 5 +- crates/ui-egui/src/tools/forms_ui.rs | 172 +++--- crates/ui-egui/src/tools/measure_ui.rs | 67 ++- crates/ui-egui/src/tools/prepare/draft.rs | 2 +- .../ui-egui/src/tools/prepare/properties.rs | 2 +- crates/ui-egui/src/tools/prepare/tabs.rs | 2 +- crates/ui-egui/src/tools/sign_ui/dialog.rs | 5 +- crates/ui-egui/src/tools/sign_ui/panel.rs | 2 +- 18 files changed, 634 insertions(+), 502 deletions(-) diff --git a/crates/ui-egui/src/tools/comments/composer.rs b/crates/ui-egui/src/tools/comments/composer.rs index 8c83c3ba1..fa2d7f9b1 100644 --- a/crates/ui-egui/src/tools/comments/composer.rs +++ b/crates/ui-egui/src/tools/comments/composer.rs @@ -1,7 +1,10 @@ use super::geometry::{drawn_text_box_rect, page_rotation, text_box_rect}; use super::paint::{edited_in_view, new_comment}; use super::state::{ComposerKind, PageCx}; -use super::*; +use super::{ + Color32, CommentPrefs, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NoteIcon, PagePointer, QuickTool, Shape, Tokens, pos2, + theme, +}; /// The floating composer card (new note, new text box, edit). Returns the edit to apply. pub(crate) fn composer(ctx: &egui::Context, view: &mut DocView, info: &DocInfo, prefs: &CommentPrefs) -> Option { diff --git a/crates/ui-egui/src/tools/comments/geometry.rs b/crates/ui-egui/src/tools/comments/geometry.rs index 75d56c225..051cf09db 100644 --- a/crates/ui-egui/src/tools/comments/geometry.rs +++ b/crates/ui-egui/src/tools/comments/geometry.rs @@ -1,5 +1,5 @@ use super::state::PageCx; -use super::*; +use super::{Annotation, DocInfo, PageInfo, PageText, Pos2, QuickTool, Rect, pos2, vec2}; pub(super) fn is_markup(subtype: &str) -> bool { matches!(subtype, "Highlight" | "Underline" | "StrikeOut" | "Squiggly") diff --git a/crates/ui-egui/src/tools/comments/input.rs b/crates/ui-egui/src/tools/comments/input.rs index d8cc33400..ef0341b71 100644 --- a/crates/ui-egui/src/tools/comments/input.rs +++ b/crates/ui-egui/src/tools/comments/input.rs @@ -4,7 +4,7 @@ use super::geometry::{ }; use super::paint::{drawn_shape, edit_anchor, erase, new_comment}; use super::state::{Composer, ComposerKind, Gesture, PageCx}; -use super::*; +use super::{Annotation, CommentTool, DocInfo, DocView, Edit, Pos2, QuickTool, Rect, Shape, vec2}; /// Comment input on one page, before text selection runs. Returns `true` when the pointer /// gesture belongs to commenting (text selection must ignore it). @@ -31,247 +31,15 @@ pub(crate) fn page_input(ui: &egui::Ui, resp: &egui::Response, cx: &PageCx<'_>, cv.context_at = Some((cx.page, cx.to_user(p))); cv.selected = cx.hit(p).map(|a| (cx.page, a.index)); } + let gestures = Gestures { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page }; match cx.tool { - // The shape just drawn is selected with its handles showing: they resize it without a - // trip to the Select tool (#2338). Anywhere else the tool keeps drawing. QuickTool::Comment(_) if draw_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), - QuickTool::Comment(tool) if tool.draws() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - let p = clamp_to(page_rect, p); - let u = cx.to_user(p); - if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { - let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); - if last.distance(p) >= 1.5 { - points.push(u); - } - } else { - points.truncate(1); - points.push(u); - } - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if tool == CommentTool::Eraser { - view.pending_edit = erase(cx, &points); - } else if let Some(shape) = drawn_shape(tool, &points) { - let now = ui.input(|i| i.time); - cv.settle(now, page, tool, &points); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats - // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a - // single PDF point is visible and remains editable like any other drawing. - if tool == CommentTool::Ink - && resp.clicked() - && over_page - && let Some(p) = pointer - && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) - { - cv.selected = None; - let now = ui.input(|i| i.time); - cv.settle(now, cx.page, tool, &[cx.to_user(p)]); - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - true - } - QuickTool::Comment(tool) if tool.clicks_points() => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); - // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. - // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. - if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) - && !ui.ctx().egui_wants_keyboard_input() - && ui.input_mut(|i| { - i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete) - }) - { - cv.undo_point(); - return true; - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let u = cx.to_user(p); - match cv.gesture.as_mut() { - Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { - // Clicking the first point again closes a polygon. - let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; - let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); - if closes { - let pts = std::mem::take(points); - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &pts) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - return true; - } - if !repeat { - points.push(u); - } - } - _ => { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); - cv.selected = None; - } - } - } - if finish - && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() - && page == cx.page - && t == tool - { - cv.gesture = None; - if let Some(shape) = drawn_shape(tool, &points) { - view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); - } - } - true - } - QuickTool::Comment(CommentTool::Callout) => { - // Drag from what the callout points at to where its text box goes. - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let [point, at] = points[..] - && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 - { - cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); - } - } - true - } - QuickTool::Comment(CommentTool::Attach) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - cv.attach_at = Some((cx.page, cx.to_user(p))); - cv.selected = None; - } - true - } - QuickTool::Comment(CommentTool::Caret) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let (vx, vy) = cx.xf.screen_to_view(p); - let snapped = cx.info.pages.get(cx.page).zip(caret_text.as_deref()).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); - let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); - cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); - cv.selected = None; - } - true - } - QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => { - if !cx.allowed { - return false; - } - if over_page { - ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); - } - // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its - // text wraps inside it (#2669). - if tool == CommentTool::TextBox { - if resp.drag_started() - && pressed_here - && let Some(o) = origin - { - cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); - cv.selected = None; - } - if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() - && *page == cx.page - && let Some(p) = pointer - { - points.truncate(1); - points.push(cx.to_user(clamp_to(page_rect, p))); - } - if resp.drag_stopped() - && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() - && page == cx.page - { - cv.gesture = None; - if let (Some(first), Some(last)) = (points.first(), points.last()) { - let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); - // A drag of a few pixels is a click: the box fits its text. - let drawn = - (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); - let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); - cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); - } - } - } - if resp.clicked() - && over_page - && let Some(p) = pointer - { - let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; - cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); - cv.selected = None; - } - true - } + QuickTool::Comment(tool) if tool.draws() => gestures.draw_shape(view, tool), + QuickTool::Comment(tool) if tool.clicks_points() => gestures.click_points(view, tool), + QuickTool::Comment(CommentTool::Callout) => gestures.callout(view), + QuickTool::Comment(CommentTool::Attach) => gestures.attach(view), + QuickTool::Comment(CommentTool::Caret) => gestures.caret(view, caret_text.as_deref()), + QuickTool::Comment(tool @ (CommentTool::Note | CommentTool::TextBox)) => gestures.note_or_box(view, tool), QuickTool::Select => select_input(ui, resp, cx, view, pointer, origin, pressed_here), QuickTool::Fill(_) if fill_grab => select_input(ui, resp, cx, view, pointer, origin, pressed_here), _ => false, @@ -512,6 +280,274 @@ pub(crate) fn page_after_text(resp: &egui::Response, cx: &PageCx<'_>, view: &mut } } +/// What the pointer is doing on one page, for the comment tools that draw or place things. +#[derive(Clone, Copy)] +struct Gestures<'a> { + ui: &'a egui::Ui, + resp: &'a egui::Response, + cx: &'a PageCx<'a>, + pointer: Option, + origin: Option, + page_rect: Rect, + pressed_here: bool, + over_page: bool, +} + +impl Gestures<'_> { + fn draw_shape(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, tool, points }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + let p = clamp_to(page_rect, p); + let u = cx.to_user(p); + if matches!(*tool, CommentTool::Ink | CommentTool::Eraser) { + let last = points.last().map(|l| cx.to_screen(*l)).unwrap_or(p); + if last.distance(p) >= 1.5 { + points.push(u); + } + } else { + points.truncate(1); + points.push(u); + } + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, tool, points }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if tool == CommentTool::Eraser { + view.pending_edit = erase(cx, &points); + } else if let Some(shape) = drawn_shape(tool, &points) { + let now = ui.input(|i| i.time); + cv.settle(now, page, tool, &points); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + // A quick press/release does not start an egui drag, but Acrobat's Draw tool treats + // it as a one-point ink stroke (a dot). The appearance uses round line caps, so a + // single PDF point is visible and remains editable like any other drawing. + if tool == CommentTool::Ink + && resp.clicked() + && over_page + && let Some(p) = pointer + && let Some(shape) = drawn_shape(tool, &[cx.to_user(p)]) + { + cv.selected = None; + let now = ui.input(|i| i.time); + cv.settle(now, cx.page, tool, &[cx.to_user(p)]); + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + true + } + + fn click_points(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + let finish = resp.double_clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)); + // Backspace (or Delete) takes back the last point, e.g. after a wrong click; Undo does too. + // Only while a drawing is in progress: otherwise Delete belongs to the selected comment. + if matches!(cv.gesture, Some(Gesture::Draw { page, .. }) if page == cx.page) + && !ui.ctx().egui_wants_keyboard_input() + && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Backspace) || i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)) + { + cv.undo_point(); + return true; + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let u = cx.to_user(p); + match cv.gesture.as_mut() { + Some(Gesture::Draw { page, tool: t, points }) if *page == cx.page && *t == tool => { + // Clicking the first point again closes a polygon. + let closes = points.len() >= 3 && tool != CommentTool::PolyLine && cx.to_screen(points[0]).distance(p) <= 6.0; + let repeat = points.last().is_some_and(|l| cx.to_screen(*l).distance(p) < 2.0); + if closes { + let pts = std::mem::take(points); + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &pts) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + return true; + } + if !repeat { + points.push(u); + } + } + _ => { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![u] }); + cv.selected = None; + } + } + } + if finish + && let Some(Gesture::Draw { page, tool: t, points }) = cv.gesture.clone() + && page == cx.page + && t == tool + { + cv.gesture = None; + if let Some(shape) = drawn_shape(tool, &points) { + view.pending_edit = Some(new_comment(cx, tool, shape, String::new())); + } + } + true + } + + fn callout(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + // Drag from what the callout points at to where its text box goes. + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool: CommentTool::Callout, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let [point, at] = points[..] + && (point[0] - at[0]).hypot(point[1] - at[1]) >= 8.0 + { + cv.composer = Some(Composer { page, at, kind: ComposerKind::Callout { point }, text: String::new(), focus: true }); + } + } + true + } + + fn attach(&self, view: &mut DocView) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + cv.attach_at = Some((cx.page, cx.to_user(p))); + cv.selected = None; + } + true + } + + fn caret(&self, view: &mut DocView, caret_text: Option<&pdfcraft_render::PageText>) -> bool { + let Self { ui, resp, cx, pointer, over_page, .. } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let (vx, vy) = cx.xf.screen_to_view(p); + let snapped = cx.info.pages.get(cx.page).zip(caret_text).and_then(|(page, t)| snapped_caret(page, t, vx, vy)); + let (at, size) = snapped.unwrap_or_else(|| (cx.to_user(p), CARET_SIZE)); + cv.composer = Some(Composer { page: cx.page, at, kind: ComposerKind::Caret { size }, text: String::new(), focus: true }); + cv.selected = None; + } + true + } + + fn note_or_box(&self, view: &mut DocView, tool: CommentTool) -> bool { + let Self { ui, resp, cx, pointer, origin, page_rect, pressed_here, over_page } = *self; + let cv = &mut view.comments; + if !cx.allowed { + return false; + } + if over_page { + ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair); + } + // A text box also takes a drag, as in Acrobat: the box is drawn at that size and its + // text wraps inside it (#2669). + if tool == CommentTool::TextBox { + if resp.drag_started() + && pressed_here + && let Some(o) = origin + { + cv.gesture = Some(Gesture::Draw { page: cx.page, tool, points: vec![cx.to_user(o)] }); + cv.selected = None; + } + if let Some(Gesture::Draw { page, points, .. }) = cv.gesture.as_mut() + && *page == cx.page + && let Some(p) = pointer + { + points.truncate(1); + points.push(cx.to_user(clamp_to(page_rect, p))); + } + if resp.drag_stopped() + && let Some(Gesture::Draw { page, points, .. }) = cv.gesture.clone() + && page == cx.page + { + cv.gesture = None; + if let (Some(first), Some(last)) = (points.first(), points.last()) { + let r = Rect::from_two_pos(cx.to_screen(*first), cx.to_screen(*last)); + // A drag of a few pixels is a click: the box fits its text. + let drawn = + (r.width() >= 6.0 && r.height() >= 6.0).then(|| cx.info.pages.get(page)).flatten().map(|p| drawn_box(p, *first, *last)); + let (at, size) = drawn.map_or((*first, None), |(at, size)| (at, Some(size))); + cv.composer = Some(Composer { page, at, kind: ComposerKind::TextBox { size }, text: String::new(), focus: true }); + } + } + } + if resp.clicked() + && over_page + && let Some(p) = pointer + { + let kind = if tool == CommentTool::Note { ComposerKind::Note } else { ComposerKind::TextBox { size: None } }; + cv.composer = Some(Composer { page: cx.page, at: cx.to_user(p), kind, text: String::new(), focus: true }); + cv.selected = None; + } + true + } +} + /// Delete / Escape handling for comments (only while no text field has focus). pub(crate) fn keys(ctx: &egui::Context, view: &mut DocView, tool: &mut QuickTool, allowed: bool) { if !ctx.input(|i| i.pointer.any_down()) { diff --git a/crates/ui-egui/src/tools/comments/menu.rs b/crates/ui-egui/src/tools/comments/menu.rs index 3d9a79311..d3de66a4e 100644 --- a/crates/ui-egui/src/tools/comments/menu.rs +++ b/crates/ui-egui/src/tools/comments/menu.rs @@ -1,6 +1,9 @@ use super::paint::edit_anchor; use super::state::{Composer, ComposerKind}; -use super::*; +use super::{ + Annotation, Color32, CommentPrefs, CommentTool, DocInfo, DocView, Edit, Rect, ReviewState, Rgb, SELECT_BLUE, SWATCHES, Sense, Stroke, color32, + icons, theme, tool_for, vec2, +}; /// Save attachment… and, for a PDF, Open in a new tab, first in a file attachment comment's /// menus as in the Attachments panel: the comment is where the reader finds its file. Reading diff --git a/crates/ui-egui/src/tools/comments/paint.rs b/crates/ui-egui/src/tools/comments/paint.rs index 55e99d79a..bfcd67982 100644 --- a/crates/ui-egui/src/tools/comments/paint.rs +++ b/crates/ui-egui/src/tools/comments/paint.rs @@ -1,6 +1,9 @@ use super::geometry::{HANDLES, grabs_points, handle_pos, is_markup, reshapable, reshaped, resizable}; use super::state::{Gesture, PageCx, SETTLE_SECS}; -use super::*; +use super::{ + Annotation, Color32, CommentTool, CornerRadius, DocInfo, DocView, Edit, LineEnding, NewAnnotation, Pos2, QuickTool, Rect, SELECT_BLUE, Shape, + Stroke, color32, vec2, +}; /// Paint comment selection, hover and gesture previews on a page. pub(crate) fn paint_page(ui: &egui::Ui, painter: &egui::Painter, cx: &PageCx<'_>, view: &DocView) { diff --git a/crates/ui-egui/src/tools/comments/state.rs b/crates/ui-egui/src/tools/comments/state.rs index 97ccb365a..36149bb2a 100644 --- a/crates/ui-egui/src/tools/comments/state.rs +++ b/crates/ui-egui/src/tools/comments/state.rs @@ -1,5 +1,5 @@ use super::geometry::{corner_ratio, is_markup, resized}; -use super::*; +use super::{Annotation, CommentPrefs, CommentTool, DocInfo, Edit, PagePointer, PageXform, Pos2, QuickTool, Rect}; /// The user's login name, which Acrobat uses as the default comment author. pub(super) fn login_name() -> String { diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 66daa6edb..297c3d9a3 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -20,7 +20,7 @@ mod layout; pub(crate) use background::TextBackground; pub use image::{CropMode, ImageSelection}; -pub(crate) use image::{ImageAction, image_input, image_owns_pointer}; +pub(crate) use image::{ImageAction, ImageCx, image_input, image_owns_pointer}; #[cfg(test)] use image::{rotate_knob, turn_degrees}; #[cfg(test)] diff --git a/crates/ui-egui/src/tools/edit_text_ui/background.rs b/crates/ui-egui/src/tools/edit_text_ui/background.rs index 71b2bffd3..591cfbf2a 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/background.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/background.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Color32, Edit, LineEditor, PageXform, Pos2, TextTarget}; /// A cached, read-only page background. The source glyphs are removed before rasterization; /// colour, pictures and unselected text beneath the editor remain visible. diff --git a/crates/ui-egui/src/tools/edit_text_ui/image.rs b/crates/ui-egui/src/tools/edit_text_ui/image.rs index 9f55a7d5e..2c231c19d 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ACCENT, Color32, CornerRadius, DocInfo, DocView, Edit, MAX_OUTLINES, PagePointer, PageXform, Pos2, Rect, Stroke, user_box}; /// A selected page image, and what the pointer is doing to it. #[derive(Clone, Debug, PartialEq)] @@ -46,6 +46,18 @@ impl CropMode { } } +/// What the pointer and the page are, for the image tools on one page. +#[derive(Clone, Copy)] +pub(crate) struct ImageCx<'a> { + pub ui: &'a egui::Ui, + pub resp: &'a egui::Response, + pub xf: &'a PageXform, + pub ptr: &'a PagePointer, + pub page: usize, + pub info: &'a DocInfo, + pub allow_interaction: bool, +} + /// The screen boxes of the crop's eight handles: corners, then the top, right, bottom and left /// edge midpoints. fn crop_handles(k: Rect) -> [Pos2; 8] { @@ -54,19 +66,8 @@ fn crop_handles(k: Rect) -> [Pos2; 8] { /// Crop mode for the selected image `i` whose visible frame is `b` (screen). Returns `true` /// (the pointer and keys are the crop's while it lasts). -#[allow(clippy::too_many_arguments)] -fn crop_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - view: &mut DocView, - i: usize, - b: Rect, - allow_interaction: bool, -) -> bool { +fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; let painter = ui.painter(); let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false }; let hover_pos = ptr.pos; @@ -276,44 +277,26 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { if snap { (d / 15.0).round() * 15.0 } else { d } } -/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. -#[allow(clippy::too_many_arguments)] -pub(crate) fn image_input( - ui: &egui::Ui, - resp: &egui::Response, - xf: &PageXform, - ptr: &PagePointer, - page: usize, - info: &DocInfo, - images: &[pdfcraft_engine::PageImage], - view: &mut DocView, - action: &mut Option, - allow_interaction: bool, -) -> bool { - let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); - let painter = ui.painter(); - let hover_pos = ptr.pos; - let image_contains = |index: usize, point: Pos2| { - let (x, y) = xf.screen_to_view(point); - let Some(p) = info.pages.get(page) else { return false }; - let user = p.view_to_user(x, y); - images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) - }; - let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); - let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); - // Few images: faint outlines for all of them, as before. Many: only the selected and - // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. - // Fully clipped placements (empty visible frames) get no outline. - let faint_all = boxes.len() <= MAX_OUTLINES; - // The picture being dragged has its frame at the pointer; its outline where it was would - // trail behind until the release. - let moving = selected.filter(|_| dragging); +/// Which boxes are outlined on the page, and why. +struct OutlineState { + selected: Option, + hovered_box: Option, + /// The picture being dragged, whose outline would trail behind until the release. + moving: Option, + dragging: bool, + /// Few images: faint outlines for all of them. + faint_all: bool, +} + +/// Outlines the images: all of them faintly when there are few, else the selected and hovered +/// ones; the hovered or selected one is emphasised. +fn draw_outlines(painter: &egui::Painter, boxes: &[Rect], state: OutlineState) { + let OutlineState { selected, hovered_box, moving, dragging, faint_all } = state; if faint_all { for (_, b) in boxes.iter().enumerate().filter(|(i, b)| b.is_positive() && moving != Some(*i)) { painter.rect_stroke(*b, CornerRadius::ZERO, Stroke::new(0.75, ACCENT.gamma_multiply(0.35)), egui::StrokeKind::Outside); } } - let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); if !faint_all { for i in selected.filter(|_| moving.is_none()).into_iter().chain(hovered_box.filter(|h| Some(*h) != selected)) { if let Some(b) = boxes.get(i).filter(|b| b.is_positive()) { @@ -332,24 +315,169 @@ pub(crate) fn image_input( painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT.gamma_multiply(0.7)), egui::StrokeKind::Outside); } } +} + +fn draw_handles(painter: &egui::Painter, rect: Rect) { + for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { + painter.rect( + Rect::from_center_size(c, egui::vec2(8.0, 8.0)), + CornerRadius::ZERO, + Color32::WHITE, + Stroke::new(1.0, ACCENT), + egui::StrokeKind::Middle, + ); + } +} + +/// Turning the picture by its rotate handle: the turned outline as a preview (the engine redraws +/// the pixels on release). +fn turn_preview(cx: &ImageCx<'_>, view: &mut DocView, i: usize, b: Rect, knob: Pos2) { + let ImageCx { ui, resp, ptr, page, .. } = *cx; + let painter = ui.painter(); + let hover_pos = ptr.pos; + let shift = ui.input(|i| i.modifiers.shift); + let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); + // The turned outline as a preview; the engine redraws the pixels on release. + let centre = b.center(); + let (sin, cos) = degrees.to_radians().sin_cos(); + let turn = |c: Pos2| { + let v = c - centre; + centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) + }; + let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); + painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); + let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); + let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); + painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); + painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + s.turning = false; + } + if degrees.abs() >= 0.05 { + view.pending_edit = Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); + } + } +} + +/// Where the screen may still show the picture from before an applied move, and whether the +/// page's whole raster is the old one. +#[derive(Clone, Copy)] +struct Blanked { + left_at: Option, + raster_old: bool, +} + +/// A frame of the picture being moved or resized: the stretched picture, its frame and handles, +/// and on release the edit. +fn drag_preview( + cx: &ImageCx<'_>, + images: &[pdfcraft_engine::PageImage], + view: &mut DocView, + (i, b): (usize, Rect), + (start, opposite, p): (Pos2, Option, Pos2), + blanked: Blanked, +) { + let ImageCx { ui, resp, xf, ptr, page, info, .. } = *cx; + let painter = ui.painter(); + let Blanked { left_at, raster_old } = blanked; + let screen = |r: Rect| Rect::from_two_pos(xf.norm_to_screen(r.min.x, r.min.y), xf.norm_to_screen(r.max.x, r.max.y)); + let preview = match opposite { + // Resize from the opposite corner, keeping the aspect ratio. + Some(fixed) => { + let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); + // Use the axis with the larger relative pointer change. Taking the maximum + // absolute size freezes shrinking when the other axis hasn't moved. Measure + // from the press point so grabbing a handle off-centre cannot cause a jump. + let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; + let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; + let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; + let k = (1.0 + delta).max(0.05); + let (w, h) = (w0 * k, h0 * k); + let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; + let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; + Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) + } + None => b.translate(p - start), + }; + // Move/resize the full placement by the visible frame's change. The engine + // transforms an isolated crop together with its image, preserving its contents. + let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); + // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the + // placement's scale instead of producing inf/NaN. + let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; + let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); + let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); + let visible = preview.intersect(xf.rect); + if visible.is_positive() { + // The pixels follow the frame: the page raster's picture of the image, stretched + // into the preview over a blank where it was. Re-rendering every frame of a drag + // would lag; the edit renders it properly on release. + if preview != b + && let Some(tex) = view.page_raster(page) + { + // Its pixels: where the whole-page raster has it. + let source = left_at.filter(|_| raster_old).map_or(b, screen); + let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); + stretched_picture(painter, xf, tex, &blank, source, preview); + } + painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); + painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); + draw_handles(painter, preview); + } + if resp.drag_stopped() || !ptr.primary_down { + if let Some(s) = view.image_selection.as_mut() { + s.drag = None; + let norm = |r: Rect| { + let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); + Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) + }; + s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); + } + if preview != b { + view.pending_edit = + Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); + } + } + ui.ctx().set_cursor_icon(match opposite { + Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, + Some(_) => egui::CursorIcon::ResizeNwSe, + None => egui::CursorIcon::Move, + }); +} + +/// Images on a page: select, move, resize, right-click. Returns `true` when the pointer was used. +pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView, action: &mut Option) -> bool { + let ImageCx { ui, resp, xf, ptr, page, info, allow_interaction } = *cx; + let boxes: Vec = images.iter().map(|im| im.visible_rect.map_or(Rect::NOTHING, |r| xf.user_rect(info, page, r.map(|v| v as f32)))).collect(); + let painter = ui.painter(); + let hover_pos = ptr.pos; + let image_contains = |index: usize, point: Pos2| { + let (x, y) = xf.screen_to_view(point); + let Some(p) = info.pages.get(page) else { return false }; + let user = p.view_to_user(x, y); + images.get(index).is_some_and(|im| im.contains(user.map(f64::from))) + }; + let selected = view.image_selection.as_ref().filter(|s| s.page == page).map(|s| s.index).filter(|i| *i < boxes.len()); + let dragging = view.image_selection.as_ref().is_some_and(|s| s.drag.is_some()); + // Few images: faint outlines for all of them, as before. Many: only the selected and + // hovered boxes, so a ~1000-image CAD page stays interactive in Edit text & images. + // Fully clipped placements (empty visible frames) get no outline. + let faint_all = boxes.len() <= MAX_OUTLINES; + // The picture being dragged has its frame at the pointer; its outline where it was would + // trail behind until the release. + let moving = selected.filter(|_| dragging); + let hovered_box = hover_pos.and_then(|p| boxes.iter().enumerate().rposition(|(i, b)| b.is_positive() && b.contains(p) && image_contains(i, p))); + draw_outlines(painter, &boxes, OutlineState { selected, hovered_box, moving, dragging, faint_all }); // The selected image: frame, corner handles, dragging. if let Some(i) = selected.filter(|i| boxes.get(*i).is_some_and(|b| b.is_positive())) { let b = boxes[i]; if view.image_selection.as_ref().is_some_and(|s| s.crop.is_some()) { - return crop_input(ui, resp, xf, ptr, page, info, view, i, b, allow_interaction); + return crop_input(cx, view, i, b); } let corners = [b.left_top(), b.right_top(), b.left_bottom(), b.right_bottom()]; - let handles = |rect: Rect| { - for c in [rect.left_top(), rect.right_top(), rect.left_bottom(), rect.right_bottom()] { - painter.rect( - Rect::from_center_size(c, egui::vec2(8.0, 8.0)), - CornerRadius::ZERO, - Color32::WHITE, - Stroke::new(1.0, ACCENT), - egui::StrokeKind::Middle, - ); - } - }; // Until the page is rendered again its raster has an applied move or resize's picture // where it was: it stays blanked there and drawn where it went. // The whole-page raster and its tiles are replaced separately: the old frame stays blanked @@ -372,7 +500,7 @@ pub(crate) fn image_input( } if view.image_selection.as_ref().is_some_and(|s| s.drag.is_none()) { painter.rect_stroke(b, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(b); + draw_handles(painter, b); } if allow_interaction && let Some(corner) = hover_pos.and_then(|p| corners.iter().position(|c| c.distance(p) < 8.0)) { ui.ctx().set_cursor_icon(if matches!(corner, 0 | 3) { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); @@ -408,99 +536,13 @@ pub(crate) fn image_input( } } if view.image_selection.as_ref().is_some_and(|s| s.turning) { - let shift = ui.input(|i| i.modifiers.shift); - let degrees = hover_pos.map_or(0.0, |p| turn_degrees(b.center(), p, shift)); - // The turned outline as a preview; the engine redraws the pixels on release. - let centre = b.center(); - let (sin, cos) = degrees.to_radians().sin_cos(); - let turn = |c: Pos2| { - let v = c - centre; - centre + egui::vec2(v.x * cos - v.y * sin, v.x * sin + v.y * cos) - }; - let outline = [b.left_top(), b.right_top(), b.right_bottom(), b.left_bottom()].map(turn).to_vec(); - painter.add(egui::Shape::closed_line(outline, Stroke::new(1.5, ACCENT))); - let galley = painter.layout_no_wrap(format!("{degrees:.0}\u{b0}"), egui::FontId::proportional(12.0), ACCENT); - let label = Rect::from_center_size(turn(knob) - egui::vec2(0.0, 14.0), galley.size() + egui::vec2(8.0, 4.0)); - painter.rect(label, CornerRadius::same(3), Color32::WHITE, Stroke::new(1.0, ACCENT), egui::StrokeKind::Middle); - painter.galley(label.center() - galley.size() / 2.0, galley, ACCENT); - ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - s.turning = false; - } - if degrees.abs() >= 0.05 { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::RotateBy(f64::from(degrees)) }); - } - } + turn_preview(cx, view, i, b, knob); return true; } if let Some((start, opposite)) = view.image_selection.as_ref().and_then(|s| s.drag) && let Some(p) = hover_pos { - let preview = match opposite { - // Resize from the opposite corner, keeping the aspect ratio. - Some(fixed) => { - let (w0, h0) = (b.width().max(1.0), b.height().max(1.0)); - // Use the axis with the larger relative pointer change. Taking the maximum - // absolute size freezes shrinking when the other axis hasn't moved. Measure - // from the press point so grabbing a handle off-centre cannot cause a jump. - let horizontal = (p.x - start.x) * (start.x - fixed.x).signum() / w0; - let vertical = (p.y - start.y) * (start.y - fixed.y).signum() / h0; - let delta = if horizontal.abs() > vertical.abs() { horizontal } else { vertical }; - let k = (1.0 + delta).max(0.05); - let (w, h) = (w0 * k, h0 * k); - let x = if start.x < fixed.x { fixed.x - w } else { fixed.x }; - let y = if start.y < fixed.y { fixed.y - h } else { fixed.y }; - Rect::from_min_size(Pos2::new(x, y), egui::vec2(w, h)) - } - None => b.translate(p - start), - }; - // Move/resize the full placement by the visible frame's change. The engine - // transforms an isolated crop together with its image, preserving its contents. - let raw = images.get(i).map_or(b, |im| xf.user_rect(info, page, im.rect.map(|v| v as f32))); - // A zero-width or zero-height visible frame (or a degenerate ratio) keeps the - // placement's scale instead of producing inf/NaN. - let ratio = |new: f32, old: f32| if old.abs() > 1e-3 && (new / old).is_finite() { new / old } else { 1.0 }; - let factor = egui::vec2(ratio(preview.width(), b.width()), ratio(preview.height(), b.height())); - let target = Rect::from_min_max(preview.min + (raw.min - b.min) * factor, preview.min + (raw.max - b.min) * factor); - let visible = preview.intersect(xf.rect); - if visible.is_positive() { - // The pixels follow the frame: the page raster's picture of the image, stretched - // into the preview over a blank where it was. Re-rendering every frame of a drag - // would lag; the edit renders it properly on release. - if preview != b - && let Some(tex) = view.page_raster(page) - { - // Its pixels: where the whole-page raster has it. - let source = left_at.filter(|_| raster_old).map_or(b, screen); - let blank: Vec = left_at.map(screen).into_iter().chain(std::iter::once(b)).collect(); - stretched_picture(painter, xf, tex, &blank, source, preview); - } - painter.rect_filled(visible, CornerRadius::ZERO, ACCENT.gamma_multiply(0.04)); - painter.rect_stroke(visible, CornerRadius::ZERO, Stroke::new(1.5, ACCENT), egui::StrokeKind::Outside); - handles(preview); - } - if resp.drag_stopped() || !ptr.primary_down { - if let Some(s) = view.image_selection.as_mut() { - s.drag = None; - let norm = |r: Rect| { - let ((u0, v0), (u1, v1)) = (xf.screen_to_norm(r.min), xf.screen_to_norm(r.max)); - Rect::from_two_pos(Pos2::new(u0, v0), Pos2::new(u1, v1)) - }; - s.settled = (preview != b).then(|| (left_at.unwrap_or_else(|| norm(b)), norm(preview))); - } - if preview != b { - view.pending_edit = - Some(Edit::EditPageImage { page, index: i, change: pdfcraft_engine::ImageEdit::Move(user_box(xf, info, page, target)) }); - } - } - ui.ctx().set_cursor_icon(match opposite { - Some(fixed) if (start.x - fixed.x) * (start.y - fixed.y) < 0.0 => egui::CursorIcon::ResizeNeSw, - Some(_) => egui::CursorIcon::ResizeNwSe, - None => egui::CursorIcon::Move, - }); + drag_preview(cx, images, view, (i, b), (start, opposite, p), Blanked { left_at, raster_old }); return true; } if allow_interaction diff --git a/crates/ui-egui/src/tools/edit_text_ui/layout.rs b/crates/ui-egui/src/tools/edit_text_ui/layout.rs index a59c10899..533cfa731 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/layout.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/layout.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeMap, Color32, FontFamily, FontId, LineEditor, Rect}; /// The look shown for a paragraph: the family and weight guessed from its PDF font name. fn source_look(base_font: &str, size: f64, color: [f64; 3], detected_bold: bool, detected_italic: bool) -> pdfcraft_engine::AddedText { diff --git a/crates/ui-egui/src/tools/fill_sign/scan.rs b/crates/ui-egui/src/tools/fill_sign/scan.rs index 2b247f53a..6179a9ab8 100644 --- a/crates/ui-egui/src/tools/fill_sign/scan.rs +++ b/crates/ui-egui/src/tools/fill_sign/scan.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Color32, CornerRadius, DEFAULT_SENSITIVITY, PREVIEW_MAX_SIDE, PhotoRect, Pos2, SCAN_MAX_SIDE, SavedSig, ScanError, Sense, SigDraft, + SignatureImage, SignaturePhoto, Stroke, Tokens, Turn, image_preview, pos2, vec2, +}; /// The Scan tab's photo, the frame around the signature and how strongly faint ink is picked up. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 28494160c..5a5ba3e1a 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -623,6 +623,100 @@ pub(crate) fn text_box(doc: &pdfcraft_engine::Document, view: &DocView, form: &[ no_scroll.then(|| doc.field_text_box(&fx.name, fx.widget)).flatten() } +/// After the text editor is drawn: keeps a field that doesn't scroll within what fits, puts the +/// caret where the press that opened the editor landed, and takes focus when asked. +fn settle_text_editor( + ui: &mut egui::Ui, + fx: &mut Focus, + r: &egui::Response, + laid_out: Option<(std::sync::Arc, Pos2)>, + fitted: Option<(&form_appearance::TextBox, String)>, +) { + if let Some((tb, before)) = fitted + && fx.text != before + { + let (kept, caret) = tb.fitted_edit(&before, &fx.text); + if kept != fx.text { + fx.text = kept; + if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); + st.store(ui.ctx(), r.id); + } + ui.ctx().request_repaint(); + } + } + if let Some(press) = fx.press.take() + && let Some((galley, pos)) = laid_out + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + // The press went to the page, not to this editor, which did not exist yet. + let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); + st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); + st.store(ui.ctx(), r.id); + if ui.input(|i| i.pointer.primary_down()) { + ui.ctx().set_dragged_id(r.id); + } + } + if fx.request_focus { + r.request_focus(); + fx.request_focus = false; + if std::mem::take(&mut fx.select_all) + && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) + { + let n = fx.text.chars().count(); + st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); + st.store(ui.ctx(), r.id); + } + } +} + +/// Tab / Shift+Tab: commits the field and moves to the next fillable widget in the document's tab +/// order, returning the edit to apply (the commit's, or `edit`). +fn tab_to_next_field(view: &mut DocView, form: &[FormField], f: &FormField, widget: usize, forward: bool, edit: Option) -> Option { + commit(view, form); + let pending = view.pending_edit.take(); + // The next field that can be filled in, check boxes and radio buttons included (#2223), in + // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. + let mut order: Vec<(usize, &FormField, usize)> = form + .iter() + .filter(|x| fillable(x)) + .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) + .collect(); + order.sort_by_key(|o| o.0); + if let Some(pos) = + order.iter().position(|(_, x, wi)| x.name == f.name && *wi == widget).or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) + { + let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; + let (_, target, wi) = order[k]; + view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); + if let Some(p) = target.widgets[wi].page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + edit.or(pending) +} + +/// The arrows move to the group's next or previous button and select it, wrapping. +fn step_radio(view: &mut DocView, f: &FormField, widget: usize, forward: bool) -> Option { + let mut edit = None; + let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); + if let Some(pos) = stops.iter().position(|&i| i == widget) { + let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; + if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { + view.forms.focus = Some(open_focus(f, wi)); + edit = select_edit(f, target); + if let Some(p) = target.page + && view.page_screen_rect(p).is_none() + { + view.go_to_page(p); + } + } + } + edit +} + /// The in-place editor or option list for the focused field. Returns an edit to apply. /// `text_box`, for a text field that doesn't scroll, is how its widget lays text out: the editor /// then takes no more than fits. @@ -714,42 +808,7 @@ pub(crate) fn overlay( }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - if let (Some(tb), Some(before)) = (text_box, before) - && fx.text != before - { - let (kept, caret) = tb.fitted_edit(&before, &fx.text); - if kept != fx.text { - fx.text = kept; - if let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) { - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(caret)))); - st.store(ui.ctx(), r.id); - } - ui.ctx().request_repaint(); - } - } - if let Some(press) = fx.press.take() - && let Some((galley, pos)) = laid_out - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - // The press went to the page, not to this editor, which did not exist yet. - let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); - st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); - st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { - ui.ctx().set_dragged_id(r.id); - } - } - if fx.request_focus { - r.request_focus(); - fx.request_focus = false; - if std::mem::take(&mut fx.select_all) - && let Some(mut st) = egui::TextEdit::load_state(ui.ctx(), r.id) - { - let n = fx.text.chars().count(); - st.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n)))); - st.store(ui.ctx(), r.id); - } - } + settle_text_editor(ui, fx, &r, laid_out, text_box.zip(before)); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) @@ -890,20 +949,7 @@ pub(crate) fn overlay( // turns the selected button off. edit = if f.kind == FormFieldKind::Radio { select_edit(f, w) } else { toggle_edit(f, w) }; } else if let Some(forward) = step { - // The arrows move to the group's next or previous button and select it, wrapping. - let stops: Vec = f.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(|(i, _)| i).collect(); - if let Some(pos) = stops.iter().position(|&i| i == focus.widget) { - let k = if forward { (pos + 1) % stops.len() } else { (pos + stops.len() - 1) % stops.len() }; - if let Some((wi, target)) = stops.get(k).and_then(|&wi| Some((wi, f.widgets.get(wi)?))) { - view.forms.focus = Some(open_focus(f, wi)); - edit = select_edit(f, target); - if let Some(p) = target.page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - } + edit = step_radio(view, f, focus.widget, forward); } } _ => close = true, @@ -912,31 +958,7 @@ pub(crate) fn overlay( view.forms.focus = None; } if let Some(forward) = next { - commit(view, form); - let pending = view.pending_edit.take(); - // The next field that can be filled in, check boxes and radio buttons included (#2223), in - // the document's tab order (each page's /Tabs: rows, columns or structure), wrapping. - let mut order: Vec<(usize, &FormField, usize)> = form - .iter() - .filter(|x| fillable(x)) - .flat_map(|x| x.widgets.iter().enumerate().filter(|(_, w)| w.page.is_some() && !w.hidden).map(move |(wi, w)| (w.tab, x, wi))) - .collect(); - order.sort_by_key(|o| o.0); - if let Some(pos) = order - .iter() - .position(|(_, x, wi)| x.name == f.name && *wi == focus.widget) - .or_else(|| order.iter().position(|(_, x, _)| x.name == f.name)) - { - let k = if forward { (pos + 1) % order.len() } else { (pos + order.len() - 1) % order.len() }; - let (_, target, wi) = order[k]; - view.forms.focus = Some(Focus { select_all: true, ..open_focus(target, wi) }); - if let Some(p) = target.widgets[wi].page - && view.page_screen_rect(p).is_none() - { - view.go_to_page(p); - } - } - edit = edit.or(pending); + edit = tab_to_next_field(view, form, f, focus.widget, forward, edit); } edit.or_else(|| view.pending_edit.take()) } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 2e9e600df..c72e039b1 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -166,6 +166,42 @@ impl MeasureView { self.scale = None; } } +/// Snaps `point` to the page's drawn geometry (endpoints, midpoints, intersections, paths), +/// recording in `state` why it couldn't when the content is too complex or unreadable. +fn snap_to_content( + state: &mut MeasureView, + doc: &Document, + page: usize, + view_xy: [f32; 2], + pixels_per_unit: f32, + point: Point, +) -> Option<(Point, measure::snap::SnapKind)> { + if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { + state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); + } + if let Some((_, _, Err(e))) = &state.paths { + state.error = Some(e.clone()); + } + if let Some((_, _, Ok(paths))) = &state.paths { + if paths.truncated { + state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); + } else if paths.unreadable > 0 { + state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); + } + let tolerance = doc + .measurement_to_user(page, [f64::from(view_xy[0]) + state.sensitivity / f64::from(pixels_per_unit), f64::from(view_xy[1])]) + .map(|p| measure::distance(point, p)) + .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); + if state.snaps.intersections && paths.intersection_limited(point, tolerance) { + state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); + } + if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { + return Some((hit.point, hit.kind)); + } + } + None +} + pub(crate) fn page_input( ui: &egui::Ui, resp: &egui::Response, @@ -204,32 +240,13 @@ pub(crate) fn page_input( let (vx, vy) = xf.screen_to_view(pos); let [x, y] = info.view_to_user(vx, vy); let mut point = [f64::from(x), f64::from(y)]; + let mut snapped = None; if state.snap_enabled { - if state.paths.as_ref().is_none_or(|(p, g, _)| *p != page || *g != doc.edit_generation()) { - state.paths = Some((page, doc.edit_generation(), doc.measurement_paths(page))); - } - let pixels_per_unit = xf.zoom().max(1e-6); - if let Some((_, _, Err(e))) = &state.paths { - state.error = Some(e.clone()); - } - if let Some((_, _, Ok(paths))) = &state.paths { - if paths.truncated { - state.error = Some(tl!("This drawing exceeds the snapping limit; only the first paths are used.").into()); - } else if paths.unreadable > 0 { - state.error = Some(tl!("Some page content couldn't be read; snapping uses the rest.").into()); - } - let tolerance = doc - .measurement_to_user(page, [f64::from(vx) + state.sensitivity / f64::from(pixels_per_unit), f64::from(vy)]) - .map(|p| measure::distance(point, p)) - .unwrap_or(state.sensitivity / f64::from(pixels_per_unit)); - if state.snaps.intersections && paths.intersection_limited(point, tolerance) { - state.error = Some(tl!("This area has too many overlapping paths; intersection snapping is limited.").into()); - } - if let Ok(Some(hit)) = paths.snap(point, tolerance, state.snaps) { - point = hit.point; - snap = Some(hit.kind); - } - } + snapped = snap_to_content(state, doc, page, [vx, vy], xf.zoom().max(1e-6), point); + } + if let Some((snapped_point, kind)) = snapped { + point = snapped_point; + snap = Some(kind); } if ui.input(|i| i.modifiers.shift) && let Some(last) = state.points.last() diff --git a/crates/ui-egui/src/tools/prepare/draft.rs b/crates/ui-egui/src/tools/prepare/draft.rs index 5bba8fe18..c6f9bf445 100644 --- a/crates/ui-egui/src/tools/prepare/draft.rs +++ b/crates/ui-egui/src/tools/prepare/draft.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Calculate, FieldLook, FieldProps, FormField, FormFieldKind, Format, Validate}; // ───────────────────────────────────────────────────────────────────────── Field Properties diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index 8099d5748..f922143f6 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -1,6 +1,6 @@ use super::draft::{FieldDraft, FieldTab}; use super::tabs::{actions_tab, calculate_tab, format_tab, validate_tab}; -use super::*; +use super::{BorderStyle, FieldFont, FormFieldKind, theme, widgets}; /// A check box for a `/Ff` bit (`inverted`: checked when the bit is clear). fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: &str) { diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index b74e67dd1..04831353b 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -1,5 +1,5 @@ use super::draft::{ACTION_KINDS, ActionDraft, FieldDraft}; -use super::*; +use super::{CalcOp, Calculate, DATE_PRESETS, Format, TIME_PRESETS, Validate, format_value, theme, widgets}; /// The categories of the Format tab. #[derive(Clone, Copy, PartialEq)] diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index ad5ef4b01..feb4fdd20 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Align, Appearance, AppearanceLabels, Color32, CornerRadius, KEY_ALGORITHMS, Layout, NewIdDraft, PdfCraftApp, Rect, SavedSig, SignGraphic, + SignStep, Stroke, Tokens, icons, sign, theme, vec2, widgets, +}; /// Draw the signing dialogs; returns `true` when the dialog should close. pub(crate) fn dialog(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { diff --git a/crates/ui-egui/src/tools/sign_ui/panel.rs b/crates/ui-egui/src/tools/sign_ui/panel.rs index af5bfd77c..415c035f0 100644 --- a/crates/ui-egui/src/tools/sign_ui/panel.rs +++ b/crates/ui-egui/src/tools/sign_ui/panel.rs @@ -1,5 +1,5 @@ use super::dialog::title; -use super::*; +use super::{Align, Certificate, Color32, Layout, SignatureInfo, SignatureStatus, Tokens, icons, sign, theme, widgets}; /// The message bar for signed documents: (icon, colour, template, argument). /// The template renders through [`crate::i18n::tr_fmt`] at the call site, so the bar follows From 2ca92751cfb87dc9126eb0112278c4f4225dcb21 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 21/33] Refactor ui workflows for strict Clippy checks --- .../src/workflows/combine_grid/card.rs | 217 +++++++++++------- .../src/workflows/combine_grid/thumbs.rs | 2 +- .../src/workflows/combine_ui/actions.rs | 149 +++++++----- .../ui-egui/src/workflows/combine_ui/files.rs | 2 +- .../ui-egui/src/workflows/combine_ui/parts.rs | 2 +- .../src/workflows/combine_ui/session.rs | 2 +- .../ui-egui/src/workflows/combine_ui/table.rs | 5 +- .../ui-egui/src/workflows/combine_ui/view.rs | 2 +- crates/ui-egui/src/workflows/print_ui/body.rs | 5 +- .../ui-egui/src/workflows/print_ui/draft.rs | 2 +- 10 files changed, 239 insertions(+), 149 deletions(-) diff --git a/crates/ui-egui/src/workflows/combine_grid/card.rs b/crates/ui-egui/src/workflows/combine_grid/card.rs index 63cf14c5e..3e98daa54 100644 --- a/crates/ui-egui/src/workflows/combine_grid/card.rs +++ b/crates/ui-egui/src/workflows/combine_grid/card.rs @@ -1,5 +1,8 @@ use super::thumbs::{Key, Look, MAX_SIDE, PREVIEW, PREVIEW_SIDE, SIZE_STEP, Thumbs, Want}; -use super::*; +use super::{ + ABOVE, Align2, Arc, BAR_H, Color32, CombineFile, CornerRadius, ERROR, FALLBACK_PAGE, GridDrag, Lock, MAX_EXPAND, ON_PAPER, PdfCraftApp, Rect, + RowAction, Sense, Stroke, TextureHandle, Tokens, Vec2, WARNING, fit_name, icons, paper_size, pos2, theme, vec2, +}; /// One card's place and what it shows. pub(super) struct Card { @@ -18,6 +21,16 @@ pub(super) struct Card { pub(super) fit: Vec2, } +/// The paper box a card's page is drawn in. +struct Paper { + rect: Rect, + size: Vec2, + /// The page's size in points. + size_pt: (f32, f32), + /// Whether the page's own size is known (else a fallback is drawn and no preview is asked for). + known: bool, +} + impl Card { pub(super) fn id(&self, ui: &egui::Ui, f: &CombineFile) -> egui::Id { match self.page { @@ -81,56 +94,7 @@ impl Card { painter.rect_filled(paper, CornerRadius::ZERO, Color32::WHITE); // What goes on the paper: the thumbnail, or why there isn't one. - let mut why: Option<(&str, Color32, String)> = None; - if let Some(lock) = f.lock { - let text = match lock { - Lock::Open => tl!("Password-protected"), - Lock::Permissions => tl!("Its security settings don't allow copying pages"), - }; - why = Some(("lock", ON_PAPER, text.to_owned())); - } else if let Some(p) = &f.problem { - why = Some(("circle-x", ERROR, p.clone())); - } else { - match page { - Err(e) => why = Some(("circle-x", ERROR, e)), - // The pages couldn't be counted, or the page's size is unknown (some passwords - // open a file for combining but not for reading it here): nothing is drawn (the - // engine decides at Combine), and the card says so rather than look blank. - Ok(p) if p >= f.pages || known.is_none() => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), - Ok(p) => { - // Pixels per point: the size the paper is drawn at, on both sides, and never a - // side longer than a thumbnail's (a long, thin page fits by its long side). - let (w, h) = size_pt; - let scale = (size.x * self.ppp / w).min(size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); - let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); - let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; - let look = thumbs.look((f.group, p), key); - let mut want = false; - match look { - Look::Ready(tex) => { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), - Look::Pending(stand_in) => { - if let Some(tex) = stand_in { - painter.image(tex, paper, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } - want = true; - } - } - if want && scale.is_finite() && scale > 0.0 { - thumbs.wants.push(Want { - slot: (f.group, p), - key, - bytes: f.bytes.clone(), - password: f.password().map(Arc::from), - scale, - visible: ui.is_rect_visible(c), - }); - } - } - } - } + let why = self.paper_content(ui, &painter, f, thumbs, page, Paper { rect: paper, size, size_pt, known: known.is_some() }); if let Some((icon, colour, _)) = &why { icons::paint(ui, Rect::from_center_size(paper.center(), Vec2::splat(28.0)), icon, 28.0, *colour); } @@ -187,26 +151,7 @@ impl Card { let previewable = why.is_none(); // A file that adds more pages than one can be spread out into them. let expandable = self.page.is_none() && takes > 1 && takes <= MAX_EXPAND && f.lock.is_none() && f.problem.is_none() && self.check.is_ok(); - // Shown large: the file from its first page, or a page card's page. - let preview = match self.page { - Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), - None => RowAction::Preview(f.id), - }; - // The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, - // or the page), show large. - let mut buttons: Vec<(&str, String, RowAction)> = Vec::new(); - if expandable { - buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); - } - if let Some((nth, ..)) = self.page { - buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); - buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); - } else { - buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); - } - if previewable { - buttons.push(("search", tl!("Preview").to_owned(), preview)); - } + let buttons = self.card_buttons(f, expandable, previewable); // Nothing of the card shows over a dialog (the preview, the password box). let covered = ui.ctx().memory(|m| m.top_modal_layer().is_some()); let mut over_bar = false; @@ -227,24 +172,7 @@ impl Card { } } - // The full name and everything known about the file, on hover (on a page card, which page). - let mut tip = match &page_label { - Some(page) => format!("{}\n{page}", f.name), - None => format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())), - }; - for line in why.iter().map(|(_, _, s)| s).chain(&f.notes) { - tip.push('\n'); - tip.push_str(line); - } - if self.twice { - tip.push('\n'); - tip.push_str(tl!("Added more than once")); - } - // Page ranges are typed in the list. - if self.page.is_none() && self.check.is_err() { - tip.push('\n'); - tip.push_str(tl!("Switch to List view to change the pages it takes.")); - } + let tip = self.tooltip(f, page_label.as_deref(), &pages, why.as_ref().map(|(_, _, reason)| reason.as_str())); // (Over the bar, its buttons say what they do instead.) let resp = if covered || over_bar { resp } else { resp.on_hover_text(tip) }; @@ -279,6 +207,117 @@ impl Card { } action } + + /// The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, or + /// the page), show large. + fn card_buttons(&self, f: &CombineFile, expandable: bool, previewable: bool) -> Vec<(&'static str, String, RowAction)> { + // Shown large: the file from its first page, or a page card's page. + let preview = match self.page { + Some((nth, ..)) => RowAction::PreviewAt(f.id, nth), + None => RowAction::Preview(f.id), + }; + // The card's own actions, in this order: expand (or, on a page, collapse), remove (the file, + // or the page), show large. + let mut buttons: Vec<(&'static str, String, RowAction)> = Vec::new(); + if expandable { + buttons.push(("maximize-2", tl!("Expand").to_owned(), RowAction::Expand(f.id, true))); + } + if let Some((nth, ..)) = self.page { + buttons.push(("minimize-2", tl!("Collapse").to_owned(), RowAction::Expand(f.id, false))); + buttons.push(("trash-2", tl!("Remove page").to_owned(), RowAction::RemovePages(Some((f.id, nth))))); + } else { + buttons.push(("trash-2", crate::i18n::fmt(tl!("Remove {name}"), &[("name", &f.name)]), RowAction::RemoveFile(f.id))); + } + if previewable { + buttons.push(("search", tl!("Preview").to_owned(), preview)); + } + buttons + } + + /// The full name and everything known about the file, on hover (on a page card, which page). + fn tooltip(&self, f: &CombineFile, page_label: Option<&str>, pages: &str, why: Option<&str>) -> String { + let mut tip = match page_label { + Some(page) => format!("{}\n{page}", f.name), + None => format!("{}\n{pages} · {}", f.name, crate::panels::human_size(f.bytes.len())), + }; + for line in why.into_iter().chain(f.notes.iter().map(String::as_str)) { + tip.push('\n'); + tip.push_str(line); + } + if self.twice { + tip.push('\n'); + tip.push_str(tl!("Added more than once")); + } + // Page ranges are typed in the list. + if self.page.is_none() && self.check.is_err() { + tip.push('\n'); + tip.push_str(tl!("Switch to List view to change the pages it takes.")); + } + tip + } + + /// What goes on the paper: the thumbnail, or why there isn't one (an icon, its colour and the reason). + fn paper_content( + &self, + ui: &egui::Ui, + painter: &egui::Painter, + f: &CombineFile, + thumbs: &mut Thumbs, + page: Result, + paper: Paper, + ) -> Option<(&'static str, Color32, String)> { + let mut why: Option<(&'static str, Color32, String)> = None; + if let Some(lock) = f.lock { + let text = match lock { + Lock::Open => tl!("Password-protected"), + Lock::Permissions => tl!("Its security settings don't allow copying pages"), + }; + why = Some(("lock", ON_PAPER, text.to_owned())); + } else if let Some(p) = &f.problem { + why = Some(("circle-x", ERROR, p.clone())); + } else { + match page { + Err(e) => why = Some(("circle-x", ERROR, e)), + // The pages couldn't be counted, or the page's size is unknown (some passwords + // open a file for combining but not for reading it here): nothing is drawn (the + // engine decides at Combine), and the card says so rather than look blank. + Ok(p) if p >= f.pages || !paper.known => why = Some(("eye-off", ON_PAPER, tl!("No preview").to_owned())), + Ok(p) => { + // Pixels per point: the size the paper is drawn at, on both sides, and never a + // side longer than a thumbnail's (a long, thin page fits by its long side). + let (w, h) = paper.size_pt; + let scale = (paper.size.x * self.ppp / w).min(paper.size.y * self.ppp / h).min(MAX_SIDE / w.max(h)); + let px = [w * scale, h * scale].map(|v| (v.max(1.0).ceil() as u32).div_ceil(SIZE_STEP).saturating_mul(SIZE_STEP)); + let key = Key { source: Arc::as_ptr(&f.bytes) as usize, auth: f.auth, page: p, px }; + let look = thumbs.look((f.group, p), key); + let mut want = false; + match look { + Look::Ready(tex) => { + painter.image(tex, paper.rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + Look::Failed(e) => why = Some(("triangle-alert", WARNING, crate::i18n::fmt(tl!("Preview unavailable: {e}"), &[("e", &e)]))), + Look::Pending(stand_in) => { + if let Some(tex) = stand_in { + painter.image(tex, paper.rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + want = true; + } + } + if want && scale.is_finite() && scale > 0.0 { + thumbs.wants.push(Want { + slot: (f.group, p), + key, + bytes: f.bytes.clone(), + password: f.password().map(Arc::from), + scale, + visible: ui.is_rect_visible(self.rect), + }); + } + } + } + } + why + } } /// The file a card's magnifier opened, shown large over the page: one of the pages it adds at a diff --git a/crates/ui-egui/src/workflows/combine_grid/thumbs.rs b/crates/ui-egui/src/workflows/combine_grid/thumbs.rs index 5494cdc00..28c54b4be 100644 --- a/crates/ui-egui/src/workflows/combine_grid/thumbs.rs +++ b/crates/ui-egui/src/workflows/combine_grid/thumbs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, HashMap, RenderConfig, RenderPool, RenderRequest, Retiring, TextureHandle}; /// Render threads for thumbnails at once, counting those of retired pools until they exit. pub(super) const MAX_THREADS: usize = 3; diff --git a/crates/ui-egui/src/workflows/combine_ui/actions.rs b/crates/ui-egui/src/workflows/combine_ui/actions.rs index 1eee3f8d3..a4f80abdc 100644 --- a/crates/ui-egui/src/workflows/combine_ui/actions.rs +++ b/crates/ui-egui/src/workflows/combine_ui/actions.rs @@ -1,7 +1,7 @@ use super::files::{Preview, RowAction}; use super::parts::{moved_pages, removed_pages, step_to}; use super::view::expandable; -use super::*; +use super::{BTreeSet, PdfCraftApp}; impl PdfCraftApp { pub(crate) fn combine_apply(&mut self, action: RowAction) { @@ -29,11 +29,38 @@ impl PdfCraftApp { a => a, }; // Page edits work on the list as parts. + let Some(action) = self.combine_page_action(action) else { return }; + // A file's card clicked (or the list's keys): the files are selected, no page. + if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) { + self.combine_tab.selected_pages.clear(); + } match action { - RowAction::ClickPage(id, nth, m) => return self.combine_click_page(id, nth, m), + RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. } | RowAction::Unlock(..) => self.combine_select_action(action), + RowAction::MoveUp + | RowAction::MoveDown + | RowAction::Remove + | RowAction::RemoveFile(_) + | RowAction::Drop { .. } + | RowAction::DropAt { .. } + | RowAction::Sort(_) => { + self.combine_order_action(action); + } + // (Page edits were done above.) + RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {} + _ => self.combine_preview_action(action), + } + } + + /// Page edits work on the list as parts. Returns the action back when it is not one. + fn combine_page_action(&mut self, action: RowAction) -> Option { + match action { + RowAction::ClickPage(id, nth, m) => { + self.combine_click_page(id, nth, m); + return None; + } RowAction::MovePages { page, revision, gap } => { if revision != self.combine_tab.revision { - return; + return None; } let moving = if self.combine_tab.selected_pages.contains(&page) { self.combine_tab.selected_pages.clone() } else { BTreeSet::from([page]) }; @@ -47,7 +74,7 @@ impl PdfCraftApp { if let Some(moved) = moved_pages(&parts, &moving, gap, &mut new_id) { self.combine_set_parts(moved); } - return; + return None; } RowAction::RemovePages(one) => { let removing = match one { @@ -55,7 +82,7 @@ impl PdfCraftApp { None => self.combine_tab.selected_pages.clone(), }; if removing.is_empty() { - return; + return None; } let parts = self.combine_parts(); let next = &mut self.combine_tab.next_id; @@ -68,14 +95,15 @@ impl PdfCraftApp { if let Some(left) = removed_pages(&parts, &removing, &mut new_id) { self.combine_set_parts(left); } - return; + return None; } _ => {} } - // A file's card clicked (or the list's keys): the files are selected, no page. - if matches!(action, RowAction::Click(..) | RowAction::SelectAll | RowAction::Step { .. }) { - self.combine_tab.selected_pages.clear(); - } + Some(action) + } + + /// Selecting files, and asking for the passwords of locked ones. + fn combine_select_action(&mut self, action: RowAction) { let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); let n = ids.len(); let index_of = |id: Option| id.and_then(|id| ids.iter().position(|x| *x == id)); @@ -129,6 +157,31 @@ impl PdfCraftApp { tab.cursor = Some(id); tab.reveal = Some(id); } + RowAction::Unlock(row, at) => { + let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); + let rows: Vec = match row.and_then(|i| ids.get(i).copied()) { + // A selected row brings the rest of the selection. + Some(id) if tab.selected.contains(&id) => { + ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect() + } + Some(id) => { + tab.selected = BTreeSet::from([id]); + (tab.anchor, tab.cursor) = (Some(id), Some(id)); + vec![id] + } + None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(), + }; + self.combine_ask_password(rows, at); + } + _ => {} + } + } + + /// Moving, removing, dropping and sorting files. + fn combine_order_action(&mut self, action: RowAction) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let tab = &mut self.combine_tab; + match action { RowAction::MoveUp | RowAction::MoveDown => { let up = matches!(action, RowAction::MoveUp); let selected: Vec = ids.iter().map(|id| tab.selected.contains(id)).collect(); @@ -181,6 +234,39 @@ impl PdfCraftApp { self.combine_tab.cursor = None; } } + RowAction::Drop { from, to } => { + let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; + // Dragging a selected file takes the whole selection along. + let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; + // Dropped on the selection itself: nothing to do. + if moving.contains(&target) { + return; + } + // Below the row when dragged down, above it when dragged up. + self.combine_move_to_gap(moving, if from < to { to + 1 } else { to }); + } + RowAction::DropAt { file, revision, gap } => { + // The list changed under the drag (e.g. undo): the gap no longer means the same. + if revision != tab.revision || !ids.contains(&file) { + return; + } + let moving: BTreeSet = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) }; + self.combine_move_to_gap(moving, gap); + } + RowAction::Sort(key) => { + let ascending = !matches!(tab.sort, Some((k, true)) if k == key); + self.combine_record(); + self.combine_sort(key, ascending); + } + _ => {} + } + } + + /// Previewing and expanding files. + fn combine_preview_action(&mut self, action: RowAction) { + let ids: Vec = self.combine_draft.iter().map(|f| f.id).collect(); + let tab = &mut self.combine_tab; + match action { RowAction::Preview(id) => { if ids.contains(&id) { self.combine_tab.preview = Some(Preview { file: id, at: 0, page: None, pages: None }); @@ -228,48 +314,7 @@ impl PdfCraftApp { self.combine_tab.preview = Some(Preview { file: id, at, page: None, pages: None }); } } - // (Page edits were done above.) - RowAction::ClickPage(..) | RowAction::MovePages { .. } | RowAction::RemovePages(_) => {} - RowAction::Drop { from, to } => { - let (Some(&dragged), Some(&target)) = (ids.get(from), ids.get(to)) else { return }; - // Dragging a selected file takes the whole selection along. - let moving: BTreeSet = if tab.selected.contains(&dragged) { tab.selected.clone() } else { BTreeSet::from([dragged]) }; - // Dropped on the selection itself: nothing to do. - if moving.contains(&target) { - return; - } - // Below the row when dragged down, above it when dragged up. - self.combine_move_to_gap(moving, if from < to { to + 1 } else { to }); - } - RowAction::DropAt { file, revision, gap } => { - // The list changed under the drag (e.g. undo): the gap no longer means the same. - if revision != tab.revision || !ids.contains(&file) { - return; - } - let moving: BTreeSet = if tab.selected.contains(&file) { tab.selected.clone() } else { BTreeSet::from([file]) }; - self.combine_move_to_gap(moving, gap); - } - RowAction::Unlock(row, at) => { - let locked: BTreeSet = self.combine_draft.iter().filter(|f| f.lock.is_some()).map(|f| f.id).collect(); - let rows: Vec = match row.and_then(|i| ids.get(i).copied()) { - // A selected row brings the rest of the selection. - Some(id) if tab.selected.contains(&id) => { - ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect() - } - Some(id) => { - tab.selected = BTreeSet::from([id]); - (tab.anchor, tab.cursor) = (Some(id), Some(id)); - vec![id] - } - None => ids.iter().copied().filter(|x| tab.selected.contains(x) && locked.contains(x)).collect(), - }; - self.combine_ask_password(rows, at); - } - RowAction::Sort(key) => { - let ascending = !matches!(tab.sort, Some((k, true)) if k == key); - self.combine_record(); - self.combine_sort(key, ascending); - } + _ => {} } } } diff --git a/crates/ui-egui/src/workflows/combine_ui/files.rs b/crates/ui-egui/src/workflows/combine_ui/files.rs index fdfa41dea..4947e4f8b 100644 --- a/crates/ui-egui/src/workflows/combine_ui/files.rs +++ b/crates/ui-egui/src/workflows/combine_ui/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, BTreeSet, Modifiers, SourceProblem, SystemTime}; /// A column of the list: what it shows and what it sorts by. #[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] diff --git a/crates/ui-egui/src/workflows/combine_ui/parts.rs b/crates/ui-egui/src/workflows/combine_ui/parts.rs index 74c668382..7de66b074 100644 --- a/crates/ui-egui/src/workflows/combine_ui/parts.rs +++ b/crates/ui-egui/src/workflows/combine_ui/parts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{BTreeSet, MAX_FOLDER_DEPTH, MAX_FOLDER_FILES, Ordering, SystemTime}; /// The PDFs, images and text files in `dir` (and, if `recursive`, the folders inside it, not /// following links), in natural order, at most [`MAX_FOLDER_FILES`]; and whether there were more. diff --git a/crates/ui-egui/src/workflows/combine_ui/session.rs b/crates/ui-egui/src/workflows/combine_ui/session.rs index c18b64e0e..ad8d2a402 100644 --- a/crates/ui-egui/src/workflows/combine_ui/session.rs +++ b/crates/ui-egui/src/workflows/combine_ui/session.rs @@ -2,7 +2,7 @@ use super::files::{AssessError, Assessed, CombineFile, Incoming, Secret, Snapsho #[cfg(not(target_arch = "wasm32"))] use super::parts::combinable_in; use super::parts::{Part, items_of, moved_order, natural, parts_of, range_of, title_of}; -use super::*; +use super::{Arc, BTreeSet, CombineTab, HISTORY, MAX_FOLDER_FILES, Modifiers, PdfCraftApp}; impl PdfCraftApp { /// Show the Combine files tab (File ▸ Combine files…), opening it if needed. diff --git a/crates/ui-egui/src/workflows/combine_ui/table.rs b/crates/ui-egui/src/workflows/combine_ui/table.rs index 3a95e8706..86015db39 100644 --- a/crates/ui-egui/src/workflows/combine_ui/table.rs +++ b/crates/ui-egui/src/workflows/combine_ui/table.rs @@ -1,6 +1,9 @@ use super::files::{Columns, Lock, RowAction, SortKey}; use super::parts::{ago, utc_stamp}; -use super::*; +use super::{ + Align, Color32, Column, CornerRadius, ERROR, Layout, Modifiers, PdfCraftApp, Rect, Sense, Stroke, TableBuilder, Tokens, WARNING, icons, theme, + vec2, +}; /// A column heading being dragged to a new place. #[derive(Clone, Copy)] diff --git a/crates/ui-egui/src/workflows/combine_ui/view.rs b/crates/ui-egui/src/workflows/combine_ui/view.rs index 1e2033230..4e1d0c610 100644 --- a/crates/ui-egui/src/workflows/combine_ui/view.rs +++ b/crates/ui-egui/src/workflows/combine_ui/view.rs @@ -1,6 +1,6 @@ use super::files::{CombineFile, CombineView, Lock, RowAction}; use super::table::{TableEvents, table}; -use super::*; +use super::{Align, BTreeSet, CornerRadius, ERROR, Layout, Modifiers, PdfCraftApp, Rect, Stroke, Tokens, icons, theme, vec2, widgets}; /// The Combine files page. pub(crate) fn page(app: &mut PdfCraftApp, ui: &mut egui::Ui) { diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index 524382039..d5cdbf16e 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -1,5 +1,8 @@ use super::draft::{Handling, PrintDraft, Which}; -use super::*; +use super::{ + Binding, BookletSubset, Color32, Content, Orientation, PAPERS, PREVIEW_BOX, PREVIEW_H, PREVIEW_W, PageOrder, Pos2, Rect, SizeMode, Stroke, + Subset, Tokens, pos2, print, printer_label, spool, still_looking, theme, vec2, widgets, +}; /// Properties… on CUPS: the driver's options for this print, grouped as the driver groups them. /// Only choices away from the printer's defaults are kept, and sent with the job. diff --git a/crates/ui-egui/src/workflows/print_ui/draft.rs b/crates/ui-egui/src/workflows/print_ui/draft.rs index 1609262ab..be2a22efb 100644 --- a/crates/ui-egui/src/workflows/print_ui/draft.rs +++ b/crates/ui-egui/src/workflows/print_ui/draft.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Binding, BookletSubset, Content, Layout, Orientation, PAPERS, PageOrder, SizeMode, Subset, print, spool}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Which { From 9530dbb3e804992e4d57407dd60b4db08b03ff61 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 22/33] Refactor xfa for strict Clippy checks --- crates/xfa/src/data/build.rs | 2 +- crates/xfa/src/data/dates.rs | 2 +- crates/xfa/src/data/import.rs | 2 +- crates/xfa/src/data/merge.rs | 2 +- crates/xfa/src/data/passwords.rs | 2 +- crates/xfa/src/data/path.rs | 2 +- crates/xfa/src/data/values.rs | 2 +- crates/xfa/src/data/write.rs | 2 +- crates/xfa/src/layout/common.rs | 2 +- crates/xfa/src/layout/draw.rs | 5 +- crates/xfa/src/layout/field.rs | 5 +- crates/xfa/src/layout/flow.rs | 2 +- crates/xfa/src/layout/items.rs | 2 +- crates/xfa/src/layout/measure.rs | 5 +- crates/xfa/src/layout/pages.rs | 7 +- crates/xfa/src/pdf.rs | 128 +++++++++++++++++-------------- crates/xfa/src/pdf/common.rs | 2 +- crates/xfa/src/pdf/strip.rs | 2 +- crates/xfa/src/tests.rs | 16 ++++ 19 files changed, 118 insertions(+), 74 deletions(-) diff --git a/crates/xfa/src/data/build.rs b/crates/xfa/src/data/build.rs index 68e8a2a36..98e34f0f4 100644 --- a/crates/xfa/src/data/build.rs +++ b/crates/xfa/src/data/build.rs @@ -1,5 +1,5 @@ use super::values::{FieldDatum, Want, wanted, with_template}; -use super::*; +use super::{Document, HashMap, MAX_DEPTH, XFA_DATA_NS}; pub(super) fn xml_name(s: &str) -> String { let mut out: String = s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' { c } else { '_' }).collect(); diff --git a/crates/xfa/src/data/dates.rs b/crates/xfa/src/data/dates.rs index 029d91b27..565c44117 100644 --- a/crates/xfa/src/data/dates.rs +++ b/crates/xfa/src/data/dates.rs @@ -1,5 +1,5 @@ use super::path::template_field; -use super::*; +use super::{Field, Template}; /// The date letters (`yyyy-mm-dd`, `yyyymmdd`) of an XFA date picture clause such as /// `date{YYYY-MM-DD}`, or `None` when it has none this reads (empty, or with symbols beyond diff --git a/crates/xfa/src/data/import.rs b/crates/xfa/src/data/import.rs index cec30b11b..7582d8676 100644 --- a/crates/xfa/src/data/import.rs +++ b/crates/xfa/src/data/import.rs @@ -2,7 +2,7 @@ use super::build::{escape, fresh_datasets, xml_name}; use super::merge::{Op, WRAP_CLOSE, WRAP_OPEN, qname, start_tag_end}; use super::values::Want; use super::write::{DatasetsWrite, rewrite}; -use super::*; +use super::{DataPath, Document, MAX_DEPTH, MAX_NODES, ObjRef, Object, XFA_DATA_NS, XfaError}; /// Data read from a file, held as a tree so it can be written into any packet with the /// prefix that packet uses, never as the file's own markup. diff --git a/crates/xfa/src/data/merge.rs b/crates/xfa/src/data/merge.rs index 6888b1439..48b4cedab 100644 --- a/crates/xfa/src/data/merge.rs +++ b/crates/xfa/src/data/merge.rs @@ -2,7 +2,7 @@ use super::build::{Build, CONFLICT, CONTROL, NewNodes, STRUCTURED, TOO_MANY, bui use super::import::{ImportNode, serialize_nodes}; use super::values::Want; use super::write::MAX_PACKET_WRITE; -use super::*; +use super::{DataPath, HashMap, MAX_NODES, XFA_DATA_NS}; /// What a rewrite of the datasets packet does. #[derive(Clone, Copy)] diff --git a/crates/xfa/src/data/passwords.rs b/crates/xfa/src/data/passwords.rs index 78503bb53..0f98a3f2b 100644 --- a/crates/xfa/src/data/passwords.rs +++ b/crates/xfa/src/data/passwords.rs @@ -3,7 +3,7 @@ use super::merge::Op; use super::path::{data_path, parse_datasets}; use super::values::{Want, path_of, text_of, with_template}; use super::write::{datasets_stream_ref, rewrite}; -use super::*; +use super::{DataNode, DataPath, Document, MAX_DEPTH, Node, ObjRef, Object, SomIndices, Template, Ui, XfaError}; /// The SOM path and default value of every AcroForm text field with the Password flag (bit 14, /// `1 << 13`) in `doc`, for static XFA forms whose password fields set the flag in the AcroForm diff --git a/crates/xfa/src/data/path.rs b/crates/xfa/src/data/path.rs index 90cc4346f..ef61cdbd5 100644 --- a/crates/xfa/src/data/path.rs +++ b/crates/xfa/src/data/path.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DataNode, DataPath, Field, MAX_DEPTH, MAX_NODES, Node, SomIndices, Template}; fn to_node(n: roxmltree::Node, depth: usize) -> DataNode { let name = n.tag_name().name().to_string(); diff --git a/crates/xfa/src/data/values.rs b/crates/xfa/src/data/values.rs index 41fb6644e..8042e774f 100644 --- a/crates/xfa/src/data/values.rs +++ b/crates/xfa/src/data/values.rs @@ -1,6 +1,6 @@ use super::dates::{data_date_pattern, data_to_iso, iso_to_data, iso_to_pattern, pattern_to_iso}; use super::path::{data_path, parse_datasets, som_to_path}; -use super::*; +use super::{DataPath, Dict, Document, ObjRef, Object, Template}; /// What a field holds, for writing the datasets. #[derive(Clone, Debug, PartialEq)] diff --git a/crates/xfa/src/data/write.rs b/crates/xfa/src/data/write.rs index 282dd33ef..4667036fe 100644 --- a/crates/xfa/src/data/write.rs +++ b/crates/xfa/src/data/write.rs @@ -2,7 +2,7 @@ use super::build::{MAX_INDEX, build_children, fresh_datasets}; use super::merge::{Op, Within, merge_all, readable}; use super::path::path_som; use super::values::{FieldDatum, Want, wanted, with_template}; -use super::*; +use super::{DataOp, DataPath, Dict, Document, Encoding, HashMap, ObjRef, Object, PdfString, Stream, Template, XfaError, encode}; /// What [`write_datasets`] did. #[derive(Clone, Debug, Default, PartialEq)] diff --git a/crates/xfa/src/layout/common.rs b/crates/xfa/src/layout/common.rs index 3b60f8a6a..625f305a6 100644 --- a/crates/xfa/src/layout/common.rs +++ b/crates/xfa/src/layout/common.rs @@ -1,5 +1,5 @@ use super::items::{Action, Item, Rounded}; -use super::*; +use super::{HashMap, Layout, MAX_DEPTH, Node, Rect, Script, date_letters, iso_to_pattern}; pub(super) fn collect_values(nodes: &[Node], out: &mut HashMap, depth: usize) { if depth > MAX_DEPTH { diff --git a/crates/xfa/src/layout/draw.rs b/crates/xfa/src/layout/draw.rs index 4f318a73d..f6520c162 100644 --- a/crates/xfa/src/layout/draw.rs +++ b/crates/xfa/src/layout/draw.rs @@ -1,6 +1,9 @@ use super::common::clean_name; use super::items::{Item, Rounded}; -use super::*; +use super::{ + Arc, Border, Ctx, Draw, ExclGroup, Face, Font, Layout, Layouter, MAX_DEPTH, MAX_IMAGE_BYTES, Node, Para, Presence, Rect, SomIndices, Stroke, + Subform, Value, XfaError, data_path, text, +}; impl Layouter<'_> { // ───────────────────────────────────────────────────────────────────────── drawing diff --git a/crates/xfa/src/layout/field.rs b/crates/xfa/src/layout/field.rs index 40ec1497d..cc15f1678 100644 --- a/crates/xfa/src/layout/field.rs +++ b/crates/xfa/src/layout/field.rs @@ -1,6 +1,9 @@ use super::common::{button_action, date_pattern}; use super::items::{BorderShape, Item, Widget, WidgetBorder, WidgetKind}; -use super::*; +use super::{ + Access, Border, Ctx, Engine, Face, Field, HAlign, Layouter, MAX_DATA_IMAGE, Margin, Para, Placement, Presence, Rect, Ui, VAlign, Value, XfaError, + data_date_letters, data_path, data_to_iso, is_on, iso_to_pattern, text, +}; impl Layouter<'_> { /// Draw field `f` at `rect`: its border (a button's is its widget's box), its caption, then its diff --git a/crates/xfa/src/layout/flow.rs b/crates/xfa/src/layout/flow.rs index 48c57cd88..8f656d7ea 100644 --- a/crates/xfa/src/layout/flow.rs +++ b/crates/xfa/src/layout/flow.rs @@ -1,6 +1,6 @@ use super::common::{clamp_opt, divide, is_row, moved, solid_extent, splittable}; use super::items::Item; -use super::*; +use super::{Ctx, Frame, Layout, Layouter, MAX_DEPTH, Node, Rect, SomIndices, Subform, XfaError, data_path, instance_count}; impl Layouter<'_> { /// Place `node` as one block of size (`w`, `h`) at the cursor: in this content area when it fits, diff --git a/crates/xfa/src/layout/items.rs b/crates/xfa/src/layout/items.rs index 51383792c..8a6838868 100644 --- a/crates/xfa/src/layout/items.rs +++ b/crates/xfa/src/layout/items.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Face, HAlign, Rect, Span}; /// How far below its baseline an underline is drawn, in the text's size: an underline is the /// level line pushed just before its span, this far below it (the PDF writer tells it apart from diff --git a/crates/xfa/src/layout/measure.rs b/crates/xfa/src/layout/measure.rs index cd004df98..2fffb3dfa 100644 --- a/crates/xfa/src/layout/measure.rs +++ b/crates/xfa/src/layout/measure.rs @@ -1,6 +1,9 @@ use super::common::clamp_opt; use super::field::auto_check_reserve; -use super::*; +use super::{ + Access, Area, Ctx, ExclGroup, Face, Field, Font, Layout, Layouter, MAX_DEPTH, MAX_MEASURES, Measured, Node, Para, Placement, Rect, Subform, Ui, + Value, data_path, text, +}; impl Layouter<'_> { /// The width and height `node` takes in `avail_w`: its own size, else what its content needs diff --git a/crates/xfa/src/layout/pages.rs b/crates/xfa/src/layout/pages.rs index 13047058b..dac17c116 100644 --- a/crates/xfa/src/layout/pages.rs +++ b/crates/xfa/src/layout/pages.rs @@ -1,5 +1,10 @@ +#[cfg(test)] +use super::EMBED_COPIED; use super::items::{Item, Page, UNDERLINE_DROP}; -use super::*; +use super::{ + Block, Border, Ctx, Cursor, FlowMark, Font, Frame, Layouter, MAX_ITEMS, MAX_MEASURES, MAX_PAGES, PAGE_COUNT_MARK, PageRole, Para, Rc, Rect, + SomIndices, XfaError, text, +}; impl Layouter<'_> { pub(super) fn warn_once(&mut self, msg: impl Into) { diff --git a/crates/xfa/src/pdf.rs b/crates/xfa/src/pdf.rs index ab5d94bb4..0c21507ab 100644 --- a/crates/xfa/src/pdf.rs +++ b/crates/xfa/src/pdf.rs @@ -163,38 +163,8 @@ impl Emitter<'_> { format!("/{} {} Tf {} rg", face.resource_name(), n(face.size), rgb(face.color)).into_bytes() } - /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with - /// its border, background and kind, and return the annotation's object. - fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { + fn populate_widget_kind(&mut self, w: &Widget, d: &mut Dict, mk: &mut Dict) -> (i64, bool, Option) { let r = w.rect; - let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); - let mut d = Dict::new(); - d.set(b"Type".to_vec(), Object::name("Annot")); - d.set(b"Subtype".to_vec(), Object::name("Widget")); - d.set(b"F".to_vec(), Object::Int(4)); - d.set(b"P".to_vec(), Object::Ref(page_ref)); - d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect())); - d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face)))); - if let Some(t) = &w.tooltip { - d.set(b"TU".to_vec(), Object::String(PdfString::text(t))); - } - d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som))); - let mut mk = Dict::new(); - if let Some(c) = w.border.color - && w.border.shape != BorderShape::None - { - mk.set(b"BC".to_vec(), rgb_array(c)); - } - if let Some(c) = w.border.fill { - mk.set(b"BG".to_vec(), rgb_array(c)); - } - let mut bs = Dict::new(); - bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); - bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); - d.set(b"BS".to_vec(), Object::Dict(bs)); - if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { - d.set(ROUND_KEY.to_vec(), Object::Bool(true)); - } let mut ff: i64 = 0; if w.read_only { ff |= 1; @@ -383,34 +353,45 @@ impl Emitter<'_> { d.set(b"FT".to_vec(), Object::name("Btn")); ff |= 1 << 16; mk.set(b"CA".to_vec(), Object::String(PdfString::text(caption))); - if let Some(Action::Script(_)) = &w.action { - // An XFA click script is no Acrobat JavaScript: no PDF action, which other - // viewers would run; PdfCraft finds the script by the field's SOM path. - d.set(CLICK_KEY.to_vec(), Object::Bool(true)); - } else if let Some(a) = &w.action { - let mut ad = Dict::new(); - match a { - Action::Reset => { - ad.set(b"S".to_vec(), Object::name("ResetForm")); - } - Action::Print => { - ad.set(b"S".to_vec(), Object::name("Named")); - ad.set(b"N".to_vec(), Object::name("Print")); - } - Action::SaveAs => { - ad.set(b"S".to_vec(), Object::name("Named")); - ad.set(b"N".to_vec(), Object::name("SaveAs")); - } - Action::Url(u) => { - ad.set(b"S".to_vec(), Object::name("URI")); - ad.set(b"URI".to_vec(), Object::String(PdfString::literal(u.as_bytes().to_vec()))); - } - Action::Script(_) => {} - } - d.set(b"A".to_vec(), Object::Dict(ad)); - } + write_button_action(w.action.as_ref(), d); } } + (ff, top_level, under) + } + + /// Write laid-out widget `w` on `page` as an AcroForm widget annotation (and its field), with + /// its border, background and kind, and return the annotation's object. + fn widget(&mut self, w: &Widget, page: &Page, page_ref: ObjRef) -> Result, XfaError> { + let r = w.rect; + let (x0, y0, x1, y1) = (r.x, page.height - r.bottom(), r.right(), page.height - r.y); + let mut d = Dict::new(); + d.set(b"Type".to_vec(), Object::name("Annot")); + d.set(b"Subtype".to_vec(), Object::name("Widget")); + d.set(b"F".to_vec(), Object::Int(4)); + d.set(b"P".to_vec(), Object::Ref(page_ref)); + d.set(b"Rect".to_vec(), Object::Array([x0, y0, x1, y1].iter().map(|v| Object::Real(*v)).collect())); + d.set(b"DA".to_vec(), Object::String(PdfString::literal(Self::da(&w.face)))); + if let Some(t) = &w.tooltip { + d.set(b"TU".to_vec(), Object::String(PdfString::text(t))); + } + d.set(SOM_KEY.to_vec(), Object::String(PdfString::text(&w.som))); + let mut mk = Dict::new(); + if let Some(c) = w.border.color + && w.border.shape != BorderShape::None + { + mk.set(b"BC".to_vec(), rgb_array(c)); + } + if let Some(c) = w.border.fill { + mk.set(b"BG".to_vec(), rgb_array(c)); + } + let mut bs = Dict::new(); + bs.set(b"W".to_vec(), Object::Real(if w.border.shape == BorderShape::None { 0.0 } else { w.border.width.clamp(0.1, 12.0) })); + bs.set(b"S".to_vec(), Object::name(if w.border.shape == BorderShape::Underline { "U" } else { "S" })); + d.set(b"BS".to_vec(), Object::Dict(bs)); + if matches!(w.kind, WidgetKind::CheckBox { round: true, .. } | WidgetKind::Radio { round: true, .. }) { + d.set(ROUND_KEY.to_vec(), Object::Bool(true)); + } + let (ff, top_level, under) = self.populate_widget_kind(w, &mut d, &mut mk); if !mk.is_empty() { d.set(b"MK".to_vec(), Object::Dict(mk)); } @@ -751,6 +732,39 @@ impl Emitter<'_> { } } +fn write_button_action(action: Option<&Action>, d: &mut Dict) { + match action { + Some(Action::Script(_)) => { + // An XFA click script is no Acrobat JavaScript: no PDF action, which other + // viewers would run; PdfCraft finds the script by the field's SOM path. + d.set(CLICK_KEY.to_vec(), Object::Bool(true)); + } + Some(action) => { + let mut ad = Dict::new(); + match action { + Action::Reset => { + ad.set(b"S".to_vec(), Object::name("ResetForm")); + } + Action::Print => { + ad.set(b"S".to_vec(), Object::name("Named")); + ad.set(b"N".to_vec(), Object::name("Print")); + } + Action::SaveAs => { + ad.set(b"S".to_vec(), Object::name("Named")); + ad.set(b"N".to_vec(), Object::name("SaveAs")); + } + Action::Url(url) => { + ad.set(b"S".to_vec(), Object::name("URI")); + ad.set(b"URI".to_vec(), Object::String(PdfString::literal(url.as_bytes().to_vec()))); + } + Action::Script(_) => {} + } + d.set(b"A".to_vec(), Object::Dict(ad)); + } + None => {} + } +} + /// The link annotation a page's text last ended in: the next piece of the same link on the same /// line widens it. struct LinkPiece { diff --git a/crates/xfa/src/pdf/common.rs b/crates/xfa/src/pdf/common.rs index e1325b19a..7cbb1ce7a 100644 --- a/crates/xfa/src/pdf/common.rs +++ b/crates/xfa/src/pdf/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dict, Document, Family, Object, PdfString, Rect, Rounded, Stream}; /// Width and height of a JPEG from its first frame header (SOF0–SOF15, not the DNL/JPG/DAC/DHT /// markers). `None` for anything else. diff --git a/crates/xfa/src/pdf/strip.rs b/crates/xfa/src/pdf/strip.rs index de395e37e..2f3084ec9 100644 --- a/crates/xfa/src/pdf/strip.rs +++ b/crates/xfa/src/pdf/strip.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{CLICK_KEY, Dict, Document, IMAGE_FIELD_KEY, LAYOUT_KEY, NODE_KEY, ObjRef, Object, SOM_KEY, XfaError}; /// What laying a form out says when it took the usage rights off (see [`drop_usage_rights`]). pub(crate) const USAGE_RIGHTS_REMOVED: &str = diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index 39a1c9348..ca129abce 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -1512,6 +1512,11 @@ fn top_fields(doc: &Document) -> Vec<(String, pdfcraft_cos::Dict)> { /// widget, and their AcroForm fields and image XObjects once written. #[test] fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { + assert_choice_widget_layout(); + assert_choice_fields_and_picture_streams(); +} + +fn assert_choice_widget_layout() { let form = layout_xml(&fields_template("")).unwrap(); let p1 = &form.pages[0]; let w = widgets(p1); @@ -1546,7 +1551,9 @@ fn choice_lists_signatures_passwords_and_pictures_become_fields_and_images() { assert!(t.iter().any(|s| s == "12345"), "the barcode's value as text: {t:?}"); assert!(form.warnings.iter().any(|w| w.contains("barcode")), "{:?}", form.warnings); assert!(!form.warnings.iter().any(|w| w.contains("can't be chosen")), "pictures can be chosen now: {:?}", form.warnings); +} +fn assert_choice_fields_and_picture_streams() { // Written into the PDF: /Ch fields with their options and flags, a /Sig field, a password // field, and Flate images (the PNG with a soft mask). let mut doc = Document::open(Arc::new(shell(&fields_template("")))).unwrap(); @@ -3556,6 +3563,11 @@ fn laying_a_form_out_takes_off_the_usage_rights_it_voids() { #[test] fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { + assert_stripping_leaves_ordinary_document(); + assert_stripping_handles_unbound_data_and_shared_field_tree(); +} + +fn assert_stripping_leaves_ordinary_document() { use crate::pdf::strip; let mut doc = Document::open(Arc::new(shell(&fields_template("
JP
")))).unwrap(); render_into(&mut doc).unwrap(); @@ -3606,6 +3618,10 @@ fn stripping_takes_the_xfa_out_and_leaves_an_ordinary_document() { let r = strip(&mut doc).unwrap(); assert_eq!(r, crate::Stripped::default()); assert!(!doc.is_modified()); +} + +fn assert_stripping_handles_unbound_data_and_shared_field_tree() { + use crate::pdf::strip; // Data no field holds is counted; a form whose click buttons ran scripts too; the shape // check needs no packet decoded and tells a never-laid-out dynamic form from one to strip. let mut doc = Document::open(Arc::new(shell(&scripted_template().replace("", "
3kept nowherex
")))).unwrap(); From 92bc2f9acd38107f24516b9c3cdd8c214eb423d4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 23/33] Refactor xfdf for strict Clippy checks --- crates/xfdf/src/common.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/xfdf/src/common.rs b/crates/xfdf/src/common.rs index 5670415d3..c4ac9f600 100644 --- a/crates/xfdf/src/common.rs +++ b/crates/xfdf/src/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DataError, Dict, Document, Object}; // ── names ─────────────────────────────────────────────────────────────────────────────────── From d95ff3898d6ca8071fe322d2c427a00893d3173c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:12:52 +0000 Subject: [PATCH 24/33] Enforce workspace Clippy standards and document lint policy --- CLAUDE.md | 1 + Cargo.toml | 3 +++ clippy.toml | 1 + xtask/Cargo.toml | 3 +++ 4 files changed, 8 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index f51a96d56..a37cec4ca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,7 @@ PdfCraft is a clean-room, open-source, Rust-native PDF application targeting Ado - **Rebasing is a review step, not a mechanical one.** Read every hunk you resolve, and the auto-merged hunks in the files you touched too — `main` moves fast enough that a file can change by hundreds of lines under a branch. - **External PRs are held to safety, security and completeness before style.** Provenance first (no Adobe-derived asset, no committed font, no copied GPL/AGPL code, behaviour from the spec and not from reading another implementation), then no new panic path or `unsafe`, then `--root` confinement on every path including ones reached by nested dispatch, opt-in-only MCP and control channel, no new outbound network, then a headless `automation` tool, tests at the right zoom and an honest `parity/` entry. Provenance is the part a contributor cannot be expected to know, and the only part that cannot be undone after merge. - **Quality gates** before every commit: `cargo fmt --check`, clippy with `-D warnings`, the tests, `cargo xtask layers`, `cargo xtask assets`, and the wasm check once `xtask ci` exists (M0). + - New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. - **Scope builds and tests to the crates you touched** — `cargo clippy -p -j 2 --all-targets` and `cargo test -p -j 2`, under a `timeout`. A full `--workspace` build has locked up a development machine's CPU; it is CI's job and CI runs it on every PR. Run the workspace sweep locally only when you have a reason to and the machine is free. - **Check for existing work before starting a fix or feature.** Search the issues and PRs, open and closed, with `gh issue list -R storytold/pdfcraft --state all --search ""` and the same with `gh pr list` (try a few phrasings), and check whether a merged fix is already on `main` (`git merge-base --is-ancestor origin/main`). If one is, the report is likely from an older build: say so rather than fixing it again. If an open PR covers it, review or build on that PR instead of opening a competing one. If an issue exists, reference it in the PR (`Fixes #123`). - **Commits and PRs:** `main` is the only integration branch and is **never pushed to directly**. Branch from an up-to-date `main` as `/` (`feat/`, `fix/`, `docs/`, `chore/`, `refactor/`), one task id per commit (e.g. `M1.4: xref stream reader`), commit only green states, and open a PR into `main` using `.github/pull_request_template.md`. Merge only when CI is green. diff --git a/Cargo.toml b/Cargo.toml index 99f9fa251..24313021b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,9 @@ web-time = "1.1" unsafe_code = "forbid" [workspace.lints.clippy] +cognitive_complexity = "deny" +too_many_arguments = "deny" +wildcard_imports = "deny" dbg_macro = "warn" todo = "warn" diff --git a/clippy.toml b/clippy.toml index e908d1358..31e54da30 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,6 +13,7 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] +cognitive-complexity-threshold = 30 disallowed-types = [ { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 7a33bad87..028282e07 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" publish = false license.workspace = true +[lints] +workspace = true + [dependencies] pdfcraft-oracle = { workspace = true } pdfcraft-cos = { workspace = true } From 63bc58a3d57ed7a521d35c10cd7aa54c4889e36e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:16:56 +0000 Subject: [PATCH 25/33] Fix menu overflow and form pointer handling --- crates/ui-egui/src/canvas.rs | 10 +++++++--- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/tools/forms_ui.rs | 8 +++++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index adf22626b..ee556ba4a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -886,6 +886,7 @@ struct PageOut { open_initials: bool, open_signature: bool, open_table: Option<(usize, [f64; 4])>, + primary_down: bool, refused: Option, stamp_placed: bool, } @@ -1908,6 +1909,7 @@ fn draw_pages( open_initials, open_signature, open_table, + primary_down: _, refused, stamp_placed, } = page_out; @@ -2081,6 +2083,7 @@ fn draw_pages( open_initials, open_signature, open_table, + primary_down: ptr.primary_down, refused, stamp_placed, }; @@ -2542,7 +2545,7 @@ fn track_scroll(ui: &egui::Ui, view: &mut DocView, out: &egui::containers::sc /// The hover tip, find bar, size hint and the in-place editors drawn over the pages. Whether the /// text editor was finished. -fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hover_text: Option<(Pos2, String)>) -> bool { +fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, primary_down: bool, hover_text: Option<(Pos2, String)>) -> bool { let PageEnv { added, author, avail, can_modify, doc, form, info, prefs, t, today, tool, units, unobstructed, .. } = *env; if let Some((pos, text)) = hover_text { // Click-through, as a tooltip is: the pages lose the pointer under anything that isn't. @@ -2566,7 +2569,7 @@ fn frame_overlays(ui: &mut egui::Ui, view: &mut DocView, env: &PageEnv<'_>, hove view.pending_edit = Some(e); } let text_box = crate::forms_ui::text_box(doc, view, form); - if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, text_box.as_ref(), today) { + if let Some(e) = crate::forms_ui::overlay(ui.ctx(), view, info, form, primary_down, text_box.as_ref(), today) { view.pending_edit = Some(e); } if let Some(e) = crate::fill_sign::type_box(ui.ctx(), view, info, author) { @@ -3077,6 +3080,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { open_initials, open_signature, open_table, + primary_down, mut refused, stamp_placed, } = page_out; @@ -3103,7 +3107,7 @@ pub fn document_area(app: &mut PdfCraftApp, index: usize, ui: &mut egui::Ui) { let pan_held = track_scroll(ui, view, &out, glide); plan_renders(ui, view, info, out.inner, &RenderPlan { visible_pages: &visible_pages, tag, scale, sharp_held, pan_held, hand }); - let text_done = frame_overlays(ui, view, &env, hover_text); + let text_done = frame_overlays(ui, view, &env, primary_down, hover_text); if let Some(why) = view.objects.notice.take() { refused = Some(why); } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d7c8bef1c..d5a33aea3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -254,7 +254,7 @@ fn english(labeled: Labeled<'_>) -> Option { } fn translate(english: &'static str) -> String { - tl!(english).to_owned() + if english == "⋯" { tl!("More").to_owned() } else { tl!(english).to_owned() } } /// The layouts' own labels (About, Quit, Window…) through the app's translations. diff --git a/crates/ui-egui/src/tools/forms_ui.rs b/crates/ui-egui/src/tools/forms_ui.rs index 5a5ba3e1a..6bcec6469 100644 --- a/crates/ui-egui/src/tools/forms_ui.rs +++ b/crates/ui-egui/src/tools/forms_ui.rs @@ -629,6 +629,7 @@ fn settle_text_editor( ui: &mut egui::Ui, fx: &mut Focus, r: &egui::Response, + primary_down: bool, laid_out: Option<(std::sync::Arc, Pos2)>, fitted: Option<(&form_appearance::TextBox, String)>, ) { @@ -653,7 +654,7 @@ fn settle_text_editor( let at = galley.cursor_from_pos(press - pos + vec2(galley.rect.left(), 0.0)); st.cursor.set_char_range(Some(egui::text::CCursorRange::one(at))); st.store(ui.ctx(), r.id); - if ui.input(|i| i.pointer.primary_down()) { + if primary_down { ui.ctx().set_dragged_id(r.id); } } @@ -725,11 +726,12 @@ pub(crate) fn overlay( view: &mut DocView, info: &DocInfo, form: &[FormField], + primary_down: bool, text_box: Option<&form_appearance::TextBox>, today: (i64, u32, u32), ) -> Option { let pressed = view.forms.pressed; - if !ctx.input(|i| i.pointer.primary_down()) { + if !primary_down { view.forms.pressed = false; } let toggle = std::mem::take(&mut view.forms.toggle); @@ -808,7 +810,7 @@ pub(crate) fn overlay( }) // The form field's own logical name; not a visual/preordered label. .accessible_name(f.name.clone()); - settle_text_editor(ui, fx, &r, laid_out, text_box.zip(before)); + settle_text_editor(ui, fx, &r, primary_down, laid_out, text_box.zip(before)); let lost = crate::edit_text_ui::lost_focus_once(&r, &mut fx.had_focus); let (enter, esc, tab, shift) = ui.input(|i| { (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape), i.key_pressed(egui::Key::Tab), i.modifiers.shift) From 81c63c037a70cb01dca676f6c9e0293b952699b2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:25:39 +0000 Subject: [PATCH 26/33] Group annotation, form and document operation parameters --- crates/annot/README.md | 2 +- crates/annot/src/lib.rs | 2 +- crates/annot/src/modify.rs | 30 ++++-------------- crates/annot/src/tests.rs | 59 ++++++++++++++++++++++++++--------- crates/annot/src/types.rs | 27 ++++++++++++++++ crates/engine/src/edit/run.rs | 22 +++++++++++-- crates/engine/src/open.rs | 28 +++++++++-------- crates/forms/src/actions.rs | 14 ++++++--- crates/measure/src/tests.rs | 2 +- crates/xfdf/src/tests.rs | 17 ++++++++-- 10 files changed, 140 insertions(+), 63 deletions(-) diff --git a/crates/annot/README.md b/crates/annot/README.md index 810820183..999baec20 100644 --- a/crates/annot/README.md +++ b/crates/annot/README.md @@ -30,7 +30,7 @@ let index = add_annotation(&mut doc, &NewAnnotation { page, shape, style, conten add_reply(&mut doc, page, index, "Agreed", "Ada", &meta)?; set_review_state(&mut doc, page, index, ReviewState::Accepted, "Ada", &meta)?; move_annotation(&mut doc, page, index, dx, dy, &meta)?; -set_style(&mut doc, page, index, Some(rgb), Some(0.5), Some(2.0), &meta)?; +set_style(&mut doc, page, index, StyleChange::new(Some(rgb), Some(0.5), Some(2.0), None), &meta)?; delete_annotation(&mut doc, page, index)?; signature_image(&doc, page, index)?; // embedded image XObject of Fill & Sign signature/initials ``` diff --git a/crates/annot/src/lib.rs b/crates/annot/src/lib.rs index 1d85d0bd6..e9e0359ce 100644 --- a/crates/annot/src/lib.rs +++ b/crates/annot/src/lib.rs @@ -41,7 +41,7 @@ pub use modify::{ }; pub use types::{ AttachIcon, FillMark, LineEnding, Markup, Meta, NOTE_SIZE, NewAnnotation, NoteIcon, OverlayFont, OverlayLook, ReviewState, Rgb, Shape, - StampGroup, StampKind, Style, rect_quad, + StampGroup, StampKind, Style, StyleChange, TextReplacement, rect_quad, }; pub mod appearance; diff --git a/crates/annot/src/modify.rs b/crates/annot/src/modify.rs index 7872a6c81..fc8134807 100644 --- a/crates/annot/src/modify.rs +++ b/crates/annot/src/modify.rs @@ -6,6 +6,7 @@ use super::info::{line_endings_of, text_value}; use super::lookup::{annot_dict, annot_ref, annotation_flags, annots, page_ref, set_annots}; use super::types::{ FLAG_HIDDEN, FLAG_LOCKED, FLAG_NO_ROTATE, FLAG_NO_ZOOM, FLAG_PRINT, LineEnding, Markup, Meta, NewAnnotation, ReviewState, Rgb, Shape, Style, + StyleChange, TextReplacement, }; use super::{AnnotError, Dict, Document, HashMap, HashSet, ObjRef, Object, PdfString, appearance}; @@ -499,17 +500,8 @@ fn border_width_of(d: &Dict) -> f64 { } /// Change a comment's colour, opacity, line width and/or line endings, and redraw it. -#[allow(clippy::too_many_arguments)] -pub fn set_style( - doc: &mut Document, - page: usize, - index: usize, - color: Option, - opacity: Option, - width: Option, - endings: Option<&[LineEnding]>, - meta: &Meta, -) -> Result<(), AnnotError> { +pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleChange, meta: &Meta) -> Result<(), AnnotError> { + let StyleChange { color, opacity, width, endings } = change; let (_, r) = annot_ref(doc, page, index)?; unlocked(doc, r)?; let d = annot_dict(doc, r); @@ -521,7 +513,7 @@ pub fn set_style( if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) { return Err(AnnotError::Invalid("invalid style".into())); } - if let Some(ends) = endings { + if let Some(ends) = &endings { let callout = subtype == "FreeText" && d.contains(b"CL"); let pair = matches!(subtype.as_str(), "Line" | "PolyLine"); if (callout && ends.len() != 1) || (pair && ends.len() != 2) || (!callout && !pair) { @@ -691,18 +683,8 @@ fn pair_points(d: &Dict, key: &[u8]) -> Option> { /// to, so one id for both writes `inreplyto` equal to the caret's own `name`, and a reader that /// resolves names (Acrobat, and our own importer) loses the pair. `caret_meta` is a second id /// from the caller, so the edit stays deterministic. -#[allow(clippy::too_many_arguments)] -pub fn add_text_replacement( - doc: &mut Document, - page: usize, - quads: &[[f64; 8]], - replacement: &str, - author: &str, - strike: &Style, - caret: &Style, - meta: &Meta, - caret_meta: &Meta, -) -> Result { +pub fn add_text_replacement(doc: &mut Document, replacement: TextReplacement<'_>) -> Result { + let TextReplacement { page, quads, replacement, author, strike, caret, strike_meta: meta, caret_meta } = replacement; let last = *quads.last().ok_or_else(|| AnnotError::Invalid("select the text to replace first".into()))?; let s = add_annotation( doc, diff --git a/crates/annot/src/tests.rs b/crates/annot/src/tests.rs index b9e47e5fb..eb8d1a0f1 100644 --- a/crates/annot/src/tests.rs +++ b/crates/annot/src/tests.rs @@ -303,7 +303,7 @@ fn move_shifts_all_geometry_and_the_popup() { fn restyle_and_resize_redraw_the_appearance() { let mut doc = fixture(); let r = add_annotation(&mut doc, &new(0, Shape::Rectangle { rect: [10.0, 10.0, 110.0, 60.0] }), &meta("r")).unwrap(); - set_style(&mut doc, 0, r, Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None, &meta("")).unwrap(); + set_style(&mut doc, 0, r, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.5), Some(4.0), None), &meta("")).unwrap(); set_rect(&mut doc, 0, r, [0.0, 0.0, 200.0, 100.0], &meta("")).unwrap(); let doc2 = reopen(&doc); let d = &list(&doc2, 0)[r]; @@ -313,7 +313,10 @@ fn restyle_and_resize_redraw_the_appearance() { assert_eq!(d.get(b"CA").and_then(|o| o.as_f64()), Some(0.5)); // A stamp's appearance can't be regenerated, so restyling it changes nothing. let before = list(&doc, 1)[0].clone(); - assert_eq!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")), Err(AnnotError::Unsupported("Stamp".into()))); + assert_eq!( + set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")), + Err(AnnotError::Unsupported("Stamp".into())) + ); assert_eq!(list(&doc, 1)[0], before); // Lines can't be resized as rectangles. let l = add_annotation( @@ -374,7 +377,7 @@ fn text_box_text_and_colour_changes_are_drawn() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [0.0, 0.0, 300.0, 100.0], font_size: 12.0 }), &meta("t")).unwrap(); set_contents(&mut doc, 0, t, "Caf\u{e9} (draft) \u{2014} 100%", &meta("")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[t]; assert_eq!(text(d, b"Contents"), "Caf\u{e9} (draft) \u{2014} 100%"); @@ -556,8 +559,8 @@ fn fill_and_sign_items_are_drawn() { assert_eq!(text(&all[s], b"Subj"), "Signature"); // Other stamps still can't be restyled; ours can. let mut doc = doc; - set_style(&mut doc, 1, marks[0], Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); - assert!(set_style(&mut doc, 1, 0, Some([0.0; 3]), None, None, None, &meta("")).is_err()); + set_style(&mut doc, 1, marks[0], StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); + assert!(set_style(&mut doc, 1, 0, StyleChange::new(Some([0.0; 3]), None, None, None), &meta("")).is_err()); } #[test] @@ -844,7 +847,20 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let red = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let blue = Style { color: [0.0, 0.47, 0.84], opacity: 1.0, width: 1.0, fill: None }; - let i = add_text_replacement(&mut doc, 0, &quads, "new words", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).unwrap(); + let i = add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret"), + }, + ) + .unwrap(); let doc = reopen(&doc); let a = list(&doc, 0); let strike = &a[i]; @@ -866,7 +882,22 @@ fn replace_text_groups_a_strikeout_and_a_caret() { let mut doc = doc; delete_annotation(&mut doc, 0, i).unwrap(); assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Caret"))); - assert!(add_text_replacement(&mut doc, 0, &[], "x", "Ada", &red, &blue, &meta("r"), &meta("r-caret")).is_err()); + assert!( + add_text_replacement( + &mut doc, + TextReplacement { + page: 0, + quads: &[], + replacement: "x", + author: "Ada", + strike: &red, + caret: &blue, + strike_meta: &meta("r"), + caret_meta: &meta("r-caret") + } + ) + .is_err() + ); } #[test] @@ -1177,7 +1208,7 @@ fn every_line_ending_is_drawn() { ) .unwrap(); let before = rect(&list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line")); - set_style(&mut doc, 0, index, None, None, None, Some(&[LineEnding::None, LineEnding::Square]), &meta("style")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, None, Some(vec![LineEnding::None, LineEnding::Square])), &meta("style")).unwrap(); let mut doc = reopen(&doc); let line = list(&doc, 0).into_iter().find(|d| d.name(b"Subtype") == Some(b"Line")).expect("line"); let after = rect(&line); @@ -1245,7 +1276,7 @@ fn redraw_keeps_shared_annotation_appearances_and_drops_stale_alternates() { let other_dict = list(&doc, 0)[other].clone(); let source = other_dict.get(b"AP").unwrap(); let before = doc.resolve(source); - set_style(&mut doc, 0, first, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, first, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(doc.resolve(source), before, "a shared AP must not change"); for full in [false, true] { let bytes = if full { @@ -1367,7 +1398,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { .unwrap(); assert_eq!(mark(&doc, s), Some('张')); // Our own `?` appearance is redrawn on a restyle; Western European text clears the mark. - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); assert_eq!(mark(&doc, t), Some('签')); set_contents(&mut doc, 0, t, "Zürich —\nGenève", &meta("")).unwrap(); assert_eq!(mark(&doc, t), None); @@ -1395,7 +1426,7 @@ fn comment_text_the_standard_fonts_cannot_draw_is_kept_and_marked() { set_annots(&mut doc, page, on_page).unwrap(); let k = list(&doc, 0).len() - 1; let before = list(&doc, 0)[k].clone(); - let err = set_style(&mut doc, 0, k, Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None, &meta("")).unwrap_err(); + let err = set_style(&mut doc, 0, k, StyleChange::new(Some([1.0, 0.0, 0.0]), Some(0.4), Some(6.0), None), &meta("")).unwrap_err(); assert!(matches!(&err, AnnotError::Undrawable(m) if m.contains("can't redraw") && m.contains("\"欢\" (U+6B22)")), "{err:?}"); assert_eq!(list(&doc, 0)[k], before, "a refused restyle keeps the colour, opacity, width and appearance"); let err = set_rect(&mut doc, 0, k, [40.0, 40.0, 180.0, 90.0], &meta("")).unwrap_err(); @@ -1476,7 +1507,7 @@ fn a_comment_listed_many_times_is_drawn_once_for_display() { fn text_box_font_size_changes_the_da_and_grows_the_box() { let mut doc = fixture(); let t = add_annotation(&mut doc, &new(0, Shape::TextBox { rect: [100.0, 680.0, 220.0, 692.0], font_size: 12.0 }), &meta("t")).unwrap(); - set_style(&mut doc, 0, t, Some([1.0, 0.0, 0.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, t, StyleChange::new(Some([1.0, 0.0, 0.0]), None, None, None), &meta("")).unwrap(); set_contents(&mut doc, 0, t, "one two three four five six seven eight nine ten eleven twelve", &meta("")).unwrap(); let height = |doc: &Document| { let rect: Vec = list(doc, 0)[t].get(b"Rect").unwrap().as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); @@ -1522,7 +1553,7 @@ fn text_boxes_and_callouts_can_change_fill_and_border() { let index = add_annotation(&mut doc, &new(0, shape), &meta("test")).unwrap(); assert!(props(&doc, 0, index).unwrap().fillable); set_fill(&mut doc, 0, index, Some([1.0, 1.0, 1.0]), &meta("test")).unwrap(); - set_style(&mut doc, 0, index, None, None, Some(3.0), None, &meta("test")).unwrap(); + set_style(&mut doc, 0, index, StyleChange::new(None, None, Some(3.0), None), &meta("test")).unwrap(); let saved = reopen(&doc); let properties = props(&saved, 0, index).unwrap(); assert_eq!(properties.fill, Some([1.0; 3])); @@ -1791,7 +1822,7 @@ fn a_star_note_keeps_its_star_when_recoloured() { b"<< /Type /Annot /Subtype /Text /Rect [100 100 120 120] /Name /Star /C [1 0.82 0] /F 4 >>", ]); assert_eq!(props(&doc, 0, 0).and_then(|p| p.icon), Some(NoteIcon::Star)); - set_style(&mut doc, 0, 0, Some([0.0, 0.0, 1.0]), None, None, None, &meta("")).unwrap(); + set_style(&mut doc, 0, 0, StyleChange::new(Some([0.0, 0.0, 1.0]), None, None, None), &meta("")).unwrap(); let doc = reopen(&doc); let d = &list(&doc, 0)[0]; assert_eq!(d.name(b"Name"), Some(&b"Star"[..])); diff --git a/crates/annot/src/types.rs b/crates/annot/src/types.rs index 40ed744df..bd3409b5a 100644 --- a/crates/annot/src/types.rs +++ b/crates/annot/src/types.rs @@ -563,6 +563,33 @@ pub struct Style { pub fill: Option, } +/// The style properties changed by one annotation edit. `None` leaves a property unchanged. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct StyleChange { + pub color: Option, + pub opacity: Option, + pub width: Option, + pub endings: Option>, +} + +impl StyleChange { + pub fn new(color: Option, opacity: Option, width: Option, endings: Option>) -> Self { + Self { color, opacity, width, endings } + } +} + +/// Inputs for replacing selected text with a grouped strikeout and caret annotation. +pub struct TextReplacement<'a> { + pub page: usize, + pub quads: &'a [[f64; 8]], + pub replacement: &'a str, + pub author: &'a str, + pub strike: &'a Style, + pub caret: &'a Style, + pub strike_meta: &'a Meta, + pub caret_meta: &'a Meta, +} + impl Default for Style { fn default() -> Self { Self { color: [1.0, 0.82, 0.0], opacity: 1.0, width: 1.0, fill: None } diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 7bf5467fc..49f19fa63 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -331,7 +331,19 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E // One `/NM` each: the caret replies to the strikeout, and XFDF names that reply by // the strikeout's `/NM` (#2666). let (strike_meta, caret_meta) = (cx.meta(), cx.meta()); - pdfcraft_annot::add_text_replacement(doc, *page, quads, text, author, strike, caret, &strike_meta, &caret_meta)?; + pdfcraft_annot::add_text_replacement( + doc, + pdfcraft_annot::TextReplacement { + page: *page, + quads, + replacement: text, + author, + strike, + caret, + strike_meta: &strike_meta, + caret_meta: &caret_meta, + }, + )?; } Edit::MarkAnnotation { page, index, marked, author } => { pdfcraft_annot::set_marked(doc, *page, *index, *marked, author, &cx.meta())?; @@ -341,7 +353,13 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E Edit::ReshapeAnnotation { page, index, points } => pdfcraft_annot::set_points(doc, *page, *index, points, &cx.meta())?, Edit::ResizeAnnotation { page, index, rect } => pdfcraft_annot::set_rect(doc, *page, *index, *rect, &cx.meta())?, Edit::StyleAnnotation { page, index, color, opacity, width, endings } => { - pdfcraft_annot::set_style(doc, *page, *index, *color, *opacity, *width, endings.as_deref(), &cx.meta())?; + pdfcraft_annot::set_style( + doc, + *page, + *index, + pdfcraft_annot::StyleChange { color: *color, opacity: *opacity, width: *width, endings: endings.clone() }, + &cx.meta(), + )?; } Edit::FillAnnotation { page, index, fill } => pdfcraft_annot::set_fill(doc, *page, *index, *fill, &cx.meta())?, Edit::FontSizeAnnotation { page, index, size } => pdfcraft_annot::set_font_size(doc, *page, *index, *size, &cx.meta())?, diff --git a/crates/engine/src/open.rs b/crates/engine/src/open.rs index d8950d1d6..58bc8327e 100644 --- a/crates/engine/src/open.rs +++ b/crates/engine/src/open.rs @@ -25,6 +25,17 @@ pub struct PreparedDocument { document: Document, } +struct DocumentOpenData<'a> { + name: String, + path: Option, + bytes: Arc>, + info: DocInfo, + structure: Result, Box>, + render_password: Option, + password: Option<&'a str>, + xfa: Option, +} + impl OpenContext { /// Prepare a document without mutating the session that supplied this context. pub fn prepare_open( @@ -241,7 +252,8 @@ impl Session { }; // XFA notes survive the document being re-read after edits. let xfa_warnings: Vec = if info.xfa.is_some() { info.warnings.clone() } else { Vec::new() }; - let mut document = Self::build_document(&context.trust, name, path, bytes, info, cos, render_password, password, xfa); + let mut document = + Self::build_document(&context.trust, DocumentOpenData { name, path, bytes, info, structure: cos, render_password, password, xfa }); // A script that ran away on open turns the form's scripts off for this document. document.xfa_template = if ran_away { None } else { xfa_template }; document.js_output.append(script_output); @@ -254,18 +266,8 @@ impl Session { } /// The last step of opening: build the document record and register it. - #[allow(clippy::too_many_arguments)] - fn build_document( - trust: &Arc, - name: String, - path: Option, - bytes: Arc>, - mut info: DocInfo, - cos: Result, Box>, - render_password: Option, - password: Option<&str>, - xfa: Option, - ) -> Document { + fn build_document(trust: &Arc, data: DocumentOpenData<'_>) -> Document { + let DocumentOpenData { name, path, bytes, mut info, structure: cos, render_password, password, xfa } = data; let config = RenderConfig { password: render_password.as_deref().map(Arc::from), ..Default::default() }; let (mut editor, read_only_reason) = match cos { Ok(Ok(cos)) => { diff --git a/crates/forms/src/actions.rs b/crates/forms/src/actions.rs index 5eb57c6c1..5ebe3269c 100644 --- a/crates/forms/src/actions.rs +++ b/crates/forms/src/actions.rs @@ -262,17 +262,21 @@ fn write(doc: &mut Document, a: &FieldAction, pages: &[pdfcraft_cos::ObjRef]) -> /// Rewrite one trigger of a widget to `new` (`None` clears it), given the `old` value read from /// the widget. `aa` and `a_entry` are the widget's `/AA` dict and `/A` entry, updated in place. -#[allow(clippy::too_many_arguments)] +struct TriggerUpdate<'a> { + trigger: Trigger, + replacement: Option<&'a FieldAction>, + previous: Option, +} + fn replace_trigger( doc: &mut Document, pages: &[pdfcraft_cos::ObjRef], scripts: &mut pdfcraft_cos::DecodeBudget, - t: Trigger, - new: Option<&FieldAction>, - old: Option, + update: TriggerUpdate<'_>, aa: &mut Dict, a_entry: &mut Option, ) -> Result<(), FormError> { + let TriggerUpdate { trigger: t, replacement: new, previous: old } = update; let old_action = old.as_ref().and_then(|o| read(doc, o, pages, scripts)); if new.is_some() && new == old_action.as_ref() { return Ok(()); @@ -322,7 +326,7 @@ pub fn set_field_actions(doc: &mut Document, name: &str, actions: &[(Trigger, Fi None => wd.get(b"A").cloned().or_else(|| aa.get(b"U").cloned()), Some(k) => aa.get(k).cloned(), }; - replace_trigger(doc, &pages, &mut scripts, t, new, old, &mut aa, &mut a_entry)?; + replace_trigger(doc, &pages, &mut scripts, TriggerUpdate { trigger: t, replacement: new, previous: old }, &mut aa, &mut a_entry)?; } doc.update_dict(w.obj, |d| { match &a_entry { diff --git a/crates/measure/src/tests.rs b/crates/measure/src/tests.rs index 8d69a05cf..5b277e554 100644 --- a/crates/measure/src/tests.rs +++ b/crates/measure/src/tests.rs @@ -80,7 +80,7 @@ fn standard_annotations_captions_restyle_move_and_unknown_keys() { let (r, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.name(b"IT"), Some(&b"LineDimension"[..])); d.update_dict(r, |d| d.set(b"VendorKey".to_vec(), Object::Int(42))).unwrap(); - pdfcraft_annot::set_style(&mut d, 0, 0, Some([1.0, 0.0, 0.0]), None, Some(2.0), None, &meta).unwrap(); + pdfcraft_annot::set_style(&mut d, 0, 0, pdfcraft_annot::StyleChange::new(Some([1.0, 0.0, 0.0]), None, Some(2.0), None), &meta).unwrap(); let (_, dict) = annot(&d, 0, 0).unwrap(); assert_eq!(dict.int(b"VendorKey"), Some(42)); let ap = d.resolve(dict.get(b"AP").unwrap()); diff --git a/crates/xfdf/src/tests.rs b/crates/xfdf/src/tests.rs index 2bb54f923..52a4a5b86 100644 --- a/crates/xfdf/src/tests.rs +++ b/crates/xfdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, add_annotation, add_reply, add_text_replacement, summaries}; +use pdfcraft_annot::{Markup, Meta, NewAnnotation, Shape, Style, TextReplacement, add_annotation, add_reply, add_text_replacement, summaries}; use pdfcraft_cos::{SaveOptions, write_full, write_incremental}; use pdfcraft_forms::{NewField, add_field, fields, set_value}; @@ -151,7 +151,20 @@ fn a_replace_text_pair_survives_an_xfdf_round_trip() { let quads = [[100.0, 720.0, 200.0, 720.0, 100.0, 706.0, 200.0, 706.0]]; let style = Style { color: [0.89, 0.13, 0.13], opacity: 1.0, width: 1.0, fill: None }; let meta = |id: &str| Meta { date: Some("D:20261001120000Z".into()), id: id.into() }; - add_text_replacement(&mut src, 0, &quads, "new words", "Ada", &style, &style, &meta("strike"), &meta("caret")).unwrap(); + add_text_replacement( + &mut src, + TextReplacement { + page: 0, + quads: &quads, + replacement: "new words", + author: "Ada", + strike: &style, + caret: &style, + strike_meta: &meta("strike"), + caret_meta: &meta("caret"), + }, + ) + .unwrap(); assert_eq!(summaries(&src).len(), 2, "a strikeout and a caret"); let xfdf = export_xfdf(&src, true, false, "a.pdf"); From 5fecdb9cee0fa6d3c3bbe4a7a9f81a5fbeefdfcb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 27 Oct 2026 23:25:40 +0000 Subject: [PATCH 27/33] Group signing and timestamp validation inputs --- crates/sign/src/cms.rs | 27 +++++++++------- crates/sign/src/pdf/validate.rs | 57 +++++++++++++++++---------------- crates/sign/src/timestamp.rs | 30 ++++++++++------- crates/sign/tests/it/pdf.rs | 37 ++++++++++++++------- 4 files changed, 89 insertions(+), 62 deletions(-) diff --git a/crates/sign/src/cms.rs b/crates/sign/src/cms.rs index b148ea4d1..4e232af61 100644 --- a/crates/sign/src/cms.rs +++ b/crates/sign/src/cms.rs @@ -376,7 +376,7 @@ pub fn sign_detached( alg: DigestAlg, content_digest: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, DATA, None, content_digest) + build(SignedDataInput { key, cert, chain, alg, content_type: DATA, content: None, content_digest }) } /// A CMS SignedData that encapsulates `content` under `content_type` (an RFC 3161 token wraps @@ -389,20 +389,23 @@ pub fn sign_encapsulated( content_type: &str, content: &[u8], ) -> Result, SignError> { - build(key, cert, chain, alg, content_type, Some(content), &alg.digest(&[content])) + let content_digest = alg.digest(&[content]); + build(SignedDataInput { key, cert, chain, alg, content_type, content: Some(content), content_digest: &content_digest }) } -// The parameters mirror the SignedData fields being assembled. -#[allow(clippy::too_many_arguments)] -fn build( - key: &PrivateKey, - cert: &Certificate, - chain: &[Certificate], +/// The values needed to assemble a CMS SignedData object. +struct SignedDataInput<'a> { + key: &'a PrivateKey, + cert: &'a Certificate, + chain: &'a [Certificate], alg: DigestAlg, - content_type: &str, - content: Option<&[u8]>, - content_digest: &[u8], -) -> Result, SignError> { + content_type: &'a str, + content: Option<&'a [u8]>, + content_digest: &'a [u8], +} + +fn build(input: SignedDataInput<'_>) -> Result, SignError> { + let SignedDataInput { key, cert, chain, alg, content_type, content, content_digest } = input; let cert_hash = DigestAlg::Sha256.digest(&[&cert.raw]); let general_names = der::seq(&[&der::explicit(4, &cert.issuer.raw)]); let ess = der::seq(&[&der::seq(&[&der::seq(&[&der::octets(&cert_hash), &der::seq(&[&general_names, &der::uint(&cert.serial)])])])]); diff --git a/crates/sign/src/pdf/validate.rs b/crates/sign/src/pdf/validate.rs index 668798c20..08b482c4b 100644 --- a/crates/sign/src/pdf/validate.rs +++ b/crates/sign/src/pdf/validate.rs @@ -5,6 +5,22 @@ use super::{ Certificate, Dict, DigestAlg, Document, HashSet, ObjRef, Object, SignError, SignatureInfo, SignedData, Time, build_chain, build_chain_noted, }; +struct SignatureValidation<'a> { + doc: &'a Document, + bytes: &'a [u8], + trust: &'a TrustStore, + info: &'a mut SignatureInfo, + cache: &'a DigestCache, +} + +struct VerifiedSignature<'a> { + covered: usize, + certificate: Certificate, + embedded: &'a [Certificate], + cms_time: Option