fix(desktop): retry generated image placeholders

This commit is contained in:
Fini 2026-05-31 13:04:48 +08:00
parent 554184281c
commit 621e71bc39
2 changed files with 100 additions and 14 deletions

View file

@ -5,6 +5,7 @@ use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::time::Duration;
use jian_ops_schema::node::{PenNode, TextContent};
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use jian_ops_schema::style::{ImageFillBody, ImageFillMode, PenFill};
use op_editor_core::{walkers, EditorState, NodeId, PenNodeExt as _};
@ -88,9 +89,14 @@ impl ImageSearchSession {
let job = self.jobs.swap_remove(i);
let id = job.node_id.as_str().to_string();
self.in_flight.remove(&id);
self.completed.insert(id);
if let Some(url) = url {
changed |= apply_result(state, &job.node_id, &url);
if apply_result(state, &job.node_id, &url) {
changed = true;
} else {
self.completed.insert(id);
}
} else {
self.completed.insert(id);
}
}
Err(TryRecvError::Empty) => {
@ -228,10 +234,7 @@ fn is_image_area_frame_by_heuristic(node: &PenNode) -> bool {
if !has_image_area_keyword(name) {
return false;
}
if !matches!(node.width_px(), Some(w) if w >= 80.0) {
return false;
}
if !matches!(node.height_px(), Some(h) if h >= 60.0) {
if !is_image_area_size(&frame.container.width, &frame.container.height) {
return false;
}
if !matches!(frame.container.fill.as_deref(), Some([PenFill::Solid(_)])) {
@ -253,10 +256,7 @@ fn is_image_area_rectangle_by_heuristic(node: &PenNode) -> bool {
if !has_image_area_keyword(name) {
return false;
}
if !matches!(node.width_px(), Some(w) if w >= 80.0) {
return false;
}
if !matches!(node.height_px(), Some(h) if h >= 60.0) {
if !is_image_area_size(&rect.container.width, &rect.container.height) {
return false;
}
if !matches!(rect.container.fill.as_deref(), Some([PenFill::Solid(_)])) {
@ -268,6 +268,20 @@ fn is_image_area_rectangle_by_heuristic(node: &PenNode) -> bool {
matches!(children.as_slice(), [] | [PenNode::IconFont(_)])
}
fn is_image_area_size(width: &Option<SizingBehavior>, height: &Option<SizingBehavior>) -> bool {
let (width_ok, width_concrete) = image_area_dimension_ok(width, 80.0);
let (height_ok, height_concrete) = image_area_dimension_ok(height, 60.0);
width_ok && height_ok && (width_concrete || height_concrete)
}
fn image_area_dimension_ok(size: &Option<SizingBehavior>, min_px: f64) -> (bool, bool) {
match size {
Some(SizingBehavior::Number(px)) if *px >= min_px => (true, true),
Some(SizingBehavior::Keyword(SizingKeyword::FillContainer)) => (true, false),
_ => (false, false),
}
}
fn has_image_area_keyword(name: &str) -> bool {
name.split(|c: char| !c.is_ascii_alphanumeric())
.map(str::to_ascii_lowercase)

View file

@ -3,7 +3,7 @@ use jian_ops_schema::node::base::PenNodeBase;
use jian_ops_schema::node::{
ContainerProps, FrameNode, ImageNode, PenNode, RectangleNode, TextContent, TextNode,
};
use jian_ops_schema::sizing::SizingBehavior;
use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use jian_ops_schema::style::{ImageFillMode, PenFill, SolidFillBody};
fn image_node(id: &str, src: &str, query: Option<&str>) -> PenNode {
@ -107,6 +107,22 @@ fn frame_node(
}
fn rectangle_node(id: &str, name: &str, fill: Option<Vec<PenFill>>) -> PenNode {
rectangle_node_with_sizing(
id,
name,
fill,
Some(SizingBehavior::Number(240.0)),
Some(SizingBehavior::Number(160.0)),
)
}
fn rectangle_node_with_sizing(
id: &str,
name: &str,
fill: Option<Vec<PenFill>>,
width: Option<SizingBehavior>,
height: Option<SizingBehavior>,
) -> PenNode {
PenNode::Rectangle(RectangleNode {
base: PenNodeBase {
id: id.to_string(),
@ -114,8 +130,8 @@ fn rectangle_node(id: &str, name: &str, fill: Option<Vec<PenFill>>) -> PenNode {
..Default::default()
},
container: ContainerProps {
width: Some(SizingBehavior::Number(240.0)),
height: Some(SizingBehavior::Number(160.0)),
width,
height,
fill,
..Default::default()
},
@ -253,6 +269,25 @@ fn collect_targets_includes_solid_rectangle_image_areas() {
assert_eq!(targets[0].query, "Latte Image");
}
#[test]
fn collect_targets_includes_fill_width_rectangle_image_areas() {
let mut state = EditorState::default();
state.active_children_mut().clear();
state.active_children_mut().push(rectangle_node_with_sizing(
"photo",
"Latte Image",
Some(vec![solid_fill()]),
Some(SizingBehavior::Keyword(SizingKeyword::FillContainer)),
Some(SizingBehavior::Number(180.0)),
));
let targets = collect_targets(&state, &HashSet::new());
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].node_id.as_str(), "photo");
assert_eq!(targets[0].query, "Latte Image");
}
#[test]
fn apply_result_repaints_placeholder_frame_with_image_fill_and_clears_children() {
let mut state = EditorState::default();
@ -341,7 +376,7 @@ fn poll_into_applies_finished_job_to_placeholder_frame() {
assert!(session.poll_into(&mut state));
assert!(!session.is_pending());
assert!(session.in_flight.is_empty());
assert!(session.completed.contains("photo"));
assert!(!session.completed.contains("photo"));
let PenNode::Frame(frame) = &state.active_children()[0] else {
panic!("expected frame");
@ -353,6 +388,43 @@ fn poll_into_applies_finished_job_to_placeholder_frame() {
assert_eq!(frame.children.as_deref(), Some(&[][..]));
}
#[test]
fn successful_apply_does_not_suppress_later_unfilled_retry() {
let mut state = EditorState::default();
state.active_children_mut().clear();
state.active_children_mut().push(rectangle_node(
"photo",
"Latte Image",
Some(vec![solid_fill()]),
));
let (tx, rx) = std::sync::mpsc::channel();
tx.send(Some("https://example.com/photo.jpg".to_string()))
.unwrap();
let mut session = ImageSearchSession {
in_flight: HashSet::from(["photo".to_string()]),
completed: HashSet::new(),
jobs: vec![ImageSearchJob {
node_id: NodeId::new("photo"),
rx,
}],
};
assert!(session.poll_into(&mut state));
let PenNode::Rectangle(rect) = &mut state.active_children_mut()[0] else {
panic!("expected rectangle");
};
rect.container.fill = Some(vec![solid_fill()]);
let mut known = session.completed.clone();
known.extend(session.in_flight.iter().cloned());
let targets = collect_targets(&state, &known);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].node_id.as_str(), "photo");
}
#[test]
fn openverse_credentials_require_both_fields() {
let mut state = EditorState::default();