Merge remote-tracking branch 'origin/main' into codex/docbin-craft-libs

This commit is contained in:
Dylan M. Taylor committed 2026-11-13 21:18:03 +00:00
commit 4857eee142
4 files changed
+256 -6

No files matched your search

+13 -5
View File
@@ -147,7 +147,8 @@ pub(super) fn move_object(s: &mut Session, pos: Pos, page: Option<u64>, 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, or on pages without anchoring
/// text, retain page-relative anchoring; a body object is placed by its paragraph (#5705).
fn move_object_once(s: &mut Session, pos: &Pos, page: usize, x: f32, y: f32, (w, h): (f32, f32)) -> Result<Pos, CmdError> {
use wordcraft_doc::para::Anchor;
if pos.path.depth() > 0 || !movable_story(s, pos.story) {
@@ -196,7 +197,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 {
return Ok(at);
}
// Then offsets from its paragraph, as laid out with the object there.
@@ -223,16 +224,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);
}
})?;
+161
View File
@@ -1254,6 +1254,167 @@ 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}");
}
/// A body picture set to stay on the page (`moveWithText` off) is still placed by its paragraph
/// once moved: the move is the placement, and the file keeps no page number to put it back
/// (#5705). Header and footer pictures stay page-anchored (see above).
#[test]
fn a_moved_body_picture_is_placed_by_its_paragraph_even_when_fixed_to_the_page() {
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: fixed on the page until it is moved.
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));
s.run("arrange.bounds", &json!({"page": 0, "x": 50.0, "y": 80.0})).unwrap();
let (pos, obj) = selected(&s).unwrap();
let float = obj.frame().unwrap().2;
assert_eq!((float.h_rel, float.v_rel), (Anchor::Column, Anchor::Paragraph));
assert_eq!(float.page, 0);
let hit = s.layout().object(&pos, 0).expect("laid out");
assert!((hit.rect.x - 50.0).abs() < 1.0 && (hit.rect.y - 80.0).abs() < 1.0, "dropped: {:?}", hit.rect);
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="paragraph""#), "body picture should export with paragraph anchor: {content}");
assert!(!content.contains("anchor-page-number"), "{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
+3 -1
View File
@@ -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 =
@@ -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:?}");
}
}