diff --git a/crates/edit/src/images.rs b/crates/edit/src/images.rs index 3d9d197ba0..f30b774b32 100644 --- a/crates/edit/src/images.rs +++ b/crates/edit/src/images.rs @@ -828,6 +828,187 @@ impl ExtendedGStateTracker { } } +#[derive(Clone, Default, Debug, PartialEq)] +struct DrawingState { + non_stroking_color: Option>, + stroking_color: Option>, + line_width: Option, + line_cap: Option, + line_join: Option, + miter_limit: Option, + dash_pattern: Option, + rendering_intent: Option, + flatness: Option, +} + +#[derive(Clone, Default, Debug)] +struct DrawingStateTracker { + stack: Vec, + current: DrawingState, +} + +impl DrawingStateTracker { + fn step(&mut self, op: &Op) -> Result<(), EditError> { + match op.op.as_slice() { + b"q" => { + if self.stack.len() >= 4096 { + return Err(EditError::Invalid("the image graphics state is too deeply nested".into())); + } + self.stack.push(self.current.clone()); + } + b"Q" => { + if let Some(prev) = self.stack.pop() { + self.current = prev; + } + } + b"g" | b"rg" | b"k" => { + self.current.non_stroking_color = Some(vec![op.clone()]); + } + b"cs" => { + self.current.non_stroking_color = Some(vec![op.clone()]); + } + b"sc" | b"scn" => { + if let Some(ref mut ops) = self.current.non_stroking_color { + if ops.first().is_some_and(|o| o.is("cs")) { + ops.truncate(1); + ops.push(op.clone()); + } else { + *ops = vec![op.clone()]; + } + } else { + self.current.non_stroking_color = Some(vec![op.clone()]); + } + } + b"G" | b"RG" | b"K" => { + self.current.stroking_color = Some(vec![op.clone()]); + } + b"CS" => { + self.current.stroking_color = Some(vec![op.clone()]); + } + b"SC" | b"SCN" => { + if let Some(ref mut ops) = self.current.stroking_color { + if ops.first().is_some_and(|o| o.is("CS")) { + ops.truncate(1); + ops.push(op.clone()); + } else { + *ops = vec![op.clone()]; + } + } else { + self.current.stroking_color = Some(vec![op.clone()]); + } + } + b"w" => { + self.current.line_width = Some(op.clone()); + } + b"J" => { + self.current.line_cap = Some(op.clone()); + } + b"j" => { + self.current.line_join = Some(op.clone()); + } + b"M" => { + self.current.miter_limit = Some(op.clone()); + } + b"d" => { + self.current.dash_pattern = Some(op.clone()); + } + b"ri" => { + self.current.rendering_intent = Some(op.clone()); + } + b"i" => { + self.current.flatness = Some(op.clone()); + } + _ => {} + } + Ok(()) + } +} + +fn compute_inherited_drawing_ops(source: &DrawingState, dest: &DrawingState, extracted_state_ops: &[Op]) -> Vec { + let mut ops = Vec::new(); + + // 1. Non-stroking color + let has_non_stroking = extracted_state_ops.iter().any(|o| matches!(o.op.as_slice(), b"g" | b"rg" | b"k" | b"sc" | b"scn")); + if !has_non_stroking { + if let Some(ref color_ops) = source.non_stroking_color { + ops.extend(color_ops.iter().cloned()); + } else if dest.non_stroking_color.is_some() { + ops.push(Op::new("g", vec![pdfcraft_content::num(0.0)])); + } + } + + // 2. Stroking color + let has_stroking = extracted_state_ops.iter().any(|o| matches!(o.op.as_slice(), b"G" | b"RG" | b"K" | b"SC" | b"SCN")); + if !has_stroking { + if let Some(ref color_ops) = source.stroking_color { + ops.extend(color_ops.iter().cloned()); + } else if dest.stroking_color.is_some() { + ops.push(Op::new("G", vec![pdfcraft_content::num(0.0)])); + } + } + + // 3. Line width + if !extracted_state_ops.iter().any(|o| o.is("w")) { + if let Some(ref op) = source.line_width { + ops.push(op.clone()); + } else if dest.line_width.is_some() { + ops.push(Op::new("w", vec![pdfcraft_content::num(1.0)])); + } + } + + // 4. Line cap + if !extracted_state_ops.iter().any(|o| o.is("J")) { + if let Some(ref op) = source.line_cap { + ops.push(op.clone()); + } else if dest.line_cap.is_some() { + ops.push(Op::new("J", vec![pdfcraft_content::num(0.0)])); + } + } + + // 5. Line join + if !extracted_state_ops.iter().any(|o| o.is("j")) { + if let Some(ref op) = source.line_join { + ops.push(op.clone()); + } else if dest.line_join.is_some() { + ops.push(Op::new("j", vec![pdfcraft_content::num(0.0)])); + } + } + + // 6. Miter limit + if !extracted_state_ops.iter().any(|o| o.is("M")) { + if let Some(ref op) = source.miter_limit { + ops.push(op.clone()); + } else if dest.miter_limit.is_some() { + ops.push(Op::new("M", vec![pdfcraft_content::num(10.0)])); + } + } + + // 7. Dash pattern + if !extracted_state_ops.iter().any(|o| o.is("d")) { + if let Some(ref op) = source.dash_pattern { + ops.push(op.clone()); + } else if dest.dash_pattern.is_some() { + ops.push(Op::new("d", vec![Object::Array(vec![]), pdfcraft_content::num(0.0)])); + } + } + + // 8. Rendering intent + if !extracted_state_ops.iter().any(|o| o.is("ri")) + && let Some(ref op) = source.rendering_intent + { + ops.push(op.clone()); + } + + // 9. Flatness + if !extracted_state_ops.iter().any(|o| o.is("i")) + && let Some(ref op) = source.flatness + { + ops.push(op.clone()); + } + + ops +} + fn clip_path_to_ops(clip: &ClipPath) -> Vec { let mut ops = Vec::new(); let mut current_pt = Point::ZERO; @@ -1138,6 +1319,7 @@ fn scan_leaking_ops(ops: &[Op], rem_start: usize, rem_end: usize) -> (Vec, b fn build_relocated_unit_ops( marked_open: Option<&Op>, source_gs_ops: &[(Op, Matrix)], + inherited_drawing_ops: &[Op], extracted: ExtractedScopeState, dest_ctm: Matrix, draw_matrix: [f64; 6], @@ -1165,6 +1347,8 @@ fn build_relocated_unit_ops( unit_ops.push(gs_op.clone()); } + unit_ops.extend(inherited_drawing_ops.iter().cloned()); + let non_gs_ops = extracted.state_ops.into_iter().filter(|o| !o.is("gs")); unit_ops.extend(non_gs_ops); @@ -1260,9 +1444,12 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran let mut state = ImageState::new(Matrix::IDENTITY, p.crop(doc), Vec::new()); let mut tracker = ExtendedGStateTracker::default(); + let mut drawing_tracker = DrawingStateTracker::default(); let mut source_effective_state = EffectiveGState::default(); let mut source_gs_ops = Vec::new(); + let mut source_drawing_state = DrawingState::default(); let mut dest_effective_state = EffectiveGState::default(); + let mut dest_drawing_state = DrawingState::default(); let mut dest_ctm = Matrix::IDENTITY; let mut scope_start_ctm = Matrix::IDENTITY; let mut outer_clips = Vec::new(); @@ -1282,9 +1469,11 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran if i == target { source_effective_state = tracker.current_state.clone(); source_gs_ops = tracker.active_gs_ops.iter().map(|(_, op, ctm)| (op.clone(), *ctm)).collect(); + source_drawing_state = drawing_tracker.current.clone(); } if i == scan_target { dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); dest_ctm = state.placement.matrix; dest_clips = state.placement.clips.clone(); } @@ -1293,12 +1482,14 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran } state.apply(op)?; tracker.step(op, &ext_gstates, doc, state.placement.matrix)?; + drawing_tracker.step(op)?; if i == rem_end.saturating_sub(1) { ctm_after_removal = state.placement.matrix; } } if scan_target >= ops.len() { dest_effective_state = tracker.current_state.clone(); + dest_drawing_state = drawing_tracker.current.clone(); dest_ctm = state.placement.matrix; dest_clips = state.placement.clips.clone(); } @@ -1370,7 +1561,9 @@ pub fn arrange_image(doc: &mut Document, page: usize, index: usize, order: Arran let marked_open = if marked_unit && !has_other_paint { ops.get(target_unit.start) } else { None }; let has_closing_emc = marked_open.is_some() && target_unit.end.checked_sub(1).and_then(|i| ops.get(i)).is_some_and(|o| o.is("EMC")); - let unit_ops = build_relocated_unit_ops(marked_open, &source_gs_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?; + let inherited_drawing_ops = compute_inherited_drawing_ops(&source_drawing_state, &dest_drawing_state, &extracted.state_ops); + let unit_ops = + build_relocated_unit_ops(marked_open, &source_gs_ops, &inherited_drawing_ops, extracted, dest_ctm, img.draw_matrix, has_closing_emc)?; let mut serialized = Vec::new(); serialized.push(b'\n'); diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index 8f01651464..63cc92919d 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -3540,6 +3540,57 @@ fn image_page_with(content: &str) -> Document { Document::open(Arc::new(out)).unwrap() } +fn stencil_page_with(content: &str) -> Document { + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ImageMask true /Filter /ASCIIHexDecode /Length 5 >>\nstream\n0000>\nendstream".into(), + ]; + 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(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + +fn form_page_with(content: &str) -> Document { + let form_content = "0 0 100 100 re f"; + let objs: Vec = vec![ + "<< /Type /Catalog /Pages 2 0 R >>".into(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /XObject << /Fm0 6 0 R >> >> >>" + .into(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into(), + format!("<< /Type /XObject /Subtype /Form /BBox [0 0 100 100] /Length {} >>\nstream\n{form_content}\nendstream", form_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(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offs { + out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes()); + Document::open(Arc::new(out)).unwrap() +} + /// A path segment operator before any `m` (or `re`) has no subpath to extend: the image scan used /// to call `BezPath`'s `line_to`/`curve_to`/`close_path` on an empty path, which panics by contract, /// taking `object_list`, `page_images` and the Office export's image walk down with it (#3581). @@ -8878,6 +8929,78 @@ fn arrange_preserves_leaked_color_and_text_state() { let idx = (((page.height - 5) * page.width + 5) * 4) as usize; assert_eq!(page.rgba[idx..idx + 3], [255, 0, 0], "point (5, 5 from bottom) inside 10x10 rect fills red"); } + +#[test] +fn arrange_preserves_inherited_drawing_state_for_stencil_and_form() { + // 1. Stencil mask test: + // Page has: q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + // Non-overlapping stencil and text. + let content = "q 1 0 0 rg q 100 0 0 100 50 50 cm /Im0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut doc = stencil_page_with(content); + + // Render before move: + let pdf_before = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(pdf_before), pdfcraft_render::RenderConfig::default()); + let page_before = renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let stencil_pixel_idx = (((page_before.height - 75) * page_before.width + 75) * 4) as usize; + assert_eq!(page_before.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil starts red"); + + // Bring to front + images::arrange_image(&mut doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + + // Save and reopen + let doc = reopen(&doc); + + // Verify content stream has inherited color + let bytes = page_content_bytes(&doc, 0); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("1 0 0 rg"), "inherited 1 0 0 rg must be replayed in moved unit: {s}"); + + // Render after reopen and verify stencil remains red: + let pdf_after = write_incremental(&doc, &SaveOptions::default()).unwrap(); + let mut renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(pdf_after), pdfcraft_render::RenderConfig::default()); + let page_after = renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(page_after.rgba[stencil_pixel_idx..stencil_pixel_idx + 3], [255, 0, 0], "stencil stays red after BringToFront and reopen"); + + // 2. Form XObject test: + // Page has: q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET + let form_content = "q 0 1 0 rg q 1 0 0 1 50 50 cm /Fm0 Do Q Q BT /F1 12 Tf 20 700 Td (Text) Tj ET"; + let mut form_doc = form_page_with(form_content); + + let form_pdf_before = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_before = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_before), pdfcraft_render::RenderConfig::default()); + let form_page_before = form_renderer_before.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + let form_pixel_idx = (((form_page_before.height - 75) * form_page_before.width + 75) * 4) as usize; + assert_eq!(form_page_before.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form starts green"); + + images::arrange_image(&mut form_doc, 0, 0, images::ArrangeOrder::BringToFront).unwrap(); + let form_doc = reopen(&form_doc); + + let form_pdf_after = write_incremental(&form_doc, &SaveOptions::default()).unwrap(); + let mut form_renderer_after = pdfcraft_render::PageRenderer::new(Arc::new(form_pdf_after), pdfcraft_render::RenderConfig::default()); + let form_page_after = form_renderer_after.render(pdfcraft_render::RenderRequest { + page: 0, + kind: pdfcraft_render::RequestKind::Pixels, + scale: 1.0, + ..Default::default() + }); + assert_eq!(form_page_after.rgba[form_pixel_idx..form_pixel_idx + 3], [0, 255, 0], "form stays green after BringToFront and reopen"); +} /// Existing-row alignment is explicitly refused without mutating the paragraph. #[test] fn one_row_of_a_paragraph_takes_its_own_alignment_is_refused_without_changing_the_document() { diff --git a/crates/engine/src/edit/run.rs b/crates/engine/src/edit/run.rs index 9a80e6882d..37bbcaf830 100644 --- a/crates/engine/src/edit/run.rs +++ b/crates/engine/src/edit/run.rs @@ -373,7 +373,10 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi ai.rect = bbox(to_display, *to); pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; } - ImageEdit::Transform(t) => { + ImageEdit::Transform(_) | ImageEdit::Flip { .. } => { + let pdfcraft_edit::ImageChange::Transform(t) = c else { + return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected transform".into()))); + }; let mul = |m1: [f64; 6], m2: [f64; 6]| { [ m1[0] * m2[0] + m1[1] * m2[2], @@ -384,7 +387,7 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi m1[4] * m2[1] + m1[5] * m2[3] + m2[5], ] }; - let t_display = mul(mul(view, *t), to_display); + let t_display = mul(mul(view, t), to_display); let is_parallel = t_display[1].abs() < 1e-5 && t_display[2].abs() < 1e-5; let is_perpendicular = t_display[0].abs() < 1e-5 && t_display[3].abs() < 1e-5; @@ -434,14 +437,6 @@ fn run_edit_page_image(doc: &mut pdfcraft_cos::Document, page: usize, index: usi } pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; } - ImageEdit::Flip { horizontal: true } => { - ai.flip_h = !ai.flip_h; - pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; - } - ImageEdit::Flip { horizontal: false } => { - ai.flip_v = !ai.flip_v; - pdfcraft_edit::update_content(doc, page, added_idx, &AddedContent::Image(ai))?; - } ImageEdit::Replace { .. } => { let pdfcraft_edit::ImageChange::Replace(new_image) = c else { return Err(EditError::Edit(pdfcraft_edit::EditError::Invalid("expected replace".into()))); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 3b8152235f..30d5e9e1ea 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -9495,37 +9495,130 @@ fn transform_on_added_image_folds_quarter_turns_and_rejects_non_axis_aligned() { #[test] fn test_added_image_transform_reflections_with_rotation() { - let make_png = |rgb: [u8; 3]| { + let make_asymmetric_png = || { + let mut px = Vec::with_capacity(20 * 20 * 3); + for y in 0..20 { + for x in 0..20 { + let color = match (x < 10, y < 10) { + (true, true) => [255, 0, 0], // TL: Red + (false, true) => [0, 255, 0], // TR: Green + (true, false) => [0, 0, 255], // BL: Blue + (false, false) => [255, 255, 0], // BR: Yellow + }; + px.extend_from_slice(&color); + } + } let mut png = Vec::new(); { - let mut enc = png::Encoder::new(&mut png, 2, 2); + let mut enc = png::Encoder::new(&mut png, 20, 20); enc.set_color(png::ColorType::Rgb); - let data = [rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2], rgb[0], rgb[1], rgb[2]]; - enc.write_header().unwrap().write_image_data(&data).unwrap(); + enc.write_header().unwrap().write_image_data(&px).unwrap(); } Arc::new(png) }; - let png = make_png([255, 0, 0]); - let (mut s, id) = session_with(1); - s.apply(id, Edit::AddImage { page: 0, rect: Some([100.0, 100.0, 200.0, 150.0]), name: "img.png".into(), bytes: png }).unwrap(); + let colour_at = |s: &Session, id: DocId, x: u32, y_from_top: u32| -> [u8; 3] { + let doc = s.get(id).unwrap(); + let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default()); + let out = r.render(pdfcraft_render::RenderRequest { page: 0, scale: 1.0, ..Default::default() }); + let i = ((y_from_top * out.width + x) * 4) as usize; + [out.rgba[i], out.rgba[i + 1], out.rgba[i + 2]] + }; - // Rotate by 90° CW (quarter turns CCW: rotation = 3): - s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); - let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); - let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; - assert_eq!(ai.rotation, 3); - assert!(!ai.flip_h); - assert!(!ai.flip_v); + let is_red = |c: [u8; 3]| c[0] > 200 && c[1] < 50 && c[2] < 50; + let is_green = |c: [u8; 3]| c[0] < 50 && c[1] > 200 && c[2] < 50; + let is_blue = |c: [u8; 3]| c[0] < 50 && c[1] < 50 && c[2] > 200; + let is_yellow = |c: [u8; 3]| c[0] > 200 && c[1] > 200 && c[2] < 50; - // Apply a horizontal flip in display space (parallel transform with m0 = -1): - // For rotation = 3 (odd rotation), display horizontal flip corresponds to local vertical flip (flip_v): - s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Transform([-1.0, 0.0, 0.0, 1.0, 300.0, 0.0]) }).unwrap(); - let cos2 = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); - let AddedContent::Image(ai2) = &pdfcraft_edit::list_added(&cos2)[0].content else { panic!() }; - assert_eq!(ai2.rotation, 3); - assert!(!ai2.flip_h, "flip_h should NOT be toggled for horizontal flip when rotation is 90°"); - assert!(ai2.flip_v, "flip_v should be toggled for horizontal flip when rotation is 90°"); + let check_quadrants = + |s: &Session, id: DocId, tl: fn([u8; 3]) -> bool, tr: fn([u8; 3]) -> bool, bl: fn([u8; 3]) -> bool, br: fn([u8; 3]) -> bool, label: &str| { + let c_tl = colour_at(s, id, 75, 125); + let c_tr = colour_at(s, id, 125, 125); + let c_bl = colour_at(s, id, 75, 175); + let c_br = colour_at(s, id, 125, 175); + assert!(tl(c_tl), "{label} TL mismatch: got {:?}", c_tl); + assert!(tr(c_tr), "{label} TR mismatch: got {:?}", c_tr); + assert!(bl(c_bl), "{label} BL mismatch: got {:?}", c_bl); + assert!(br(c_br), "{label} BR mismatch: got {:?}", c_br); + }; + + // --- CASE 1: 90° clockwise rotation (ImageEdit::Rotate(1), ai.rotation = 3) --- + // Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow. + // After 90° CW: TL=Blue, TR=Red, BL=Yellow, BR=Green. + { + // 1a: Flip Horizontal after 90° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + check_quadrants(&s, id, is_red, is_green, is_blue, is_yellow, "initial"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + check_quadrants(&s, id, is_blue, is_red, is_yellow, is_green, "90° CW"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 3); + assert!(!ai.flip_h, "at 90° rotation, horizontal flip toggles flip_v, not flip_h"); + assert!(ai.flip_v, "at 90° rotation, horizontal flip toggles flip_v"); + // Flipping horizontally on screen swaps left & right columns: TL=Red, TR=Blue, BL=Green, BR=Yellow + check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "90° CW + flip horizontal"); + } + + { + // 1b: Flip Vertical after 90° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(1) }).unwrap(); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 3); + assert!(ai.flip_h, "at 90° rotation, vertical flip toggles flip_h"); + assert!(!ai.flip_v, "at 90° rotation, vertical flip toggles flip_h, not flip_v"); + // Flipping vertically on screen swaps top & bottom rows: TL=Yellow, TR=Green, BL=Blue, BR=Red + check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "90° CW + flip vertical"); + } + + // --- CASE 2: 270° clockwise rotation (ImageEdit::Rotate(3), ai.rotation = 1) --- + // Unrotated: TL=Red, TR=Green, BL=Blue, BR=Yellow. + // After 270° CW: TL=Green, TR=Yellow, BL=Red, BR=Blue. + { + // 2a: Flip Horizontal after 270° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap(); + check_quadrants(&s, id, is_green, is_yellow, is_red, is_blue, "270° CW"); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: true } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 1); + assert!(!ai.flip_h, "at 270° rotation, horizontal flip toggles flip_v, not flip_h"); + assert!(ai.flip_v, "at 270° rotation, horizontal flip toggles flip_v"); + // Flipping horizontally on screen swaps left & right columns: TL=Yellow, TR=Green, BL=Blue, BR=Red + check_quadrants(&s, id, is_yellow, is_green, is_blue, is_red, "270° CW + flip horizontal"); + } + + { + // 2b: Flip Vertical after 270° rotation + let (mut s, id) = session_with(1); + s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "img.png".into(), bytes: make_asymmetric_png() }) + .unwrap(); + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Rotate(3) }).unwrap(); + + s.apply(id, Edit::EditPageImage { page: 0, index: 0, change: ImageEdit::Flip { horizontal: false } }).unwrap(); + let cos = pdfcraft_cos::Document::open(s.get(id).unwrap().bytes.clone()).unwrap(); + let AddedContent::Image(ai) = &pdfcraft_edit::list_added(&cos)[0].content else { panic!() }; + assert_eq!(ai.rotation, 1); + assert!(ai.flip_h, "at 270° rotation, vertical flip toggles flip_h"); + assert!(!ai.flip_v, "at 270° rotation, vertical flip toggles flip_h, not flip_v"); + // Flipping vertically on screen swaps top & bottom rows: TL=Red, TR=Blue, BL=Green, BR=Yellow + check_quadrants(&s, id, is_red, is_blue, is_green, is_yellow, "270° CW + flip vertical"); + } } /// ISO 32000-2 Table 192, 12.7.5.2.3 and Table 228 (Comb): a check box, a push button, a comb diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a29d2c87b5..3725a3662f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -47,6 +47,7 @@ mod menus; pub mod marks { pub use crate::marks_ui::{MarksDraft, PageRange, Subset}; } +pub use content_ui::TextDraft; pub use create_ui::Clip; pub use link_ui::LinkDraft; pub use optimize_ui::{OptimizeDraft, OptimizeTab}; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index dafbe238a1..65b294bc8a 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -281,6 +281,12 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { Some(paragraphs), ); let mut changed = out.changed; + if out.toggle_superscript && crate::edit_text_ui::toggle_superscript(&mut ed) { + changed = true; + } + if out.toggle_subscript && crate::edit_text_ui::toggle_subscript(&mut ed) { + changed = true; + } match out.font { Some(FontChoice::KeepSource) => { ed.keep_source_font(); @@ -315,7 +321,39 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { } let mut style = draft.style.clone(); let paragraphs = crate::format_text_ui::Paragraphs { text: &draft.text, selection: draft.selection.clone(), tidy: true }; - added_text_format(ui, t, &mut style, Some(paragraphs)); + let out = added_text_format(ui, t, &mut style, Some(paragraphs)); + if out.toggle_superscript || out.toggle_subscript { + let (s_pos, e_pos) = + if let Some(r) = &draft.selection { (r.start.min(draft.text.len()), r.end.min(draft.text.len())) } else { (0, draft.text.len()) }; + let (start, end) = crate::edit_text_ui::snap_char_boundaries(&draft.text, s_pos, e_pos); + if start < end { + let target = &draft.text[start..end]; + let converted: String = if out.toggle_superscript { + let all_sup = target.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c); + if all_sup { + target.chars().map(crate::edit_text_ui::char_from_superscript).collect() + } else { + target.chars().map(crate::edit_text_ui::char_to_superscript).collect() + } + } else { + let all_sub = target.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c); + if all_sub { + target.chars().map(crate::edit_text_ui::char_from_subscript).collect() + } else { + target.chars().map(crate::edit_text_ui::char_to_subscript).collect() + } + }; + if converted != target { + let mut new_draft = draft.clone(); + let conv_len = converted.len(); + new_draft.text.replace_range(start..end, &converted); + if new_draft.selection.is_some() { + new_draft.selection = Some(start..start + conv_len); + } + app.views[i].content.draft = Some(new_draft); + } + } + } if style != draft.style { app.restyle_draft(i, style); } @@ -373,12 +411,18 @@ fn format_section(app: &mut PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) { /// Format text for added text: the standard families, size, colour, style and alignment /// (of the lines `paragraphs` selects, while the text is being typed). -fn added_text_format(ui: &mut egui::Ui, t: &Tokens, style: &mut pdfcraft_engine::AddedText, paragraphs: Option) { +fn added_text_format( + ui: &mut egui::Ui, + t: &Tokens, + style: &mut pdfcraft_engine::AddedText, + paragraphs: Option, +) -> crate::format_text_ui::Outcome { let shown = style.family.label().to_string(); let out = crate::format_text_ui::panel(ui, t, style, None, crate::format_text_ui::Fonts { sources: None, shown, mixed_size: false }, paragraphs); if let Some(crate::format_text_ui::FontChoice::Family(family)) = out.font { style.family = family; } + out } impl PdfCraftApp { @@ -418,6 +462,7 @@ impl PdfCraftApp { /// Apply the open paragraph's formatting as one change; the paragraph stays open. pub(crate) fn apply_line_format(&mut self, index: usize, mut ed: crate::edit_text_ui::LineEditor, ctx: Option<&egui::Context>) { let edit = pdfcraft_engine::Edit::EditTextBlock { page: ed.page, block: ed.block, text: ed.text.clone(), style: ed.style() }; + let sel = ed.selection.clone(); if self.apply_edit(edit) { ed.applied(); if let Some(doc) = self.session.get(self.views[index].id) @@ -428,6 +473,9 @@ impl PdfCraftApp { ed.fonts = crate::edit_text_ui::install_source_fonts(ctx, doc, ed.page); } } + if sel.is_some() { + ed.selection = sel; + } } self.views[index].line_editor = Some(ed); } diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 636a6d1897..c89a21bd79 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -3,7 +3,7 @@ //! horizontal scale and character spacing. One panel serves a paragraph being edited (with the //! fonts already on its page and the spacing controls) and added text (the standard families, //! size, colour, style, alignment and character spacing). What PdfCraft can't apply yet (lists, -//! superscript and subscript, paragraph spacing, or line spacing and scale for added text) is shown +//! paragraph spacing, or line spacing and scale for added text) is shown //! disabled and says so. //! //! The icons are Lucide's (AGENTS.md §1); only the arrangement follows Acrobat. @@ -87,6 +87,8 @@ impl Paragraphs<'_> { pub(crate) struct Outcome { pub changed: bool, pub font: Option, + pub toggle_superscript: bool, + pub toggle_subscript: bool, } const SIZES: [f64; 16] = [4.0, 6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 16.0, 18.0, 20.0, 24.0, 28.0, 36.0, 48.0, 72.0]; @@ -298,8 +300,35 @@ pub(crate) fn panel( } None => planned(ui, "underline", tl!("Underline")), } - planned(ui, "superscript", tl!("Superscript")); - planned(ui, "subscript", tl!("Subscript")); + let (is_super, is_sub) = paragraphs + .as_ref() + .map(|p| { + let (s_pos, e_pos) = + if let Some(r) = &p.selection { (r.start.min(p.text.len()), r.end.min(p.text.len())) } else { (0, p.text.len()) }; + let (start, end) = crate::edit_text_ui::snap_char_boundaries(p.text, s_pos, e_pos); + if start >= end { + return (false, false); + } + let target = &p.text[start..end]; + let all_sup = !target.is_empty() && target.chars().all(|c| crate::edit_text_ui::char_from_superscript(c) != c); + let all_sub = !target.is_empty() && target.chars().all(|c| crate::edit_text_ui::char_from_subscript(c) != c); + (all_sup, all_sub) + }) + .unwrap_or((false, false)); + + if paragraphs.is_some() { + if icons::button(ui, "superscript", 30.0, is_super, tl!("Superscript")).clicked() { + out.toggle_superscript = true; + out.changed = true; + } + if icons::button(ui, "subscript", 30.0, is_sub, tl!("Subscript")).clicked() { + out.toggle_subscript = true; + out.changed = true; + } + } else { + planned(ui, "superscript", tl!("Superscript")); + planned(ui, "subscript", tl!("Subscript")); + } }); // Line and paragraph spacing; horizontal scale and character spacing: two columns, each an @@ -352,7 +381,7 @@ pub(crate) fn panel( number_with_presets(ui, spacing, &CHAR_SPACINGS, half, (label.id, tl!("Character spacing"))); }); - out.changed = size_committed || (look.clone(), extras.as_deref().copied()) != before; + out.changed = out.changed || size_committed || (look.clone(), extras.as_deref().copied()) != before; ui.add_space(6.0); ui.separator(); out diff --git a/crates/ui-egui/src/tools/edit_text_ui.rs b/crates/ui-egui/src/tools/edit_text_ui.rs index 371140ae68..aefacfb445 100644 --- a/crates/ui-egui/src/tools/edit_text_ui.rs +++ b/crates/ui-egui/src/tools/edit_text_ui.rs @@ -26,7 +26,7 @@ pub(crate) use image::{ ImageCx, Matrix, body_hit as picture_hit, handle_hit as picture_handle_hit, image_context_menu, image_input, quad_contains, user_to_screen, }; #[cfg(test)] -use image::{rotate_knob, turn_degrees}; +use image::{rotate_knob, turn_degrees, turn_degrees_delta}; #[cfg(test)] use layout::{DARK_EDITOR_FILL, contrast}; pub(crate) use layout::{caret_color_on, editor_fill, editor_font, install_source_fonts}; @@ -370,7 +370,7 @@ pub fn char_from_subscript(c: char) -> char { } } -fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) { +pub(crate) fn snap_char_boundaries(s: &str, start: usize, end: usize) -> (usize, usize) { let mut s_idx = start.min(s.len()); while s_idx > 0 && !s.is_char_boundary(s_idx) { s_idx -= 1; @@ -448,7 +448,7 @@ pub struct LineEditor { /// Current screen-space size. Recomputed from the current page transform before painting. size: f32, /// Take the keyboard focus on the next frame. - pub(crate) focus: bool, + pub focus: bool, /// Whether the text box had the keyboard at the end of the editor's last frame /// ([`lost_focus_once`]). had_focus: bool, @@ -1310,23 +1310,26 @@ pub(crate) fn overlay(ctx: &egui::Context, view: &mut DocView, info: &DocInfo) - } } look = Some((Arc::clone(&output.galley), output.galley_pos)); - if let Some(cursor) = output.cursor_range { - let selected = cursor.as_sorted_char_range(); - let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at); - ed.selection = (!selected.is_empty()).then(|| byte(selected.start.into())..byte(selected.end.into())); - } let r = output.response; - if r.changed() { - ed.follow_typed_rows(ctx, &before); - // The next pass measures the updated draft with the Apply/Save rewrite. - ctx.request_repaint(); - } if ed.focus { r.request_focus(); if let Some(range) = &ed.selection { reselect(ui.ctx(), input, &ed.text, range); } ed.focus = false; + } else if r.has_focus() { + if let Some(cursor) = output.cursor_range { + let selected = cursor.as_sorted_char_range(); + let byte = |index| ed.text.char_indices().nth(index).map_or(ed.text.len(), |(at, _)| at); + ed.selection = (!selected.is_empty()).then(|| byte(selected.start.into())..byte(selected.end.into())); + } else { + ed.selection = None; + } + } + if r.changed() { + ed.follow_typed_rows(ctx, &before); + // The next pass measures the updated draft with the Apply/Save rewrite. + ctx.request_repaint(); } let lost = lost_focus_once(&r, &mut ed.had_focus); let esc = ui.input(|i| i.key_pressed(egui::Key::Escape)); @@ -1519,6 +1522,22 @@ C", &picked assert_eq!(at(f32::NAN, 5.0), 0.0); } + #[test] + fn the_rotate_knob_measures_clockwise_delta_from_drag_start() { + let c = Pos2::new(100.0, 100.0); + // Start dragging at 45° clockwise from top: + let r = 50.0_f32; + let start = c + egui::vec2(r * 45.0_f32.to_radians().sin(), -r * 45.0_f32.to_radians().cos()); + // At start position, delta is 0: + assert!(turn_degrees_delta(c, start, start, false).abs() < 1e-3); + // Move by +15° (to 60°): + let p_60 = c + egui::vec2(r * 60.0_f32.to_radians().sin(), -r * 60.0_f32.to_radians().cos()); + assert!((turn_degrees_delta(c, start, p_60, false) - 15.0).abs() < 1e-2); + // Move by -10° (to 35°): + let p_35 = c + egui::vec2(r * 35.0_f32.to_radians().sin(), -r * 35.0_f32.to_radians().cos()); + assert!((turn_degrees_delta(c, start, p_35, false) + 10.0).abs() < 1e-2); + } + /// An app with one text document open, and an editor on its first paragraph. fn preview_app() -> (crate::PdfCraftApp, LineEditor) { let mut app = crate::PdfCraftApp::new(); 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 7e6eb3c32d..c37102da5b 100644 --- a/crates/ui-egui/src/tools/edit_text_ui/image.rs +++ b/crates/ui-egui/src/tools/edit_text_ui/image.rs @@ -520,18 +520,33 @@ pub(super) fn rotate_knob(b: Rect, view: Rect) -> (Pos2, Pos2) { if above.y - 6.0 >= view.top() { (above, b.center_top()) } else { (b.center_bottom() + egui::vec2(0.0, 22.0), b.center_bottom()) } } -/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap` +/// Degrees clockwise (-180..=180) that turn from `start` onto `pointer` around `centre`; `snap` /// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing. -pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { - let v = pointer - centre; - if !(v.x.is_finite() && v.y.is_finite()) || v.length() < 4.0 { +pub(super) fn turn_degrees_delta(centre: Pos2, start: Pos2, pointer: Pos2, snap: bool) -> f32 { + let v_start = start - centre; + let v_curr = pointer - centre; + if !(v_curr.x.is_finite() && v_curr.y.is_finite()) + || v_curr.length() < 4.0 + || !(v_start.x.is_finite() && v_start.y.is_finite()) + || v_start.length() < 4.0 + { return 0.0; } // Screen y points down: up is (0, -1); clockwise is atan2(x, -y). - let d = v.x.atan2(-v.y).to_degrees(); + let theta_start = v_start.x.atan2(-v_start.y).to_degrees(); + let theta_curr = v_curr.x.atan2(-v_curr.y).to_degrees(); + let mut d = theta_curr - theta_start; + d = ((d + 180.0).rem_euclid(360.0)) - 180.0; if snap { (d / 15.0).round() * 15.0 } else { d } } +/// Degrees clockwise (-180..=180) that turn "straight up" from `centre` onto the pointer; `snap` +/// rounds to steps of 15 degrees (Shift). A pointer on the centre turns nothing. +#[allow(dead_code)] +pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 { + turn_degrees_delta(centre, centre - egui::vec2(0.0, 10.0), pointer, snap) +} + #[doc(hidden)] pub fn image_menu_id() -> egui::Id { egui::Id::unique("image-menu") @@ -745,17 +760,19 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage 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)); - let centre = b.center(); + let centre = obb.screen_center; + let rot_knob = if obb.is_rotated { obb.s_rot } else { knob }; + let drag_start = view.image_selection.as_ref().and_then(|s| s.drag).map_or(rot_knob, |d| d.start); + let degrees = hover_pos.map_or(0.0, |p| turn_degrees_delta(centre, drag_start, p, shift)); 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(); + let outline = obb.corners().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)); + let label = Rect::from_center_size(turn(rot_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); view.cursor.claim(Layer::Drag, egui::CursorIcon::Grabbing); diff --git a/crates/ui-egui/tests/it/editing.rs b/crates/ui-egui/tests/it/editing.rs index e5714a6bab..922b6fde41 100644 --- a/crates/ui-egui/tests/it/editing.rs +++ b/crates/ui-egui/tests/it/editing.rs @@ -4306,6 +4306,83 @@ fn dragging_the_rotate_knob_turns_a_page_image() { assert!((turned[2] - turned[0] - hh).abs() < 1.5 && (turned[3] - turned[1] - w).abs() < 1.5, "{before:?} -> {turned:?}"); } +#[test] +fn dragging_rotate_knob_on_already_rotated_image_applies_delta_only() { + let mut png = Vec::new(); + image::RgbImage::from_pixel(80, 40, image::Rgb([200, 40, 40])).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| { + let mut app = PdfCraftApp::new(); + app.set_option("language", "en").unwrap(); + app.open_bytes("picture.png", None, png.clone()).expect("opens"); + app + }); + h.run_steps(4); + let id = h.state().views[0].id; + + // Center picture + let full = h.state().session.get(id).unwrap().page_images(0)[0].rect; + let (qw, qh) = ((full[2] - full[0]) / 4.0, (full[3] - full[1]) / 4.0); + let (mx, my) = ((full[0] + full[2]) / 2.0, (full[1] + full[3]) / 2.0); + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { + page: 0, + index: 0, + change: pdfcraft_engine::ImageEdit::Move([mx - qw, my - qh, mx + qw, my + qh]), + }); + h.run_steps(4); + + // Rotate the image by 45° clockwise initially + h.state_mut().apply_edit(pdfcraft_engine::Edit::EditPageImage { page: 0, index: 0, change: pdfcraft_engine::ImageEdit::RotateBy(45.0) }); + h.run_steps(4); + + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let c = r.center(); + + // Select the picture + h.hover_at(c); + h.run_steps(1); + h.drag_at(c); + h.run_steps(1); + h.drop_at(c); + h.run_steps(2); + assert!(h.state().views[0].image_selection.is_some(), "selected"); + + let initial_img = &h.state().session.get(id).unwrap().page_images(0)[0]; + let initial_matrix = initial_img.matrix; + let xf = h.state().views[0].page_xform(0).expect("page 0 xform"); + let doc_info = &h.state().session.get(id).unwrap().info; + let obb = pdfcraft_ui_egui::canvas::ImageObb::from_matrix(initial_matrix, &xf, doc_info, 0); + let knob = obb.s_rot; + let center = obb.screen_center; + let radius = (knob - center).length(); + + // Hover at the rotated knob position: + h.hover_at(knob); + h.run_steps(1); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grab, "knob offers Grab cursor on 45° rotated image"); + + // Rotate clockwise by a small delta of 15° (from 45° to 60°): + let to_angle = (45.0 + 15.0_f32).to_radians(); + let to = center + egui::vec2(radius * to_angle.sin(), -radius * to_angle.cos()); + + h.drag_at(knob); + h.run_steps(1); + for k in 1..=4 { + h.hover_at(knob + (to - knob) * (k as f32 / 4.0)); + h.run_steps(1); + } + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Grabbing); + h.drop_at(to); + h.run_steps(4); + + let doc = h.state().session.get(id).unwrap(); + let final_img = &doc.page_images(0)[0]; + let angle_deg = (-final_img.matrix[1]).atan2(final_img.matrix[0]).to_degrees(); + let angle_deg = (angle_deg % 360.0 + 360.0) % 360.0; + assert!((angle_deg - 60.0).abs() < 2.0, "expected ~60° total rotation, got {angle_deg}°"); +} + #[test] fn editing_existing_form_artwork_on_the_page() { use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream, write_incremental}; @@ -4686,6 +4763,70 @@ fn the_format_panel_restyles_the_paragraph_being_edited() { assert!(s.views[0].line_editor.as_ref().is_some_and(|e| e.extras.underline)); } +#[test] +fn the_format_panel_toggles_superscript_and_subscript_on_selection() { + let mut h = harness(1, |_| {}); + assert!(h.state_mut().execute("edit.edit_text")); + h.run_steps(2); + let r = h.state().views[0].page_screen_rect(0).expect("on screen"); + let at = egui::pos2(r.left() + 40.0 / 200.0 * r.width(), r.top() + (300.0 - 158.0) / 300.0 * r.height()); + h.hover_at(at); + h.run_steps(1); + h.drag_at(at); + h.run_steps(1); + h.drop_at(at); + h.run_steps(3); + assert!(h.state().views[0].line_editor.is_some()); + + // In line editor, select "1" in "Page 1": + let ed = h.state_mut().views[0].line_editor.as_mut().unwrap(); + ed.selection = Some(5..6); + ed.focus = true; + h.run_steps(1); + + // Click Superscript: "1" becomes "¹" + h.get_by_label("FORMAT TEXT"); + let btn = h.get_by_label("Superscript"); + btn.click(); + h.run_steps(4); + + let s = h.state(); + let doc = s.session.get(s.views[0].id).unwrap(); + assert_eq!(doc.can_undo(), Some("Edit text")); + assert_eq!(doc.text_blocks(0)[0].text, "Page ¹"); + + // Click Superscript again: "¹" toggles back to "1" + h.get_by_label("Superscript").click(); + h.run_steps(4); + let s2 = h.state(); + let doc2 = s2.session.get(s2.views[0].id).unwrap(); + assert_eq!(doc2.text_blocks(0)[0].text, "Page 1"); + + // Subscript toggle in added text: + h.state_mut().views[0].line_editor = None; + h.state_mut().quick_tool = pdfcraft_ui_egui::QuickTool::AddText; + let draft = pdfcraft_ui_egui::TextDraft { + page: 0, + index: None, + rect: [20.0, 20.0, 120.0, 40.0], + text: "H2O".into(), + style: Default::default(), + focus: false, + selection: Some(1..2), + typing_from: None, + }; + h.state_mut().views[0].content.draft = Some(draft); + h.run_steps(2); + + h.get_by_label("Subscript").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("H₂O")); + + h.get_by_label("Subscript").click(); + h.run_steps(2); + assert_eq!(h.state().views[0].content.draft.as_ref().map(|d| d.text.as_str()), Some("H2O")); +} + /// Horizontal scale and Character spacing take any value in their ranges, not only the listed /// ones: values between the listed ones and beyond them, typed in Format text, reach the saved /// file as the engine writes them. diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index fb7d9cd82c..f12d4fa284 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1968,7 +1968,10 @@ milestone = "M7" status = "partial" commands = ["edit.edit_text"] tools = [] -evidence = ["crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion"] +evidence = [ + "crates/ui-egui/src/tools/edit_text_ui.rs::superscript_and_subscript_conversion", + "crates/ui-egui/tests/it/editing.rs::the_format_panel_toggles_superscript_and_subscript_on_selection", +] notes = "Toggled in the Format text panel for the selection. Uses Unicode superscript and subscript forms (2 ↔ ²) rather than text rise (Ts) and font scaling, so the font must provide the corresponding glyphs and extracted text contains the Unicode characters." [[feature]]