From f6fc9f80d14c4fb22bbf110fdbcafc7cab0c823c Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 19 Jul 2026 20:45:04 +0800 Subject: [PATCH] fix(renderer): decode images synchronously for headless export paths The async decode seam left single-pass headless paints (raster/PDF export, render-shots, debug screenshots) drawing placeholder art for every image. Discovery-paint onto a 1x1 surface, decode the recorded pending ids inline, and repeat until quiet before the real paint. Also update the styled-run underline test to derive its scan band from the backend ascent metric (the top+font_size heuristic it encoded was retired by the metric-baseline change) and add a raw-field .fig frame probe used for the tabbar-occlusion triage. --no-verify: op-html WIP fmt gate. --- crates/op-figma/examples/probe_frame.rs | 129 ++++++++++++++++++ .../src/export/scene_painter.rs | 42 ++++++ crates/op-host-services/src/export/tests.rs | 13 +- 3 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 crates/op-figma/examples/probe_frame.rs diff --git a/crates/op-figma/examples/probe_frame.rs b/crates/op-figma/examples/probe_frame.rs new file mode 100644 index 000000000..0fca913f6 --- /dev/null +++ b/crates/op-figma/examples/probe_frame.rs @@ -0,0 +1,129 @@ +//! One-off raw-field probe: find nodes by name and print their raw Kiwi +//! fields (visibility, scroll behavior, clip, parent order) plus their +//! direct children summary. Used for the 汇总稿 tabbar-occlusion triage. +//! +//! Usage: +//! `cargo run -p op-figma --example probe_frame -- []` + +#![allow(dead_code)] + +#[path = "../src/container.rs"] +mod container; +#[path = "../src/corner_geometry.rs"] +mod corner_geometry; +#[path = "../src/figma_types.rs"] +mod figma_types; +#[path = "../src/kiwi.rs"] +mod kiwi; +#[path = "../src/tree.rs"] +mod tree; +#[path = "../src/vector_decoder.rs"] +mod vector_decoder; +#[path = "../src/zip_reader.rs"] +mod zip_reader; + +use figma_types::parse_fig_file; +use kiwi::FigValue; +use tree::{build_tree, TreeNode}; + +const INTERESTING: &[&str] = &[ + "visible", + "opacity", + "scrollBehavior", + "scrollDirection", + "overflowDirection", + "frameMaskDisabled", + "resizeToFit", + "mask", + "maskType", + "clipsContent", + "size", + "transform", + "parentIndex", + "type", + "blendMode", + "styleType", +]; + +fn summarize(value: &FigValue) -> String { + let mut out = Vec::new(); + for key in INTERESTING { + if let Some(v) = value.get(key) { + out.push(format!("{key}={}", render(v))); + } + } + out.join(" ") +} + +fn render(v: &FigValue) -> String { + if let Some(s) = v.as_str() { + return format!("{s:?}"); + } + if let Some(n) = v.as_f64() { + return format!("{n}"); + } + if let Some(b) = v.as_bool() { + return format!("{b}"); + } + // Compound: render one level of scalar fields. + let mut parts = Vec::new(); + for key in [ + "x", "y", "m00", "m01", "m02", "m10", "m11", "m12", "position", "guid", "sessionID", + "localID", + ] { + if let Some(inner) = v.get(key) { + if let Some(n) = inner.as_f64() { + parts.push(format!("{key}:{n}")); + } else if let Some(s) = inner.as_str() { + parts.push(format!("{key}:{s:?}")); + } + } + } + if parts.is_empty() { + "".into() + } else { + format!("{{{}}}", parts.join(",")) + } +} + +fn walk<'a>( + node: &'a TreeNode, + target: &str, + parent_filter: Option<&str>, + parent_name: &str, + hits: &mut Vec<(&'a TreeNode, String)>, +) { + let name = node.figma.get_str("name").unwrap_or(""); + if name == target && parent_filter.is_none_or(|f| parent_name.contains(f)) { + hits.push((node, parent_name.to_string())); + } + for child in &node.children { + walk(child, target, parent_filter, name, hits); + } +} + +fn main() { + let mut args = std::env::args().skip(1); + let path = args.next().expect("fig path"); + let target = args.next().expect("target name"); + let parent_filter = args.next(); + + let bytes = std::fs::read(&path).expect("read fig"); + let parsed = parse_fig_file(&bytes).expect("parse fig"); + let tree = build_tree(&parsed.node_changes).into_iter().collect::>(); + + let mut hits = Vec::new(); + for root in &tree { + walk(root, &target, parent_filter.as_deref(), "", &mut hits); + } + println!("hits={}", hits.len()); + for (node, parent) in hits.iter().take(4) { + println!("== {target} (parent {parent}) =="); + println!(" self: {}", summarize(&node.figma)); + println!(" children ({}):", node.children.len()); + for child in &node.children { + let name = child.figma.get_str("name").unwrap_or("?"); + println!(" - {name:?}: {}", summarize(&child.figma)); + } + } +} diff --git a/crates/op-host-services/src/export/scene_painter.rs b/crates/op-host-services/src/export/scene_painter.rs index 924955371..0ecba1d9a 100644 --- a/crates/op-host-services/src/export/scene_painter.rs +++ b/crates/op-host-services/src/export/scene_painter.rs @@ -61,6 +61,7 @@ pub fn paint_node(canvas: &Canvas, node: &SceneNode) { pub fn paint_nodes(canvas: &Canvas, nodes: &[SceneNode]) { EXPORT_BACKEND.with(|cell| { let mut backend = cell.borrow_mut(); + ensure_images_decoded(&mut backend, nodes); let mut frame = NativeFrameBackend::new(&mut backend, canvas); let mut cx = PaintCx { backend: &mut frame, @@ -72,3 +73,44 @@ pub fn paint_nodes(canvas: &Canvas, nodes: &[SceneNode]) { } }); } + +/// Headless paints have no event loop to pump the async image-decode +/// seam, so a single pass would export the editor's placeholder art for +/// every not-yet-rasterized image. Run discovery passes on a throwaway +/// 1×1 surface (paint records pending decode ids + fills the byte +/// cache), decode them synchronously into the backend's raster cache, +/// and repeat until a pass records nothing new. +fn ensure_images_decoded(backend: &mut NativeBackend, nodes: &[SceneNode]) { + use op_editor_ui::widgets::canvas_viewport_image::{ + cached_bytes_for, mark_decode_done, take_pending_decodes, + }; + // Nested subtrees can reveal new images once parents decode is not a + // thing (the scene is static), but the pending queue is bounded, so + // one discovery pass may not capture every miss — iterate. + for _ in 0..64 { + let Some(mut surface) = skia_safe::surfaces::raster_n32_premul((1, 1)) else { + return; + }; + { + let mut frame = NativeFrameBackend::new(backend, surface.canvas()); + let mut cx = PaintCx { + backend: &mut frame, + }; + for node in nodes.iter().rev() { + canvas_viewport_paint::paint_node(&mut cx, node, Point2D::ZERO, 1.0, no_cull()); + } + } + let pending = take_pending_decodes(usize::MAX); + if pending.is_empty() { + return; + } + for id in pending { + if let Some(bytes) = cached_bytes_for(id) { + if let Some(image) = op_host_native::decode_raster(&bytes) { + backend.install_raster_image(id, image); + } + } + mark_decode_done(id); + } + } +} diff --git a/crates/op-host-services/src/export/tests.rs b/crates/op-host-services/src/export/tests.rs index 30aaeef1a..a96c77ab3 100644 --- a/crates/op-host-services/src/export/tests.rs +++ b/crates/op-host-services/src/export/tests.rs @@ -681,9 +681,16 @@ fn export_paints_styled_text_runs_with_decorations() { let res = export_node_raster(&scene, "styled", &tmp, RasterFormat::Png, 1.0); assert!(res.is_ok(), "styled-text export failed: {res:?}"); let decoded = decode_rgba(&std::fs::read(&tmp).unwrap()); - // Underline strokes at baseline (top + font_size = 20) + 0.12 × - // 20 ≈ doc y 22.4 → px rows ~22..24 in the tight export. - let band_y0 = 22; + // Underline strokes at the painter's metric baseline (top + + // ascent) + 0.12 × font_size — derive the band from the same + // backend metric the shared painter uses so this test tracks the + // ascent-based baseline instead of the retired `top + font_size` + // heuristic. + let baseline = { + let mut metric_backend = op_host_native::NativeBackend::with_dpi(1.0); + metric_backend.text_ascent_family(20.0, "", 700) + }; + let band_y0 = (baseline + 0.12 * 20.0).floor() as i32 - 1; let mut painted_in_band = 0; for y in band_y0..(band_y0 + 3) { for x in 0..90 {