diff --git a/crates/engine/src/cmd/objects/arrange.rs b/crates/engine/src/cmd/objects/arrange.rs index abdc85695a..15cc5555d7 100644 --- a/crates/engine/src/cmd/objects/arrange.rs +++ b/crates/engine/src/cmd/objects/arrange.rs @@ -147,7 +147,8 @@ pub(super) fn move_object(s: &mut Session, pos: Pos, page: Option, x: f32, } /// [`move_object`]'s one placement: move the object's character to the anchor under (x, y) on -/// `page` and offset it from there. +/// `page` and offset it from there. Objects in headers/footers, on pages without anchoring text, +/// or configured not to move with text retain page-relative anchoring. fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, h): (f32, f32)) -> Result { use wordcraft_doc::para::Anchor; if pos.path.depth() > 0 || !movable_story(s, pos.story) { @@ -164,6 +165,8 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, Some(path) => (Pos { story, path, off: 0 }, false), None => (nearest_line(&layout, page, y, story).ok_or_else(|| CmdError::Params("no text on that page to anchor to".into()))?, true), }; + let was_with_text = + s.doc.para_at(pos).and_then(|p| p.object_at(pos.off)).and_then(|o| o.frame()).map_or(story == StoryRef::Body, |(_, _, f)| moves_with_text(f)); // Move its character to the anchor, unless it's already in that paragraph and drawn on this // page (one anchored on a later page of it is drawn there, #4250). let at = if anchor.story == pos.story && anchor.path == pos.path && !on_page && layout.object(pos, page).is_some_and(|o| o.page == page) { @@ -196,7 +199,7 @@ fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, f.x = x.clamp(-MAX_OFFSET, MAX_OFFSET); f.y = y.clamp(-MAX_OFFSET, MAX_OFFSET); })?; - if on_page { + if on_page || story != StoryRef::Body || !was_with_text { return Ok(at); } // Then offsets from its paragraph, as laid out with the object there. @@ -223,16 +226,23 @@ pub(super) fn unalign(s: &mut Session) -> Result<(), CmdError> { return Ok(()); } let hit = s.layout().object(&pos, s.page_hint).ok_or_else(|| CmdError::Failed("the object isn't laid out".into()))?; - let (ox, oy) = (hit.rect.x - hit.origin.x, hit.rect.y - hit.origin.y); + let (ox, oy) = ( + if float.h_rel == Anchor::Page { hit.rect.x } else { hit.rect.x - hit.origin.x }, + if float.v_rel == Anchor::Page { hit.rect.y } else { hit.rect.y - hit.origin.y }, + ); edit_float(s, &pos, |f| { if f.h_align.is_some() { f.h_align = None; - f.h_rel = Anchor::Column; + if f.h_rel != Anchor::Page { + f.h_rel = Anchor::Column; + } f.x = ox.clamp(-MAX_OFFSET, MAX_OFFSET); } if f.v_align.is_some() { f.v_align = None; - f.v_rel = Anchor::Paragraph; + if f.v_rel != Anchor::Page { + f.v_rel = Anchor::Paragraph; + } f.y = oy.clamp(-MAX_OFFSET, MAX_OFFSET); } })?; diff --git a/crates/engine/src/cmd/objects/tests.rs b/crates/engine/src/cmd/objects/tests.rs index 13836d60c5..cf302543ac 100644 --- a/crates/engine/src/cmd/objects/tests.rs +++ b/crates/engine/src/cmd/objects/tests.rs @@ -1254,6 +1254,165 @@ fn header_pictures_can_be_resized_and_dragged() { s.run("edit.undo", &json!({})).unwrap(); let u = s.layout().object(&selected(&s).unwrap().0, 0).expect("laid out"); assert!((u.rect.x - 200.0).abs() < 1.0 && (u.rect.y - 60.0).abs() < 1.0, "{:?}", u.rect); + let float = selected(&s).unwrap().1.frame().unwrap().2.clone(); + assert_eq!((float.h_rel, float.v_rel), (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Page)); + assert_eq!((float.x, float.y), (200.0, 60.0)); +} + +/// #5812: arrange.bounds on a header picture (or after moveWithText false) keeps page anchoring +/// and exports a page-anchored frame in .odt with coordinates relative to the page. +#[test] +fn arrange_bounds_on_header_picture_writes_page_anchored_odt_frame() { + use wordcraft_doc::para::Anchor; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("insert.editHeader", &json!({})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + s.run("arrange.bounds", &json!({"x": 0.0, "y": 0.0, "page": 0})).unwrap(); + let float = selected(&s).unwrap().1.frame().unwrap().2.clone(); + assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page)); + assert_eq!((float.x, float.y), (0.0, 0.0)); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap(); + let mut styles = String::new(); + std::io::Read::read_to_string(&mut z.by_name("styles.xml").unwrap(), &mut styles).unwrap(); + assert!(styles.contains(r#"text:anchor-type="page""#), "frame should be page-anchored: {styles}"); + assert!(styles.contains(r#"svg:x="0pt""#) && styles.contains(r#"svg:y="0pt""#), "offsets should be 0pt: {styles}"); + assert!(!styles.contains(r#"text:anchor-type="paragraph""#), "frame should not be paragraph-anchored: {styles}"); + + // Also test after arrange.moveWithText false + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + s.run("arrange.bounds", &json!({"x": 10.0, "y": 20.0, "page": 0})).unwrap(); + let float = selected(&s).unwrap().1.frame().unwrap().2.clone(); + assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page)); + assert_eq!((float.x, float.y), (10.0, 20.0)); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap(); + let mut styles = String::new(); + std::io::Read::read_to_string(&mut z.by_name("styles.xml").unwrap(), &mut styles).unwrap(); + assert!(styles.contains(r#"text:anchor-type="page""#), "{styles}"); + assert!(styles.contains(r#"svg:x="10pt""#) && styles.contains(r#"svg:y="20pt""#), "{styles}"); +} + +/// Turning off `moveWithText` on a body picture anchors it to the page, and subsequent +/// `arrange.bounds` retains page-relative anchoring and writes a page-anchored ODT frame. +#[test] +fn body_picture_with_move_with_text_false_retains_page_anchor_and_coordinates() { + use wordcraft_doc::para::Anchor; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "First line of paragraph.\n"})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + // Default body float moves with text + let (_pos, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Column)); + + // Turn off moveWithText + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Page)); + + // Now call arrange.bounds on a page where a paragraph starts + s.run("arrange.bounds", &json!({"page": 0, "x": 50.0, "y": 80.0})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!((float.h_rel, float.v_rel), (Anchor::Page, Anchor::Page)); + assert_eq!((float.x, float.y), (50.0, 80.0)); + + let mut z = zip::ZipArchive::new(std::io::Cursor::new(wordcraft_formats::odt::export(&s.doc).unwrap())).unwrap(); + let mut content = String::new(); + std::io::Read::read_to_string(&mut z.by_name("content.xml").unwrap(), &mut content).unwrap(); + assert!(content.contains(r#"text:anchor-type="page""#), "body picture should export with page anchor: {content}"); + assert!(content.contains(r#"svg:x="50pt""#) && content.contains(r#"svg:y="80pt""#), "{content}"); +} + +/// Changing `moveWithText` on an object with `h_align` set preserves its horizontal +/// anchor relation and layout position without displacement. +#[test] +fn move_with_text_preserves_horizontal_position_when_h_align_set() { + use wordcraft_doc::para::{Anchor, FloatAlign}; + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "First body paragraph with some text.\n"})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + + // Set horizontal alignment to End (right) + let (pos, _) = selected(&s).unwrap(); + super::select::edit_float(&mut s, &pos, |f| { + f.h_align = Some(FloatAlign::End); + f.h_rel = Anchor::Margin; + }) + .unwrap(); + s.touch(); + + let hit_before = s.layout().object(&pos, 0).unwrap(); + let x_before = hit_before.rect.x; + + // Toggle moveWithText false + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + let (pos, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Page); + assert_eq!(float.h_align, Some(FloatAlign::End)); + assert_eq!(float.h_rel, Anchor::Margin); + s.touch(); + let hit_after = s.layout().object(&pos, 0).unwrap(); + assert!((hit_after.rect.x - x_before).abs() < 0.01, "{:?} vs {:?}", hit_after.rect.x, x_before); + + // Toggle moveWithText true + s.run("arrange.moveWithText", &json!({"value": true})).unwrap(); + let (pos, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Paragraph); + assert_eq!(float.h_align, Some(FloatAlign::End)); + assert_eq!(float.h_rel, Anchor::Margin); + s.touch(); + let hit_after2 = s.layout().object(&pos, 0).unwrap(); + assert!((hit_after2.rect.x - x_before).abs() < 0.01, "{:?} vs {:?}", hit_after2.rect.x, x_before); +} + +/// Toggling `moveWithText` preserves horizontal anchor relations (`Margin`, `RightMargin`, +/// `Character`, `Column`, `Page`) and offset when the object has no horizontal alignment. +#[test] +fn move_with_text_preserves_horizontal_anchor_relations_without_alignment() { + use wordcraft_doc::para::Anchor; + for rel in [Anchor::Margin, Anchor::RightMargin, Anchor::Character, Anchor::Column, Anchor::Page] { + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": "A line of body text.\n"})).unwrap(); + let data = super::super::insert::base64_encode(&png(20, 10)); + s.run("insert.picture", &json!({"data": data, "width": 100})).unwrap(); + s.run("arrange.wrap", &json!({"wrap": "square"})).unwrap(); + + let (pos, _) = selected(&s).unwrap(); + super::select::edit_float(&mut s, &pos, |f| { + f.h_align = None; + f.h_rel = rel; + f.x = 35.0; + }) + .unwrap(); + s.touch(); + + // Toggle moveWithText false + s.run("arrange.moveWithText", &json!({"value": false})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Page, "expected Page v_rel for {rel:?}"); + assert_eq!(float.h_rel, rel, "expected {rel:?} preserved on moveWithText false"); + assert!((float.x - 35.0).abs() < 0.01, "expected x preserved on moveWithText false"); + + // Toggle moveWithText true + s.run("arrange.moveWithText", &json!({"value": true})).unwrap(); + let (_, obj) = selected(&s).unwrap(); + let float = obj.frame().unwrap().2; + assert_eq!(float.v_rel, Anchor::Paragraph, "expected Paragraph v_rel for {rel:?}"); + assert_eq!(float.h_rel, rel, "expected {rel:?} preserved on moveWithText true"); + assert!((float.x - 35.0).abs() < 0.01, "expected x preserved on moveWithText true"); + } } /// Shape Format › Text (#369): direction and alignment on the box the caret is in, then diff --git a/crates/formats/src/odt/frames.rs b/crates/formats/src/odt/frames.rs index 51accf7fb8..0d13c7a874 100644 --- a/crates/formats/src/odt/frames.rs +++ b/crates/formats/src/odt/frames.rs @@ -576,7 +576,9 @@ pub(super) fn turned(f: &Float) -> bool { /// How a floating picture is written: its `text:anchor-type`, its graphic style's /// `style:graphic-properties` attributes, and the frame's `svg:x`/`svg:y` attributes, or its -/// `draw:transform` when it is turned (`w` × `h` its size). +/// `draw:transform` when it is turned (`w` × `h` its size). Frames anchored to the page on both +/// horizontal and vertical axes export with `page` anchor; mixed or paragraph-relative frames +/// export with `paragraph` (or `char` for line-relative vertical) anchor to preserve placement. pub(super) fn frame_of(f: &Float, w: f32, h: f32) -> (&'static str, String, String) { // A page number holds until it is placed by its paragraph: moved in WordCraft. let by_paragraph = diff --git a/crates/formats/src/odt/tests_more/headers.rs b/crates/formats/src/odt/tests_more/headers.rs index 80478447d4..595abc2838 100644 --- a/crates/formats/src/odt/tests_more/headers.rs +++ b/crates/formats/src/odt/tests_more/headers.rs @@ -395,3 +395,82 @@ fn libreoffices_watermark_reads_as_the_watermark() { let [w, h] = wm.size.unwrap(); assert!((w - 6.69 * 72.0).abs() < 0.5 && (h - 2.24 * 72.0).abs() < 0.5); } + +/// A picture in a header anchored to the page on both axes exports as a page-anchored +/// ODT frame with matching coordinates and round-trips with page-relative relations. +#[test] +fn a_page_anchored_picture_in_a_header_exports_with_page_anchor() { + let mut d = Document::new(); + d.body = vec![text_para("Body", Default::default())]; + let pic = { + let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255])); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png + }; + let media = d.add_media(pic, "png"); + let float = wordcraft_doc::para::Float { + wrap: wordcraft_doc::para::Wrap::Square, + h_rel: wordcraft_doc::para::Anchor::Page, + v_rel: wordcraft_doc::para::Anchor::Page, + x: 0.0, + y: 0.0, + ..Default::default() + }; + let mut hp = Paragraph::new(); + hp.text = wordcraft_doc::para::OBJ.into(); + hp.objects.push(InlineObject::Image { media, w: 100.0, h: 50.0, alt: "pic".into(), float, crop: [0.0; 4], fx: Default::default(), ole: None }); + hp.runs = vec![wordcraft_doc::para::Run { len: hp.text.len(), props: Default::default() }]; + d.parts.insert(1, part(PartKind::Header, vec![para_block(hp)])); + d.last_section.headers.default = Some(1); + + let saved = export(&d).unwrap(); + let styles = crate::odt::tests::entry(&saved, "styles.xml"); + assert!(styles.contains(r#"text:anchor-type="page""#), "{styles}"); + assert!(styles.contains(r#"svg:x="0pt""#) && styles.contains(r#"svg:y="0pt""#), "{styles}"); + assert!(!styles.contains(r#"text:anchor-type="paragraph""#), "{styles}"); + + let back = import(&saved).unwrap(); + let h = back.parts.get(&back.last_section.headers.default.unwrap()).unwrap().blocks[0].as_para().unwrap(); + let InlineObject::Image { float, .. } = &h.objects[0] else { panic!("not an image") }; + assert_eq!((float.h_rel, float.v_rel, float.x, float.y), (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Page, 0.0, 0.0)); +} + +/// Mixed page-relative and paragraph/line/char anchor relations export with the proper +/// anchor type and retain placement and anchor relationships through ODT export and import. +#[test] +fn mixed_anchor_relations_preserve_placement_and_roundtrip() { + for (h_rel, v_rel, expected_anchor, expected_hrel, expected_vrel) in [ + (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Paragraph, "paragraph", "page", "paragraph"), + (wordcraft_doc::para::Anchor::Column, wordcraft_doc::para::Anchor::Page, "paragraph", "paragraph-content", "page"), + (wordcraft_doc::para::Anchor::Character, wordcraft_doc::para::Anchor::Page, "paragraph", "char", "page"), + (wordcraft_doc::para::Anchor::Page, wordcraft_doc::para::Anchor::Line, "char", "page", "line"), + ] { + let mut d = Document::new(); + let pic = { + let img = image::RgbaImage::from_fn(4, 4, |_, _| image::Rgba([10, 20, 30, 255])); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + png + }; + let media = d.add_media(pic, "png"); + let float = wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, h_rel, v_rel, x: 15.0, y: 25.0, ..Default::default() }; + let mut p = Paragraph::new(); + p.text = wordcraft_doc::para::OBJ.into(); + p.objects.push(InlineObject::Image { media, w: 100.0, h: 50.0, alt: "pic".into(), float, crop: [0.0; 4], fx: Default::default(), ole: None }); + p.runs = vec![wordcraft_doc::para::Run { len: p.text.len(), props: Default::default() }]; + d.body = vec![para_block(p)]; + + let saved = export(&d).unwrap(); + let content = crate::odt::tests::entry(&saved, "content.xml"); + assert!(content.contains(&format!(r#"text:anchor-type="{expected_anchor}""#)), "missing {expected_anchor}: {content}"); + assert!(content.contains(&format!(r#"style:horizontal-rel="{expected_hrel}""#)), "missing {expected_hrel}: {content}"); + assert!(content.contains(&format!(r#"style:vertical-rel="{expected_vrel}""#)), "missing {expected_vrel}: {content}"); + + let back = import(&saved).unwrap(); + let p_back = back.body[0].as_para().unwrap(); + let InlineObject::Image { float: back_float, .. } = &p_back.objects[0] else { panic!("not an image") }; + assert_eq!((back_float.h_rel, back_float.v_rel), (h_rel, v_rel)); + assert!((back_float.x - 15.0).abs() < 0.01 && (back_float.y - 25.0).abs() < 0.01, "{back_float:?}"); + } +}