M7.5: Edit image for added images: rotate (the box turns around its centre), flip horizontal/vertical, crop each side (clipped), replace the picture keeping box and transforms; Edit panel tools, content_update rotate/flip/crop/image; pixel-checked rendering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
92d37ca696
commit
098d9ed4e1
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
Every asset PrintCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with SHA-256 hashes, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
|
||||
|
||||
## In this repository (217)
|
||||
## In this repository (219)
|
||||
|
||||
| Asset | Title | Author | Licence | Source | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
|
|
@ -14,6 +14,8 @@ Every asset PrintCraft includes, bundles or uses to build its published material
|
|||
| `assets/icons/align-right.svg` | Lucide icon "align-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/columns-3.svg` | Lucide icon "columns-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/columns-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/rows-3.svg` | Lucide icon "rows-3" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/rows-3.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/flip-horizontal-2.svg` | Lucide icon "flip-horizontal-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/flip-vertical-2.svg` | Lucide icon "flip-vertical-2" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/app-window.svg` | Lucide icon "app-window" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/app-window.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/arrow-left-right.svg` | Lucide icon "arrow-left-right" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-left-right.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
| `assets/icons/arrow-up-down.svg` | Lucide icon "arrow-up-down" | Lucide Contributors (portions Cole Bemis, Feather) | ISC | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/arrow-up-down.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
|
||||
|
|
|
|||
|
|
@ -74,6 +74,28 @@ kind = "icon"
|
|||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "401a4ceb06a88210638e3264e05f2a5281acad42df5c96363160db75f7fd105a"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/flip-horizontal-2.svg"
|
||||
title = "Lucide icon \"flip-horizontal-2\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-horizontal-2.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "b50af0f3281f7d639a2958c12f0336e41ba889a6f4ff277de16d5c3324c43778"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/flip-vertical-2.svg"
|
||||
title = "Lucide icon \"flip-vertical-2\""
|
||||
author = "Lucide Contributors (portions Cole Bemis, Feather)"
|
||||
source = "https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/flip-vertical-2.svg)"
|
||||
licence = "ISC"
|
||||
licence_file = "assets/icons/LICENSE-lucide.txt"
|
||||
kind = "icon"
|
||||
usage = "UI icon, embedded via crates/ui-egui/src/icon_data.rs"
|
||||
sha256 = "1372eabd5f05d58ec554a6f67da83337367f8c818b2ada7d1591c0ba227ca0a6"
|
||||
|
||||
[[asset]]
|
||||
path = "assets/icons/app-window.svg"
|
||||
title = "Lucide icon \"app-window\""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-flip-horizontal-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 12H8" />
|
||||
<path d="M16 12h-2" />
|
||||
<path d="M22 12h-2" />
|
||||
<path d="M4 12H2" />
|
||||
<path d="M7.298 20.288A1 1 0 008 22h8a1 1 0 00.703-1.712l-3.991-3.99a1 1 0 00-1.424-.001z" />
|
||||
<path d="M7.298 3.712A1 1 0 018 2h8a1 1 0 01.703 1.712l-3.991 3.99a1 1 0 01-1.424.001z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
|
|
@ -0,0 +1,20 @@
|
|||
<!-- @license lucide-static v1.49.0 - ISC -->
|
||||
<svg
|
||||
class="lucide lucide-flip-vertical-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 14v2" />
|
||||
<path d="M12 20v2" />
|
||||
<path d="M12 2v2" />
|
||||
<path d="M12 8v2" />
|
||||
<path d="M20.288 16.703A1 1 0 0022 16V8a1 1 0 00-1.712-.703l-3.99 3.991a1 1 0 00-.001 1.424z" />
|
||||
<path d="M3.712 16.703A1 1 0 012 16V8a1 1 0 011.712-.703l3.99 3.991a1 1 0 01.001 1.424z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 581 B |
|
|
@ -149,13 +149,47 @@ impl Automation {
|
|||
Some(r) => content.with_rect(to_display(r, ph)),
|
||||
None => content,
|
||||
};
|
||||
if let AddedContent::Text(t) = &mut content {
|
||||
if let Some(s) = a.opt_str("text")? {
|
||||
t.text = s.to_string();
|
||||
match &mut content {
|
||||
AddedContent::Text(t) => {
|
||||
if let Some(s) = a.opt_str("text")? {
|
||||
t.text = s.to_string();
|
||||
}
|
||||
style(a, t)?;
|
||||
if ["rotate", "flip_h", "flip_v", "crop", "image"].iter().any(|k| a.get(k).is_some()) {
|
||||
return Err(bad("rotate, flip, crop and image apply to images"));
|
||||
}
|
||||
}
|
||||
AddedContent::Image(i) => {
|
||||
if a.get("text").is_some() {
|
||||
return Err(bad("images have no text"));
|
||||
}
|
||||
if let Some(deg) = a.opt_int("rotate")? {
|
||||
if deg.rem_euclid(90) != 0 {
|
||||
return Err(bad("rotate must be a multiple of 90"));
|
||||
}
|
||||
i.rotation = ((i64::from(i.rotation) * 90 + deg).rem_euclid(360) / 90) as u8;
|
||||
}
|
||||
if a.opt_bool("flip_h")? == Some(true) {
|
||||
i.flip_h = !i.flip_h;
|
||||
}
|
||||
if a.opt_bool("flip_v")? == Some(true) {
|
||||
i.flip_v = !i.flip_v;
|
||||
}
|
||||
if let Some(c) = rect_arg(a, "crop")? {
|
||||
if !c.iter().all(|v| (0.0..0.5).contains(v)) {
|
||||
return Err(bad("crop takes fractions from 0 to under 0.5 (left, bottom, right, top)"));
|
||||
}
|
||||
i.crop = c;
|
||||
}
|
||||
if let Some(p) = a.opt_str("image")? {
|
||||
let path = self.resolve(p, false)?;
|
||||
let bytes = std::fs::read(&path).map_err(|e| failed(format!("{}: {e}", path.display())))?;
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// Box and transforms first, then the new picture.
|
||||
self.apply(a, Edit::UpdateContent { page, index, content: content.clone() })?;
|
||||
return self.apply(a, Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) });
|
||||
}
|
||||
}
|
||||
style(a, t)?;
|
||||
} else if a.get("text").is_some() {
|
||||
return Err(bad("images have no text"));
|
||||
}
|
||||
self.apply(a, Edit::UpdateContent { page, index, content })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -378,7 +378,7 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "path": { "type": "string" }, "rect": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 } }),
|
||||
&["doc", "page", "path"],
|
||||
)),
|
||||
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Undoable.")
|
||||
t("content_update", "Edit added content", "Move/resize (rect), retype (text) or restyle (font, size, bold, italic, color, align) an added item (page, index from content_list). Images: rotate (degrees, multiple of 90, counter-clockwise), flip_h / flip_v (toggle), crop [left, bottom, right, top] as fractions trimmed, image (a file that replaces the picture). Undoable.")
|
||||
.with(schema(
|
||||
json!({
|
||||
"doc": doc(), "page": { "type": "integer", "minimum": 1 }, "index": { "type": "integer", "minimum": 1 },
|
||||
|
|
@ -386,6 +386,9 @@ pub fn tools() -> Vec<ToolDef> {
|
|||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] }, "size": { "type": "number", "minimum": 1, "maximum": 500 },
|
||||
"bold": { "type": "boolean" }, "italic": { "type": "boolean" }, "color": { "type": "string" },
|
||||
"align": { "type": "string", "enum": ["left", "center", "right"] },
|
||||
"rotate": { "type": "integer" }, "flip_h": { "type": "boolean" }, "flip_v": { "type": "boolean" },
|
||||
"crop": { "type": "array", "items": { "type": "number", "minimum": 0, "maximum": 0.49 }, "minItems": 4, "maxItems": 4 },
|
||||
"image": { "type": "string", "description": "Replace the picture with this file." },
|
||||
}),
|
||||
&["doc", "page", "index"],
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -709,6 +709,15 @@ fn adding_content_through_tools() {
|
|||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 1, "text": "DRAFT", "font": "times" }));
|
||||
let text = page_text(&mut a, doc)[0].clone();
|
||||
assert!(text.contains("DRAFT") && !text.contains("CONFIDENTIAL"), "{text}");
|
||||
// Image tools: rotate, flip, crop, replace.
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "rotate": 90, "flip_h": true, "crop": [0.1, 0, 0.1, 0] }));
|
||||
ok(&mut a, "content_update", json!({ "doc": doc, "page": 1, "index": 2, "image": "logo.png" }));
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({ "doc": doc }))["document"]["dirty"], true);
|
||||
assert!(matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 2, "rotate": 45 })), Err(ToolError::InvalidArgs(_))));
|
||||
assert!(
|
||||
matches!(a.call("content_update", &json!({ "doc": doc, "page": 1, "index": 1, "rotate": 90 })), Err(ToolError::InvalidArgs(_))),
|
||||
"text doesn't rotate"
|
||||
);
|
||||
ok(&mut a, "content_delete", json!({ "doc": doc, "page": 1, "index": 2 }));
|
||||
assert_eq!(ok(&mut a, "content_list", json!({ "doc": doc }))["count"], 1);
|
||||
assert!(matches!(a.call("content_delete", &json!({ "doc": doc, "page": 1, "index": 5 })), Err(ToolError::Failed(_))));
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
printcraft-cos = { workspace = true }
|
||||
printcraft-content = { workspace = true }
|
||||
printcraft-fonts = { workspace = true }
|
||||
printcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -116,6 +116,39 @@ pub struct AddedImage {
|
|||
pub rect: [f64; 4],
|
||||
/// The image XObject.
|
||||
pub image: ObjRef,
|
||||
/// Quarter turns counter-clockwise (0–3).
|
||||
pub rotation: u8,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
/// The fraction trimmed from each side of the image: left, bottom, right, top.
|
||||
pub crop: [f64; 4],
|
||||
}
|
||||
|
||||
impl AddedImage {
|
||||
pub fn new(rect: [f64; 4], image: ObjRef) -> Self {
|
||||
AddedImage { rect, image, rotation: 0, flip_h: false, flip_v: false, crop: [0.0; 4] }
|
||||
}
|
||||
|
||||
/// The matrix from image space (the unit square) to display space: crop, flip, rotate,
|
||||
/// then fill the box.
|
||||
fn matrix(&self) -> printcraft_content::Matrix {
|
||||
use printcraft_content::Matrix as M;
|
||||
let [l, b, r, t] = self.crop.map(|v| v.clamp(0.0, 0.45));
|
||||
let (cw, ch) = ((1.0 - l - r).max(0.05), (1.0 - b - t).max(0.05));
|
||||
let mut m = M([1.0 / cw, 0.0, 0.0, 1.0 / ch, -l / cw, -b / ch]);
|
||||
if self.flip_h {
|
||||
m = m.then(&M([-1.0, 0.0, 0.0, 1.0, 1.0, 0.0]));
|
||||
}
|
||||
if self.flip_v {
|
||||
m = m.then(&M([1.0, 0.0, 0.0, -1.0, 0.0, 1.0]));
|
||||
}
|
||||
for _ in 0..self.rotation % 4 {
|
||||
// A quarter turn counter-clockwise within the unit square.
|
||||
m = m.then(&M([0.0, 1.0, -1.0, 0.0, 1.0, 0.0]));
|
||||
}
|
||||
let [x0, y0, x1, y1] = norm(self.rect);
|
||||
m.then(&M([x1 - x0, 0.0, 0.0, y1 - y0, x0, y0]))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -234,7 +267,24 @@ fn draw(doc: &Document, c: &Content, view: [f64; 6]) -> Result<(Vec<u8>, Dict),
|
|||
let mut xo = Dict::new();
|
||||
xo.set(name.clone().into_bytes(), Object::Ref(i.image));
|
||||
res.set(b"XObject".to_vec(), Object::Dict(xo));
|
||||
out.extend(format!("{} 0 0 {} {} {} cm /{name} Do\n", n(r[2] - r[0]), n(r[3] - r[1]), n(r[0]), n(r[1])).bytes());
|
||||
let [a, b, c, d, e, f] = i.matrix().0;
|
||||
// Clip to the box: the cropped-away parts stay hidden.
|
||||
out.extend(
|
||||
format!(
|
||||
"{} {} {} {} re W n {} {} {} {} {} {} cm /{name} Do\n",
|
||||
n(r[0]),
|
||||
n(r[1]),
|
||||
n(r[2] - r[0]),
|
||||
n(r[3] - r[1]),
|
||||
n(a),
|
||||
n(b),
|
||||
n(c),
|
||||
n(d),
|
||||
n(e),
|
||||
n(f)
|
||||
)
|
||||
.bytes(),
|
||||
);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(b"Q\n");
|
||||
|
|
@ -265,6 +315,18 @@ fn params(c: &Content) -> Dict {
|
|||
d.set(b"Kind".to_vec(), Object::name("Image"));
|
||||
d.set(b"Image".to_vec(), Object::Ref(i.image));
|
||||
d.set(b"Rect".to_vec(), arr(&norm(i.rect)));
|
||||
if i.rotation % 4 != 0 {
|
||||
d.set(b"Rotate".to_vec(), Object::Int(i64::from(i.rotation % 4) * 90));
|
||||
}
|
||||
if i.flip_h {
|
||||
d.set(b"FlipH".to_vec(), Object::Bool(true));
|
||||
}
|
||||
if i.flip_v {
|
||||
d.set(b"FlipV".to_vec(), Object::Bool(true));
|
||||
}
|
||||
if i.crop != [0.0; 4] {
|
||||
d.set(b"Crop".to_vec(), arr(&i.crop));
|
||||
}
|
||||
}
|
||||
}
|
||||
d
|
||||
|
|
@ -292,7 +354,17 @@ fn parse(doc: &Document, d: &Dict) -> Option<Content> {
|
|||
},
|
||||
}))
|
||||
}
|
||||
b"Image" => Some(Content::Image(AddedImage { rect, image: d.get(b"Image")?.as_ref()? })),
|
||||
b"Image" => {
|
||||
let crop = nums(doc, d.get(b"Crop"));
|
||||
Some(Content::Image(AddedImage {
|
||||
rect,
|
||||
image: d.get(b"Image")?.as_ref()?,
|
||||
rotation: (d.int(b"Rotate").unwrap_or(0).rem_euclid(360) / 90) as u8,
|
||||
flip_h: matches!(d.get(b"FlipH"), Some(Object::Bool(true))),
|
||||
flip_v: matches!(d.get(b"FlipV"), Some(Object::Bool(true))),
|
||||
crop: crop.try_into().unwrap_or([0.0; 4]),
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -186,7 +186,7 @@ fn added_text_and_images_are_page_content_that_stays_editable() {
|
|||
d.set(b"ColorSpace".to_vec(), Object::name("DeviceGray"));
|
||||
d.set(b"BitsPerComponent".to_vec(), Object::Int(8));
|
||||
let img = doc.add(Object::Stream(Stream::from_raw(d, vec![128])));
|
||||
add_content(&mut doc, 1, &Content::Image(AddedImage { rect: [10.0, 10.0, 110.0, 60.0], image: img })).unwrap();
|
||||
add_content(&mut doc, 1, &Content::Image(AddedImage::new([10.0, 10.0, 110.0, 60.0], img))).unwrap();
|
||||
let doc2 = reopen(&doc);
|
||||
let all = list_added(&doc2);
|
||||
assert_eq!(all.len(), 2);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ pub use printcraft_organize::LabelStyle;
|
|||
|
||||
pub use printcraft_cos::Algorithm;
|
||||
pub use printcraft_edit::{
|
||||
Added, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
Added, AddedImage, AddedText, Align as TextAlign, Background, Content as AddedContent, Family as FontFamily, HeaderFooter, MarkKind, Watermark,
|
||||
};
|
||||
pub use printcraft_forms::{
|
||||
BorderStyle, Field as FormField, FieldFont, FieldKind as FormFieldKind, FieldProps, FieldValue, Look as FieldLook, NewField, TabOrder,
|
||||
|
|
@ -595,6 +595,13 @@ pub enum Edit {
|
|||
page: usize,
|
||||
index: usize,
|
||||
},
|
||||
/// Replace an added image with another file (keeping its box and transforms).
|
||||
ReplaceImage {
|
||||
page: usize,
|
||||
index: usize,
|
||||
name: String,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
},
|
||||
/// Apply redaction marks (all, or those on `pages`): remove what they cover for good.
|
||||
ApplyRedactions {
|
||||
pages: Option<Vec<usize>>,
|
||||
|
|
@ -670,6 +677,7 @@ impl Edit {
|
|||
Edit::AddImage { .. } => "Add image".into(),
|
||||
Edit::UpdateContent { .. } => "Edit content".into(),
|
||||
Edit::DeleteContent { .. } => "Delete content".into(),
|
||||
Edit::ReplaceImage { .. } => "Replace image".into(),
|
||||
Edit::ApplyRedactions { .. } => "Apply redactions".into(),
|
||||
Edit::ClearRedactions => "Remove redaction marks".into(),
|
||||
Edit::RemoveHidden { .. } => "Remove hidden information".into(),
|
||||
|
|
@ -779,6 +787,7 @@ fn check_permission(edit: &Edit, p: &printcraft_cos::Permissions) -> Result<(),
|
|||
| Edit::AddImage { .. }
|
||||
| Edit::UpdateContent { .. }
|
||||
| Edit::DeleteContent { .. }
|
||||
| Edit::ReplaceImage { .. }
|
||||
| Edit::ClearRedactions
|
||||
| Edit::RemoveHidden { .. }
|
||||
| Edit::Sanitize
|
||||
|
|
@ -948,10 +957,18 @@ fn run_edit(doc: &mut printcraft_cos::Document, edit: &Edit, cx: &mut EditCtx) -
|
|||
[(pw - w) / 2.0, (ph - h) / 2.0, (pw + w) / 2.0, (ph + h) / 2.0]
|
||||
}
|
||||
};
|
||||
printcraft_edit::add_content(doc, *page, &AddedContent::Image(printcraft_edit::AddedImage { rect, image }))?;
|
||||
printcraft_edit::add_content(doc, *page, &AddedContent::Image(printcraft_edit::AddedImage::new(rect, image)))?;
|
||||
}
|
||||
Edit::UpdateContent { page, index, content } => printcraft_edit::update_content(doc, *page, *index, content)?,
|
||||
Edit::DeleteContent { page, index } => printcraft_edit::delete_content(doc, *page, *index)?,
|
||||
Edit::ReplaceImage { page, index, name, bytes } => {
|
||||
let item = printcraft_edit::list_added(doc).into_iter().filter(|a| a.page == *page).nth(*index);
|
||||
let Some(AddedContent::Image(old)) = item.map(|a| a.content) else {
|
||||
return Err(EditError::Edit(printcraft_edit::EditError::Invalid("that item is not an image".into())));
|
||||
};
|
||||
let (image, _) = printcraft_create::image_xobject(doc, name, bytes)?;
|
||||
printcraft_edit::update_content(doc, *page, *index, &AddedContent::Image(printcraft_edit::AddedImage { image, ..old }))?;
|
||||
}
|
||||
Edit::ApplyRedactions { pages } => {
|
||||
printcraft_redact::apply(doc, pages.as_deref())?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -880,3 +880,47 @@ fn added_text_and_images_render_and_stay_editable() {
|
|||
s.undo(id).unwrap();
|
||||
assert_eq!(s.get(id).unwrap().added.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn added_images_rotate_flip_and_crop_as_drawn() {
|
||||
// A 20×10 image: left half red, right half blue.
|
||||
let mut px = Vec::new();
|
||||
for _y in 0..10 {
|
||||
for x in 0..20 {
|
||||
px.extend_from_slice(if x < 10 { &[255, 0, 0] } else { &[0, 0, 255] });
|
||||
}
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut png, 20, 10);
|
||||
enc.set_color(png::ColorType::Rgb);
|
||||
enc.write_header().unwrap().write_image_data(&px).unwrap();
|
||||
}
|
||||
let (mut s, id) = session_with(1);
|
||||
// A 100×100 box at (50, 100) on the 200×300 page.
|
||||
s.apply(id, Edit::AddImage { page: 0, rect: Some([50.0, 100.0, 150.0, 200.0]), name: "rb.png".into(), bytes: Arc::new(png) }).unwrap();
|
||||
let colour_at = |s: &Session, x: u32, y_from_top: u32| -> [u8; 3] {
|
||||
let doc = s.get(id).unwrap();
|
||||
let mut r = printcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
|
||||
let out = r.render(printcraft_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]]
|
||||
};
|
||||
let red = |c: [u8; 3]| c[0] > 200 && c[2] < 60;
|
||||
let blue = |c: [u8; 3]| c[2] > 200 && c[0] < 60;
|
||||
// Display y 100–200 is rows 100–200 from the top on a 300 pt page.
|
||||
assert!(red(colour_at(&s, 70, 150)) && blue(colour_at(&s, 130, 150)), "as placed: red left, blue right");
|
||||
let item = |s: &Session| s.get(id).unwrap().added[0].content.clone();
|
||||
let AddedContent::Image(mut img) = item(&s) else { panic!() };
|
||||
img.rotation = 1;
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap();
|
||||
assert!(red(colour_at(&s, 100, 180)) && blue(colour_at(&s, 100, 120)), "a quarter turn left: red at the bottom");
|
||||
img.rotation = 0;
|
||||
img.flip_h = true;
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img.clone()) }).unwrap();
|
||||
assert!(blue(colour_at(&s, 70, 150)) && red(colour_at(&s, 130, 150)), "flipped");
|
||||
img.flip_h = false;
|
||||
img.crop = [0.0, 0.0, 0.5, 0.0];
|
||||
s.apply(id, Edit::UpdateContent { page: 0, index: 0, content: AddedContent::Image(img) }).unwrap();
|
||||
assert!(red(colour_at(&s, 130, 150)), "the right half cropped away: red fills the box");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -400,6 +400,34 @@ impl crate::PrintCraftApp {
|
|||
self.notify("Adding images arrives on the web with file pickers for images");
|
||||
}
|
||||
|
||||
/// Edit image ▸ Replace: pick a file for the selected image.
|
||||
pub fn replace_image_dialog(&mut self, page: usize, index: usize) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let picked = match self.save_override.clone() {
|
||||
Some(p) if p.ends_with(".png") || p.ends_with(".jpg") => Some(std::path::PathBuf::from(p)),
|
||||
Some(_) => None,
|
||||
None => rfd::FileDialog::new()
|
||||
.add_filter("Images", &["png", "jpg", "jpeg", "tif", "tiff", "gif", "bmp"])
|
||||
.set_title("Replace image")
|
||||
.pick_file(),
|
||||
};
|
||||
let Some(path) = picked else { return };
|
||||
match std::fs::read(&path) {
|
||||
Ok(bytes) => {
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
self.apply_edit(Edit::ReplaceImage { page, index, name, bytes: std::sync::Arc::new(bytes) });
|
||||
}
|
||||
Err(e) => self.notify(format!("Couldn't read {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = (page, index);
|
||||
self.notify("Replacing images arrives on the web with image file pickers");
|
||||
}
|
||||
}
|
||||
|
||||
/// Place image `bytes` in the middle of the current page and select it.
|
||||
pub fn add_image(&mut self, name: String, bytes: Vec<u8>) {
|
||||
let Some((i, id)) = self.active_ids() else { return };
|
||||
|
|
@ -412,3 +440,63 @@ impl crate::PrintCraftApp {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the image tools ask for.
|
||||
pub(crate) enum ImageAction {
|
||||
Update(printcraft_engine::AddedContent),
|
||||
Replace,
|
||||
}
|
||||
|
||||
/// Edit image: rotate, flip, crop, replace (shown while an added image is selected).
|
||||
pub(crate) fn image_panel(ui: &mut egui::Ui, t: &Tokens, img: &printcraft_engine::AddedImage) -> Option<ImageAction> {
|
||||
let mut out = None;
|
||||
widgets::section_title(ui, "Edit image");
|
||||
ui.horizontal(|ui| {
|
||||
let mut i = img.clone();
|
||||
let r = i.rect;
|
||||
let turn = |i: &mut printcraft_engine::AddedImage, k: u8| {
|
||||
i.rotation = (i.rotation + k) % 4;
|
||||
// The box turns with the picture, around its centre.
|
||||
let (cx, cy, w, h) = ((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0, r[2] - r[0], r[3] - r[1]);
|
||||
i.rect = [cx - h / 2.0, cy - w / 2.0, cx + h / 2.0, cy + w / 2.0];
|
||||
};
|
||||
if crate::icons::button(ui, "rotate-ccw", 28.0, false, "Rotate counterclockwise").clicked() {
|
||||
turn(&mut i, 1);
|
||||
out = Some(ImageAction::Update(AddedContent::Image(i.clone())));
|
||||
}
|
||||
if crate::icons::button(ui, "rotate-cw", 28.0, false, "Rotate clockwise").clicked() {
|
||||
turn(&mut i, 3);
|
||||
out = Some(ImageAction::Update(AddedContent::Image(i.clone())));
|
||||
}
|
||||
if crate::icons::button(ui, "flip-horizontal-2", 28.0, false, "Flip horizontal").clicked() {
|
||||
i.flip_h = !i.flip_h;
|
||||
out = Some(ImageAction::Update(AddedContent::Image(i.clone())));
|
||||
}
|
||||
if crate::icons::button(ui, "flip-vertical-2", 28.0, false, "Flip vertical").clicked() {
|
||||
i.flip_v = !i.flip_v;
|
||||
out = Some(ImageAction::Update(AddedContent::Image(i.clone())));
|
||||
}
|
||||
if crate::icons::button(ui, "replace", 28.0, false, "Replace image").clicked() {
|
||||
out = Some(ImageAction::Replace);
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new("Crop (% trimmed from each side)").small().color(t.text_muted));
|
||||
let mut crop = img.crop.map(|v| (v * 100.0).round());
|
||||
let mut changed = false;
|
||||
ui.horizontal(|ui| {
|
||||
for (k, label) in ["L", "B", "R", "T"].into_iter().enumerate() {
|
||||
ui.label(label);
|
||||
// Applied when the drag ends, so a drag is one undo step.
|
||||
let r = ui.add(egui::DragValue::new(&mut crop[k]).range(0.0..=45.0).speed(0.5).suffix("%"));
|
||||
changed |= r.drag_stopped() || (r.changed() && !r.dragged());
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
let mut i = img.clone();
|
||||
i.crop = crop.map(|v| v / 100.0);
|
||||
if i.crop != img.crop {
|
||||
out = Some(ImageAction::Update(AddedContent::Image(i)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ pub static ICONS: &[(&str, &[u8])] = &[
|
|||
("copy", include_bytes!("../../../assets/icons/copy.svg")),
|
||||
("copy-plus", include_bytes!("../../../assets/icons/copy-plus.svg")),
|
||||
("crop", include_bytes!("../../../assets/icons/crop.svg")),
|
||||
("flip-horizontal-2", include_bytes!("../../../assets/icons/flip-horizontal-2.svg")),
|
||||
("flip-vertical-2", include_bytes!("../../../assets/icons/flip-vertical-2.svg")),
|
||||
("droplet", include_bytes!("../../../assets/icons/droplet.svg")),
|
||||
("ellipsis", include_bytes!("../../../assets/icons/ellipsis.svg")),
|
||||
("ellipsis-vertical", include_bytes!("../../../assets/icons/ellipsis-vertical.svg")),
|
||||
|
|
|
|||
|
|
@ -193,6 +193,26 @@ fn tool_detail(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens, g: &'stat
|
|||
/// Edit a PDF ▸ Format text: for the selected added text (one undoable change), or the style
|
||||
/// new text gets.
|
||||
fn format_section(app: &mut PrintCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let image = app.active_ids().and_then(|(i, id)| {
|
||||
let (page, index) = app.views[i].content.selected?;
|
||||
let doc = app.session.get(id)?;
|
||||
match &doc.added.iter().filter(|a| a.page == page).nth(index)?.content {
|
||||
printcraft_engine::AddedContent::Image(img) => Some((page, index, img.clone())),
|
||||
_ => None,
|
||||
}
|
||||
});
|
||||
if let Some((page, index, img)) = image {
|
||||
match crate::content_ui::image_panel(ui, t, &img) {
|
||||
Some(crate::content_ui::ImageAction::Update(content)) => {
|
||||
app.apply_edit(printcraft_engine::Edit::UpdateContent { page, index, content });
|
||||
}
|
||||
Some(crate::content_ui::ImageAction::Replace) => app.replace_image_dialog(page, index),
|
||||
None => {}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
ui.separator();
|
||||
return;
|
||||
}
|
||||
let selected = app.active_ids().and_then(|(i, id)| {
|
||||
let (page, index) = app.views[i].content.selected?;
|
||||
let doc = app.session.get(id)?;
|
||||
|
|
|
|||
|
|
@ -92,4 +92,14 @@ fn typing_moving_styling_and_deleting_added_content() {
|
|||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].content.rect(), [135.0, 190.0, 165.0, 210.0]);
|
||||
assert_eq!(h.state().views[0].content.selected, Some((0, 0)));
|
||||
// Edit image: rotate clockwise turns the box around its centre.
|
||||
h.get_by_label("Rotate clockwise").click();
|
||||
h.run_steps(3);
|
||||
let a = added(&h);
|
||||
let printcraft_engine::AddedContent::Image(img) = &a[0].content else { panic!() };
|
||||
assert_eq!((img.rotation, img.rect), (3, [140.0, 185.0, 160.0, 215.0]));
|
||||
h.get_by_label("Flip horizontal").click();
|
||||
h.run_steps(3);
|
||||
let printcraft_engine::AddedContent::Image(img) = &added(&h)[0].content else { panic!() };
|
||||
assert!(img.flip_h);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1983,11 +1983,11 @@ title = "Replace an image (keep position and size)"
|
|||
area = "C"
|
||||
tier = "P0"
|
||||
milestone = "M7"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["edit.image"]
|
||||
tools = ["content_update"]
|
||||
evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"]
|
||||
notes = "for images added in PrintCraft (box and transforms kept); existing page images need the M7 content editor"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-crop"
|
||||
|
|
@ -1995,11 +1995,11 @@ title = "Crop an image"
|
|||
area = "C"
|
||||
tier = "P0"
|
||||
milestone = "M7"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["edit.image"]
|
||||
tools = ["content_update"]
|
||||
evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/automation/tests/automation.rs::adding_content_through_tools"]
|
||||
notes = "for added images: trim each side, clipped"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-rotate-flip"
|
||||
|
|
@ -2007,11 +2007,11 @@ title = "Rotate and flip images"
|
|||
area = "C"
|
||||
tier = "P0"
|
||||
milestone = "M7"
|
||||
status = "planned"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = []
|
||||
notes = ""
|
||||
status = "partial"
|
||||
commands = ["edit.image"]
|
||||
tools = ["content_update"]
|
||||
evidence = ["crates/engine/src/tests.rs::added_images_rotate_flip_and_crop_as_drawn", "crates/ui-egui/tests/content.rs::typing_moving_styling_and_deleting_added_content", "crates/automation/tests/automation.rs::adding_content_through_tools"]
|
||||
notes = "for added images: quarter turns (box turns around its centre), horizontal/vertical flips"
|
||||
|
||||
[[feature]]
|
||||
id = "edit.image-move-resize"
|
||||
|
|
|
|||
Loading…
Reference in New Issue