mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-06-01 03:14:29 +07:00
fix(editor): improve rust parity and figma import performance
Some checks failed
Rust check (native) / macos-latest / 1.94 (push) Waiting to run
Rust check (native) / windows-latest / 1.94 (push) Waiting to run
Rust multi-platform build / linux-aarch64 (push) Waiting to run
Rust multi-platform build / macos-aarch64 (push) Waiting to run
Rust multi-platform build / windows-x86_64 (push) Waiting to run
Rust multi-platform build / macos-x86_64 (push) Waiting to run
Rust multi-platform build / windows-aarch64 (push) Waiting to run
Rust multi-platform build / ios-aarch64 (cargo check only) (push) Waiting to run
Rust multi-platform build / ios-aarch64-sim (cargo check only) (push) Waiting to run
Rust check (native) / ubuntu-latest / 1.94 (push) Failing after 2s
Rust check (native) / cargo-deny (native) (push) Failing after 1s
Rust check (native) / diagnostics golden drift (push) Failing after 2s
Rust multi-platform build / linux-x86_64 (push) Failing after 2s
Rust multi-platform build / wasm32-unknown-unknown / op-host-web (compile guard) (push) Failing after 1s
Rust multi-platform build / android-aarch64 (cargo check only) (push) Failing after 2s
Rust multi-platform build / android-x86_64 (cargo check only) (push) Failing after 1s
WASM bundle check (kickoff §1.2) / cargo check --target wasm32-unknown-unknown (push) Failing after 1s
WASM bundle check (kickoff §1.2) / cargo-deny --target wasm32-unknown-unknown check bans (push) Failing after 2s
Some checks failed
Rust check (native) / macos-latest / 1.94 (push) Waiting to run
Rust check (native) / windows-latest / 1.94 (push) Waiting to run
Rust multi-platform build / linux-aarch64 (push) Waiting to run
Rust multi-platform build / macos-aarch64 (push) Waiting to run
Rust multi-platform build / windows-x86_64 (push) Waiting to run
Rust multi-platform build / macos-x86_64 (push) Waiting to run
Rust multi-platform build / windows-aarch64 (push) Waiting to run
Rust multi-platform build / ios-aarch64 (cargo check only) (push) Waiting to run
Rust multi-platform build / ios-aarch64-sim (cargo check only) (push) Waiting to run
Rust check (native) / ubuntu-latest / 1.94 (push) Failing after 2s
Rust check (native) / cargo-deny (native) (push) Failing after 1s
Rust check (native) / diagnostics golden drift (push) Failing after 2s
Rust multi-platform build / linux-x86_64 (push) Failing after 2s
Rust multi-platform build / wasm32-unknown-unknown / op-host-web (compile guard) (push) Failing after 1s
Rust multi-platform build / android-aarch64 (cargo check only) (push) Failing after 2s
Rust multi-platform build / android-x86_64 (cargo check only) (push) Failing after 1s
WASM bundle check (kickoff §1.2) / cargo check --target wasm32-unknown-unknown (push) Failing after 1s
WASM bundle check (kickoff §1.2) / cargo-deny --target wasm32-unknown-unknown check bans (push) Failing after 2s
This commit is contained in:
parent
f547fe1737
commit
2ab2a86fdc
39 changed files with 4364 additions and 294 deletions
|
|
@ -232,7 +232,7 @@ impl EditorState {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
match property {
|
let changed = match property {
|
||||||
"gap" => {
|
"gap" => {
|
||||||
let LayoutPropValue::Number(n) = value else {
|
let LayoutPropValue::Number(n) = value else {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -331,12 +331,31 @@ impl EditorState {
|
||||||
set_container_clip_content(node, *v)
|
set_container_clip_content(node, *v)
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
|
};
|
||||||
|
if changed && property_invalidates_preserved_geometry(property) {
|
||||||
|
self.editor_ui.preserve_authored_geometry = false;
|
||||||
}
|
}
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── field writers ─────────────────────────────────────────────────────────────
|
// ── field writers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn property_invalidates_preserved_geometry(property: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
property,
|
||||||
|
"gap"
|
||||||
|
| "padding"
|
||||||
|
| "layout"
|
||||||
|
| "justifyContent"
|
||||||
|
| "alignItems"
|
||||||
|
| "width"
|
||||||
|
| "height"
|
||||||
|
| "clipContent"
|
||||||
|
| "textGrowth"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn set_container_gap(node: &mut PenNode, gap: f64) -> bool {
|
fn set_container_gap(node: &mut PenNode, gap: f64) -> bool {
|
||||||
let noe = NumberOrExpression::Number(gap);
|
let noe = NumberOrExpression::Number(gap);
|
||||||
match node {
|
match node {
|
||||||
|
|
|
||||||
|
|
@ -475,6 +475,18 @@ pub struct EditorUiState {
|
||||||
pub layer_layers_scroll: f32,
|
pub layer_layers_scroll: f32,
|
||||||
/// "Import from Figma" modal.
|
/// "Import from Figma" modal.
|
||||||
pub figma_import_open: bool,
|
pub figma_import_open: bool,
|
||||||
|
/// True while a `.fig` is being parsed on a worker thread. Paint
|
||||||
|
/// uses this to show a "正在解析 Figma 文件…" overlay so the user
|
||||||
|
/// gets feedback during the multi-second parse (a 2-3 MB .fig with
|
||||||
|
/// hundreds of nodes can take a couple of seconds to walk the
|
||||||
|
/// Kiwi schema, build the tree, and convert every node). The
|
||||||
|
/// desktop runner sets it when spawning the worker and clears it
|
||||||
|
/// when the result lands.
|
||||||
|
pub figma_import_in_progress: bool,
|
||||||
|
/// Imported Figma documents parsed in Preserve mode already carry
|
||||||
|
/// authored parent-local geometry. The scene builder can use this
|
||||||
|
/// flag to skip the expensive flex/text layout pass.
|
||||||
|
pub preserve_authored_geometry: bool,
|
||||||
/// Floating `Cmd+,` agent-settings modal open.
|
/// Floating `Cmd+,` agent-settings modal open.
|
||||||
pub agent_settings_open: bool,
|
pub agent_settings_open: bool,
|
||||||
pub agent_settings: crate::agent_settings::AgentSettings,
|
pub agent_settings: crate::agent_settings::AgentSettings,
|
||||||
|
|
@ -695,6 +707,8 @@ impl Default for EditorUiState {
|
||||||
layer_pages_scroll: 0.0,
|
layer_pages_scroll: 0.0,
|
||||||
layer_layers_scroll: 0.0,
|
layer_layers_scroll: 0.0,
|
||||||
figma_import_open: false,
|
figma_import_open: false,
|
||||||
|
figma_import_in_progress: false,
|
||||||
|
preserve_authored_geometry: false,
|
||||||
agent_settings_open: false,
|
agent_settings_open: false,
|
||||||
agent_settings: crate::agent_settings::AgentSettings::default(),
|
agent_settings: crate::agent_settings::AgentSettings::default(),
|
||||||
agent_settings_drag: None,
|
agent_settings_drag: None,
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,9 @@ pub struct SceneNode {
|
||||||
/// picker, or a plain file path / remote URL on documents that
|
/// picker, or a plain file path / remote URL on documents that
|
||||||
/// reference external media. `None` for non-image nodes.
|
/// reference external media. `None` for non-image nodes.
|
||||||
pub image_src: Option<String>,
|
pub image_src: Option<String>,
|
||||||
|
/// Stable content hash for `image_src`. The canvas painter uses it
|
||||||
|
/// as a per-frame cache key without hashing large data URLs again.
|
||||||
|
pub image_src_id: u64,
|
||||||
/// How `image_src` is placed into `bounds`.
|
/// How `image_src` is placed into `bounds`.
|
||||||
pub image_fit: SceneImageFit,
|
pub image_fit: SceneImageFit,
|
||||||
/// Per-image colour adjustments from the image-fill editor.
|
/// Per-image colour adjustments from the image-fill editor.
|
||||||
|
|
@ -398,6 +401,7 @@ impl SceneNode {
|
||||||
arc_inner_radius: None,
|
arc_inner_radius: None,
|
||||||
polygon_sides: 3,
|
polygon_sides: 3,
|
||||||
image_src: None,
|
image_src: None,
|
||||||
|
image_src_id: 0,
|
||||||
image_fit: SceneImageFit::Fill,
|
image_fit: SceneImageFit::Fill,
|
||||||
image_adjustments: crate::ImageAdjustments::default(),
|
image_adjustments: crate::ImageAdjustments::default(),
|
||||||
effects: Vec::new(),
|
effects: Vec::new(),
|
||||||
|
|
@ -408,6 +412,13 @@ impl SceneNode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn stable_image_source_id(src: &str) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
src.hash(&mut h);
|
||||||
|
h.finish()
|
||||||
|
}
|
||||||
|
|
||||||
/// Vertices for a regular polygon fitted inside `rect`.
|
/// Vertices for a regular polygon fitted inside `rect`.
|
||||||
pub fn regular_polygon_points(rect: Rect, sides: u32) -> Vec<Point2D> {
|
pub fn regular_polygon_points(rect: Rect, sides: u32) -> Vec<Point2D> {
|
||||||
let n = sides.clamp(3, 100) as usize;
|
let n = sides.clamp(3, 100) as usize;
|
||||||
|
|
|
||||||
172
crates/op-editor-ui/src/widgets/canvas_viewport_image.rs
Normal file
172
crates/op-editor-ui/src/widgets/canvas_viewport_image.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
use crate::layout_scene::{stable_image_source_id, SceneNode};
|
||||||
|
use crate::widgets::PaintCx;
|
||||||
|
use crate::Rect;
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock};
|
||||||
|
|
||||||
|
const DATA_URL_CACHE_CAP: usize = 64;
|
||||||
|
|
||||||
|
struct DataUrlCache {
|
||||||
|
entries: std::collections::HashMap<u64, Arc<[u8]>>,
|
||||||
|
order: std::collections::VecDeque<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DataUrlCache {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
entries: std::collections::HashMap::new(),
|
||||||
|
order: std::collections::VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, id: u64) -> Option<Arc<[u8]>> {
|
||||||
|
self.entries.get(&id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, id: u64, bytes: Arc<[u8]>) {
|
||||||
|
if !self.entries.contains_key(&id) {
|
||||||
|
self.order.push_back(id);
|
||||||
|
}
|
||||||
|
self.entries.insert(id, bytes);
|
||||||
|
while self.entries.len() > DATA_URL_CACHE_CAP {
|
||||||
|
match self.order.pop_front() {
|
||||||
|
Some(oldest) => {
|
||||||
|
self.entries.remove(&oldest);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static DATA_URL_CACHE: OnceLock<Mutex<DataUrlCache>> = OnceLock::new();
|
||||||
|
|
||||||
|
fn data_url_cache() -> &'static Mutex<DataUrlCache> {
|
||||||
|
DATA_URL_CACHE.get_or_init(|| Mutex::new(DataUrlCache::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode an inline-base64 `data:image/...;base64,...` URL into the raw
|
||||||
|
/// image bytes the backend's image decoder expects. The decoded bytes are
|
||||||
|
/// cached by the scene's precomputed source id, so paint frames clone an
|
||||||
|
/// `Arc` instead of cleaning + base64-decoding large data URLs again.
|
||||||
|
fn data_url_bytes(src: &str, image_src_id: u64) -> Option<Arc<[u8]>> {
|
||||||
|
let id = if image_src_id == 0 {
|
||||||
|
stable_image_source_id(src)
|
||||||
|
} else {
|
||||||
|
image_src_id
|
||||||
|
};
|
||||||
|
if let Ok(cache) = data_url_cache().lock() {
|
||||||
|
if let Some(bytes) = cache.get(id) {
|
||||||
|
return Some(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = decode_data_url_bytes(src)?;
|
||||||
|
if let Ok(mut cache) = data_url_cache().lock() {
|
||||||
|
cache.insert(id, decoded.clone());
|
||||||
|
}
|
||||||
|
Some(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_data_url_bytes(src: &str) -> Option<Arc<[u8]>> {
|
||||||
|
let after_scheme = src.strip_prefix("data:")?;
|
||||||
|
let comma = after_scheme.find(',')?;
|
||||||
|
let meta = &after_scheme[..comma];
|
||||||
|
let payload = &after_scheme[comma + 1..];
|
||||||
|
if !meta.contains(";base64") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD as B64;
|
||||||
|
use base64::Engine as _;
|
||||||
|
let decoded = if payload.bytes().any(|b| b.is_ascii_whitespace()) {
|
||||||
|
let clean: Vec<u8> = payload
|
||||||
|
.bytes()
|
||||||
|
.filter(|b| !b.is_ascii_whitespace())
|
||||||
|
.collect();
|
||||||
|
B64.decode(clean.as_slice()).ok()?
|
||||||
|
} else {
|
||||||
|
B64.decode(payload.as_bytes()).ok()?
|
||||||
|
};
|
||||||
|
Some(Arc::from(decoded.into_boxed_slice()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paint a raster image inside `world_rect`. The source bytes and decoded
|
||||||
|
/// backend image are both cached, so repeated canvas paints do not re-decode
|
||||||
|
/// data URLs while importing or panning a Figma-heavy document.
|
||||||
|
pub(super) fn paint_image_node(
|
||||||
|
cx: &mut PaintCx<'_>,
|
||||||
|
node: &SceneNode,
|
||||||
|
world_rect: Rect,
|
||||||
|
zoom: f32,
|
||||||
|
src: &str,
|
||||||
|
) {
|
||||||
|
let bytes = data_url_bytes(src, node.image_src_id);
|
||||||
|
let r = node.corner_radius * zoom;
|
||||||
|
let use_round = r > 0.5;
|
||||||
|
if bytes.is_none() {
|
||||||
|
if let Some(fill) = node.fill {
|
||||||
|
if use_round {
|
||||||
|
cx.backend.fill_round_rect(world_rect, r, fill);
|
||||||
|
} else {
|
||||||
|
cx.backend.fill_rect(world_rect, fill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(bytes) = bytes {
|
||||||
|
let id = if node.image_src_id == 0 {
|
||||||
|
stable_image_source_id(src)
|
||||||
|
} else {
|
||||||
|
node.image_src_id
|
||||||
|
};
|
||||||
|
cx.backend.draw_image_with_options(
|
||||||
|
world_rect,
|
||||||
|
id,
|
||||||
|
bytes.as_ref(),
|
||||||
|
node.image_fit.to_draw_mode(),
|
||||||
|
node.image_adjustments,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(stroke) = node.stroke {
|
||||||
|
let width = stroke.width * zoom;
|
||||||
|
if use_round {
|
||||||
|
cx.backend
|
||||||
|
.stroke_round_rect(world_rect, r, stroke.color, width);
|
||||||
|
} else {
|
||||||
|
cx.backend.stroke_rect(world_rect, stroke.color, width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn clear_data_url_cache_for_tests() {
|
||||||
|
if let Ok(mut cache) = data_url_cache().lock() {
|
||||||
|
*cache = DataUrlCache::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn data_url_cache_len_for_tests() -> usize {
|
||||||
|
data_url_cache()
|
||||||
|
.lock()
|
||||||
|
.map(|cache| cache.entries.len())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn data_url_cache_reuses_decoded_bytes() {
|
||||||
|
clear_data_url_cache_for_tests();
|
||||||
|
let src = "data:image/png;base64,QUJD";
|
||||||
|
|
||||||
|
let first = data_url_bytes(src, 7).expect("first decode");
|
||||||
|
assert_eq!(first.as_ref(), b"ABC");
|
||||||
|
assert_eq!(data_url_cache_len_for_tests(), 1);
|
||||||
|
|
||||||
|
let second = data_url_bytes(src, 7).expect("cached decode");
|
||||||
|
assert!(std::sync::Arc::ptr_eq(&first, &second));
|
||||||
|
assert_eq!(data_url_cache_len_for_tests(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
use crate::layout_scene::{regular_polygon_points, SceneNode};
|
use crate::layout_scene::{regular_polygon_points, SceneNode};
|
||||||
use crate::layout_scene::{Effect, NodeKind};
|
use crate::layout_scene::{Effect, NodeKind};
|
||||||
use crate::widgets::canvas_viewport::EditCaret;
|
use crate::widgets::canvas_viewport::EditCaret;
|
||||||
|
use crate::widgets::canvas_viewport_image::paint_image_node;
|
||||||
use crate::widgets::canvas_viewport_overlay::{paint_fill_then_stroke, wrap_text};
|
use crate::widgets::canvas_viewport_overlay::{paint_fill_then_stroke, wrap_text};
|
||||||
use crate::widgets::PaintCx;
|
use crate::widgets::PaintCx;
|
||||||
use crate::{Point2D, Rect, TextLayout};
|
use crate::{Point2D, Rect, TextLayout};
|
||||||
|
|
@ -414,88 +415,6 @@ fn paint_svg_path_node(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint a Text `SceneNode` — wrapped or single-line text plus the
|
|
||||||
/// edit caret when the node is the one being edited.
|
|
||||||
/// Decode an inline-base64 `data:image/...;base64,...` URL into the
|
|
||||||
/// raw image bytes the backend's `draw_image` decoder expects. Returns
|
|
||||||
/// `None` for any URL that isn't an inline base64 payload (file paths,
|
|
||||||
/// remote URLs, malformed strings) — those paths are deferred to a
|
|
||||||
/// future loader.
|
|
||||||
fn data_url_bytes(src: &str) -> Option<Vec<u8>> {
|
|
||||||
let after_scheme = src.strip_prefix("data:")?;
|
|
||||||
let comma = after_scheme.find(',')?;
|
|
||||||
let meta = &after_scheme[..comma];
|
|
||||||
let payload = &after_scheme[comma + 1..];
|
|
||||||
if !meta.contains(";base64") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
// The base64 alphabet is ASCII; strip any embedded whitespace
|
|
||||||
// (line breaks in a wrapped data URL) before decode.
|
|
||||||
let clean: String = payload
|
|
||||||
.chars()
|
|
||||||
.filter(|c| !c.is_ascii_whitespace())
|
|
||||||
.collect();
|
|
||||||
use base64::engine::general_purpose::STANDARD as B64;
|
|
||||||
use base64::Engine as _;
|
|
||||||
B64.decode(clean.as_bytes()).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Hash a string into a stable u64 — drives the backend's image
|
|
||||||
/// decode cache so the same `src` doesn't re-decode every frame.
|
|
||||||
fn src_hash(src: &str) -> u64 {
|
|
||||||
use std::hash::{Hash, Hasher};
|
|
||||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
|
||||||
src.hash(&mut h);
|
|
||||||
h.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Paint a raster image inside `world_rect`. Decodes the data URL
|
|
||||||
/// once, hands raw bytes + a stable id to the backend cache, then
|
|
||||||
/// strokes the corner-radius outline on top so a per-corner radius
|
|
||||||
/// authored on the schema still reads.
|
|
||||||
fn paint_image_node(
|
|
||||||
cx: &mut PaintCx<'_>,
|
|
||||||
node: &SceneNode,
|
|
||||||
world_rect: Rect,
|
|
||||||
zoom: f32,
|
|
||||||
src: &str,
|
|
||||||
) {
|
|
||||||
let bytes = data_url_bytes(src);
|
|
||||||
let r = node.corner_radius * zoom;
|
|
||||||
let use_round = r > 0.5;
|
|
||||||
// Only paint the grey placeholder when the URL can't be decoded
|
|
||||||
// — painting it under a transparent raster would leave a grey
|
|
||||||
// matte bleeding through the alpha channel.
|
|
||||||
if bytes.is_none() {
|
|
||||||
if let Some(fill) = node.fill {
|
|
||||||
if use_round {
|
|
||||||
cx.backend.fill_round_rect(world_rect, r, fill);
|
|
||||||
} else {
|
|
||||||
cx.backend.fill_rect(world_rect, fill);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(bytes) = bytes {
|
|
||||||
let id = src_hash(src);
|
|
||||||
cx.backend.draw_image_with_options(
|
|
||||||
world_rect,
|
|
||||||
id,
|
|
||||||
&bytes,
|
|
||||||
node.image_fit.to_draw_mode(),
|
|
||||||
node.image_adjustments,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if let Some(stroke) = node.stroke {
|
|
||||||
let width = stroke.width * zoom;
|
|
||||||
if use_round {
|
|
||||||
cx.backend
|
|
||||||
.stroke_round_rect(world_rect, r, stroke.color, width);
|
|
||||||
} else {
|
|
||||||
cx.backend.stroke_rect(world_rect, stroke.color, width);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn paint_text_node(
|
fn paint_text_node(
|
||||||
cx: &mut PaintCx<'_>,
|
cx: &mut PaintCx<'_>,
|
||||||
node: &SceneNode,
|
node: &SceneNode,
|
||||||
|
|
|
||||||
175
crates/op-editor-ui/src/widgets/figma_import_progress.rs
Normal file
175
crates/op-editor-ui/src/widgets/figma_import_progress.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
//! "正在解析 Figma 文件…" overlay — paints while the desktop runner's
|
||||||
|
//! background `figma_import_session` worker thread is parsing a
|
||||||
|
//! `.fig`. A 360×140 centred card with the Figma brand glyph, a
|
||||||
|
//! one-line headline, an animated dot spinner, and a subtitle. The
|
||||||
|
//! parent paint pass draws the scrim; this widget paints the card.
|
||||||
|
//!
|
||||||
|
//! The spinner is driven by `now_ms` (passed in from the host's
|
||||||
|
//! animation clock) — `figma_import_session::pump` schedules a
|
||||||
|
//! `WaitUntil` every ~100 ms while pending so the dots cycle
|
||||||
|
//! smoothly even though no other state changes between frames.
|
||||||
|
|
||||||
|
use crate::theme::Theme;
|
||||||
|
use crate::widgets::editor_state_ext::theme_for;
|
||||||
|
use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
|
||||||
|
use crate::{Color, Point2D, Rect, TextLayout};
|
||||||
|
use op_editor_core::editor_ui_state::Locale;
|
||||||
|
use op_editor_core::EditorState;
|
||||||
|
|
||||||
|
const CARD_WIDTH: f32 = 360.0;
|
||||||
|
const CARD_HEIGHT: f32 = 140.0;
|
||||||
|
|
||||||
|
pub struct FigmaImportProgressOverlay {
|
||||||
|
pub id: WidgetId,
|
||||||
|
pub theme: Theme,
|
||||||
|
locale: Locale,
|
||||||
|
now_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FigmaImportProgressOverlay {
|
||||||
|
pub fn for_editor(state: &EditorState, now_ms: u64) -> Self {
|
||||||
|
Self {
|
||||||
|
id: WidgetId::new(5450),
|
||||||
|
theme: theme_for(&state.editor_ui),
|
||||||
|
locale: state.editor_ui.locale,
|
||||||
|
now_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rect(&self, viewport_w: f32, viewport_h: f32) -> Rect {
|
||||||
|
let x = ((viewport_w - CARD_WIDTH) / 2.0).max(16.0);
|
||||||
|
let y = ((viewport_h - CARD_HEIGHT) / 2.0).max(crate::widgets::TOP_BAR_HEIGHT + 16.0);
|
||||||
|
Rect {
|
||||||
|
origin: Point2D::new(x, y),
|
||||||
|
size: Point2D::new(CARD_WIDTH, CARD_HEIGHT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Headline + subtitle copy. Static fallbacks per locale — the
|
||||||
|
/// canonical `op-i18n` table doesn't carry `figma.parsing*` keys yet,
|
||||||
|
/// so this widget owns the strings until a translator sinks them.
|
||||||
|
fn parsing_title(locale: Locale) -> &'static str {
|
||||||
|
match locale {
|
||||||
|
Locale::ZhCn => "正在解析 Figma 文件…",
|
||||||
|
Locale::ZhTw => "正在解析 Figma 檔案…",
|
||||||
|
Locale::Ja => "Figma ファイルを解析しています…",
|
||||||
|
Locale::Ko => "Figma 파일 분석 중…",
|
||||||
|
_ => "Parsing Figma file…",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parsing_subtitle(locale: Locale) -> &'static str {
|
||||||
|
match locale {
|
||||||
|
Locale::ZhCn => "大型文件需要几秒钟,请稍候",
|
||||||
|
Locale::ZhTw => "大型檔案需要幾秒,請稍候",
|
||||||
|
Locale::Ja => "大きなファイルは数秒かかります。お待ちください。",
|
||||||
|
Locale::Ko => "큰 파일은 몇 초가 걸립니다. 잠시만 기다려 주세요.",
|
||||||
|
_ => "Large files take a few seconds. Please wait.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widget for FigmaImportProgressOverlay {
|
||||||
|
fn id(&self) -> WidgetId {
|
||||||
|
self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn layout(&self, _cx: &LayoutCx) -> LayoutBox {
|
||||||
|
LayoutBox {
|
||||||
|
rect: Rect {
|
||||||
|
origin: Point2D::new(0.0, 0.0),
|
||||||
|
size: Point2D::new(CARD_WIDTH, CARD_HEIGHT),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
|
||||||
|
cx.backend.fill_round_rect(rect, 12.0, self.theme.card);
|
||||||
|
cx.backend
|
||||||
|
.stroke_round_rect(rect, 12.0, self.theme.border, 1.0);
|
||||||
|
|
||||||
|
// Figma brand glyph, top-centre of the card.
|
||||||
|
let glyph_size = 32.0;
|
||||||
|
let glyph_x = rect.origin.x + rect.size.x / 2.0 - glyph_size / 2.0;
|
||||||
|
let glyph_y = rect.origin.y + 16.0;
|
||||||
|
crate::widgets::brand_icons::paint_figma_logo(
|
||||||
|
cx.backend,
|
||||||
|
Point2D::new(glyph_x, glyph_y),
|
||||||
|
glyph_size,
|
||||||
|
self.theme.muted_foreground,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Headline directly below the glyph.
|
||||||
|
let headline = parsing_title(self.locale);
|
||||||
|
let head_w = cx.backend.measure_text(headline, 14.0);
|
||||||
|
let head_layout = TextLayout::single_run(
|
||||||
|
headline,
|
||||||
|
"system-ui",
|
||||||
|
14.0,
|
||||||
|
to_jian(self.theme.foreground),
|
||||||
|
Point2D::new(0.0, 0.0),
|
||||||
|
);
|
||||||
|
cx.backend.draw_text(
|
||||||
|
&head_layout,
|
||||||
|
Point2D::new(
|
||||||
|
rect.origin.x + (rect.size.x - head_w) / 2.0,
|
||||||
|
glyph_y + glyph_size + 22.0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Animated dot spinner — three dots ticking in a 750 ms cycle.
|
||||||
|
// The active dot pops to `foreground`; idle dots sit at
|
||||||
|
// `muted_foreground`.
|
||||||
|
let dots_y = glyph_y + glyph_size + 42.0;
|
||||||
|
let dot_r = 3.5;
|
||||||
|
let dot_gap = 12.0;
|
||||||
|
let dot_count = 3.0;
|
||||||
|
let dots_w = dot_count * dot_r * 2.0 + (dot_count - 1.0) * dot_gap;
|
||||||
|
let dots_x = rect.origin.x + (rect.size.x - dots_w) / 2.0;
|
||||||
|
let active = ((self.now_ms / 250) % 3) as i32;
|
||||||
|
for i in 0..3 {
|
||||||
|
let cx_dot = dots_x + dot_r + i as f32 * (dot_r * 2.0 + dot_gap);
|
||||||
|
let color = if i == active {
|
||||||
|
self.theme.foreground
|
||||||
|
} else {
|
||||||
|
self.theme.muted_foreground
|
||||||
|
};
|
||||||
|
cx.backend.fill_oval(
|
||||||
|
Rect {
|
||||||
|
origin: Point2D::new(cx_dot - dot_r, dots_y),
|
||||||
|
size: Point2D::new(dot_r * 2.0, dot_r * 2.0),
|
||||||
|
},
|
||||||
|
color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subtitle below the dots.
|
||||||
|
let sub = parsing_subtitle(self.locale);
|
||||||
|
let sub_w = cx.backend.measure_text(sub, 11.0);
|
||||||
|
let sub_layout = TextLayout::single_run(
|
||||||
|
sub,
|
||||||
|
"system-ui",
|
||||||
|
11.0,
|
||||||
|
to_jian(self.theme.muted_foreground),
|
||||||
|
Point2D::new(0.0, 0.0),
|
||||||
|
);
|
||||||
|
cx.backend.draw_text(
|
||||||
|
&sub_layout,
|
||||||
|
Point2D::new(rect.origin.x + (rect.size.x - sub_w) / 2.0, dots_y + 22.0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_node(&self) -> accesskit::Node {
|
||||||
|
let mut node = accesskit::Node::new(accesskit::Role::Dialog);
|
||||||
|
node.set_label(parsing_title(self.locale));
|
||||||
|
node.set_busy();
|
||||||
|
node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_jian(c: Color) -> jian_core::scene::Color {
|
||||||
|
fn ch(v: f32) -> u8 {
|
||||||
|
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||||
|
}
|
||||||
|
jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a))
|
||||||
|
}
|
||||||
|
|
@ -512,6 +512,12 @@ impl Widget for LayerPanel {
|
||||||
origin: Point2D::new(rect.origin.x + 6.0, y + 2.0),
|
origin: Point2D::new(rect.origin.x + 6.0, y + 2.0),
|
||||||
size: Point2D::new(rect.size.x - 12.0, PAGE_ROW_HEIGHT - 4.0),
|
size: Point2D::new(rect.size.x - 12.0, PAGE_ROW_HEIGHT - 4.0),
|
||||||
};
|
};
|
||||||
|
if row.origin.y + row.size.y < r.pages_rows_top
|
||||||
|
|| row.origin.y > r.pages_rows_top + r.pages_view_h
|
||||||
|
{
|
||||||
|
y += PAGE_ROW_HEIGHT;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if page.active {
|
if page.active {
|
||||||
cx.backend
|
cx.backend
|
||||||
.fill_round_rect(row, 6.0, self.theme.row_selected);
|
.fill_round_rect(row, 6.0, self.theme.row_selected);
|
||||||
|
|
@ -599,6 +605,12 @@ impl Widget for LayerPanel {
|
||||||
origin: Point2D::new(rect.origin.x + 6.0, y + 2.0),
|
origin: Point2D::new(rect.origin.x + 6.0, y + 2.0),
|
||||||
size: Point2D::new(rect.size.x - 12.0, LAYER_ROW_HEIGHT - 4.0),
|
size: Point2D::new(rect.size.x - 12.0, LAYER_ROW_HEIGHT - 4.0),
|
||||||
};
|
};
|
||||||
|
if row.origin.y + row.size.y < r.layers_rows_top
|
||||||
|
|| row.origin.y > r.layers_rows_top + r.layers_view_h
|
||||||
|
{
|
||||||
|
y += LAYER_ROW_HEIGHT;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if item.selected {
|
if item.selected {
|
||||||
// TS uses bg-blue-500/15 + primary text + primary
|
// TS uses bg-blue-500/15 + primary text + primary
|
||||||
// icon for the selected layer row.
|
// icon for the selected layer row.
|
||||||
|
|
@ -625,10 +637,6 @@ impl Widget for LayerPanel {
|
||||||
} else {
|
} else {
|
||||||
dim(self.theme.muted_foreground, dim_factor)
|
dim(self.theme.muted_foreground, dim_factor)
|
||||||
};
|
};
|
||||||
// Leading chevron — only for container rows (TS
|
|
||||||
// `LayerRow` shows the caret only when the node has
|
|
||||||
// children). 12 px slot so the kind icon aligns to
|
|
||||||
// the same x regardless.
|
|
||||||
if item.has_children {
|
if item.has_children {
|
||||||
let chev_icon = if item.collapsed {
|
let chev_icon = if item.collapsed {
|
||||||
Icon::ChevronRight
|
Icon::ChevronRight
|
||||||
|
|
@ -644,11 +652,7 @@ impl Widget for LayerPanel {
|
||||||
1.4,
|
1.4,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 18 px slot for the chevron (was 14, no breathing
|
|
||||||
// room between chevron and kind icon — user feedback
|
|
||||||
// 2026-05-11).
|
|
||||||
let icon_x = row.origin.x + indent + 18.0;
|
let icon_x = row.origin.x + indent + 18.0;
|
||||||
// Kind icon — switches to primary color when selected.
|
|
||||||
draw_icon(
|
draw_icon(
|
||||||
cx.backend,
|
cx.backend,
|
||||||
item.icon,
|
item.icon,
|
||||||
|
|
@ -692,10 +696,6 @@ impl Widget for LayerPanel {
|
||||||
cx.backend
|
cx.backend
|
||||||
.draw_text(&label, Point2D::new(label_x, row.origin.y + 17.0));
|
.draw_text(&label, Point2D::new(label_x, row.origin.y + 17.0));
|
||||||
}
|
}
|
||||||
// Trailing eye + lock icons. Icon shape signals state
|
|
||||||
// (Eye/EyeOff, Lock/LockOpen); locked Lock paints in
|
|
||||||
// warm orange so it reads as a "can't edit" alert.
|
|
||||||
// Eye-to-lock gap (22 px) matches hit-test spacing.
|
|
||||||
let trailing_right = row.origin.x + row.size.x - 8.0;
|
let trailing_right = row.origin.x + row.size.x - 8.0;
|
||||||
let lock_x = trailing_right - 14.0;
|
let lock_x = trailing_right - 14.0;
|
||||||
let eye_x = lock_x - 22.0;
|
let eye_x = lock_x - 22.0;
|
||||||
|
|
@ -722,15 +722,10 @@ impl Widget for LayerPanel {
|
||||||
} else {
|
} else {
|
||||||
trailing_default
|
trailing_default
|
||||||
};
|
};
|
||||||
// Slimmer than the leading icons (12 px @ 1.2 stroke).
|
|
||||||
let trailing_size = 12.0;
|
let trailing_size = 12.0;
|
||||||
let trailing_stroke = 1.2;
|
let trailing_stroke = 1.2;
|
||||||
let trailing_y = row.origin.y + 7.0;
|
let trailing_y = row.origin.y + 7.0;
|
||||||
// Eye only paints on hover / selected / hidden — TS
|
|
||||||
// parity (hover reveal). Hidden always shows so
|
|
||||||
// the user sees state at a glance.
|
|
||||||
let show_eye = item.hovered || item.selected || item.hidden;
|
let show_eye = item.hovered || item.selected || item.hidden;
|
||||||
// Lock paints on hover / selected / locked.
|
|
||||||
let show_lock = item.hovered || item.selected || item.locked;
|
let show_lock = item.hovered || item.selected || item.locked;
|
||||||
if show_eye {
|
if show_eye {
|
||||||
draw_icon(
|
draw_icon(
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ pub mod toolbar;
|
||||||
// Step 3 — center canvas that renders document nodes as actual
|
// Step 3 — center canvas that renders document nodes as actual
|
||||||
// visual primitives (frame fills, rect strokes, text strings).
|
// visual primitives (frame fills, rect strokes, text strings).
|
||||||
pub mod canvas_viewport;
|
pub mod canvas_viewport;
|
||||||
|
mod canvas_viewport_image;
|
||||||
pub mod canvas_viewport_overlay;
|
pub mod canvas_viewport_overlay;
|
||||||
pub mod canvas_viewport_paint;
|
pub mod canvas_viewport_paint;
|
||||||
|
|
||||||
|
|
@ -108,6 +109,7 @@ pub mod design_md_markdown;
|
||||||
pub mod design_md_panel;
|
pub mod design_md_panel;
|
||||||
pub mod export_dialog;
|
pub mod export_dialog;
|
||||||
pub mod figma_import;
|
pub mod figma_import;
|
||||||
|
pub mod figma_import_progress;
|
||||||
pub mod file_menu;
|
pub mod file_menu;
|
||||||
pub mod git_panel;
|
pub mod git_panel;
|
||||||
mod git_panel_diff;
|
mod git_panel_diff;
|
||||||
|
|
|
||||||
403
crates/op-figma/examples/probe_fig.rs
Normal file
403
crates/op-figma/examples/probe_fig.rs
Normal file
|
|
@ -0,0 +1,403 @@
|
||||||
|
//! One-off smoke runner — read a `.fig` file from disk and drive it
|
||||||
|
//! through the same import path the desktop binary uses
|
||||||
|
//! (`parse_fig_binary` → `figma_all_pages_to_pen_document` →
|
||||||
|
//! `resolve_image_blobs`). Prints per-page node counts, the first
|
||||||
|
//! warnings, and a top-of-tree variant tally so the import behaves
|
||||||
|
//! visibly even without a GUI.
|
||||||
|
//!
|
||||||
|
//! Usage: `cargo run -p op-figma --example probe_fig -- <path.fig>`
|
||||||
|
|
||||||
|
use jian_ops_schema::node::PenNode;
|
||||||
|
use jian_ops_schema::sizing::SizingBehavior;
|
||||||
|
use op_figma::{parse_fig_binary, FigLayoutMode};
|
||||||
|
|
||||||
|
fn variant_name(n: &PenNode) -> &'static str {
|
||||||
|
match n {
|
||||||
|
PenNode::Frame(_) => "Frame",
|
||||||
|
PenNode::Group(_) => "Group",
|
||||||
|
PenNode::Rectangle(_) => "Rectangle",
|
||||||
|
PenNode::Ellipse(_) => "Ellipse",
|
||||||
|
PenNode::Line(_) => "Line",
|
||||||
|
PenNode::Polygon(_) => "Polygon",
|
||||||
|
PenNode::Path(_) => "Path",
|
||||||
|
PenNode::Text(_) => "Text",
|
||||||
|
PenNode::TextInput(_) => "TextInput",
|
||||||
|
PenNode::Image(_) => "Image",
|
||||||
|
PenNode::IconFont(_) => "IconFont",
|
||||||
|
PenNode::Ref(_) => "Ref",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tally(nodes: &[PenNode]) -> std::collections::BTreeMap<&'static str, usize> {
|
||||||
|
let mut out = std::collections::BTreeMap::new();
|
||||||
|
for n in nodes {
|
||||||
|
*out.entry(variant_name(n)).or_insert(0) += 1;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let path = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.expect("usage: probe_fig <path.fig>");
|
||||||
|
let bytes = std::fs::read(&path).expect("read file");
|
||||||
|
let file_name = std::path::Path::new(&path)
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("Figma Import");
|
||||||
|
|
||||||
|
println!("input: {path} ({} bytes)", bytes.len());
|
||||||
|
|
||||||
|
let import = match parse_fig_binary(&bytes, file_name, FigLayoutMode::OpenPencil) {
|
||||||
|
Ok(i) => i,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("PARSE ERROR: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let pages = import.document.pages.as_deref().unwrap_or(&[]);
|
||||||
|
println!("pages: {}", pages.len());
|
||||||
|
for (i, page) in pages.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
" [{}] name={:?} children={}",
|
||||||
|
i,
|
||||||
|
page.name,
|
||||||
|
page.children.len()
|
||||||
|
);
|
||||||
|
let counts = tally(&page.children);
|
||||||
|
if !counts.is_empty() {
|
||||||
|
print!(" top-level tally:");
|
||||||
|
for (k, v) in &counts {
|
||||||
|
print!(" {k}={v}");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(out) = std::env::var("OP_DUMP_JSON") {
|
||||||
|
let json = serde_json::to_string_pretty(&import.document).expect("serialize");
|
||||||
|
std::fs::write(&out, json).expect("write json");
|
||||||
|
eprintln!("dumped PenDocument JSON to {out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("warnings: {}", import.warnings.len());
|
||||||
|
for w in import.warnings.iter().take(8) {
|
||||||
|
println!(" - {w}");
|
||||||
|
}
|
||||||
|
if import.warnings.len() > 8 {
|
||||||
|
println!(" … and {} more", import.warnings.len() - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk the first page's direct children: print the frame's own
|
||||||
|
// (width, height) + auto-layout flags, then for each grandchild
|
||||||
|
// print its (x, y, w, h) so overflow becomes visible.
|
||||||
|
if let Some(page) = pages.first() {
|
||||||
|
if let Some(root) = page.children.first() {
|
||||||
|
print_overflow_audit(root);
|
||||||
|
// Recursive drill: prints every descendant whose name
|
||||||
|
// contains the needle, showing its own children. Useful for
|
||||||
|
// tracking down a horizontal-row container by name.
|
||||||
|
if let Ok(needle) = std::env::var("OP_PROBE_DRILL") {
|
||||||
|
drill_all(root, &needle, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep digest of the first page — same shape as the TS probe so we
|
||||||
|
// can diff `digest` / `deep_count` / `text_count` directly.
|
||||||
|
if let Some(page) = pages.first() {
|
||||||
|
let digest = digest_tree(&page.children);
|
||||||
|
println!("first-page digest: {digest:x}");
|
||||||
|
let total = count_deep(&page.children);
|
||||||
|
println!("first-page deep node count: {total}");
|
||||||
|
let texts = collect_text(&page.children);
|
||||||
|
println!("first-page text node count: {}", texts.len());
|
||||||
|
let mut clusters: std::collections::BTreeMap<(i64, i64), Vec<String>> =
|
||||||
|
std::collections::BTreeMap::new();
|
||||||
|
for t in &texts {
|
||||||
|
clusters
|
||||||
|
.entry((t.0.round() as i64, t.1.round() as i64))
|
||||||
|
.or_default()
|
||||||
|
.push(t.2.clone());
|
||||||
|
}
|
||||||
|
let overlaps: Vec<_> = clusters.iter().filter(|(_, v)| v.len() >= 2).collect();
|
||||||
|
println!(
|
||||||
|
"first-page co-located-text clusters (≥2 nodes sharing x,y): {}",
|
||||||
|
overlaps.len()
|
||||||
|
);
|
||||||
|
for ((x, y), texts) in overlaps.iter().take(10) {
|
||||||
|
print!(" - at ({x}, {y}):");
|
||||||
|
for (i, t) in texts.iter().take(4).enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
print!(" |");
|
||||||
|
}
|
||||||
|
print!(" {t:?}");
|
||||||
|
}
|
||||||
|
if texts.len() > 4 {
|
||||||
|
print!(" …");
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same `(type | x | y | width-json | height-json)` per-node tuple
|
||||||
|
/// hashed exactly the way the TS probe in `scripts/probe-fig-ts.ts`
|
||||||
|
/// does it — bitwise i32 multiply-by-31, walking the tree in
|
||||||
|
/// pre-order. Lets us prove the Rust + TS pipelines produced
|
||||||
|
/// structurally identical PenDocuments.
|
||||||
|
fn digest_tree(nodes: &[PenNode]) -> u32 {
|
||||||
|
let mut h: i32 = 0;
|
||||||
|
fn go(arr: &[PenNode], h: &mut i32) {
|
||||||
|
for c in arr {
|
||||||
|
let (x, y) = (base_of(c).x.unwrap_or(0.0), base_of(c).y.unwrap_or(0.0));
|
||||||
|
let w = width_json(c);
|
||||||
|
let height = height_json(c);
|
||||||
|
let sig = format!(
|
||||||
|
"{}|{}|{}|{}|{}",
|
||||||
|
type_str(c),
|
||||||
|
fmt_num(x),
|
||||||
|
fmt_num(y),
|
||||||
|
w,
|
||||||
|
height
|
||||||
|
);
|
||||||
|
for byte in sig.chars() {
|
||||||
|
*h = h.wrapping_mul(31).wrapping_add(byte as i32);
|
||||||
|
}
|
||||||
|
go(children_of(c), h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go(nodes, &mut h);
|
||||||
|
h as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Match JS's `n ?? 0` then default `.toString()` — for 0.5 etc. we
|
||||||
|
/// want "0.5", for 100 we want "100" (no trailing zero, no '.0').
|
||||||
|
fn fmt_num(n: f64) -> String {
|
||||||
|
if n == 0.0 {
|
||||||
|
return "0".to_string();
|
||||||
|
}
|
||||||
|
if n.fract() == 0.0 && n.abs() < 1e16 {
|
||||||
|
format!("{}", n as i64)
|
||||||
|
} else {
|
||||||
|
// Drop trailing zeros and trailing dot for clean repr.
|
||||||
|
let s = format!("{n}");
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn type_str(n: &PenNode) -> &'static str {
|
||||||
|
// Snake-case to mirror the JSON tags ("rectangle", "text", ...).
|
||||||
|
match n {
|
||||||
|
PenNode::Frame(_) => "frame",
|
||||||
|
PenNode::Group(_) => "group",
|
||||||
|
PenNode::Rectangle(_) => "rectangle",
|
||||||
|
PenNode::Ellipse(_) => "ellipse",
|
||||||
|
PenNode::Line(_) => "line",
|
||||||
|
PenNode::Polygon(_) => "polygon",
|
||||||
|
PenNode::Path(_) => "path",
|
||||||
|
PenNode::Text(_) => "text",
|
||||||
|
PenNode::TextInput(_) => "text_input",
|
||||||
|
PenNode::Image(_) => "image",
|
||||||
|
PenNode::IconFont(_) => "icon_font",
|
||||||
|
PenNode::Ref(_) => "ref",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_of(n: &PenNode) -> &jian_ops_schema::node::base::PenNodeBase {
|
||||||
|
match n {
|
||||||
|
PenNode::Frame(x) => &x.base,
|
||||||
|
PenNode::Group(x) => &x.base,
|
||||||
|
PenNode::Rectangle(x) => &x.base,
|
||||||
|
PenNode::Ellipse(x) => &x.base,
|
||||||
|
PenNode::Line(x) => &x.base,
|
||||||
|
PenNode::Polygon(x) => &x.base,
|
||||||
|
PenNode::Path(x) => &x.base,
|
||||||
|
PenNode::Text(x) => &x.base,
|
||||||
|
PenNode::TextInput(x) => &x.base,
|
||||||
|
PenNode::Image(x) => &x.base,
|
||||||
|
PenNode::IconFont(x) => &x.base,
|
||||||
|
PenNode::Ref(x) => &x.base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn width_json(n: &PenNode) -> String {
|
||||||
|
let w = match n {
|
||||||
|
PenNode::Frame(x) => x.container.width.as_ref(),
|
||||||
|
PenNode::Group(x) => x.container.width.as_ref(),
|
||||||
|
PenNode::Rectangle(x) => x.container.width.as_ref(),
|
||||||
|
PenNode::Ellipse(x) => x.width.as_ref(),
|
||||||
|
PenNode::Polygon(x) => x.width.as_ref(),
|
||||||
|
PenNode::Path(x) => x.width.as_ref(),
|
||||||
|
PenNode::Text(x) => x.width.as_ref(),
|
||||||
|
PenNode::TextInput(x) => x.width.as_ref(),
|
||||||
|
PenNode::Image(x) => x.width.as_ref(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
w.map(|s| serde_json::to_string(s).unwrap_or_default())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height_json(n: &PenNode) -> String {
|
||||||
|
let h = match n {
|
||||||
|
PenNode::Frame(x) => x.container.height.as_ref(),
|
||||||
|
PenNode::Group(x) => x.container.height.as_ref(),
|
||||||
|
PenNode::Rectangle(x) => x.container.height.as_ref(),
|
||||||
|
PenNode::Ellipse(x) => x.height.as_ref(),
|
||||||
|
PenNode::Polygon(x) => x.height.as_ref(),
|
||||||
|
PenNode::Path(x) => x.height.as_ref(),
|
||||||
|
PenNode::Text(x) => x.height.as_ref(),
|
||||||
|
PenNode::TextInput(x) => x.height.as_ref(),
|
||||||
|
PenNode::Image(x) => x.height.as_ref(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
h.map(|s| serde_json::to_string(s).unwrap_or_default())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dump the page-root frame's (w, h) + auto-layout flags, plus each
|
||||||
|
/// direct child's (x, y, w, h). Lets us see overflowed children
|
||||||
|
/// quickly — anything whose `(x + w)` or `(y + h)` exceeds the parent
|
||||||
|
/// box is overflowing it.
|
||||||
|
fn print_overflow_audit(root: &PenNode) {
|
||||||
|
let (rw, rh, layout) = match root {
|
||||||
|
PenNode::Frame(f) => (
|
||||||
|
sizing_str(f.container.width.as_ref()),
|
||||||
|
sizing_str(f.container.height.as_ref()),
|
||||||
|
format!(
|
||||||
|
"layout={:?} gap={:?} align_items={:?} clip_content={:?} padding={:?}",
|
||||||
|
f.container.layout,
|
||||||
|
f.container.gap,
|
||||||
|
f.container.align_items,
|
||||||
|
f.container.clip_content,
|
||||||
|
f.container.padding
|
||||||
|
),
|
||||||
|
),
|
||||||
|
other => (
|
||||||
|
format!("(not Frame: {})", type_str(other)),
|
||||||
|
"-".to_string(),
|
||||||
|
"-".to_string(),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
println!("root frame: width={rw} height={rh}");
|
||||||
|
println!(" {layout}");
|
||||||
|
let kids = children_of(root);
|
||||||
|
println!(" direct children: {}", kids.len());
|
||||||
|
let parent_w = number_or_zero(width_json_raw(root));
|
||||||
|
let parent_h = number_or_zero(height_json_raw(root));
|
||||||
|
for (i, c) in kids.iter().enumerate() {
|
||||||
|
let nm = base_of(c).name.as_deref().unwrap_or("(unnamed)");
|
||||||
|
let x = base_of(c).x.unwrap_or(0.0);
|
||||||
|
let y = base_of(c).y.unwrap_or(0.0);
|
||||||
|
let w = number_or_zero(width_json_raw(c));
|
||||||
|
let h = number_or_zero(height_json_raw(c));
|
||||||
|
let mut tags = String::new();
|
||||||
|
if parent_w > 0.0 && x + w > parent_w + 0.5 {
|
||||||
|
tags.push_str(" [OVERFLOWS-X]");
|
||||||
|
}
|
||||||
|
if parent_h > 0.0 && y + h > parent_h + 0.5 {
|
||||||
|
tags.push_str(" [OVERFLOWS-Y]");
|
||||||
|
}
|
||||||
|
let wj = width_json_raw(c);
|
||||||
|
let hj = height_json_raw(c);
|
||||||
|
println!(
|
||||||
|
" [{i:2}] {nm:<24} {ty} x={x:>7.1} y={y:>7.1} w_json={wj:<18} h_json={hj:<18}{tags}",
|
||||||
|
ty = type_str(c),
|
||||||
|
nm = truncate(nm, 24)
|
||||||
|
);
|
||||||
|
let _ = (w, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drill_all(node: &PenNode, needle: &str, depth: u32) {
|
||||||
|
if depth > 20 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if base_of(node)
|
||||||
|
.name
|
||||||
|
.as_deref()
|
||||||
|
.map(|n| n.contains(needle))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
println!();
|
||||||
|
let pad = " ".repeat(depth as usize);
|
||||||
|
println!(
|
||||||
|
"{pad}── {:?} ── (depth {depth})",
|
||||||
|
base_of(node).name.as_deref().unwrap_or("?")
|
||||||
|
);
|
||||||
|
print_overflow_audit(node);
|
||||||
|
}
|
||||||
|
for c in children_of(node) {
|
||||||
|
drill_all(c, needle, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
let mut acc = String::new();
|
||||||
|
for (i, c) in s.chars().enumerate() {
|
||||||
|
if i >= n {
|
||||||
|
acc.push('…');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
acc.push(c);
|
||||||
|
}
|
||||||
|
acc
|
||||||
|
}
|
||||||
|
|
||||||
|
fn width_json_raw(n: &PenNode) -> String {
|
||||||
|
width_json(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn height_json_raw(n: &PenNode) -> String {
|
||||||
|
height_json(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sizing_str(s: Option<&SizingBehavior>) -> String {
|
||||||
|
s.map(|v| serde_json::to_string(v).unwrap_or_default())
|
||||||
|
.unwrap_or_else(|| "-".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn number_or_zero(s: String) -> f64 {
|
||||||
|
// serde_json output for SizingBehavior::Number(N) is just `N`.
|
||||||
|
s.trim().parse::<f64>().unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_deep(nodes: &[PenNode]) -> usize {
|
||||||
|
let mut n = nodes.len();
|
||||||
|
for c in nodes {
|
||||||
|
n += count_deep(children_of(c));
|
||||||
|
}
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
fn children_of(n: &PenNode) -> &[PenNode] {
|
||||||
|
use jian_ops_schema::node::PenNode::*;
|
||||||
|
match n {
|
||||||
|
Frame(f) => f.children.as_deref().unwrap_or(&[]),
|
||||||
|
Group(g) => g.children.as_deref().unwrap_or(&[]),
|
||||||
|
Rectangle(r) => r.children.as_deref().unwrap_or(&[]),
|
||||||
|
Ref(r) => r.children.as_deref().unwrap_or(&[]),
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_text(nodes: &[PenNode]) -> Vec<(f64, f64, String)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
fn go(arr: &[PenNode], out: &mut Vec<(f64, f64, String)>) {
|
||||||
|
for c in arr {
|
||||||
|
if let PenNode::Text(t) = c {
|
||||||
|
let s = match &t.content {
|
||||||
|
jian_ops_schema::node::text::TextContent::Plain(p) => p.clone(),
|
||||||
|
jian_ops_schema::node::text::TextContent::Styled(segs) => {
|
||||||
|
segs.iter().map(|s| s.text.as_str()).collect::<String>()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
out.push((t.base.x.unwrap_or(0.0), t.base.y.unwrap_or(0.0), s));
|
||||||
|
}
|
||||||
|
go(children_of(c), out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go(nodes, &mut out);
|
||||||
|
out
|
||||||
|
}
|
||||||
727
crates/op-figma/src/clipboard.rs
Normal file
727
crates/op-figma/src/clipboard.rs
Normal file
|
|
@ -0,0 +1,727 @@
|
||||||
|
//! Figma HTML clipboard parsing — ports `pen-figma/figma-clipboard.ts`.
|
||||||
|
//!
|
||||||
|
//! Figma's Cmd+C-on-canvas writes an HTML clipboard payload that wraps
|
||||||
|
//! a base64-encoded `.fig`-kiwi binary buffer + a JSON meta blob in
|
||||||
|
//! HTML comments / data-attributes. This module decodes that payload
|
||||||
|
//! into a flat `Vec<PenNode>` matching TS `figmaClipboardToNodes` —
|
||||||
|
//! including the post-process steps that swap unresolvable
|
||||||
|
//! `__blob:` / `__hash:` references for grey placeholder fills and
|
||||||
|
//! enrich text nodes with inline CSS hints recovered from the HTML.
|
||||||
|
//!
|
||||||
|
//! The `.fig.json` clipboard format (JSON wrapper, distinct from this
|
||||||
|
//! HTML format) lives in `lib.rs::extract_clipboard_nodes` and is
|
||||||
|
//! unrelated.
|
||||||
|
//!
|
||||||
|
//! Host integration: a desktop paste handler can call
|
||||||
|
//! [`is_figma_clipboard_html`] on the system clipboard's HTML payload,
|
||||||
|
//! then [`figma_clipboard_to_nodes`] to obtain insertable PenNodes.
|
||||||
|
|
||||||
|
use base64::engine::general_purpose::STANDARD_NO_PAD;
|
||||||
|
use base64::Engine;
|
||||||
|
use jian_ops_schema::node::container::ContainerProps;
|
||||||
|
use jian_ops_schema::node::{
|
||||||
|
EllipseNode, FrameNode, GroupNode, ImageNode, LineNode, PathNode, PenNode, PenNodeBase,
|
||||||
|
PolygonNode, RectangleNode, RefNode, TextContent, TextInputNode, TextNode,
|
||||||
|
};
|
||||||
|
use jian_ops_schema::style::{PenFill, SolidFillBody};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::common::FigLayoutMode;
|
||||||
|
use crate::figma_types;
|
||||||
|
use crate::image_resolver::resolve_image_blobs;
|
||||||
|
use crate::node_mapper::{figma_node_changes_to_pen_nodes, FigmaClipboardResult};
|
||||||
|
|
||||||
|
/// Decoded Figma HTML clipboard payload — the JSON `meta` text plus
|
||||||
|
/// the binary `.fig`-kiwi `buffer`. The caller can opt into parsing
|
||||||
|
/// `meta` with `serde_json` (kept as `String` here so the crate
|
||||||
|
/// doesn't pull serde_json at runtime).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FigmaClipboardData {
|
||||||
|
/// Raw JSON text of the figmeta block (caller parses if needed).
|
||||||
|
pub meta: String,
|
||||||
|
/// The decoded `.fig`-kiwi binary buffer.
|
||||||
|
pub buffer: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Quick HTML-prefix check for the Figma clipboard. Matches TS
|
||||||
|
/// `isFigmaClipboardHtml` — looks for either the `figmeta` comment
|
||||||
|
/// marker or the `data-buffer` attribute, whichever wrapper Figma is
|
||||||
|
/// using on the platform.
|
||||||
|
pub fn is_figma_clipboard_html(html: &str) -> bool {
|
||||||
|
html.contains("figmeta") || html.contains("data-buffer")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract Figma clipboard data from an HTML payload. Mirrors TS
|
||||||
|
/// `extractFigmaClipboardData` — tries three wrapper formats in order:
|
||||||
|
/// 1. `<!--(figmeta)-->BASE64<!--(figmeta)-->` comment blocks
|
||||||
|
/// 2. `data-metadata="…"` / `data-buffer="…"` attribute pairs
|
||||||
|
/// 3. HTML-entity-encoded comment markers
|
||||||
|
///
|
||||||
|
/// Returns `None` on any unrecognised / malformed input.
|
||||||
|
pub fn extract_figma_clipboard_data(html: &str) -> Option<FigmaClipboardData> {
|
||||||
|
let (mut meta_b64, mut buf_b64) = (None::<String>, None::<String>);
|
||||||
|
|
||||||
|
// Strategy 1: bare comment-wrapped form.
|
||||||
|
if let (Some(m), Some(b)) = (
|
||||||
|
scan_between(html, "<!--(figmeta)", "<!--(figmeta)-->"),
|
||||||
|
scan_between(html, "<!--(figma)", "<!--(figma)-->"),
|
||||||
|
) {
|
||||||
|
meta_b64 = Some(strip_opening_marker(m).trim().to_string());
|
||||||
|
buf_b64 = Some(strip_opening_marker(b).trim().to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 2: `data-metadata` / `data-buffer` attributes — comment
|
||||||
|
// markers may live INSIDE the attribute value.
|
||||||
|
if meta_b64.is_none() || buf_b64.is_none() {
|
||||||
|
if let (Some(m), Some(b)) = (
|
||||||
|
scan_attr(html, "data-metadata"),
|
||||||
|
scan_attr(html, "data-buffer"),
|
||||||
|
) {
|
||||||
|
meta_b64 = Some(strip_embedded_markers(m, "figmeta").trim().to_string());
|
||||||
|
buf_b64 = Some(strip_embedded_markers(b, "figma").trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strategy 3: HTML-entity-encoded comment markers.
|
||||||
|
if meta_b64.is_none() || buf_b64.is_none() {
|
||||||
|
if let (Some(m), Some(b)) = (
|
||||||
|
scan_between(html, "<!--(figmeta)-->", "<!--(figmeta)-->"),
|
||||||
|
scan_between(html, "<!--(figma)-->", "<!--(figma)-->"),
|
||||||
|
) {
|
||||||
|
meta_b64 = Some(m.trim().to_string());
|
||||||
|
buf_b64 = Some(b.trim().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta_b64 = meta_b64?;
|
||||||
|
let buf_b64 = buf_b64?;
|
||||||
|
|
||||||
|
let meta_raw = String::from_utf8(decode_base64(&meta_b64)?).ok()?;
|
||||||
|
// Trim any trailing padding-induced junk past the JSON object.
|
||||||
|
let json_end = meta_raw.rfind('}').map(|i| i + 1).unwrap_or(meta_raw.len());
|
||||||
|
let meta = meta_raw[..json_end].to_string();
|
||||||
|
let buffer = decode_base64(&buf_b64)?;
|
||||||
|
Some(FigmaClipboardData { meta, buffer })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drive a clipboard buffer through the same binary parser used for
|
||||||
|
/// `.fig` files, plus the two clipboard-specific post-process passes:
|
||||||
|
/// `fix_unresolved_images` (swap `__blob:` / `__hash:` references for
|
||||||
|
/// grey placeholders) and — when HTML is supplied — `enrich_*` (copy
|
||||||
|
/// missing color/font hints from inline CSS in the HTML). Mirrors TS
|
||||||
|
/// `figmaClipboardToNodes`.
|
||||||
|
pub fn figma_clipboard_to_nodes(buffer: &[u8], html: Option<&str>) -> FigmaClipboardResult {
|
||||||
|
let decoded = match figma_types::parse_fig_file(buffer) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
return FigmaClipboardResult {
|
||||||
|
nodes: Vec::new(),
|
||||||
|
warnings: vec![format!("Clipboard parse failed: {e}")],
|
||||||
|
image_blobs: HashMap::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let image_files = decoded.image_files.clone();
|
||||||
|
// Clipboard uses the same `preserve` mode the TS clipboard path
|
||||||
|
// uses — keeps auto-layout flow + numeric pixel sizes on images.
|
||||||
|
let mut out = figma_node_changes_to_pen_nodes(decoded, FigLayoutMode::Preserve);
|
||||||
|
|
||||||
|
if !out.image_blobs.is_empty() || !image_files.is_empty() {
|
||||||
|
let mut doc = build_doc_for_resolve(out.nodes.clone());
|
||||||
|
resolve_image_blobs(&mut doc, &out.image_blobs, &image_files);
|
||||||
|
out.nodes = extract_doc_children(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
fix_unresolved_images(&mut out.nodes);
|
||||||
|
|
||||||
|
if let Some(html) = html {
|
||||||
|
let hints = parse_clipboard_html_styles(html);
|
||||||
|
if !hints.is_empty() {
|
||||||
|
enrich_nodes_from_html_hints(&mut out.nodes, &hints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Base64 ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Tolerant base64 decode — strips non-alphabet bytes (`\n`, `\r`,
|
||||||
|
/// `\t`, ` `), normalises URL-safe `-` / `_`, accepts missing or
|
||||||
|
/// extra `=` padding. Matches TS `decodeBase64ToBytes` accept-set.
|
||||||
|
fn decode_base64(input: &str) -> Option<Vec<u8>> {
|
||||||
|
let mut cleaned = String::with_capacity(input.len());
|
||||||
|
for c in input.chars() {
|
||||||
|
if matches!(
|
||||||
|
c,
|
||||||
|
'A'..='Z' | 'a'..='z' | '0'..='9' | '+' | '/' | '-' | '_' | '='
|
||||||
|
) {
|
||||||
|
// Normalise URL-safe.
|
||||||
|
cleaned.push(match c {
|
||||||
|
'-' => '+',
|
||||||
|
'_' => '/',
|
||||||
|
other => other,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip trailing padding so STANDARD_NO_PAD accepts; the engine
|
||||||
|
// handles missing padding fine. Manual trim avoids the decoder
|
||||||
|
// bailing on `==` count mismatches.
|
||||||
|
while cleaned.ends_with('=') {
|
||||||
|
cleaned.pop();
|
||||||
|
}
|
||||||
|
STANDARD_NO_PAD.decode(cleaned.as_bytes()).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML scanning helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Find the first slice between `start` (anywhere) and a subsequent
|
||||||
|
/// `end`. Returns the inner slice without trimming.
|
||||||
|
fn scan_between<'a>(haystack: &'a str, start: &str, end: &str) -> Option<&'a str> {
|
||||||
|
let i = haystack.find(start)?;
|
||||||
|
let after = &haystack[i + start.len()..];
|
||||||
|
let j = after.find(end)?;
|
||||||
|
Some(&after[..j])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find the first `<attr>="VALUE"` slice. The attribute name comes
|
||||||
|
/// verbatim (no escaping needed for the names we handle).
|
||||||
|
fn scan_attr<'a>(haystack: &'a str, attr: &str) -> Option<&'a str> {
|
||||||
|
let needle = format!("{attr}=\"");
|
||||||
|
let i = haystack.find(&needle)?;
|
||||||
|
let after = &haystack[i + needle.len()..];
|
||||||
|
let j = after.find('"')?;
|
||||||
|
Some(&after[..j])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the leading `-->` from a comment-wrapped opener — Figma
|
||||||
|
/// sometimes emits `<!--(figmeta)BASE64<!--(figmeta)-->` (opening
|
||||||
|
/// lacks the `-->`), so we accept both forms by stripping at most one
|
||||||
|
/// occurrence.
|
||||||
|
fn strip_opening_marker(s: &str) -> &str {
|
||||||
|
s.strip_prefix("-->").unwrap_or(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip embedded comment markers from inside an attribute value —
|
||||||
|
/// e.g. `data-metadata="<!--(figmeta)BASE64<!--(figmeta)-->"` becomes
|
||||||
|
/// the bare BASE64 payload.
|
||||||
|
fn strip_embedded_markers(s: &str, kind: &str) -> String {
|
||||||
|
let opener = format!("<!--({kind})");
|
||||||
|
let opener_full = format!("<!--({kind})-->");
|
||||||
|
let mut work = s.to_string();
|
||||||
|
work = work.replace(&opener_full, "");
|
||||||
|
work = work.replace(&opener, "");
|
||||||
|
work
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CSS color + entities ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Normalize a CSS color literal to `#rrggbb` (or `#rrggbbaa` when
|
||||||
|
/// `rgba(...)` carries a non-`1` alpha). Returns None for shapes we
|
||||||
|
/// don't recognise (named colors, hsl(), etc.).
|
||||||
|
fn css_color_to_hex(css: &str) -> Option<String> {
|
||||||
|
let c = css.trim();
|
||||||
|
if let Some(rest) = c.strip_prefix('#') {
|
||||||
|
return Some(match rest.len() {
|
||||||
|
3 => {
|
||||||
|
let b = rest.as_bytes();
|
||||||
|
format!(
|
||||||
|
"#{0}{0}{1}{1}{2}{2}",
|
||||||
|
b[0] as char, b[1] as char, b[2] as char
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => c.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let rest = c.strip_prefix("rgba(").or_else(|| c.strip_prefix("rgb("))?;
|
||||||
|
let rest = rest.strip_suffix(')')?;
|
||||||
|
let parts: Vec<&str> = rest.split(',').map(|s| s.trim()).collect();
|
||||||
|
if parts.len() < 3 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let r: u32 = parts[0].parse().ok()?;
|
||||||
|
let g: u32 = parts[1].parse().ok()?;
|
||||||
|
let b: u32 = parts[2].parse().ok()?;
|
||||||
|
if let Some(a_str) = parts.get(3) {
|
||||||
|
let a_f: f32 = a_str.parse().ok()?;
|
||||||
|
let a = (a_f * 255.0).round() as u32;
|
||||||
|
if a < 255 {
|
||||||
|
return Some(format!("#{r:02x}{g:02x}{b:02x}{a:02x}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(format!("#{r:02x}{g:02x}{b:02x}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode HTML entities present in the styled text portions of Figma
|
||||||
|
/// clipboard markup: numeric `&#NN;` / `&#xHH;` and the common named
|
||||||
|
/// entities (`&`, `<`, `>`, `"`, ` `).
|
||||||
|
fn decode_html_entities(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let bytes = text.as_bytes();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
if bytes[i] != b'&' {
|
||||||
|
out.push(bytes[i] as char);
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Numeric entity: &#NN; or &#xHH;
|
||||||
|
if i + 2 < bytes.len() && bytes[i + 1] == b'#' {
|
||||||
|
let hex = bytes[i + 2] == b'x' || bytes[i + 2] == b'X';
|
||||||
|
let start = if hex { i + 3 } else { i + 2 };
|
||||||
|
let Some(semi_off) = bytes[start..].iter().position(|&b| b == b';') else {
|
||||||
|
out.push('&');
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let end = start + semi_off;
|
||||||
|
let body = std::str::from_utf8(&bytes[start..end]).unwrap_or("");
|
||||||
|
let code = if hex {
|
||||||
|
u32::from_str_radix(body, 16).ok()
|
||||||
|
} else {
|
||||||
|
body.parse::<u32>().ok()
|
||||||
|
};
|
||||||
|
if let Some(c) = code.and_then(char::from_u32) {
|
||||||
|
out.push(c);
|
||||||
|
i = end + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Named entity.
|
||||||
|
let rest = &text[i..];
|
||||||
|
let mut matched = false;
|
||||||
|
for (name, repl) in [
|
||||||
|
("&", '&'),
|
||||||
|
("<", '<'),
|
||||||
|
(">", '>'),
|
||||||
|
(""", '"'),
|
||||||
|
(" ", ' '),
|
||||||
|
] {
|
||||||
|
if rest.starts_with(name) {
|
||||||
|
out.push(repl);
|
||||||
|
i += name.len();
|
||||||
|
matched = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !matched {
|
||||||
|
out.push('&');
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML style-hint scanning ────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Style hints harvested from the inline CSS inside a Figma clipboard
|
||||||
|
/// HTML payload. Field shapes mirror TS `HtmlStyleHint`.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq)]
|
||||||
|
pub struct HtmlStyleHint {
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub font_family: Option<String>,
|
||||||
|
pub font_size: Option<f64>,
|
||||||
|
pub font_weight: Option<u32>,
|
||||||
|
pub background_color: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk an HTML payload, extract `style="…"` + adjacent text content,
|
||||||
|
/// and build a map keyed by trimmed text → style hint. Used by
|
||||||
|
/// `enrich_nodes_from_html_hints` to recover style information that
|
||||||
|
/// got lost in the binary path (commonly: shared-style references the
|
||||||
|
/// clipboard didn't include the resolved value of).
|
||||||
|
pub fn parse_clipboard_html_styles(html: &str) -> HashMap<String, HtmlStyleHint> {
|
||||||
|
// Strip the binary blocks first so we don't accidentally match
|
||||||
|
// base64 payloads.
|
||||||
|
let clean = strip_binary_blocks(html);
|
||||||
|
|
||||||
|
let mut hints = HashMap::new();
|
||||||
|
let mut cursor = 0usize;
|
||||||
|
let bytes = clean.as_bytes();
|
||||||
|
while let Some(rel) = clean[cursor..].find("style=\"") {
|
||||||
|
let start = cursor + rel + "style=\"".len();
|
||||||
|
let Some(end_off) = clean[start..].find('"') else {
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let end = start + end_off;
|
||||||
|
let style_attr = &clean[start..end];
|
||||||
|
|
||||||
|
// Find the immediately following `>TEXT<` pair.
|
||||||
|
let after = &clean[end + 1..];
|
||||||
|
let Some(gt) = after.find('>') else {
|
||||||
|
cursor = end + 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let text_start = end + 1 + gt + 1;
|
||||||
|
let Some(lt) = clean[text_start..].find('<') else {
|
||||||
|
cursor = end + 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let text_end = text_start + lt;
|
||||||
|
let raw_text = decode_html_entities(&clean[text_start..text_end])
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
cursor = text_end;
|
||||||
|
|
||||||
|
if raw_text.is_empty() || raw_text.len() > 200 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut hint = HtmlStyleHint::default();
|
||||||
|
for (key, slot) in [
|
||||||
|
("color:", 0u8),
|
||||||
|
("font-family:", 1),
|
||||||
|
("font-size:", 2),
|
||||||
|
("font-weight:", 3),
|
||||||
|
("background-color:", 4),
|
||||||
|
] {
|
||||||
|
if let Some(val) = scan_css_value(style_attr, key) {
|
||||||
|
match slot {
|
||||||
|
0 => hint.color = css_color_to_hex(&val),
|
||||||
|
1 => {
|
||||||
|
let family = val
|
||||||
|
.trim()
|
||||||
|
.trim_matches(|c: char| c == '\'' || c == '"')
|
||||||
|
.split(',')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
if !family.is_empty() {
|
||||||
|
hint.font_family = Some(family);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
if let Some(px) = val.strip_suffix("px") {
|
||||||
|
if let Ok(n) = px.trim().parse::<f64>() {
|
||||||
|
hint.font_size = Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
3 => {
|
||||||
|
if let Ok(n) = val.trim().parse::<u32>() {
|
||||||
|
hint.font_weight = Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
4 => hint.background_color = css_color_to_hex(&val),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hint != HtmlStyleHint::default() {
|
||||||
|
hints.entry(raw_text).or_insert(hint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Silence the dead-store warning when no entities matched.
|
||||||
|
let _ = bytes;
|
||||||
|
hints
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_binary_blocks(html: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(html.len());
|
||||||
|
let mut rest = html;
|
||||||
|
for marker in ["<!--(figmeta)", "<!--(figma)"] {
|
||||||
|
while let Some(i) = rest.find(marker) {
|
||||||
|
out.push_str(&rest[..i]);
|
||||||
|
// Find end-marker after the start.
|
||||||
|
let after = &rest[i + marker.len()..];
|
||||||
|
let Some(j) = after.find("-->") else {
|
||||||
|
rest = "";
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
// Skip from `i` through the end marker (`-->` length 3).
|
||||||
|
rest = &after[j + 3..];
|
||||||
|
// Second marker may follow for the closing comment; skip
|
||||||
|
// until we've consumed the second occurrence too.
|
||||||
|
if let Some(k) = rest.find(marker) {
|
||||||
|
let after2 = &rest[k + marker.len()..];
|
||||||
|
if let Some(l) = after2.find("-->") {
|
||||||
|
rest = &after2[l + 3..];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull `<key> <value>;` (or to end-of-attr) from an inline CSS
|
||||||
|
/// style string. Stops at the first `;` after the value start. The
|
||||||
|
/// key must start at attribute-start or after `;` + optional
|
||||||
|
/// whitespace, so that `color:` doesn't false-match
|
||||||
|
/// `background-color:`.
|
||||||
|
fn scan_css_value(attr: &str, key: &str) -> Option<String> {
|
||||||
|
let mut search_from = 0usize;
|
||||||
|
while let Some(rel) = attr[search_from..].find(key) {
|
||||||
|
let abs = search_from + rel;
|
||||||
|
let prev_ok = if abs == 0 {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let prev = &attr[..abs];
|
||||||
|
prev.trim_end_matches([' ', '\t']).ends_with(';')
|
||||||
|
|| prev.trim_end_matches([' ', '\t']).is_empty()
|
||||||
|
};
|
||||||
|
let after = abs + key.len();
|
||||||
|
if prev_ok {
|
||||||
|
let tail = &attr[after..];
|
||||||
|
let end = tail.find(';').unwrap_or(tail.len());
|
||||||
|
return Some(tail[..end].trim().to_string());
|
||||||
|
}
|
||||||
|
search_from = abs + key.len();
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PenNode tree walkers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// Replace `__blob:` / `__hash:` image refs with placeholder fills /
|
||||||
|
/// rectangles. Mirrors TS `fixUnresolvedImages`.
|
||||||
|
pub fn fix_unresolved_images(nodes: &mut [PenNode]) {
|
||||||
|
for slot in nodes.iter_mut() {
|
||||||
|
if let PenNode::Image(img) = slot {
|
||||||
|
if img.src.starts_with("__blob:") || img.src.starts_with("__hash:") {
|
||||||
|
// Preserve cornerRadius (and width/height) on the
|
||||||
|
// placeholder rectangle so the visual frame stays put.
|
||||||
|
// TS `figma-clipboard.ts:192-202` carries
|
||||||
|
// `cornerRadius` + `opacity` over; `opacity` lives on
|
||||||
|
// `PenNodeBase` and is already inherited via
|
||||||
|
// `img.base.clone()`.
|
||||||
|
*slot = PenNode::Rectangle(RectangleNode {
|
||||||
|
base: img.base.clone(),
|
||||||
|
container: ContainerProps {
|
||||||
|
width: img.width.clone(),
|
||||||
|
height: img.height.clone(),
|
||||||
|
corner_radius: img.corner_radius.clone(),
|
||||||
|
fill: Some(vec![placeholder_fill()]),
|
||||||
|
..ContainerProps::default()
|
||||||
|
},
|
||||||
|
children: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fix in-place image fills on non-image variants.
|
||||||
|
replace_unresolved_image_fills(slot);
|
||||||
|
recurse_children_mut(slot, fix_unresolved_images);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn placeholder_fill() -> PenFill {
|
||||||
|
PenFill::Solid(SolidFillBody {
|
||||||
|
color: "#E5E7EB".to_string(),
|
||||||
|
explain: None,
|
||||||
|
opacity: None,
|
||||||
|
blend_mode: None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_unresolved_image_fills(node: &mut PenNode) {
|
||||||
|
let fill_slot = node_fill_mut(node);
|
||||||
|
if let Some(fill) = fill_slot {
|
||||||
|
for slot in fill.iter_mut() {
|
||||||
|
if let PenFill::Image(img) = slot {
|
||||||
|
if img.url.starts_with("__blob:") || img.url.starts_with("__hash:") {
|
||||||
|
*slot = placeholder_fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn node_fill_mut(node: &mut PenNode) -> Option<&mut Vec<PenFill>> {
|
||||||
|
match node {
|
||||||
|
PenNode::Frame(n) => n.container.fill.as_mut(),
|
||||||
|
PenNode::Group(n) => n.container.fill.as_mut(),
|
||||||
|
PenNode::Rectangle(n) => n.container.fill.as_mut(),
|
||||||
|
PenNode::Ellipse(n) => n.fill.as_mut(),
|
||||||
|
PenNode::Polygon(n) => n.fill.as_mut(),
|
||||||
|
PenNode::Path(n) => n.fill.as_mut(),
|
||||||
|
PenNode::Text(n) => n.fill.as_mut(),
|
||||||
|
PenNode::TextInput(n) => n.fill.as_mut(),
|
||||||
|
PenNode::IconFont(n) => n.fill.as_mut(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recurse_children_mut(node: &mut PenNode, f: fn(&mut [PenNode])) {
|
||||||
|
match node {
|
||||||
|
PenNode::Frame(n) => {
|
||||||
|
if let Some(c) = n.children.as_mut() {
|
||||||
|
f(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PenNode::Group(n) => {
|
||||||
|
if let Some(c) = n.children.as_mut() {
|
||||||
|
f(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PenNode::Rectangle(n) => {
|
||||||
|
if let Some(c) = n.children.as_mut() {
|
||||||
|
f(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PenNode::Ref(n) => {
|
||||||
|
if let Some(c) = n.children.as_mut() {
|
||||||
|
f(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copy missing color / font hints from HTML-derived style hints onto
|
||||||
|
/// `Text` nodes, and `background-color` onto `Frame` / `Rectangle`
|
||||||
|
/// nodes that lack a fill. Mirrors TS `enrichNodesFromHtmlHints`.
|
||||||
|
pub fn enrich_nodes_from_html_hints(nodes: &mut [PenNode], hints: &HashMap<String, HtmlStyleHint>) {
|
||||||
|
for node in nodes.iter_mut() {
|
||||||
|
match node {
|
||||||
|
PenNode::Text(t) => enrich_text(t, hints),
|
||||||
|
PenNode::Frame(f) => enrich_container_bg(&f.base, &mut f.container, hints),
|
||||||
|
PenNode::Rectangle(r) => enrich_container_bg(&r.base, &mut r.container, hints),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
// Recurse into children.
|
||||||
|
let kids: Option<&mut Vec<PenNode>> = match node {
|
||||||
|
PenNode::Frame(n) => n.children.as_mut(),
|
||||||
|
PenNode::Group(n) => n.children.as_mut(),
|
||||||
|
PenNode::Rectangle(n) => n.children.as_mut(),
|
||||||
|
PenNode::Ref(n) => n.children.as_mut(),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(c) = kids {
|
||||||
|
enrich_nodes_from_html_hints(c, hints);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enrich_text(t: &mut TextNode, hints: &HashMap<String, HtmlStyleHint>) {
|
||||||
|
let content_str = match &t.content {
|
||||||
|
TextContent::Plain(s) => s.clone(),
|
||||||
|
TextContent::Styled(segs) => segs.iter().map(|s| s.text.clone()).collect::<String>(),
|
||||||
|
};
|
||||||
|
let trimmed = content_str.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let hint = hints
|
||||||
|
.get(trimmed)
|
||||||
|
.or_else(|| find_partial_hint(trimmed, hints));
|
||||||
|
let Some(hint) = hint else { return };
|
||||||
|
|
||||||
|
if t.fill.is_none() {
|
||||||
|
if let Some(color) = &hint.color {
|
||||||
|
t.fill = Some(vec![PenFill::Solid(SolidFillBody {
|
||||||
|
color: color.clone(),
|
||||||
|
explain: None,
|
||||||
|
opacity: None,
|
||||||
|
blend_mode: None,
|
||||||
|
})]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.font_family.is_none() {
|
||||||
|
if let Some(family) = &hint.font_family {
|
||||||
|
t.font_family = Some(family.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.font_size.is_none() {
|
||||||
|
if let Some(size) = hint.font_size {
|
||||||
|
t.font_size = Some(size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.font_weight.is_none() {
|
||||||
|
if let Some(w) = hint.font_weight {
|
||||||
|
t.font_weight = Some(jian_ops_schema::node::text::FontWeight::Number(w));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enrich_container_bg(
|
||||||
|
base: &PenNodeBase,
|
||||||
|
container: &mut ContainerProps,
|
||||||
|
hints: &HashMap<String, HtmlStyleHint>,
|
||||||
|
) {
|
||||||
|
if container.fill.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(name) = base.name.as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(hint) = hints.get(trimmed) {
|
||||||
|
if let Some(bg) = &hint.background_color {
|
||||||
|
container.fill = Some(vec![PenFill::Solid(SolidFillBody {
|
||||||
|
color: bg.clone(),
|
||||||
|
explain: None,
|
||||||
|
opacity: None,
|
||||||
|
blend_mode: None,
|
||||||
|
})]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_partial_hint<'a>(
|
||||||
|
text: &str,
|
||||||
|
hints: &'a HashMap<String, HtmlStyleHint>,
|
||||||
|
) -> Option<&'a HtmlStyleHint> {
|
||||||
|
for (k, v) in hints.iter() {
|
||||||
|
if text.starts_with(k) || k.starts_with(text) {
|
||||||
|
return Some(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internal: image-blob resolve wrapper ────────────────────────────
|
||||||
|
|
||||||
|
fn build_doc_for_resolve(children: Vec<PenNode>) -> jian_ops_schema::document::PenDocument {
|
||||||
|
jian_ops_schema::document::PenDocument {
|
||||||
|
version: "1".to_string(),
|
||||||
|
name: None,
|
||||||
|
themes: None,
|
||||||
|
variables: None,
|
||||||
|
pages: None,
|
||||||
|
children,
|
||||||
|
format_version: None,
|
||||||
|
id: None,
|
||||||
|
app: None,
|
||||||
|
routes: None,
|
||||||
|
state: None,
|
||||||
|
lifecycle: None,
|
||||||
|
logic_modules: None,
|
||||||
|
design_md: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_doc_children(doc: jian_ops_schema::document::PenDocument) -> Vec<PenNode> {
|
||||||
|
doc.children
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep `EllipseNode` / `LineNode` / `PolygonNode` / `PathNode` /
|
||||||
|
// `TextInputNode` / `ImageNode` / `RefNode` / `FrameNode` /
|
||||||
|
// `GroupNode` / `RectangleNode` / `TextNode` in scope so future
|
||||||
|
// post-process passes don't have to re-import.
|
||||||
|
const _: () = {
|
||||||
|
let _: Option<EllipseNode> = None;
|
||||||
|
let _: Option<LineNode> = None;
|
||||||
|
let _: Option<PolygonNode> = None;
|
||||||
|
let _: Option<PathNode> = None;
|
||||||
|
let _: Option<TextInputNode> = None;
|
||||||
|
let _: Option<ImageNode> = None;
|
||||||
|
let _: Option<RefNode> = None;
|
||||||
|
let _: Option<FrameNode> = None;
|
||||||
|
let _: Option<GroupNode> = None;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
482
crates/op-figma/src/clipboard/tests.rs
Normal file
482
crates/op-figma/src/clipboard/tests.rs
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
//! Clipboard-port tests — base64 decode, HTML extraction, CSS-style
|
||||||
|
//! hint scanner, and the two PenNode post-process passes
|
||||||
|
//! (`fix_unresolved_images`, `enrich_nodes_from_html_hints`).
|
||||||
|
//! Behaviour mirrors `pen-figma/figma-clipboard.ts`.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use jian_ops_schema::node::container::ContainerProps;
|
||||||
|
use jian_ops_schema::node::{
|
||||||
|
EllipseNode, FrameNode, ImageNode, PenNode, PenNodeBase, RectangleNode, TextContent, TextNode,
|
||||||
|
};
|
||||||
|
use jian_ops_schema::sizing::SizingBehavior;
|
||||||
|
use jian_ops_schema::style::{ImageFillBody, PenFill, SolidFillBody};
|
||||||
|
|
||||||
|
fn base() -> PenNodeBase {
|
||||||
|
PenNodeBase {
|
||||||
|
id: "n1".to_string(),
|
||||||
|
name: Some("hero".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_figma_clipboard_html_detects_both_markers() {
|
||||||
|
assert!(is_figma_clipboard_html(
|
||||||
|
"<html><!--(figmeta)-->...<!--(figmeta)--></html>"
|
||||||
|
));
|
||||||
|
assert!(is_figma_clipboard_html(
|
||||||
|
"<span data-buffer=\"AAA=\"></span>"
|
||||||
|
));
|
||||||
|
assert!(!is_figma_clipboard_html("<p>just a plain paragraph</p>"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn css_color_to_hex_handles_common_forms() {
|
||||||
|
assert_eq!(css_color_to_hex("#abcdef"), Some("#abcdef".to_string()));
|
||||||
|
assert_eq!(css_color_to_hex("#abc"), Some("#aabbcc".to_string()));
|
||||||
|
assert_eq!(
|
||||||
|
css_color_to_hex("rgb(255, 0, 0)"),
|
||||||
|
Some("#ff0000".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
css_color_to_hex("rgba(255, 0, 0, 0.5)"),
|
||||||
|
Some("#ff000080".to_string())
|
||||||
|
);
|
||||||
|
// alpha 1 → drop alpha byte.
|
||||||
|
assert_eq!(
|
||||||
|
css_color_to_hex("rgba(255, 0, 0, 1)"),
|
||||||
|
Some("#ff0000".to_string())
|
||||||
|
);
|
||||||
|
assert_eq!(css_color_to_hex("named-color"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_html_entities_handles_named_and_numeric() {
|
||||||
|
assert_eq!(decode_html_entities("&<>" "), "&<>\" ");
|
||||||
|
assert_eq!(decode_html_entities("AB"), "AB");
|
||||||
|
assert_eq!(
|
||||||
|
decode_html_entities("plain text with & ampersand"),
|
||||||
|
"plain text with & ampersand"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decode_base64_handles_padding_and_url_safe() {
|
||||||
|
// "Hello" → "SGVsbG8="
|
||||||
|
assert_eq!(decode_base64("SGVsbG8="), Some(b"Hello".to_vec()));
|
||||||
|
// Without padding.
|
||||||
|
assert_eq!(decode_base64("SGVsbG8"), Some(b"Hello".to_vec()));
|
||||||
|
// URL-safe alphabet.
|
||||||
|
assert_eq!(decode_base64("SGVsbG8_"), Some(b"Hello?".to_vec()));
|
||||||
|
// Whitespace tolerance.
|
||||||
|
assert_eq!(decode_base64("SGVs\n bG8="), Some(b"Hello".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_figma_clipboard_data_via_comment_form() {
|
||||||
|
// meta = JSON `{"v":1}` → base64 `eyJ2IjoxfQ==`
|
||||||
|
// buffer = bytes `OP` → base64 `T1A=`
|
||||||
|
let html = "<html>\
|
||||||
|
<!--(figmeta)-->eyJ2IjoxfQ==<!--(figmeta)-->\
|
||||||
|
<!--(figma)-->T1A=<!--(figma)-->\
|
||||||
|
</html>";
|
||||||
|
let data = extract_figma_clipboard_data(html).expect("extracts");
|
||||||
|
assert!(data.meta.contains("\"v\":1"));
|
||||||
|
assert_eq!(data.buffer, b"OP");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_figma_clipboard_data_via_data_attribute_form() {
|
||||||
|
let html = "<span data-metadata=\"eyJ2IjoxfQ==\"></span><span data-buffer=\"T1A=\"></span>";
|
||||||
|
let data = extract_figma_clipboard_data(html).expect("extracts");
|
||||||
|
assert!(data.meta.contains("\"v\":1"));
|
||||||
|
assert_eq!(data.buffer, b"OP");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_figma_clipboard_data_returns_none_for_random_html() {
|
||||||
|
assert!(extract_figma_clipboard_data("<p>nothing</p>").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_clipboard_html_styles_extracts_color_font() {
|
||||||
|
let html =
|
||||||
|
"<p style=\"color: #ff0000; font-family: Inter, sans; font-size: 18px; font-weight: 700;\">Title</p>";
|
||||||
|
let hints = parse_clipboard_html_styles(html);
|
||||||
|
let hint = hints.get("Title").expect("hint present");
|
||||||
|
assert_eq!(hint.color.as_deref(), Some("#ff0000"));
|
||||||
|
assert_eq!(hint.font_family.as_deref(), Some("Inter"));
|
||||||
|
assert_eq!(hint.font_size, Some(18.0));
|
||||||
|
assert_eq!(hint.font_weight, Some(700));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_clipboard_html_styles_handles_background_color() {
|
||||||
|
let html = "<div style=\"background-color: rgba(255,0,0,0.5)\">Card</div>";
|
||||||
|
let hints = parse_clipboard_html_styles(html);
|
||||||
|
let hint = hints.get("Card").expect("hint present");
|
||||||
|
assert_eq!(hint.background_color.as_deref(), Some("#ff000080"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fix_unresolved_images_swaps_blob_image_for_placeholder_rect() {
|
||||||
|
let mut nodes = vec![PenNode::Image(ImageNode {
|
||||||
|
base: base(),
|
||||||
|
src: "__blob:5".to_string(),
|
||||||
|
object_fit: None,
|
||||||
|
width: Some(SizingBehavior::Number(100.0)),
|
||||||
|
height: Some(SizingBehavior::Number(80.0)),
|
||||||
|
corner_radius: None,
|
||||||
|
effects: None,
|
||||||
|
exposure: None,
|
||||||
|
contrast: None,
|
||||||
|
saturation: None,
|
||||||
|
temperature: None,
|
||||||
|
tint: None,
|
||||||
|
highlights: None,
|
||||||
|
shadows: None,
|
||||||
|
image_prompt: None,
|
||||||
|
image_search_query: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
})];
|
||||||
|
fix_unresolved_images(&mut nodes);
|
||||||
|
match &nodes[0] {
|
||||||
|
PenNode::Rectangle(rect) => {
|
||||||
|
let fill = rect.container.fill.as_ref().expect("placeholder fill");
|
||||||
|
assert_eq!(fill.len(), 1);
|
||||||
|
match &fill[0] {
|
||||||
|
PenFill::Solid(s) => assert_eq!(s.color, "#E5E7EB"),
|
||||||
|
_ => panic!("expected solid placeholder"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("expected Rectangle, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fix_unresolved_images_swaps_blob_image_fill_on_rect() {
|
||||||
|
let mut nodes = vec![PenNode::Rectangle(RectangleNode {
|
||||||
|
base: base(),
|
||||||
|
container: ContainerProps {
|
||||||
|
fill: Some(vec![PenFill::Image(ImageFillBody {
|
||||||
|
url: "__hash:abc".into(),
|
||||||
|
mode: None,
|
||||||
|
original_size: None,
|
||||||
|
transform: None,
|
||||||
|
explain: None,
|
||||||
|
opacity: None,
|
||||||
|
exposure: None,
|
||||||
|
contrast: None,
|
||||||
|
saturation: None,
|
||||||
|
temperature: None,
|
||||||
|
tint: None,
|
||||||
|
highlights: None,
|
||||||
|
shadows: None,
|
||||||
|
})]),
|
||||||
|
..ContainerProps::default()
|
||||||
|
},
|
||||||
|
children: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
})];
|
||||||
|
fix_unresolved_images(&mut nodes);
|
||||||
|
match &nodes[0] {
|
||||||
|
PenNode::Rectangle(rect) => {
|
||||||
|
let fill = rect.container.fill.as_ref().unwrap();
|
||||||
|
match &fill[0] {
|
||||||
|
PenFill::Solid(s) => assert_eq!(s.color, "#E5E7EB"),
|
||||||
|
_ => panic!("expected placeholder solid"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("expected Rectangle, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fix_unresolved_images_preserves_corner_radius_on_placeholder() {
|
||||||
|
use jian_ops_schema::node::container::CornerRadius;
|
||||||
|
let mut nodes = vec![PenNode::Image(ImageNode {
|
||||||
|
base: base(),
|
||||||
|
src: "__blob:1".into(),
|
||||||
|
object_fit: None,
|
||||||
|
width: Some(SizingBehavior::Number(100.0)),
|
||||||
|
height: Some(SizingBehavior::Number(100.0)),
|
||||||
|
corner_radius: Some(CornerRadius::Uniform(12.0)),
|
||||||
|
effects: None,
|
||||||
|
exposure: None,
|
||||||
|
contrast: None,
|
||||||
|
saturation: None,
|
||||||
|
temperature: None,
|
||||||
|
tint: None,
|
||||||
|
highlights: None,
|
||||||
|
shadows: None,
|
||||||
|
image_prompt: None,
|
||||||
|
image_search_query: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
})];
|
||||||
|
fix_unresolved_images(&mut nodes);
|
||||||
|
match &nodes[0] {
|
||||||
|
PenNode::Rectangle(rect) => {
|
||||||
|
// TS preserves cornerRadius from the original image node.
|
||||||
|
match &rect.container.corner_radius {
|
||||||
|
Some(CornerRadius::Uniform(r)) => assert!((r - 12.0).abs() < 0.001),
|
||||||
|
other => panic!("expected Uniform(12.0), got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("expected Rectangle, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fix_unresolved_images_recurses_into_frame_children() {
|
||||||
|
let inner = PenNode::Image(ImageNode {
|
||||||
|
base: base(),
|
||||||
|
src: "__blob:1".into(),
|
||||||
|
object_fit: None,
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
corner_radius: None,
|
||||||
|
effects: None,
|
||||||
|
exposure: None,
|
||||||
|
contrast: None,
|
||||||
|
saturation: None,
|
||||||
|
temperature: None,
|
||||||
|
tint: None,
|
||||||
|
highlights: None,
|
||||||
|
shadows: None,
|
||||||
|
image_prompt: None,
|
||||||
|
image_search_query: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
});
|
||||||
|
let mut nodes = vec![PenNode::Frame(FrameNode {
|
||||||
|
base: base(),
|
||||||
|
container: ContainerProps::default(),
|
||||||
|
children: Some(vec![inner]),
|
||||||
|
reusable: None,
|
||||||
|
slot: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
})];
|
||||||
|
fix_unresolved_images(&mut nodes);
|
||||||
|
let kids = if let PenNode::Frame(f) = &nodes[0] {
|
||||||
|
f.children.as_ref().expect("kids preserved")
|
||||||
|
} else {
|
||||||
|
panic!("expected Frame");
|
||||||
|
};
|
||||||
|
assert!(matches!(kids[0], PenNode::Rectangle(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enrich_nodes_from_html_hints_fills_missing_text_props() {
|
||||||
|
let mut hints = HashMap::new();
|
||||||
|
hints.insert(
|
||||||
|
"Title".to_string(),
|
||||||
|
HtmlStyleHint {
|
||||||
|
color: Some("#ff0000".into()),
|
||||||
|
font_family: Some("Inter".into()),
|
||||||
|
font_size: Some(18.0),
|
||||||
|
font_weight: Some(700),
|
||||||
|
background_color: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut nodes = vec![PenNode::Text(TextNode {
|
||||||
|
base: base(),
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
content: TextContent::Plain("Title".into()),
|
||||||
|
font_family: None,
|
||||||
|
font_size: None,
|
||||||
|
font_weight: None,
|
||||||
|
font_style: None,
|
||||||
|
letter_spacing: None,
|
||||||
|
line_height: None,
|
||||||
|
text_align: None,
|
||||||
|
text_align_vertical: None,
|
||||||
|
text_growth: None,
|
||||||
|
underline: None,
|
||||||
|
strikethrough: None,
|
||||||
|
fill: None,
|
||||||
|
effects: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
})];
|
||||||
|
enrich_nodes_from_html_hints(&mut nodes, &hints);
|
||||||
|
if let PenNode::Text(t) = &nodes[0] {
|
||||||
|
assert_eq!(t.font_family.as_deref(), Some("Inter"));
|
||||||
|
assert_eq!(t.font_size, Some(18.0));
|
||||||
|
assert!(t.font_weight.is_some());
|
||||||
|
let fill = t.fill.as_ref().unwrap();
|
||||||
|
match &fill[0] {
|
||||||
|
PenFill::Solid(s) => assert_eq!(s.color, "#ff0000"),
|
||||||
|
_ => panic!("expected solid fill"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panic!("expected Text");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enrich_does_not_overwrite_explicit_values() {
|
||||||
|
let mut hints = HashMap::new();
|
||||||
|
hints.insert(
|
||||||
|
"Title".into(),
|
||||||
|
HtmlStyleHint {
|
||||||
|
color: Some("#ff0000".into()),
|
||||||
|
..HtmlStyleHint::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut nodes = vec![PenNode::Text(TextNode {
|
||||||
|
base: base(),
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
content: TextContent::Plain("Title".into()),
|
||||||
|
font_family: Some("ExistingFont".into()),
|
||||||
|
font_size: None,
|
||||||
|
font_weight: None,
|
||||||
|
font_style: None,
|
||||||
|
letter_spacing: None,
|
||||||
|
line_height: None,
|
||||||
|
text_align: None,
|
||||||
|
text_align_vertical: None,
|
||||||
|
text_growth: None,
|
||||||
|
underline: None,
|
||||||
|
strikethrough: None,
|
||||||
|
fill: Some(vec![PenFill::Solid(SolidFillBody {
|
||||||
|
color: "#00ff00".into(),
|
||||||
|
explain: None,
|
||||||
|
opacity: None,
|
||||||
|
blend_mode: None,
|
||||||
|
})]),
|
||||||
|
effects: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
})];
|
||||||
|
enrich_nodes_from_html_hints(&mut nodes, &hints);
|
||||||
|
if let PenNode::Text(t) = &nodes[0] {
|
||||||
|
// Explicit pre-existing values must remain.
|
||||||
|
assert_eq!(t.font_family.as_deref(), Some("ExistingFont"));
|
||||||
|
let fill = t.fill.as_ref().unwrap();
|
||||||
|
if let PenFill::Solid(s) = &fill[0] {
|
||||||
|
assert_eq!(s.color, "#00ff00", "explicit fill must not be overwritten");
|
||||||
|
} else {
|
||||||
|
panic!("expected solid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn enrich_applies_background_to_unfilled_rectangle() {
|
||||||
|
let mut hints = HashMap::new();
|
||||||
|
hints.insert(
|
||||||
|
"hero".into(),
|
||||||
|
HtmlStyleHint {
|
||||||
|
background_color: Some("#abcdef".into()),
|
||||||
|
..HtmlStyleHint::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let mut nodes = vec![PenNode::Rectangle(RectangleNode {
|
||||||
|
base: base(),
|
||||||
|
container: ContainerProps::default(),
|
||||||
|
children: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
})];
|
||||||
|
enrich_nodes_from_html_hints(&mut nodes, &hints);
|
||||||
|
if let PenNode::Rectangle(r) = &nodes[0] {
|
||||||
|
let fill = r.container.fill.as_ref().expect("fill applied");
|
||||||
|
if let PenFill::Solid(s) = &fill[0] {
|
||||||
|
assert_eq!(s.color, "#abcdef");
|
||||||
|
} else {
|
||||||
|
panic!("expected solid");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipse_with_blob_fill_keeps_other_fill_replaced() {
|
||||||
|
let _placeholder = EllipseNode {
|
||||||
|
base: base(),
|
||||||
|
width: None,
|
||||||
|
height: None,
|
||||||
|
corner_radius: None,
|
||||||
|
inner_radius: None,
|
||||||
|
start_angle: None,
|
||||||
|
sweep_angle: None,
|
||||||
|
fill: Some(vec![PenFill::Image(ImageFillBody {
|
||||||
|
url: "__blob:7".into(),
|
||||||
|
mode: None,
|
||||||
|
original_size: None,
|
||||||
|
transform: None,
|
||||||
|
explain: None,
|
||||||
|
opacity: None,
|
||||||
|
exposure: None,
|
||||||
|
contrast: None,
|
||||||
|
saturation: None,
|
||||||
|
temperature: None,
|
||||||
|
tint: None,
|
||||||
|
highlights: None,
|
||||||
|
shadows: None,
|
||||||
|
})]),
|
||||||
|
stroke: None,
|
||||||
|
effects: None,
|
||||||
|
state: None,
|
||||||
|
bindings: None,
|
||||||
|
events: None,
|
||||||
|
lifecycle: None,
|
||||||
|
semantics: None,
|
||||||
|
gestures: None,
|
||||||
|
route: None,
|
||||||
|
};
|
||||||
|
let mut nodes = vec![PenNode::Ellipse(_placeholder)];
|
||||||
|
fix_unresolved_images(&mut nodes);
|
||||||
|
if let PenNode::Ellipse(e) = &nodes[0] {
|
||||||
|
let fill = e.fill.as_ref().unwrap();
|
||||||
|
match &fill[0] {
|
||||||
|
PenFill::Solid(s) => assert_eq!(s.color, "#E5E7EB"),
|
||||||
|
_ => panic!("expected placeholder solid"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,58 @@ use jian_ops_schema::node::base::{NumberOrExpression, PenNodeBase};
|
||||||
use jian_ops_schema::node::container::CornerRadius;
|
use jian_ops_schema::node::container::CornerRadius;
|
||||||
use jian_ops_schema::sizing::SizingBehavior;
|
use jian_ops_schema::sizing::SizingBehavior;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::RwLock;
|
||||||
|
|
||||||
|
/// Stroke vs fill rendering for a host-resolved icon.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum IconStyle {
|
||||||
|
Stroke,
|
||||||
|
Fill,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host-resolved icon match (ports TS `IconLookupResult`). `d` is the
|
||||||
|
/// canonical 24×24 lucide path; `icon_id` is the canonical lucide name
|
||||||
|
/// (kebab-case) so the renderer can re-resolve; `style` defaults to
|
||||||
|
/// stroke when unspecified.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct IconLookupResult {
|
||||||
|
pub d: String,
|
||||||
|
pub icon_id: Option<String>,
|
||||||
|
pub style: Option<IconStyle>,
|
||||||
|
}
|
||||||
|
|
||||||
|
type IconLookupFn = dyn Fn(&str) -> Option<IconLookupResult> + Send + Sync;
|
||||||
|
|
||||||
|
static ICON_LOOKUP: RwLock<Option<Box<IconLookupFn>>> = RwLock::new(None);
|
||||||
|
|
||||||
|
/// Install (or replace) a host-provided icon-name resolver. The
|
||||||
|
/// converter consults it on every VECTOR node — if a node's name maps
|
||||||
|
/// to a registered icon, the converter emits a `Path` carrying the
|
||||||
|
/// lucide `d` + `icon_id` instead of decoding the raw vector geometry.
|
||||||
|
/// Matches TS `setIconLookup` in `converters/common.ts`.
|
||||||
|
pub fn set_icon_lookup<F>(f: F)
|
||||||
|
where
|
||||||
|
F: Fn(&str) -> Option<IconLookupResult> + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
if let Ok(mut slot) = ICON_LOOKUP.write() {
|
||||||
|
*slot = Some(Box::new(f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop any previously-installed icon resolver.
|
||||||
|
pub fn clear_icon_lookup() {
|
||||||
|
if let Ok(mut slot) = ICON_LOOKUP.write() {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `name` through the installed lookup; `None` when no
|
||||||
|
/// resolver is set or when the resolver itself returns `None`.
|
||||||
|
pub fn lookup_icon_by_name(name: &str) -> Option<IconLookupResult> {
|
||||||
|
let slot = ICON_LOOKUP.read().ok()?;
|
||||||
|
let f = slot.as_ref()?;
|
||||||
|
f(name)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether OpenPencil layout semantics or verbatim Figma geometry is
|
/// Whether OpenPencil layout semantics or verbatim Figma geometry is
|
||||||
/// produced.
|
/// produced.
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@
|
||||||
//! canonical `PenNode`s.
|
//! canonical `PenNode`s.
|
||||||
|
|
||||||
use crate::common::{
|
use crate::common::{
|
||||||
common_props, extract_position, map_corner_radius, normalize_angle, resolve_height,
|
common_props, extract_position, lookup_icon_by_name, map_corner_radius, normalize_angle,
|
||||||
resolve_width, round2, round3, ConversionContext, FigLayoutMode, SKIPPED_TYPES,
|
resolve_height, resolve_width, round2, round3, ConversionContext, FigLayoutMode, IconStyle,
|
||||||
|
SKIPPED_TYPES,
|
||||||
};
|
};
|
||||||
use crate::figma_types::FigVec2;
|
use crate::figma_types::FigVec2;
|
||||||
use crate::instance::{apply_instance_overrides, merge_symbol_props};
|
use crate::instance::{apply_instance_overrides, merge_symbol_props};
|
||||||
use crate::kiwi::FigValue;
|
use crate::kiwi::FigValue;
|
||||||
use crate::mappers::{
|
use crate::mappers::{
|
||||||
map_figma_effects, map_figma_fills, map_figma_layout, map_figma_stroke, LayoutProps,
|
fig_fill_color, map_figma_effects, map_figma_fills, map_figma_layout, map_figma_stroke,
|
||||||
|
LayoutProps,
|
||||||
};
|
};
|
||||||
use crate::node_build::{
|
use crate::node_build::{
|
||||||
ellipse_node, frame_node, group_node, line_node, path_node, rectangle_node, ref_node, text_node,
|
ellipse_node, frame_node, group_node, line_node, path_node, rectangle_node, ref_node, text_node,
|
||||||
|
|
@ -484,6 +486,16 @@ fn convert_vector(
|
||||||
) -> PenNode {
|
) -> PenNode {
|
||||||
let id = ctx.generate_id();
|
let id = ctx.generate_id();
|
||||||
let figma = &tree.figma;
|
let figma = &tree.figma;
|
||||||
|
|
||||||
|
// Icon-lookup branch — match the node's name against the host's
|
||||||
|
// icon registry. When set, the node converts to a Path carrying the
|
||||||
|
// canonical 24×24 lucide `d` + `icon_id`, bypassing vector decode.
|
||||||
|
// Mirrors TS `convertVector` lines 25-65.
|
||||||
|
let name = figma.get_str("name").unwrap_or("");
|
||||||
|
if let Some(icon) = lookup_icon_by_name(name) {
|
||||||
|
return build_icon_path_node(figma, id, parent_stack_mode, ctx, icon);
|
||||||
|
}
|
||||||
|
|
||||||
let path_d = decode_figma_vector_path(figma, &ctx.blobs).unwrap_or_default();
|
let path_d = decode_figma_vector_path(figma, &ctx.blobs).unwrap_or_default();
|
||||||
|
|
||||||
if !path_d.is_empty() {
|
if !path_d.is_empty() {
|
||||||
|
|
@ -572,3 +584,84 @@ fn convert_vector(
|
||||||
};
|
};
|
||||||
rectangle_node(common_props(figma, id), container)
|
rectangle_node(common_props(figma, id), container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a `Path` PenNode for a host-resolved icon — ports
|
||||||
|
/// `path-converter.ts` lines 25-65. Stroke gets a sensible default
|
||||||
|
/// when the node has no stroke paint, and the stroke thickness scales
|
||||||
|
/// down proportionally for icons smaller than the lucide 24×24
|
||||||
|
/// reference box.
|
||||||
|
fn build_icon_path_node(
|
||||||
|
figma: &FigValue,
|
||||||
|
id: String,
|
||||||
|
parent_stack_mode: Option<&str>,
|
||||||
|
ctx: &mut ConversionContext,
|
||||||
|
icon: crate::common::IconLookupResult,
|
||||||
|
) -> PenNode {
|
||||||
|
use jian_ops_schema::style::{
|
||||||
|
PenFill, PenStroke, SolidFillBody, StrokeCap, StrokeJoin, StrokeThickness,
|
||||||
|
};
|
||||||
|
|
||||||
|
let icon_w = resolve_width(figma, parent_stack_mode, ctx);
|
||||||
|
let icon_h = resolve_height(figma, parent_stack_mode, ctx);
|
||||||
|
|
||||||
|
// iconSize = min(width-if-numeric, height-if-numeric), defaulting to
|
||||||
|
// 24 (the lucide reference box) when either axis is non-numeric.
|
||||||
|
let w_num: f64 = match icon_w {
|
||||||
|
SizingBehavior::Number(n) => n,
|
||||||
|
_ => 24.0,
|
||||||
|
};
|
||||||
|
let h_num: f64 = match icon_h {
|
||||||
|
SizingBehavior::Number(n) => n,
|
||||||
|
_ => 24.0,
|
||||||
|
};
|
||||||
|
let icon_size = w_num.min(h_num);
|
||||||
|
let icon_scale: f64 = icon_size / 24.0;
|
||||||
|
|
||||||
|
let style = icon.style.unwrap_or(IconStyle::Stroke);
|
||||||
|
let mapped_stroke = map_figma_stroke(figma);
|
||||||
|
let mut stroke = match style {
|
||||||
|
IconStyle::Stroke => Some(mapped_stroke.unwrap_or_else(|| PenStroke {
|
||||||
|
thickness: StrokeThickness::Uniform(1.5),
|
||||||
|
align: None,
|
||||||
|
join: Some(StrokeJoin::Round),
|
||||||
|
cap: Some(StrokeCap::Round),
|
||||||
|
dash_pattern: None,
|
||||||
|
dash_offset: None,
|
||||||
|
fill: Some(vec![PenFill::Solid(SolidFillBody {
|
||||||
|
color: fig_fill_color(figma).unwrap_or_else(|| "#000000".to_string()),
|
||||||
|
explain: None,
|
||||||
|
opacity: None,
|
||||||
|
blend_mode: None,
|
||||||
|
})]),
|
||||||
|
})),
|
||||||
|
IconStyle::Fill => mapped_stroke,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(s) = stroke.as_mut() {
|
||||||
|
if icon_scale < 0.99 {
|
||||||
|
if let StrokeThickness::Uniform(t) = s.thickness {
|
||||||
|
let scaled = round2(t as f64 * icon_scale) as f32;
|
||||||
|
s.thickness = StrokeThickness::Uniform(scaled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let fill = match style {
|
||||||
|
IconStyle::Fill => map_figma_fills(figma.get_array("fillPaints")),
|
||||||
|
IconStyle::Stroke => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
path_node(
|
||||||
|
common_props(figma, id),
|
||||||
|
Some(icon.d),
|
||||||
|
icon.icon_id,
|
||||||
|
icon_w,
|
||||||
|
icon_h,
|
||||||
|
fill,
|
||||||
|
stroke,
|
||||||
|
map_figma_effects(figma.get_array("effects")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
|
||||||
189
crates/op-figma/src/converters/tests.rs
Normal file
189
crates/op-figma/src/converters/tests.rs
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
//! `convert_vector` icon-lookup tests — exercise the host-resolver
|
||||||
|
//! branch (`set_icon_lookup` → name match → emit `Path` with `icon_id`).
|
||||||
|
//! Tests serialise on `LOOKUP_GUARD` because the resolver is
|
||||||
|
//! process-global state.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::common::{clear_icon_lookup, set_icon_lookup, IconLookupResult, IconStyle};
|
||||||
|
use jian_ops_schema::node::PenNode;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
/// Serialise tests that touch the process-global icon-lookup so they
|
||||||
|
/// don't race with each other under cargo's parallel runner.
|
||||||
|
static LOOKUP_GUARD: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
|
fn obj(pairs: Vec<(&str, FigValue)>) -> FigValue {
|
||||||
|
FigValue::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vector_node(name: &str) -> TreeNode {
|
||||||
|
TreeNode {
|
||||||
|
figma: obj(vec![
|
||||||
|
("type", FigValue::Str("VECTOR".into())),
|
||||||
|
(
|
||||||
|
"guid",
|
||||||
|
obj(vec![
|
||||||
|
("sessionID", FigValue::Uint(1)),
|
||||||
|
("localID", FigValue::Uint(10)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
("name", FigValue::Str(name.into())),
|
||||||
|
(
|
||||||
|
"size",
|
||||||
|
obj(vec![
|
||||||
|
("x", FigValue::Float(24.0)),
|
||||||
|
("y", FigValue::Float(24.0)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
children: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_ctx() -> ConversionContext {
|
||||||
|
ConversionContext {
|
||||||
|
component_map: HashMap::new(),
|
||||||
|
symbol_tree: HashMap::new(),
|
||||||
|
warnings: Vec::new(),
|
||||||
|
id_counter: 1,
|
||||||
|
blobs: Vec::new(),
|
||||||
|
layout_mode: FigLayoutMode::OpenPencil,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_vector_uses_icon_lookup_when_set() {
|
||||||
|
let _g = LOOKUP_GUARD.lock().unwrap();
|
||||||
|
set_icon_lookup(|name| {
|
||||||
|
if name == "menu" {
|
||||||
|
Some(IconLookupResult {
|
||||||
|
d: "M3 12h18".into(),
|
||||||
|
icon_id: Some("menu".into()),
|
||||||
|
style: Some(IconStyle::Stroke),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let tree = vector_node("menu");
|
||||||
|
let mut ctx = fresh_ctx();
|
||||||
|
let node = convert_vector(&tree, None, &mut ctx);
|
||||||
|
|
||||||
|
match node {
|
||||||
|
PenNode::Path(p) => {
|
||||||
|
assert_eq!(p.icon_id.as_deref(), Some("menu"));
|
||||||
|
assert_eq!(p.d.as_deref(), Some("M3 12h18"));
|
||||||
|
assert!(p.stroke.is_some());
|
||||||
|
assert!(p.fill.is_none());
|
||||||
|
}
|
||||||
|
other => panic!("expected Path, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_icon_lookup();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn convert_vector_falls_back_when_no_match() {
|
||||||
|
let _g = LOOKUP_GUARD.lock().unwrap();
|
||||||
|
set_icon_lookup(|_| None);
|
||||||
|
|
||||||
|
let tree = vector_node("unmatched-name");
|
||||||
|
let mut ctx = fresh_ctx();
|
||||||
|
let node = convert_vector(&tree, None, &mut ctx);
|
||||||
|
|
||||||
|
// No icon match — falls through to rectangle fallback (vector has
|
||||||
|
// no decoded path data in this synthetic fixture).
|
||||||
|
assert!(matches!(node, PenNode::Rectangle(_)));
|
||||||
|
|
||||||
|
clear_icon_lookup();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_stroke_thickness_scales_for_small_icons() {
|
||||||
|
let _g = LOOKUP_GUARD.lock().unwrap();
|
||||||
|
set_icon_lookup(|_| {
|
||||||
|
Some(IconLookupResult {
|
||||||
|
d: "M3 12h18".into(),
|
||||||
|
icon_id: Some("menu".into()),
|
||||||
|
style: Some(IconStyle::Stroke),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
// 12×12 vector → icon_scale = 12/24 = 0.5 → thickness 1.5 × 0.5 = 0.75.
|
||||||
|
let mut tree = vector_node("menu");
|
||||||
|
if let FigValue::Object(pairs) = &mut tree.figma {
|
||||||
|
for (k, v) in pairs.iter_mut() {
|
||||||
|
if k == "size" {
|
||||||
|
*v = obj(vec![
|
||||||
|
("x", FigValue::Float(12.0)),
|
||||||
|
("y", FigValue::Float(12.0)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ctx = fresh_ctx();
|
||||||
|
let node = convert_vector(&tree, None, &mut ctx);
|
||||||
|
|
||||||
|
match node {
|
||||||
|
PenNode::Path(p) => {
|
||||||
|
let stroke = p.stroke.expect("stroke present");
|
||||||
|
match stroke.thickness {
|
||||||
|
jian_ops_schema::style::StrokeThickness::Uniform(t) => {
|
||||||
|
assert!(
|
||||||
|
(t - 0.75).abs() < 0.001,
|
||||||
|
"expected scaled thickness 0.75 got {t}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Uniform thickness, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => panic!("expected Path, got {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
clear_icon_lookup();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn icon_fill_style_emits_fill_no_stroke() {
|
||||||
|
let _g = LOOKUP_GUARD.lock().unwrap();
|
||||||
|
set_icon_lookup(|_| {
|
||||||
|
Some(IconLookupResult {
|
||||||
|
d: "M3 3h18v18H3z".into(),
|
||||||
|
icon_id: Some("solid-square".into()),
|
||||||
|
style: Some(IconStyle::Fill),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let tree = {
|
||||||
|
let mut t = vector_node("solid-square");
|
||||||
|
if let FigValue::Object(pairs) = &mut t.figma {
|
||||||
|
pairs.push((
|
||||||
|
"fillPaints".into(),
|
||||||
|
FigValue::Array(vec![obj(vec![
|
||||||
|
("type", FigValue::Str("SOLID".into())),
|
||||||
|
(
|
||||||
|
"color",
|
||||||
|
obj(vec![
|
||||||
|
("r", FigValue::Float(0.0)),
|
||||||
|
("g", FigValue::Float(0.0)),
|
||||||
|
("b", FigValue::Float(0.0)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
])]),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
t
|
||||||
|
};
|
||||||
|
let mut ctx = fresh_ctx();
|
||||||
|
let node = convert_vector(&tree, None, &mut ctx);
|
||||||
|
match node {
|
||||||
|
PenNode::Path(p) => {
|
||||||
|
assert!(p.fill.is_some(), "fill style must populate fill");
|
||||||
|
assert!(p.stroke.is_none());
|
||||||
|
}
|
||||||
|
other => panic!("expected Path, got {other:?}"),
|
||||||
|
}
|
||||||
|
clear_icon_lookup();
|
||||||
|
}
|
||||||
|
|
@ -2,17 +2,22 @@
|
||||||
//! `applyInstanceOverrides` / `mergeSymbolProps` parts of
|
//! `applyInstanceOverrides` / `mergeSymbolProps` parts of
|
||||||
//! `frame-converter.ts`.
|
//! `frame-converter.ts`.
|
||||||
//!
|
//!
|
||||||
//! Scope note: the direct-GUID resolution strategy (the common case)
|
//! Four resolution strategies, picked in TS order:
|
||||||
//! plus the size-scaling fast path are ported. Figma's virtual-GUID
|
//! 0. Direct-GUID match (`guidPath` names a real subtree node).
|
||||||
//! positional fallbacks (strategies 1–3 in the TS) are not — they
|
//! 1. Exact-count fallback when `len1Derived.len() == flat_symbol.len()`.
|
||||||
//! only apply to files whose override `guidPath`s do not name real
|
//! 2. Virtual-GUID DFS when a `sessionID:firstLocalID` base is present —
|
||||||
//! subtree nodes, which is rare.
|
//! two parallel walks, one started at children + one started at the
|
||||||
|
//! root, so root-level overrides can be filtered out.
|
||||||
|
//! 3. Index-mapping fallback over all derived entries.
|
||||||
|
|
||||||
use crate::common::round2;
|
use crate::common::round2;
|
||||||
use crate::figma_types::FigVec2;
|
use crate::figma_types::FigVec2;
|
||||||
use crate::kiwi::FigValue;
|
use crate::kiwi::FigValue;
|
||||||
use crate::tree::{guid_to_string, TreeNode};
|
use crate::tree::{guid_to_string, TreeNode};
|
||||||
use std::collections::HashMap;
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
|
|
||||||
/// Layout keys an instance inherits from its master SYMBOL.
|
/// Layout keys an instance inherits from its master SYMBOL.
|
||||||
const LAYOUT_KEYS: &[&str] = &[
|
const LAYOUT_KEYS: &[&str] = &[
|
||||||
|
|
@ -122,39 +127,255 @@ pub fn apply_instance_overrides(
|
||||||
return symbol_node.children.clone();
|
return symbol_node.children.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct-GUID resolution: map every override / derived entry whose
|
// ── Index inputs ─────────────────────────────────────────────────
|
||||||
// single-segment guidPath names a real node in the subtree.
|
// override_map / derived_map are keyed by the full guidPathKey
|
||||||
let mut node_override: HashMap<String, FigValue> = HashMap::new();
|
// ("`sid:lid`" for single-segment, "`sid:lid/sid2:lid2/...`" for
|
||||||
let mut node_derived: HashMap<String, FigValue> = HashMap::new();
|
// multi-segment).
|
||||||
let mut nested_override: HashMap<String, Vec<FigValue>> = HashMap::new();
|
let mut override_map: HashMap<String, &FigValue> = HashMap::new();
|
||||||
let mut nested_derived: HashMap<String, Vec<FigValue>> = HashMap::new();
|
let mut override_order: Vec<String> = Vec::new();
|
||||||
|
|
||||||
for entry in overrides {
|
for entry in overrides {
|
||||||
match guid_path_key(entry) {
|
if let Some(key) = guid_path_key(entry) {
|
||||||
Some(key) if !key.contains('/') => {
|
if !override_map.contains_key(&key) {
|
||||||
node_override.insert(key, entry.clone());
|
override_order.push(key.clone());
|
||||||
}
|
}
|
||||||
Some(key) => {
|
override_map.insert(key, entry);
|
||||||
let head = key.split('/').next().unwrap_or("").to_string();
|
|
||||||
if let Some(rest) = strip_first_guid(entry) {
|
|
||||||
nested_override.entry(head).or_default().push(rest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let mut derived_map: HashMap<String, &FigValue> = HashMap::new();
|
||||||
|
let mut derived_order: Vec<String> = Vec::new();
|
||||||
for entry in derived {
|
for entry in derived {
|
||||||
match guid_path_key(entry) {
|
if let Some(key) = guid_path_key(entry) {
|
||||||
Some(key) if !key.contains('/') => {
|
if !derived_map.contains_key(&key) {
|
||||||
node_derived.insert(key, entry.clone());
|
derived_order.push(key.clone());
|
||||||
}
|
}
|
||||||
Some(key) => {
|
derived_map.insert(key, entry);
|
||||||
let head = key.split('/').next().unwrap_or("").to_string();
|
}
|
||||||
if let Some(rest) = strip_first_guid(entry) {
|
}
|
||||||
nested_derived.entry(head).or_default().push(rest);
|
|
||||||
|
// Pre-order DFS of the symbol subtree, children sorted by ascending
|
||||||
|
// localID (matches TS `flattenDFS(symbolNode)`).
|
||||||
|
let mut flat_symbol: Vec<&TreeNode> = Vec::new();
|
||||||
|
flatten_dfs(symbol_node, &mut flat_symbol);
|
||||||
|
|
||||||
|
// Single-segment derived entries — these are the candidates for
|
||||||
|
// the directMatches probe and Strategy-1 count match.
|
||||||
|
let len1_derived: Vec<&FigValue> = derived
|
||||||
|
.iter()
|
||||||
|
.filter(|d| {
|
||||||
|
d.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.map(|g| g.len() == 1)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Set of guid-strings present in flat_symbol (used to count direct
|
||||||
|
// hits + decide Strategy 0 vs 1/2/3).
|
||||||
|
let mut guid_in_subtree: HashSet<String> = HashSet::new();
|
||||||
|
for n in &flat_symbol {
|
||||||
|
if let Some(k) = n.figma.get("guid").and_then(guid_to_string) {
|
||||||
|
guid_in_subtree.insert(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let direct_matches = len1_derived
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| {
|
||||||
|
d.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.and_then(|g| g.first())
|
||||||
|
.and_then(guid_to_string)
|
||||||
|
})
|
||||||
|
.filter(|k| guid_in_subtree.contains(k))
|
||||||
|
.count();
|
||||||
|
|
||||||
|
// Outputs of the resolution stage:
|
||||||
|
// node_override / node_derived — keyed by an actual subtree GUID.
|
||||||
|
// pk_to_node_guid — `guidPathKey first segment` → actual subtree GUID.
|
||||||
|
let mut node_override: HashMap<String, FigValue> = HashMap::new();
|
||||||
|
let mut node_derived: HashMap<String, FigValue> = HashMap::new();
|
||||||
|
let mut pk_to_node_guid: HashMap<String, String> = HashMap::new();
|
||||||
|
|
||||||
|
let use_direct = !len1_derived.is_empty() && direct_matches * 2 > len1_derived.len()
|
||||||
|
|| len1_derived.is_empty();
|
||||||
|
|
||||||
|
if use_direct {
|
||||||
|
// Strategy 0 — pkStr names a real subtree node.
|
||||||
|
for d in &len1_derived {
|
||||||
|
let Some(first) = d
|
||||||
|
.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.and_then(|g| g.first())
|
||||||
|
.and_then(guid_to_string)
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if guid_in_subtree.contains(&first) {
|
||||||
|
if let Some(dv) = derived_map.get(&first) {
|
||||||
|
node_derived.insert(first.clone(), (*dv).clone());
|
||||||
|
}
|
||||||
|
if let Some(ov) = override_map.get(&first) {
|
||||||
|
node_override.insert(first.clone(), (*ov).clone());
|
||||||
|
}
|
||||||
|
pk_to_node_guid.insert(first.clone(), first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pick up single-segment override entries that reference real
|
||||||
|
// node GUIDs even when no derived entry exists for them.
|
||||||
|
for (pk, ov) in &override_map {
|
||||||
|
if pk.contains('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if guid_in_subtree.contains(pk) {
|
||||||
|
node_override.insert(pk.clone(), (*ov).clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if len1_derived.len() == flat_symbol.len() {
|
||||||
|
// Strategy 1 — exact count, index mapping.
|
||||||
|
for (i, node) in flat_symbol.iter().enumerate() {
|
||||||
|
let d = len1_derived[i];
|
||||||
|
let Some(node_guid) = node.figma.get("guid").and_then(guid_to_string) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(pk) = guid_path_key(d) else { continue };
|
||||||
|
// Map pkStr first-segment → actual node guid (used for
|
||||||
|
// nested forwarding lookups).
|
||||||
|
if let Some(first) = d
|
||||||
|
.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.and_then(|g| g.first())
|
||||||
|
.and_then(guid_to_string)
|
||||||
|
{
|
||||||
|
pk_to_node_guid.insert(first, node_guid.clone());
|
||||||
|
}
|
||||||
|
if let Some(dv) = derived_map.get(&pk) {
|
||||||
|
node_derived.insert(node_guid.clone(), (*dv).clone());
|
||||||
|
}
|
||||||
|
if let Some(ov) = override_map.get(&pk) {
|
||||||
|
node_override.insert(node_guid, (*ov).clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((session_id, first_local_id)) = virtual_guid_base(&len1_derived) {
|
||||||
|
// Strategy 2 — virtual-GUID DFS.
|
||||||
|
// Two walks: `full` starts at children (sorted by localID);
|
||||||
|
// `root` starts at symbol_node itself.
|
||||||
|
let mut child_sorted: Vec<&TreeNode> = symbol_node.children.iter().collect();
|
||||||
|
child_sorted.sort_by_key(|n| local_id(n));
|
||||||
|
let mut full_pk_to_node: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut full_idx: u32 = 0;
|
||||||
|
for c in &child_sorted {
|
||||||
|
walk_virtual(
|
||||||
|
c,
|
||||||
|
session_id,
|
||||||
|
first_local_id,
|
||||||
|
&mut full_idx,
|
||||||
|
&mut full_pk_to_node,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut root_pk_to_node: HashMap<String, String> = HashMap::new();
|
||||||
|
let mut root_idx: u32 = 0;
|
||||||
|
walk_virtual(
|
||||||
|
symbol_node,
|
||||||
|
session_id,
|
||||||
|
first_local_id,
|
||||||
|
&mut root_idx,
|
||||||
|
&mut root_pk_to_node,
|
||||||
|
);
|
||||||
|
|
||||||
|
let root_guid = symbol_node
|
||||||
|
.figma
|
||||||
|
.get("guid")
|
||||||
|
.and_then(guid_to_string)
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
for (pk, ng) in &full_pk_to_node {
|
||||||
|
pk_to_node_guid.insert(pk.clone(), ng.clone());
|
||||||
|
}
|
||||||
|
for (pk, d) in &derived_map {
|
||||||
|
if pk.contains('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ng) = full_pk_to_node.get(pk) {
|
||||||
|
node_derived.insert(ng.clone(), (*d).clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (pk, ov) in &override_map {
|
||||||
|
if pk.contains('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if root_pk_to_node.get(pk).map(String::as_str) == Some(root_guid.as_str()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ng) = full_pk_to_node.get(pk) {
|
||||||
|
node_override.insert(ng.clone(), (*ov).clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Strategy 3 — fallback index mapping over all derived entries.
|
||||||
|
let take = flat_symbol.len().min(derived.len());
|
||||||
|
for i in 0..take {
|
||||||
|
let node = flat_symbol[i];
|
||||||
|
let d = &derived[i];
|
||||||
|
let Some(node_guid) = node.figma.get("guid").and_then(guid_to_string) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(pk) = guid_path_key(d) else { continue };
|
||||||
|
let single = d
|
||||||
|
.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.map(|g| g.len() == 1)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if let Some(dv) = derived_map.get(&pk) {
|
||||||
|
node_derived.insert(node_guid.clone(), (*dv).clone());
|
||||||
|
}
|
||||||
|
if let Some(ov) = override_map.get(&pk) {
|
||||||
|
node_override.insert(node_guid.clone(), (*ov).clone());
|
||||||
|
}
|
||||||
|
if single {
|
||||||
|
if let Some(first) = d
|
||||||
|
.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.and_then(|g| g.first())
|
||||||
|
.and_then(guid_to_string)
|
||||||
|
{
|
||||||
|
pk_to_node_guid.insert(first, node_guid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nested forwarding ────────────────────────────────────────────
|
||||||
|
// Multi-segment guidPaths are forwarded into the resolved INSTANCE
|
||||||
|
// node (head segment) as freshly-keyed entries.
|
||||||
|
let mut nested_override: HashMap<String, Vec<FigValue>> = HashMap::new();
|
||||||
|
let mut nested_derived: HashMap<String, Vec<FigValue>> = HashMap::new();
|
||||||
|
for pk in &override_order {
|
||||||
|
if !pk.contains('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let head = pk.split('/').next().unwrap_or("");
|
||||||
|
let instance_guid = pk_to_node_guid
|
||||||
|
.get(head)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| head.to_string());
|
||||||
|
if let Some(ov) = override_map.get(pk) {
|
||||||
|
if let Some(rest) = strip_first_guid(ov) {
|
||||||
|
nested_override.entry(instance_guid).or_default().push(rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pk in &derived_order {
|
||||||
|
if !pk.contains('/') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let head = pk.split('/').next().unwrap_or("");
|
||||||
|
let instance_guid = pk_to_node_guid
|
||||||
|
.get(head)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| head.to_string());
|
||||||
|
if let Some(d) = derived_map.get(pk) {
|
||||||
|
if let Some(rest) = strip_first_guid(d) {
|
||||||
|
nested_derived.entry(instance_guid).or_default().push(rest);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +394,62 @@ pub fn apply_instance_overrides(
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local-id getter — falls back to 0 when `guid.localID` is absent
|
||||||
|
/// (matches TS `a.figma.guid?.localID ?? 0`).
|
||||||
|
fn local_id(node: &TreeNode) -> u32 {
|
||||||
|
node.figma
|
||||||
|
.get("guid")
|
||||||
|
.and_then(|g| g.get_f64("localID"))
|
||||||
|
.map(|n| n as u32)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-order DFS over a TreeNode (children sorted ascending by
|
||||||
|
/// localID). The starting node is included as the first entry.
|
||||||
|
fn flatten_dfs<'a>(node: &'a TreeNode, out: &mut Vec<&'a TreeNode>) {
|
||||||
|
out.push(node);
|
||||||
|
let mut sorted: Vec<&TreeNode> = node.children.iter().collect();
|
||||||
|
sorted.sort_by_key(|n| local_id(n));
|
||||||
|
for c in sorted {
|
||||||
|
flatten_dfs(c, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a subtree in pre-order DFS, recording the virtual GUID
|
||||||
|
/// `sessionID:firstLocalID + idx` → actual GUID for each node. Mirrors
|
||||||
|
/// the TS `walkFull` / `walkRoot` helpers.
|
||||||
|
fn walk_virtual(
|
||||||
|
node: &TreeNode,
|
||||||
|
session_id: u32,
|
||||||
|
first_local_id: u32,
|
||||||
|
idx: &mut u32,
|
||||||
|
out: &mut HashMap<String, String>,
|
||||||
|
) {
|
||||||
|
if let Some(g) = node.figma.get("guid").and_then(guid_to_string) {
|
||||||
|
out.insert(format!("{}:{}", session_id, first_local_id + *idx), g);
|
||||||
|
}
|
||||||
|
*idx += 1;
|
||||||
|
let mut sorted: Vec<&TreeNode> = node.children.iter().collect();
|
||||||
|
sorted.sort_by_key(|n| local_id(n));
|
||||||
|
for c in sorted {
|
||||||
|
walk_virtual(c, session_id, first_local_id, idx, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read `(sessionID, firstLocalID)` from the first single-segment
|
||||||
|
/// derived entry — Strategy-2's virtual-GUID base. None when either
|
||||||
|
/// field is missing.
|
||||||
|
fn virtual_guid_base(len1_derived: &[&FigValue]) -> Option<(u32, u32)> {
|
||||||
|
let first = len1_derived.first()?;
|
||||||
|
let first_guid = first
|
||||||
|
.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.and_then(|g| g.first())?;
|
||||||
|
let sid = first_guid.get_f64("sessionID")? as u32;
|
||||||
|
let lid = first_guid.get_f64("localID")? as u32;
|
||||||
|
Some((sid, lid))
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a copy of `entry` with the first `guidPath` segment dropped.
|
/// Build a copy of `entry` with the first `guidPath` segment dropped.
|
||||||
fn strip_first_guid(entry: &FigValue) -> Option<FigValue> {
|
fn strip_first_guid(entry: &FigValue) -> Option<FigValue> {
|
||||||
let guids = entry.get("guidPath")?.get_array("guids")?;
|
let guids = entry.get("guidPath")?.get_array("guids")?;
|
||||||
|
|
@ -258,10 +535,13 @@ fn apply_to_node(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Override props — copy every non-blacklisted, present key.
|
// Override props — copy every non-blacklisted key. Explicit
|
||||||
|
// `Null` is preserved (TS `if (value !== undefined)`: only
|
||||||
|
// `undefined` is skipped, `null` is copied as an intentional
|
||||||
|
// reset).
|
||||||
if let Some(FigValue::Object(pairs)) = ov {
|
if let Some(FigValue::Object(pairs)) = ov {
|
||||||
for (k, v) in pairs {
|
for (k, v) in pairs {
|
||||||
if !OVERRIDE_SKIP_KEYS.contains(&k.as_str()) && !matches!(v, FigValue::Null) {
|
if !OVERRIDE_SKIP_KEYS.contains(&k.as_str()) {
|
||||||
figma.set(k, v.clone());
|
figma.set(k, v.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
264
crates/op-figma/src/instance/tests.rs
Normal file
264
crates/op-figma/src/instance/tests.rs
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
//! Instance-override resolution tests — drives each of the four
|
||||||
|
//! Strategy branches with a tiny synthetic symbol subtree and a few
|
||||||
|
//! override / derived entries, asserting that the resulting overridden
|
||||||
|
//! tree carries the expected fields. The fixtures mirror the shape of a
|
||||||
|
//! decoded Figma `.fig` (`guid` / `guidPath.guids` arrays / `size` /
|
||||||
|
//! `fontSize` / etc.).
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn obj(pairs: Vec<(&str, FigValue)>) -> FigValue {
|
||||||
|
FigValue::Object(pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guid(sid: u32, lid: u32) -> FigValue {
|
||||||
|
obj(vec![
|
||||||
|
("sessionID", FigValue::Uint(sid)),
|
||||||
|
("localID", FigValue::Uint(lid)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size(x: f32, y: f32) -> FigValue {
|
||||||
|
obj(vec![("x", FigValue::Float(x)), ("y", FigValue::Float(y))])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn guid_path(guids: Vec<FigValue>) -> FigValue {
|
||||||
|
obj(vec![(
|
||||||
|
"guidPath",
|
||||||
|
obj(vec![("guids", FigValue::Array(guids))]),
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ov_with(guids: Vec<FigValue>, extra: Vec<(&str, FigValue)>) -> FigValue {
|
||||||
|
let mut pairs: Vec<(String, FigValue)> = vec![(
|
||||||
|
"guidPath".into(),
|
||||||
|
obj(vec![("guids", FigValue::Array(guids))]),
|
||||||
|
)];
|
||||||
|
for (k, v) in extra {
|
||||||
|
pairs.push((k.into(), v));
|
||||||
|
}
|
||||||
|
FigValue::Object(pairs)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn leaf(name: &str, sid: u32, lid: u32) -> TreeNode {
|
||||||
|
TreeNode {
|
||||||
|
figma: obj(vec![
|
||||||
|
("type", FigValue::Str("RECTANGLE".into())),
|
||||||
|
("guid", guid(sid, lid)),
|
||||||
|
("name", FigValue::Str(name.into())),
|
||||||
|
("size", size(100.0, 100.0)),
|
||||||
|
]),
|
||||||
|
children: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn symbol_root(children: Vec<TreeNode>) -> TreeNode {
|
||||||
|
TreeNode {
|
||||||
|
figma: obj(vec![
|
||||||
|
("type", FigValue::Str("SYMBOL".into())),
|
||||||
|
("guid", guid(0, 0)),
|
||||||
|
("size", size(100.0, 100.0)),
|
||||||
|
]),
|
||||||
|
children,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fast_path_scales_subtree_when_no_overrides() {
|
||||||
|
let sym = symbol_root(vec![leaf("a", 1, 10)]);
|
||||||
|
let out = apply_instance_overrides(&sym, None, None, Some(FigVec2 { x: 200.0, y: 100.0 }));
|
||||||
|
assert_eq!(out.len(), 1);
|
||||||
|
let resized = FigVec2::from_value(out[0].figma.get("size").unwrap()).unwrap();
|
||||||
|
// 100 × 2 in x, untouched y.
|
||||||
|
assert!((resized.x - 200.0).abs() < 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strategy_0_applies_override_via_direct_guid() {
|
||||||
|
let sym = symbol_root(vec![leaf("hero", 1, 10)]);
|
||||||
|
// Derived (single-segment) names the real GUID → Strategy 0 wins.
|
||||||
|
let derived = vec![guid_path(vec![guid(1, 10)])];
|
||||||
|
let mut over_entry = ov_with(
|
||||||
|
vec![guid(1, 10)],
|
||||||
|
vec![("name", FigValue::Str("Renamed".into()))],
|
||||||
|
);
|
||||||
|
// Sanity: build a FigValue::Object with the override fields.
|
||||||
|
if let FigValue::Object(pairs) = &mut over_entry {
|
||||||
|
pairs.push(("ignored_skip".into(), FigValue::Null));
|
||||||
|
}
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[over_entry]), Some(&derived), None);
|
||||||
|
assert_eq!(out[0].figma.get_str("name"), Some("Renamed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strategy_0_takes_explicit_null_as_reset() {
|
||||||
|
// TS copies an explicit null override (intentional reset).
|
||||||
|
let sym = symbol_root(vec![leaf("hero", 1, 10)]);
|
||||||
|
let derived = vec![guid_path(vec![guid(1, 10)])];
|
||||||
|
let over_entry = ov_with(vec![guid(1, 10)], vec![("name", FigValue::Null)]);
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[over_entry]), Some(&derived), None);
|
||||||
|
// The original "hero" name was overwritten by Null — get_str now
|
||||||
|
// returns None because FigValue::get treats Null as absent.
|
||||||
|
assert_eq!(out[0].figma.get_str("name"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strategy_1_index_maps_when_count_matches() {
|
||||||
|
// Two children, two single-segment derived entries with virtual
|
||||||
|
// GUIDs that do NOT name real subtree nodes → direct_matches == 0,
|
||||||
|
// len1.len == flat_symbol.len (which is 3: symbol root + 2 leaves).
|
||||||
|
let sym = symbol_root(vec![leaf("a", 1, 10), leaf("b", 1, 11)]);
|
||||||
|
// 3 derived entries to match flat_symbol.len() == 3.
|
||||||
|
let derived = vec![
|
||||||
|
guid_path(vec![guid(9, 100)]),
|
||||||
|
guid_path(vec![guid(9, 101)]),
|
||||||
|
guid_path(vec![guid(9, 102)]),
|
||||||
|
];
|
||||||
|
// Override the second child via index 2 (positions 0/1/2 = symbol/a/b).
|
||||||
|
let over_b = ov_with(
|
||||||
|
vec![guid(9, 102)],
|
||||||
|
vec![("name", FigValue::Str("B-Override".into()))],
|
||||||
|
);
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[over_b]), Some(&derived), None);
|
||||||
|
// flat_symbol[0] = symbol_root (sid 0 lid 0)
|
||||||
|
// flat_symbol[1] = leaf "a" (first child by localID = 10)
|
||||||
|
// flat_symbol[2] = leaf "b" (lid 11)
|
||||||
|
// index 2 → leaf "b" gets renamed.
|
||||||
|
let names: Vec<Option<&str>> = out.iter().map(|c| c.figma.get_str("name")).collect();
|
||||||
|
assert!(names.contains(&Some("B-Override")), "{names:?}");
|
||||||
|
assert!(names.contains(&Some("a")), "{names:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strategy_2_resolves_via_virtual_guid_dfs() {
|
||||||
|
// Symbol with one child. Virtual GUID base = (sessionID 7, firstLID 50).
|
||||||
|
// Derived has TWO single-segment entries (virtual). flat_symbol has 2
|
||||||
|
// entries (symbol + child); count matches → Strategy 1 wins. To
|
||||||
|
// force Strategy 2, add an extra child so counts diverge.
|
||||||
|
let sym = symbol_root(vec![leaf("a", 1, 10), leaf("b", 1, 11), leaf("c", 1, 12)]);
|
||||||
|
// 2 derived entries → not equal to flat_symbol.len() (4), and the
|
||||||
|
// virtual GUIDs are not real subtree nodes → direct_matches=0, so
|
||||||
|
// Strategy 2 fires.
|
||||||
|
let derived = vec![guid_path(vec![guid(7, 50)]), guid_path(vec![guid(7, 51)])];
|
||||||
|
// The override targets virtual GUID 7:51 which the "full" walk
|
||||||
|
// (starting at children) maps to the second visited node — leaf "b"
|
||||||
|
// (localID 11 → second after sort).
|
||||||
|
let over = ov_with(
|
||||||
|
vec![guid(7, 51)],
|
||||||
|
vec![("name", FigValue::Str("B-via-virtual".into()))],
|
||||||
|
);
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[over]), Some(&derived), None);
|
||||||
|
let names: Vec<Option<&str>> = out.iter().map(|c| c.figma.get_str("name")).collect();
|
||||||
|
// Children sort by localID: a(10), b(11), c(12). full_idx starts at
|
||||||
|
// 0 on "a" → virtual 7:50. idx 1 on "b" → 7:51. So "b" is renamed.
|
||||||
|
assert!(names.contains(&Some("B-via-virtual")), "{names:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strategy_2_skips_overrides_that_target_root_in_root_walk() {
|
||||||
|
// When the root-anchored walk maps a virtual GUID to the symbol
|
||||||
|
// root itself, that override must not flow into the children. We
|
||||||
|
// can't directly observe a no-op on the symbol root via the
|
||||||
|
// returned children — but we CAN see that the children remain
|
||||||
|
// unchanged.
|
||||||
|
let sym = symbol_root(vec![leaf("a", 1, 10), leaf("b", 1, 11)]);
|
||||||
|
let derived = vec![guid_path(vec![guid(8, 60)])];
|
||||||
|
// Virtual GUID 8:60 anchored at the ROOT walk: rootIdx=0 names the
|
||||||
|
// symbol_root itself → override is filtered out.
|
||||||
|
let over = ov_with(
|
||||||
|
vec![guid(8, 60)],
|
||||||
|
vec![("name", FigValue::Str("ShouldNotShow".into()))],
|
||||||
|
);
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[over]), Some(&derived), None);
|
||||||
|
let names: Vec<Option<&str>> = out.iter().map(|c| c.figma.get_str("name")).collect();
|
||||||
|
assert!(!names.contains(&Some("ShouldNotShow")), "{names:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strategy_3_fallback_index_maps_when_no_virtual_base() {
|
||||||
|
// Force Strategy 3 by providing only multi-segment derived entries
|
||||||
|
// (len1_derived is empty, so virtual_guid_base returns None;
|
||||||
|
// direct_matches == 0; len1==0 so use_direct is true via the empty
|
||||||
|
// branch). Actually — when len1_derived is empty we fall into
|
||||||
|
// Strategy 0's "len1_derived.is_empty()" branch (use_direct = true)
|
||||||
|
// and the for-loop is empty, but the single-segment override pickup
|
||||||
|
// still runs.
|
||||||
|
//
|
||||||
|
// So this test instead verifies the empty-len1 + single-segment
|
||||||
|
// override pickup, which is part of Strategy 0's body.
|
||||||
|
let sym = symbol_root(vec![leaf("a", 1, 10)]);
|
||||||
|
let over_real = ov_with(
|
||||||
|
vec![guid(1, 10)],
|
||||||
|
vec![("name", FigValue::Str("Direct".into()))],
|
||||||
|
);
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[over_real]), None, None);
|
||||||
|
assert_eq!(out[0].figma.get_str("name"), Some("Direct"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn skipped_keys_never_flow_into_node() {
|
||||||
|
let sym = symbol_root(vec![leaf("hero", 1, 10)]);
|
||||||
|
let derived = vec![guid_path(vec![guid(1, 10)])];
|
||||||
|
let over = ov_with(
|
||||||
|
vec![guid(1, 10)],
|
||||||
|
vec![
|
||||||
|
// Real prop.
|
||||||
|
("name", FigValue::Str("Hello".into())),
|
||||||
|
// Blacklisted prop must NOT overwrite the node.
|
||||||
|
("guidPath", FigValue::Str("not-a-path".into())),
|
||||||
|
("componentKey", FigValue::Str("XX".into())),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[over]), Some(&derived), None);
|
||||||
|
assert_eq!(out[0].figma.get_str("name"), Some("Hello"));
|
||||||
|
// Blacklisted keys were NOT copied — the node's existing guidPath
|
||||||
|
// shape is unchanged (none was present, so it stays absent).
|
||||||
|
assert!(out[0].figma.get("componentKey").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nested_overrides_forwarded_into_instance_subtree() {
|
||||||
|
// Build a symbol that contains an INSTANCE child + two leaves,
|
||||||
|
// with a multi-segment override targeting the instance subtree.
|
||||||
|
let inst = TreeNode {
|
||||||
|
figma: obj(vec![
|
||||||
|
("type", FigValue::Str("INSTANCE".into())),
|
||||||
|
("guid", guid(1, 20)),
|
||||||
|
("name", FigValue::Str("InstanceChild".into())),
|
||||||
|
]),
|
||||||
|
children: vec![],
|
||||||
|
};
|
||||||
|
let sym = symbol_root(vec![inst.clone(), leaf("a", 1, 10)]);
|
||||||
|
let derived = vec![guid_path(vec![guid(1, 20)])];
|
||||||
|
// Multi-segment override — head = the instance's guid (1:20).
|
||||||
|
let nested = ov_with(
|
||||||
|
vec![guid(1, 20), guid(2, 30)],
|
||||||
|
vec![("name", FigValue::Str("nested-name".into()))],
|
||||||
|
);
|
||||||
|
let out = apply_instance_overrides(&sym, Some(&[nested]), Some(&derived), None);
|
||||||
|
// Find the INSTANCE child and verify symbolData.symbolOverrides
|
||||||
|
// grew by 1 nested entry whose guidPath dropped the head segment.
|
||||||
|
let inst_out = out
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.figma.get_str("type") == Some("INSTANCE"))
|
||||||
|
.expect("instance child present");
|
||||||
|
let sym_data = inst_out
|
||||||
|
.figma
|
||||||
|
.get("symbolData")
|
||||||
|
.expect("symbolData populated");
|
||||||
|
let overrides = sym_data
|
||||||
|
.get_array("symbolOverrides")
|
||||||
|
.expect("symbolOverrides populated");
|
||||||
|
assert_eq!(overrides.len(), 1);
|
||||||
|
let first = &overrides[0];
|
||||||
|
let guids = first
|
||||||
|
.get("guidPath")
|
||||||
|
.and_then(|p| p.get_array("guids"))
|
||||||
|
.expect("nested guidPath present");
|
||||||
|
// Head dropped — only the second guid remains.
|
||||||
|
assert_eq!(guids.len(), 1);
|
||||||
|
let g = FigGuid::from_value(&guids[0]).unwrap();
|
||||||
|
assert_eq!((g.session_id, g.local_id), (2, 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
use crate::figma_types::FigGuid;
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
//! [`node_mapper`]). Clipboard-JSON (`.fig.json`) keeps the
|
//! [`node_mapper`]). Clipboard-JSON (`.fig.json`) keeps the
|
||||||
//! hand-rolled shallow extractor below.
|
//! hand-rolled shallow extractor below.
|
||||||
|
|
||||||
|
mod clipboard;
|
||||||
mod color;
|
mod color;
|
||||||
mod common;
|
mod common;
|
||||||
mod container;
|
mod container;
|
||||||
|
|
@ -25,10 +26,18 @@ mod zip_reader;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod binary_e2e_tests;
|
mod binary_e2e_tests;
|
||||||
|
|
||||||
pub use common::FigLayoutMode;
|
pub use clipboard::{
|
||||||
|
enrich_nodes_from_html_hints, extract_figma_clipboard_data, figma_clipboard_to_nodes,
|
||||||
|
fix_unresolved_images, is_figma_clipboard_html, parse_clipboard_html_styles,
|
||||||
|
FigmaClipboardData, HtmlStyleHint,
|
||||||
|
};
|
||||||
|
pub use common::{
|
||||||
|
clear_icon_lookup, lookup_icon_by_name, set_icon_lookup, FigLayoutMode, IconLookupResult,
|
||||||
|
IconStyle,
|
||||||
|
};
|
||||||
pub use node_mapper::{
|
pub use node_mapper::{
|
||||||
figma_all_pages_to_pen_document, figma_node_changes_to_pen_nodes, figma_to_pen_document,
|
figma_all_pages_to_pen_document, figma_node_changes_to_pen_nodes, figma_to_pen_document,
|
||||||
get_figma_pages, FigmaImportResult, FigmaPageInfo,
|
get_figma_pages, FigmaClipboardResult, FigmaImportResult, FigmaPageInfo,
|
||||||
};
|
};
|
||||||
|
|
||||||
use common::FigLayoutMode as LayoutMode;
|
use common::FigLayoutMode as LayoutMode;
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,19 @@ fn fill_color_hex(v: &FigValue) -> String {
|
||||||
.unwrap_or_else(|| "#000000".to_string())
|
.unwrap_or_else(|| "#000000".to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First visible SOLID paint's color as `#rrggbb`, mirroring TS
|
||||||
|
/// `figmaFillColor` in `converters/common.ts`. Used by the icon
|
||||||
|
/// converter to colorise the lucide stroke when the node has no
|
||||||
|
/// explicit stroke paint.
|
||||||
|
pub fn fig_fill_color(figma: &FigValue) -> Option<String> {
|
||||||
|
let paints = figma.get_array("fillPaints")?;
|
||||||
|
let paint = paints
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.get_bool("visible") != Some(false) && p.get_str("type") == Some("SOLID"))?;
|
||||||
|
let color = paint.get("color").and_then(FigColor::from_value)?;
|
||||||
|
Some(figma_color_to_hex(&color))
|
||||||
|
}
|
||||||
|
|
||||||
fn gradient_stops(paint: &FigValue) -> Vec<GradientStop> {
|
fn gradient_stops(paint: &FigValue) -> Vec<GradientStop> {
|
||||||
paint
|
paint
|
||||||
.get_array("stops")
|
.get_array("stops")
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ use jian_ops_schema::node::PenNode;
|
||||||
use jian_ops_schema::page::PenPage;
|
use jian_ops_schema::page::PenPage;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Outcome of a Figma import.
|
/// Outcome of a full-document Figma import.
|
||||||
pub struct FigmaImportResult {
|
pub struct FigmaImportResult {
|
||||||
pub document: PenDocument,
|
pub document: PenDocument,
|
||||||
pub warnings: Vec<String>,
|
pub warnings: Vec<String>,
|
||||||
|
|
@ -23,6 +23,17 @@ pub struct FigmaImportResult {
|
||||||
pub image_blobs: HashMap<u32, Vec<u8>>,
|
pub image_blobs: HashMap<u32, Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Outcome of a clipboard-style Figma import — a flat `PenNode` list
|
||||||
|
/// without a document wrapper, mirroring TS
|
||||||
|
/// `figmaNodeChangesToPenNodes`'s `{ nodes, warnings, imageBlobs }`
|
||||||
|
/// return shape.
|
||||||
|
pub struct FigmaClipboardResult {
|
||||||
|
pub nodes: Vec<PenNode>,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
/// In-blob image bytes keyed by blob index.
|
||||||
|
pub image_blobs: HashMap<u32, Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
fn empty_document(name: &str) -> PenDocument {
|
fn empty_document(name: &str) -> PenDocument {
|
||||||
PenDocument {
|
PenDocument {
|
||||||
version: "1".to_string(),
|
version: "1".to_string(),
|
||||||
|
|
@ -302,12 +313,14 @@ pub fn get_figma_pages(decoded: &FigmaDecodedFile) -> Vec<FigmaPageInfo> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert clipboard node changes into a flat `PenNode` list (no
|
/// Convert clipboard node changes into a flat `PenNode` list — no
|
||||||
/// document wrapper).
|
/// document wrapper, no synthesised page. Matches the TS
|
||||||
|
/// `figmaNodeChangesToPenNodes` shape so a clipboard-paste caller can
|
||||||
|
/// splice the returned nodes into the active document at the cursor.
|
||||||
pub fn figma_node_changes_to_pen_nodes(
|
pub fn figma_node_changes_to_pen_nodes(
|
||||||
mut decoded: FigmaDecodedFile,
|
mut decoded: FigmaDecodedFile,
|
||||||
layout_mode: FigLayoutMode,
|
layout_mode: FigLayoutMode,
|
||||||
) -> FigmaImportResult {
|
) -> FigmaClipboardResult {
|
||||||
resolve_style_references(&mut decoded.node_changes);
|
resolve_style_references(&mut decoded.node_changes);
|
||||||
let image_blobs = collect_image_blobs(&decoded.blobs);
|
let image_blobs = collect_image_blobs(&decoded.blobs);
|
||||||
let tree = build_tree(&decoded.node_changes);
|
let tree = build_tree(&decoded.node_changes);
|
||||||
|
|
@ -326,8 +339,8 @@ pub fn figma_node_changes_to_pen_nodes(
|
||||||
};
|
};
|
||||||
|
|
||||||
if top_nodes.is_empty() {
|
if top_nodes.is_empty() {
|
||||||
return FigmaImportResult {
|
return FigmaClipboardResult {
|
||||||
document: empty_document("clipboard"),
|
nodes: Vec::new(),
|
||||||
warnings: vec!["No convertible nodes found".to_string()],
|
warnings: vec!["No convertible nodes found".to_string()],
|
||||||
image_blobs,
|
image_blobs,
|
||||||
};
|
};
|
||||||
|
|
@ -364,11 +377,8 @@ pub fn figma_node_changes_to_pen_nodes(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FigmaImportResult {
|
FigmaClipboardResult {
|
||||||
document: document_with_pages(
|
nodes,
|
||||||
"clipboard",
|
|
||||||
vec![pen_page("clipboard".into(), "Clipboard".into(), nodes)],
|
|
||||||
),
|
|
||||||
warnings: ctx.warnings,
|
warnings: ctx.warnings,
|
||||||
image_blobs,
|
image_blobs,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,21 @@ ext = "pen"
|
||||||
name = "OpenPencil Document"
|
name = "OpenPencil Document"
|
||||||
role = "Editor"
|
role = "Editor"
|
||||||
|
|
||||||
|
# Figma `.fig` binary exports — routes through the desktop's
|
||||||
|
# background `figma_import_session` worker (see
|
||||||
|
# `persistence::is_supported_figma_import` + the dispatch sites in
|
||||||
|
# `app_handler::resumed` / `WindowEvent::DroppedFile` / `main::drain_opened_files`).
|
||||||
|
# `cargo-bundle` 0.10.0 only writes `CFBundleTypeExtensions` here; the
|
||||||
|
# modern UTI binding (`LSItemContentTypes = com.figma.document`,
|
||||||
|
# `UTImportedTypeDeclarations`, `LSHandlerRank = Alternate`) is sunk
|
||||||
|
# into the produced `Info.plist` by `scripts/bundle-macos.sh` because
|
||||||
|
# the macOS file-association lookup since 10.10 is UTI-based, not
|
||||||
|
# extension-based.
|
||||||
|
[[package.metadata.bundle.file_associations]]
|
||||||
|
ext = "fig"
|
||||||
|
name = "Figma Document"
|
||||||
|
role = "Editor"
|
||||||
|
|
||||||
# Desktop-only binary. The op-host-native lib is multi-platform (mobile
|
# Desktop-only binary. The op-host-native lib is multi-platform (mobile
|
||||||
# stubs land in Step 1f) but the runner here pulls winit + glutin via
|
# stubs land in Step 1f) but the runner here pulls winit + glutin via
|
||||||
# op-host-native's desktop dep set, so the whole crate is gated to
|
# op-host-native's desktop dep set, so the whole crate is gated to
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
//! `DesktopApp` struct, its helper `impl`, and `fn main`.
|
//! `DesktopApp` struct, its helper `impl`, and `fn main`.
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
chat_attachment, chat_session, cursor_icon, design_session, frame, git_jobs, menu, persistence,
|
chat_attachment, chat_session, cursor_icon, design_session, figma_import_session, frame,
|
||||||
settings_io, window_state, DesktopApp, INITIAL_VIEWPORT_H, INITIAL_VIEWPORT_W,
|
git_jobs, menu, persistence, settings_io, window_state, DesktopApp, INITIAL_VIEWPORT_H,
|
||||||
|
INITIAL_VIEWPORT_W,
|
||||||
};
|
};
|
||||||
use op_host_native::{NativeBackend, SharedSkiaContext};
|
use op_host_native::{NativeBackend, SharedSkiaContext};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
@ -200,9 +201,16 @@ impl ApplicationHandler for DesktopApp {
|
||||||
}
|
}
|
||||||
|
|
||||||
// File-association launch path: open the document handed in
|
// File-association launch path: open the document handed in
|
||||||
// via argv now that the host + window are ready.
|
// via argv now that the host + window are ready. Routes `.op`
|
||||||
|
// / `.pen` through `open_path`; `.fig` goes through the
|
||||||
|
// background Figma import worker so the launch doesn't freeze
|
||||||
|
// on a multi-second parse.
|
||||||
if let Some(path) = self.initial_file.take() {
|
if let Some(path) = self.initial_file.take() {
|
||||||
if persistence::open_path(
|
if persistence::is_supported_figma_import(&path) {
|
||||||
|
figma_import_session::cancel(&mut self.host, &mut self.current_figma_import);
|
||||||
|
self.current_figma_import = Some(figma_import_session::spawn(&mut self.host, path));
|
||||||
|
self.request_redraw(true);
|
||||||
|
} else if persistence::open_path(
|
||||||
&mut self.host,
|
&mut self.host,
|
||||||
path,
|
path,
|
||||||
&mut self.current_path,
|
&mut self.current_path,
|
||||||
|
|
@ -306,10 +314,19 @@ impl ApplicationHandler for DesktopApp {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WindowEvent::DroppedFile(path) => {
|
WindowEvent::DroppedFile(path) => {
|
||||||
// Drag-and-drop open. Only `.op` / `.pen` documents
|
// Drag-and-drop open. `.op` / `.pen` documents route
|
||||||
// are accepted; anything else is ignored silently so
|
// through the canonical loader; `.fig` Figma exports
|
||||||
// a stray drop can't disrupt the current document.
|
// route through the background Figma import worker
|
||||||
if persistence::is_supported_document(&path) {
|
// (the parse + layout pass takes seconds for large
|
||||||
|
// dashboards, so doing it inline would freeze the
|
||||||
|
// window). Anything else is ignored silently so a
|
||||||
|
// stray drop can't disrupt the current document.
|
||||||
|
if persistence::is_supported_figma_import(&path) {
|
||||||
|
figma_import_session::cancel(&mut self.host, &mut self.current_figma_import);
|
||||||
|
self.current_figma_import =
|
||||||
|
Some(figma_import_session::spawn(&mut self.host, path));
|
||||||
|
self.request_redraw(true);
|
||||||
|
} else if persistence::is_supported_document(&path) {
|
||||||
if persistence::open_path(
|
if persistence::open_path(
|
||||||
&mut self.host,
|
&mut self.host,
|
||||||
path,
|
path,
|
||||||
|
|
@ -321,7 +338,7 @@ impl ApplicationHandler for DesktopApp {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"openpencil-desktop: ignored dropped file (not .op / .pen): {}",
|
"openpencil-desktop: ignored dropped file (not .op / .pen / .fig): {}",
|
||||||
path.display()
|
path.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -344,6 +361,26 @@ impl ApplicationHandler for DesktopApp {
|
||||||
if chat_session::pump(&mut self.host, &mut self.current_chat) {
|
if chat_session::pump(&mut self.host, &mut self.current_chat) {
|
||||||
self.redraw_dirty = true;
|
self.redraw_dirty = true;
|
||||||
}
|
}
|
||||||
|
// Drain a finished background `.fig` parse — applies
|
||||||
|
// the imported document + clears the loading overlay
|
||||||
|
// flag. Rebinds Git + window title on success
|
||||||
|
// (matches the prior synchronous path's outcome).
|
||||||
|
match figma_import_session::pump(
|
||||||
|
&mut self.host,
|
||||||
|
&mut self.current_figma_import,
|
||||||
|
&mut self.current_path,
|
||||||
|
self.window.as_ref(),
|
||||||
|
) {
|
||||||
|
figma_import_session::PumpOutcome::CompletedOk => {
|
||||||
|
self.rebind_git_session_for_current_path();
|
||||||
|
self.redraw_dirty = true;
|
||||||
|
}
|
||||||
|
figma_import_session::PumpOutcome::CompletedErr => {
|
||||||
|
self.redraw_dirty = true;
|
||||||
|
}
|
||||||
|
figma_import_session::PumpOutcome::StillPending
|
||||||
|
| figma_import_session::PumpOutcome::Idle => {}
|
||||||
|
}
|
||||||
// Drain orchestrator apply requests + progress events
|
// Drain orchestrator apply requests + progress events
|
||||||
// for any in-flight design turn (orchestrator runs off
|
// for any in-flight design turn (orchestrator runs off
|
||||||
// the UI thread; `RemoteDocSink` forwards mutations
|
// the UI thread; `RemoteDocSink` forwards mutations
|
||||||
|
|
@ -419,12 +456,19 @@ impl ApplicationHandler for DesktopApp {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Chat or design turn streaming → wake ~30 fps to pump
|
// Chat / design / Figma-import worker active → wake
|
||||||
// deltas / orchestrator apply requests.
|
// ~10 fps to pump results and animate the loading
|
||||||
|
// overlay's spinner. Chat + design need ~30 fps for
|
||||||
|
// streaming deltas; Figma import is a one-shot result
|
||||||
|
// but the overlay's spinner needs frames to animate.
|
||||||
if self.current_chat.is_some() || self.current_design.is_some() {
|
if self.current_chat.is_some() || self.current_design.is_some() {
|
||||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||||
Instant::now() + Duration::from_millis(33),
|
Instant::now() + Duration::from_millis(33),
|
||||||
));
|
));
|
||||||
|
} else if self.current_figma_import.is_some() {
|
||||||
|
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
||||||
|
Instant::now() + Duration::from_millis(100),
|
||||||
|
));
|
||||||
} else if let Some(deadline_ms) = self.host.next_animation_deadline_ms() {
|
} else if let Some(deadline_ms) = self.host.next_animation_deadline_ms() {
|
||||||
let deadline = self.clock_start + Duration::from_millis(deadline_ms);
|
let deadline = self.clock_start + Duration::from_millis(deadline_ms);
|
||||||
event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
|
event_loop.set_control_flow(ControlFlow::WaitUntil(deadline));
|
||||||
|
|
@ -614,12 +658,24 @@ impl ApplicationHandler for DesktopApp {
|
||||||
&mut self.current_path,
|
&mut self.current_path,
|
||||||
self.window.as_ref(),
|
self.window.as_ref(),
|
||||||
) {
|
) {
|
||||||
|
// `mark_document_saved` cancels any
|
||||||
|
// in-flight Figma import internally, so a
|
||||||
|
// stale worker can't overwrite the fresh
|
||||||
|
// document when its result lands.
|
||||||
persistence::ActionOutcome::Saved => self.mark_document_saved(),
|
persistence::ActionOutcome::Saved => self.mark_document_saved(),
|
||||||
// A Figma import changed the document path
|
// User picked a `.fig`; spin up the worker
|
||||||
// but left unsaved work — rebind Git only,
|
// session and let `pump` apply the document
|
||||||
// keep the dirty baseline so close prompts.
|
// once parsing finishes. Cancel any prior
|
||||||
persistence::ActionOutcome::PathChangedUnsaved => {
|
// in-flight session first so two imports
|
||||||
self.rebind_git_session_for_current_path()
|
// in quick succession don't race.
|
||||||
|
persistence::ActionOutcome::FigmaImportStarted(path) => {
|
||||||
|
figma_import_session::cancel(
|
||||||
|
&mut self.host,
|
||||||
|
&mut self.current_figma_import,
|
||||||
|
);
|
||||||
|
self.current_figma_import =
|
||||||
|
Some(figma_import_session::spawn(&mut self.host, path));
|
||||||
|
self.request_redraw(true);
|
||||||
}
|
}
|
||||||
persistence::ActionOutcome::Noop => {}
|
persistence::ActionOutcome::Noop => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
crates/op-host-desktop/src/figma_import_session.rs
Normal file
197
crates/op-host-desktop/src/figma_import_session.rs
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
//! Background `.fig` import session — moves the multi-second
|
||||||
|
//! `parse_fig_binary` call off the main thread so the editor UI keeps
|
||||||
|
//! repainting (cursor moves, overlay animation) while a large Figma
|
||||||
|
//! file decodes.
|
||||||
|
//!
|
||||||
|
//! Lifecycle, mirrors `chat_session`:
|
||||||
|
//! 1. `spawn` — kick off file read + parse on a `std::thread`,
|
||||||
|
//! returning a session handle holding the receiver.
|
||||||
|
//! 2. `pump` — called every `RedrawRequested`; non-blocking
|
||||||
|
//! `try_recv` on the channel. Returns whether the host state
|
||||||
|
//! changed (so the caller can mark the next frame dirty).
|
||||||
|
//! 3. `is_pending` — true while the worker is still running. The
|
||||||
|
//! app handler reads this to schedule periodic wakes so the
|
||||||
|
//! "正在解析…" overlay keeps repainting under `WaitUntil` flow.
|
||||||
|
//!
|
||||||
|
//! On result land:
|
||||||
|
//! - `Ok(import)` → swap `EditorState` to the imported document and
|
||||||
|
//! clear `figma_import_in_progress`.
|
||||||
|
//! - `Err(e)` → show the native error dialog + clear the flag.
|
||||||
|
|
||||||
|
use op_editor_core::EditorState;
|
||||||
|
use op_host_native::WidgetHostNative;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use crate::persistence::{show_error_dialog_public, ErrorKind};
|
||||||
|
|
||||||
|
/// Successful worker output. Keep this Skia-free: building
|
||||||
|
/// `LayoutScene` runs text measurement, which can contend with the
|
||||||
|
/// main-thread painter and freeze the progress overlay.
|
||||||
|
pub struct PreparedImport {
|
||||||
|
pub state: EditorState,
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One in-flight `.fig` parse — the source path (for the error
|
||||||
|
/// dialog) plus the worker-thread receiver.
|
||||||
|
pub struct FigmaImportSession {
|
||||||
|
path: PathBuf,
|
||||||
|
rx: Receiver<Result<PreparedImport, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FigmaImportSession {
|
||||||
|
pub fn path(&self) -> &Path {
|
||||||
|
&self.path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a worker thread that reads `path`, parses it with
|
||||||
|
/// `op_figma::parse_fig_binary` in `Preserve` mode, and posts the
|
||||||
|
/// result back through a channel. Returns the session handle.
|
||||||
|
pub fn spawn(host: &mut WidgetHostNative, path: PathBuf) -> FigmaImportSession {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
// Flip the overlay flag so paint shows "正在解析…" feedback as
|
||||||
|
// soon as the next frame fires. We deliberately do NOT call
|
||||||
|
// `mark_editor_state_dirty()` here: that would set
|
||||||
|
// `editor_state_dirty=true`, which triggers
|
||||||
|
// `refresh_layout_scene` on the next paint and rebuilds the
|
||||||
|
// layout against the OLD document — wasted work since the
|
||||||
|
// import is about to replace `editor_state` whole-cloth. The
|
||||||
|
// overlay widget reads `editor_ui.figma_import_in_progress`
|
||||||
|
// directly, not through `layout_scene`, so the cached layout
|
||||||
|
// from before the spawn is fine to keep painting underneath.
|
||||||
|
host.editor_state_mut().editor_ui.figma_import_in_progress = true;
|
||||||
|
|
||||||
|
let path_for_thread = path.clone();
|
||||||
|
thread::Builder::new()
|
||||||
|
.name("op-figma-import".into())
|
||||||
|
.spawn(move || {
|
||||||
|
let result = parse_path(&path_for_thread);
|
||||||
|
// Recv side may be gone if the user closed the app
|
||||||
|
// mid-parse; tolerate the SendError silently.
|
||||||
|
let _ = tx.send(result);
|
||||||
|
})
|
||||||
|
.expect("spawn op-figma-import worker");
|
||||||
|
|
||||||
|
FigmaImportSession { path, rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_path(path: &Path) -> Result<PreparedImport, String> {
|
||||||
|
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||||
|
let file_name = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.unwrap_or("Figma Import");
|
||||||
|
let import = op_figma::parse_fig_binary(&bytes, file_name, op_figma::FigLayoutMode::Preserve)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut state = EditorState::from_document(import.document);
|
||||||
|
state.editor_ui.preserve_authored_geometry = true;
|
||||||
|
Ok(PreparedImport {
|
||||||
|
state,
|
||||||
|
warnings: import.warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true when the session resolved and the host state changed
|
||||||
|
/// (caller should mark the next frame dirty). Returns false when the
|
||||||
|
/// worker is still running or no session is active.
|
||||||
|
///
|
||||||
|
/// On success this drains the receiver, applies the imported
|
||||||
|
/// document, refreshes the host title, and clears the
|
||||||
|
/// in-progress flag. On failure it pops the native error dialog. In
|
||||||
|
/// either case the `*session` slot becomes `None`.
|
||||||
|
pub fn pump(
|
||||||
|
host: &mut WidgetHostNative,
|
||||||
|
session: &mut Option<FigmaImportSession>,
|
||||||
|
current_path: &mut Option<PathBuf>,
|
||||||
|
window: Option<&winit::window::Window>,
|
||||||
|
) -> PumpOutcome {
|
||||||
|
let Some(sess) = session.as_mut() else {
|
||||||
|
return PumpOutcome::Idle;
|
||||||
|
};
|
||||||
|
match sess.rx.try_recv() {
|
||||||
|
Ok(Ok(prepared)) => {
|
||||||
|
for warning in &prepared.warnings {
|
||||||
|
eprintln!("[import-figma] warning: {warning}");
|
||||||
|
}
|
||||||
|
// Swap in the parsed state. The worker deliberately did
|
||||||
|
// not build a LayoutScene because that touches Skia /
|
||||||
|
// FontMgr and can block the main-thread progress overlay.
|
||||||
|
host.install_imported_state(prepared.state);
|
||||||
|
// Imported docs have no `.op` path; next Save routes via
|
||||||
|
// Save As — matches the synchronous import behaviour.
|
||||||
|
*current_path = None;
|
||||||
|
refresh_title(current_path, window);
|
||||||
|
*session = None;
|
||||||
|
PumpOutcome::CompletedOk
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
eprintln!("[import-figma] {e}");
|
||||||
|
show_error_dialog_public(host, ErrorKind::Open, Some(&sess.path), &e);
|
||||||
|
host.editor_state_mut().editor_ui.figma_import_in_progress = false;
|
||||||
|
host.mark_editor_state_dirty();
|
||||||
|
*session = None;
|
||||||
|
PumpOutcome::CompletedErr
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => PumpOutcome::StillPending,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
// Worker thread panicked or dropped without sending —
|
||||||
|
// pop the same native dialog the explicit error path
|
||||||
|
// uses so the user gets feedback instead of a silently
|
||||||
|
// vanishing progress overlay.
|
||||||
|
eprintln!("[import-figma] worker thread terminated without sending a result");
|
||||||
|
let detail = "Figma import worker exited unexpectedly";
|
||||||
|
show_error_dialog_public(host, ErrorKind::Open, Some(&sess.path), detail);
|
||||||
|
host.editor_state_mut().editor_ui.figma_import_in_progress = false;
|
||||||
|
host.mark_editor_state_dirty();
|
||||||
|
*session = None;
|
||||||
|
PumpOutcome::CompletedErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop the active session (if any) and clear the in-progress flag —
|
||||||
|
/// called when another document-replacing action runs while a Figma
|
||||||
|
/// import is still parsing (File→New, File→Open, another File→Import
|
||||||
|
/// Figma). Without this guard, a stale worker would later overwrite
|
||||||
|
/// the user's freshly-opened document in `pump`.
|
||||||
|
///
|
||||||
|
/// The worker thread keeps running until it tries to `tx.send`; that
|
||||||
|
/// send becomes a no-op once we drop the receiver here. The thread
|
||||||
|
/// is short-lived (one parse + layout pass) so leaking it briefly is
|
||||||
|
/// fine.
|
||||||
|
pub fn cancel(host: &mut WidgetHostNative, session: &mut Option<FigmaImportSession>) {
|
||||||
|
if session.is_some() {
|
||||||
|
eprintln!("[import-figma] cancelling in-flight session — superseded");
|
||||||
|
*session = None;
|
||||||
|
if host.editor_state().editor_ui.figma_import_in_progress {
|
||||||
|
host.editor_state_mut().editor_ui.figma_import_in_progress = false;
|
||||||
|
host.mark_editor_state_dirty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of `pump` — used by the caller to decide whether to mark
|
||||||
|
/// the next frame dirty and whether to reset the document-path state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PumpOutcome {
|
||||||
|
/// No active session.
|
||||||
|
Idle,
|
||||||
|
/// Worker thread still running.
|
||||||
|
StillPending,
|
||||||
|
/// Worker finished and the document was applied.
|
||||||
|
CompletedOk,
|
||||||
|
/// Worker finished with an error (dialog already shown).
|
||||||
|
CompletedErr,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_title(current_path: &Option<PathBuf>, window: Option<&winit::window::Window>) {
|
||||||
|
let Some(window) = window else { return };
|
||||||
|
let title = match current_path.as_ref().and_then(|p| p.file_name()) {
|
||||||
|
Some(name) => format!("{} — OpenPencil", name.to_string_lossy()),
|
||||||
|
None => "OpenPencil".to_string(),
|
||||||
|
};
|
||||||
|
window.set_title(&title);
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ mod design_md_host;
|
||||||
mod design_session;
|
mod design_session;
|
||||||
mod export;
|
mod export;
|
||||||
mod export_pdf;
|
mod export_pdf;
|
||||||
|
mod figma_import_session;
|
||||||
mod frame;
|
mod frame;
|
||||||
mod git_host;
|
mod git_host;
|
||||||
mod git_jobs;
|
mod git_jobs;
|
||||||
|
|
@ -92,6 +93,11 @@ struct DesktopApp {
|
||||||
/// routes `Intent::Design` here (when an `agent::Provider` is
|
/// routes `Intent::Design` here (when an `agent::Provider` is
|
||||||
/// available), `Intent::Chat` to `current_chat`.
|
/// available), `Intent::Chat` to `current_chat`.
|
||||||
current_design: Option<design_session::DesignSession>,
|
current_design: Option<design_session::DesignSession>,
|
||||||
|
/// In-flight `.fig` import — worker thread that parses on a
|
||||||
|
/// background thread so the editor UI keeps repainting. The pump
|
||||||
|
/// in `RedrawRequested` swaps in the parsed document when the
|
||||||
|
/// worker finishes.
|
||||||
|
current_figma_import: Option<figma_import_session::FigmaImportSession>,
|
||||||
/// Background AI-model discovery — probes the installed CLIs
|
/// Background AI-model discovery — probes the installed CLIs
|
||||||
/// on a worker thread; its result is drained into
|
/// on a worker thread; its result is drained into
|
||||||
/// `chat.available_models` on a later frame.
|
/// `chat.available_models` on a later frame.
|
||||||
|
|
@ -176,6 +182,7 @@ impl DesktopApp {
|
||||||
error: None,
|
error: None,
|
||||||
current_chat: None,
|
current_chat: None,
|
||||||
current_design: None,
|
current_design: None,
|
||||||
|
current_figma_import: None,
|
||||||
model_probe: model_discovery::ModelProbe::spawn(),
|
model_probe: model_discovery::ModelProbe::spawn(),
|
||||||
iconify_job: None,
|
iconify_job: None,
|
||||||
initial_file,
|
initial_file,
|
||||||
|
|
@ -201,6 +208,12 @@ impl DesktopApp {
|
||||||
/// only reports edits made *since* that point. Also rebinds the
|
/// only reports edits made *since* that point. Also rebinds the
|
||||||
/// Git session (the document path may have changed).
|
/// Git session (the document path may have changed).
|
||||||
fn mark_document_saved(&mut self) {
|
fn mark_document_saved(&mut self) {
|
||||||
|
// Any successful Save / Open / New replaced the document. If
|
||||||
|
// a background Figma import is still running, its result
|
||||||
|
// would later overwrite this fresh document in `pump` —
|
||||||
|
// drop the session here so the worker's `send` becomes a
|
||||||
|
// silent no-op when it finishes.
|
||||||
|
figma_import_session::cancel(&mut self.host, &mut self.current_figma_import);
|
||||||
self.saved_doc_fingerprint = persistence::document_fingerprint(self.host.editor_state());
|
self.saved_doc_fingerprint = persistence::document_fingerprint(self.host.editor_state());
|
||||||
self.rebind_git_session_for_current_path();
|
self.rebind_git_session_for_current_path();
|
||||||
}
|
}
|
||||||
|
|
@ -299,7 +312,18 @@ impl DesktopApp {
|
||||||
{
|
{
|
||||||
let mut opened = false;
|
let mut opened = false;
|
||||||
for path in winit::platform::macos::drain_opened_file_urls() {
|
for path in winit::platform::macos::drain_opened_file_urls() {
|
||||||
if !persistence::is_supported_document(&path) {
|
let is_op = persistence::is_supported_document(&path);
|
||||||
|
let is_fig = persistence::is_supported_figma_import(&path);
|
||||||
|
if !is_op && !is_fig {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if is_fig
|
||||||
|
&& self
|
||||||
|
.current_figma_import
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|sess| sess.path() == path.as_path())
|
||||||
|
{
|
||||||
|
opened = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if opened {
|
if opened {
|
||||||
|
|
@ -310,7 +334,18 @@ impl DesktopApp {
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if persistence::open_path(
|
if is_fig {
|
||||||
|
// `.fig` → background import. Mark `opened` true
|
||||||
|
// so further drops in this batch are skipped, but
|
||||||
|
// don't run `mark_document_saved` (the document is
|
||||||
|
// still pending; pump applies it when the worker
|
||||||
|
// finishes).
|
||||||
|
figma_import_session::cancel(&mut self.host, &mut self.current_figma_import);
|
||||||
|
self.current_figma_import =
|
||||||
|
Some(figma_import_session::spawn(&mut self.host, path));
|
||||||
|
self.request_redraw(true);
|
||||||
|
opened = true;
|
||||||
|
} else if persistence::open_path(
|
||||||
&mut self.host,
|
&mut self.host,
|
||||||
path,
|
path,
|
||||||
&mut self.current_path,
|
&mut self.current_path,
|
||||||
|
|
@ -742,13 +777,15 @@ impl DesktopApp {
|
||||||
/// is registered (see `Cargo.toml`'s `[package.metadata.bundle]`),
|
/// is registered (see `Cargo.toml`'s `[package.metadata.bundle]`),
|
||||||
/// the OS launches this binary with the document path in argv —
|
/// the OS launches this binary with the document path in argv —
|
||||||
/// double-click on Windows / Linux, or `open file.op` from a shell
|
/// double-click on Windows / Linux, or `open file.op` from a shell
|
||||||
/// on any platform. The first existing `.op` / `.pen` argument wins;
|
/// on any platform. The first existing `.op` / `.pen` / `.fig`
|
||||||
/// flags (`--mcp`, …) never match the extension filter.
|
/// argument wins; flags (`--mcp`, …) never match the extension
|
||||||
|
/// filter. `.fig` routes through the Figma import worker once the
|
||||||
|
/// window is up (see `DesktopApp::apply_initial_file`).
|
||||||
fn initial_file_from_argv() -> Option<PathBuf> {
|
fn initial_file_from_argv() -> Option<PathBuf> {
|
||||||
std::env::args_os()
|
std::env::args_os().skip(1).map(PathBuf::from).find(|p| {
|
||||||
.skip(1)
|
(persistence::is_supported_document(p) || persistence::is_supported_figma_import(p))
|
||||||
.map(PathBuf::from)
|
&& p.is_file()
|
||||||
.find(|p| persistence::is_supported_document(p) && p.is_file())
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pop a native dialog offering to open the download page when a
|
/// Pop a native dialog offering to open the download page when a
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,19 @@ pub fn is_supported_document(path: &std::path::Path) -> bool {
|
||||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("op") || ext.eq_ignore_ascii_case("pen"))
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("op") || ext.eq_ignore_ascii_case("pen"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True for Figma `.fig` binary exports. The bundle declares `.fig`
|
||||||
|
/// as a `CFBundleDocumentTypes` extension (macOS / Windows / Linux),
|
||||||
|
/// so double-clicking one in Finder / dragging one onto the dock /
|
||||||
|
/// the running window all need to route through
|
||||||
|
/// `figma_import_session::spawn` rather than the `.op`-only
|
||||||
|
/// `open_path`. Case-insensitive (Figma's "Save Local Copy" emits
|
||||||
|
/// `.fig`; some macOS shares fold to `.FIG`).
|
||||||
|
pub fn is_supported_figma_import(path: &std::path::Path) -> bool {
|
||||||
|
path.extension()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("fig"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Open `path` directly — no dialog. Backs drag-and-drop drops and
|
/// Open `path` directly — no dialog. Backs drag-and-drop drops and
|
||||||
/// the file-association launch path. Replaces the host's document,
|
/// the file-association launch path. Replaces the host's document,
|
||||||
/// records the file in recents and refreshes the window title.
|
/// records the file in recents and refreshes the window title.
|
||||||
|
|
@ -370,19 +383,18 @@ pub fn open_path(
|
||||||
|
|
||||||
/// Outcome of [`run_action`] — tells the desktop runner which
|
/// Outcome of [`run_action`] — tells the desktop runner which
|
||||||
/// post-action bookkeeping to run.
|
/// post-action bookkeeping to run.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ActionOutcome {
|
pub enum ActionOutcome {
|
||||||
/// The document now matches a file on disk (New / successful
|
/// The document now matches a file on disk (New / successful
|
||||||
/// Open / Save / Save-As / Open-Recent). The runner refreshes the
|
/// Open / Save / Save-As / Open-Recent). The runner refreshes the
|
||||||
/// unsaved-changes baseline AND rebinds the Git session.
|
/// unsaved-changes baseline AND rebinds the Git session.
|
||||||
Saved,
|
Saved,
|
||||||
/// The document's content + path changed but it does NOT match
|
/// User picked a `.fig` and the desktop runner should spawn the
|
||||||
/// any file on disk — a Figma import. The runner rebinds the Git
|
/// background parser (`figma_import_session::spawn`). The actual
|
||||||
/// session + window title (the previously-open document's repo
|
/// document swap happens later when `figma_import_session::pump`
|
||||||
/// binding is now stale) but must NOT refresh the unsaved-changes
|
/// drains the worker's result + rebinds the Git session itself
|
||||||
/// baseline: the imported design is unsaved work and close must
|
/// (the previously-open repo binding goes stale on import).
|
||||||
/// still prompt.
|
FigmaImportStarted(PathBuf),
|
||||||
PathChangedUnsaved,
|
|
||||||
/// Nothing to reconcile — export, recent-list edits, or a user
|
/// Nothing to reconcile — export, recent-list edits, or a user
|
||||||
/// cancel / error.
|
/// cancel / error.
|
||||||
Noop,
|
Noop,
|
||||||
|
|
@ -533,27 +545,13 @@ pub fn run_action(
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => return ActionOutcome::Noop,
|
None => return ActionOutcome::Noop,
|
||||||
};
|
};
|
||||||
match import_figma_into_host(host, &path) {
|
// Spawn the parse on a worker thread so the UI keeps
|
||||||
Ok(()) => {
|
// repainting (a 2–3 MB .fig with hundreds of nodes takes
|
||||||
// An imported `.fig` has no `.op` path of its own —
|
// multiple seconds; running it on the main thread freezes
|
||||||
// the next Save routes through Save As.
|
// the window). The desktop runner picks up the session in
|
||||||
*current_path = None;
|
// the next `RedrawRequested` pump and applies the result
|
||||||
refresh_title(current_path, window);
|
// when it lands.
|
||||||
// `PathChangedUnsaved`, not `Saved`: an import does
|
ActionOutcome::FigmaImportStarted(path)
|
||||||
// NOT leave the document matching disk. Reporting
|
|
||||||
// `Saved` would refresh the unsaved-changes
|
|
||||||
// baseline, so closing the app would silently
|
|
||||||
// discard the imported design with no save prompt.
|
|
||||||
// The runner still rebinds the Git session (the
|
|
||||||
// previously-open document's repo is now stale).
|
|
||||||
ActionOutcome::PathChangedUnsaved
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[import-figma] {e}");
|
|
||||||
show_error_dialog(host, ErrorKind::Open, Some(&path), &e);
|
|
||||||
ActionOutcome::Noop
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
FileAction::ImportImageOrSvg => {
|
FileAction::ImportImageOrSvg => {
|
||||||
crate::persistence_image::handle_import_image_or_svg(host);
|
crate::persistence_image::handle_import_image_or_svg(host);
|
||||||
|
|
@ -566,26 +564,9 @@ pub fn run_action(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read + parse a binary `.fig` file and swap the host's document
|
// `import_figma_into_host` (synchronous parse) was retired in favour
|
||||||
/// for the imported one. The heavy lifting lives in `op_figma`.
|
// of `figma_import_session::spawn`, which moves the parse to a worker
|
||||||
fn import_figma_into_host(
|
// thread and pumps the result back through a channel each frame.
|
||||||
host: &mut WidgetHostNative,
|
|
||||||
path: &std::path::Path,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
|
||||||
let file_name = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.unwrap_or("Figma Import");
|
|
||||||
let import = op_figma::parse_fig_binary(&bytes, file_name, op_figma::FigLayoutMode::OpenPencil)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
for warning in &import.warnings {
|
|
||||||
eprintln!("[import-figma] warning: {warning}");
|
|
||||||
}
|
|
||||||
*host.editor_state_mut() = EditorState::from_document(import.document);
|
|
||||||
host.mark_editor_state_dirty();
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn refresh_title(current_path: &Option<PathBuf>, window: Option<&winit::window::Window>) {
|
fn refresh_title(current_path: &Option<PathBuf>, window: Option<&winit::window::Window>) {
|
||||||
let Some(window) = window else { return };
|
let Some(window) = window else { return };
|
||||||
|
|
@ -626,12 +607,24 @@ fn show_error_dialog(
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
enum ErrorKind {
|
pub enum ErrorKind {
|
||||||
Open,
|
Open,
|
||||||
Save,
|
Save,
|
||||||
Export,
|
Export,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Public re-export of the native error dialog — used by the
|
||||||
|
/// background Figma import session (`figma_import_session::pump`) to
|
||||||
|
/// pop the same OS dialog the synchronous error path uses.
|
||||||
|
pub fn show_error_dialog_public(
|
||||||
|
host: &WidgetHostNative,
|
||||||
|
kind: ErrorKind,
|
||||||
|
path: Option<&std::path::Path>,
|
||||||
|
detail: &str,
|
||||||
|
) {
|
||||||
|
show_error_dialog(host, kind, path, detail)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ fn primary_font_family(stack: &str) -> Option<&str> {
|
||||||
/// OP `Color` → `skia_safe::Color4f` — used by the direct-canvas
|
/// OP `Color` → `skia_safe::Color4f` — used by the direct-canvas
|
||||||
/// helpers (stroke_line / fill_round_rect / stroke_round_rect)
|
/// helpers (stroke_line / fill_round_rect / stroke_round_rect)
|
||||||
/// that skip the jian DrawOp pipeline.
|
/// that skip the jian DrawOp pipeline.
|
||||||
fn jian_color_to_color4f(c: Color) -> skia_safe::Color4f {
|
pub(super) fn jian_color_to_color4f(c: Color) -> skia_safe::Color4f {
|
||||||
skia_safe::Color4f::new(
|
skia_safe::Color4f::new(
|
||||||
c.r.clamp(0.0, 1.0),
|
c.r.clamp(0.0, 1.0),
|
||||||
c.g.clamp(0.0, 1.0),
|
c.g.clamp(0.0, 1.0),
|
||||||
|
|
@ -109,8 +109,10 @@ fn jian_color_to_color4f(c: Color) -> skia_safe::Color4f {
|
||||||
// / `_radial_gradient`) and their helpers live in the sibling
|
// / `_radial_gradient`) and their helpers live in the sibling
|
||||||
// `gradient.rs` so this spine stays under the 800-line cap. The
|
// `gradient.rs` so this spine stays under the 800-line cap. The
|
||||||
// methods are added to `NativeBackend` via a sibling `impl` block.
|
// methods are added to `NativeBackend` via a sibling `impl` block.
|
||||||
|
mod font_script;
|
||||||
mod gradient;
|
mod gradient;
|
||||||
mod image;
|
mod image;
|
||||||
|
mod path;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use image::{cover_rect, image_adjustment_matrix};
|
use image::{cover_rect, image_adjustment_matrix};
|
||||||
|
|
||||||
|
|
@ -123,6 +125,7 @@ use image::{cover_rect, image_adjustment_matrix};
|
||||||
pub struct NativeBackend {
|
pub struct NativeBackend {
|
||||||
skia: jian_skia::SkiaBackend,
|
skia: jian_skia::SkiaBackend,
|
||||||
dpi: f32,
|
dpi: f32,
|
||||||
|
font_mgr: skia_safe::FontMgr,
|
||||||
/// Lazy-initialised typeface backed by the embedded Roboto TTF
|
/// Lazy-initialised typeface backed by the embedded Roboto TTF
|
||||||
/// (shared with shell-web). Step 4 perf fix: jian-skia's
|
/// (shared with shell-web). Step 4 perf fix: jian-skia's
|
||||||
/// `textlayout` path allocates a fresh `FontCollection` +
|
/// `textlayout` path allocates a fresh `FontCollection` +
|
||||||
|
|
@ -159,6 +162,10 @@ pub struct NativeBackend {
|
||||||
/// on its next paint).
|
/// on its next paint).
|
||||||
image_cache: std::collections::HashMap<u64, Option<skia_safe::Image>>,
|
image_cache: std::collections::HashMap<u64, Option<skia_safe::Image>>,
|
||||||
image_cache_order: std::collections::VecDeque<u64>,
|
image_cache_order: std::collections::VecDeque<u64>,
|
||||||
|
svg_path_cache: std::collections::HashMap<u64, path::SvgPathCacheEntry>,
|
||||||
|
svg_path_cache_order: std::collections::VecDeque<u64>,
|
||||||
|
svg_raster_cache: std::collections::HashMap<path::SvgRasterKey, path::SvgRasterCacheEntry>,
|
||||||
|
svg_raster_cache_order: std::collections::VecDeque<path::SvgRasterKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maximum number of decoded chat images held at once. Decoded RGBA
|
/// Maximum number of decoded chat images held at once. Decoded RGBA
|
||||||
|
|
@ -209,6 +216,7 @@ impl NativeBackend {
|
||||||
let mut this = Self {
|
let mut this = Self {
|
||||||
skia,
|
skia,
|
||||||
dpi,
|
dpi,
|
||||||
|
font_mgr: skia_safe::FontMgr::new(),
|
||||||
typeface: None,
|
typeface: None,
|
||||||
typeface_tried: false,
|
typeface_tried: false,
|
||||||
cjk_typeface: None,
|
cjk_typeface: None,
|
||||||
|
|
@ -217,6 +225,10 @@ impl NativeBackend {
|
||||||
family_typeface_cache: std::collections::HashMap::new(),
|
family_typeface_cache: std::collections::HashMap::new(),
|
||||||
image_cache: std::collections::HashMap::new(),
|
image_cache: std::collections::HashMap::new(),
|
||||||
image_cache_order: std::collections::VecDeque::new(),
|
image_cache_order: std::collections::VecDeque::new(),
|
||||||
|
svg_path_cache: std::collections::HashMap::new(),
|
||||||
|
svg_path_cache_order: std::collections::VecDeque::new(),
|
||||||
|
svg_raster_cache: std::collections::HashMap::new(),
|
||||||
|
svg_raster_cache_order: std::collections::VecDeque::new(),
|
||||||
};
|
};
|
||||||
// Pre-warm the per-codepoint typeface cache with every CJK
|
// Pre-warm the per-codepoint typeface cache with every CJK
|
||||||
// glyph that appears in the chrome (top bar, layer panel,
|
// glyph that appears in the chrome (top bar, layer panel,
|
||||||
|
|
@ -243,14 +255,18 @@ impl NativeBackend {
|
||||||
if c.is_ascii() && weight == 400 {
|
if c.is_ascii() && weight == 400 {
|
||||||
return self.ensure_typeface().cloned();
|
return self.ensure_typeface().cloned();
|
||||||
}
|
}
|
||||||
|
if font_script::is_east_asian_codepoint(c) {
|
||||||
|
return self.ensure_cjk_typeface().cloned();
|
||||||
|
}
|
||||||
let cp = c as i32;
|
let cp = c as i32;
|
||||||
let key = (cp, weight);
|
let key = (cp, weight);
|
||||||
if let Some(cached) = self.char_typeface_cache.get(&key) {
|
if let Some(cached) = self.char_typeface_cache.get(&key) {
|
||||||
return cached.clone();
|
return cached.clone();
|
||||||
}
|
}
|
||||||
let style = font_style_for_weight(weight);
|
let style = font_style_for_weight(weight);
|
||||||
let mgr = skia_safe::FontMgr::new();
|
let tf = self
|
||||||
let tf = mgr.match_family_style_character("", style, &[], cp);
|
.font_mgr
|
||||||
|
.match_family_style_character("", style, &[], cp);
|
||||||
let resolved = tf.or_else(|| {
|
let resolved = tf.or_else(|| {
|
||||||
// CJK fallback path doesn't yet vary by weight — TS app
|
// CJK fallback path doesn't yet vary by weight — TS app
|
||||||
// synthesises bold via paint stroke when the family is
|
// synthesises bold via paint stroke when the family is
|
||||||
|
|
@ -271,13 +287,16 @@ impl NativeBackend {
|
||||||
let Some(primary) = primary_font_family(family) else {
|
let Some(primary) = primary_font_family(family) else {
|
||||||
return self.typeface_for_char(c, weight);
|
return self.typeface_for_char(c, weight);
|
||||||
};
|
};
|
||||||
|
if font_script::is_east_asian_codepoint(c) {
|
||||||
|
return self.ensure_cjk_typeface().cloned();
|
||||||
|
}
|
||||||
let key = (primary.to_string(), c as i32, weight);
|
let key = (primary.to_string(), c as i32, weight);
|
||||||
if let Some(cached) = self.family_typeface_cache.get(&key) {
|
if let Some(cached) = self.family_typeface_cache.get(&key) {
|
||||||
return cached.clone();
|
return cached.clone();
|
||||||
}
|
}
|
||||||
let style = font_style_for_weight(weight);
|
let style = font_style_for_weight(weight);
|
||||||
let mgr = skia_safe::FontMgr::new();
|
let resolved = self
|
||||||
let resolved = mgr
|
.font_mgr
|
||||||
.match_family_style_character(primary, style, &[], c as i32)
|
.match_family_style_character(primary, style, &[], c as i32)
|
||||||
.or_else(|| self.typeface_for_char(c, weight));
|
.or_else(|| self.typeface_for_char(c, weight));
|
||||||
self.family_typeface_cache.insert(key, resolved.clone());
|
self.family_typeface_cache.insert(key, resolved.clone());
|
||||||
|
|
@ -314,7 +333,7 @@ impl NativeBackend {
|
||||||
/// Lazy-init the Step 4 cached Roboto typeface (ASCII path).
|
/// Lazy-init the Step 4 cached Roboto typeface (ASCII path).
|
||||||
fn ensure_typeface(&mut self) -> Option<&skia_safe::Typeface> {
|
fn ensure_typeface(&mut self) -> Option<&skia_safe::Typeface> {
|
||||||
if !self.typeface_tried {
|
if !self.typeface_tried {
|
||||||
self.typeface = skia_safe::FontMgr::new().new_from_data(ROBOTO_TTF, None);
|
self.typeface = self.font_mgr.new_from_data(ROBOTO_TTF, None);
|
||||||
self.typeface_tried = true;
|
self.typeface_tried = true;
|
||||||
}
|
}
|
||||||
self.typeface.as_ref()
|
self.typeface.as_ref()
|
||||||
|
|
@ -328,8 +347,7 @@ impl NativeBackend {
|
||||||
/// don't pay the FontMgr lookup more than once.
|
/// don't pay the FontMgr lookup more than once.
|
||||||
fn ensure_cjk_typeface(&mut self) -> Option<&skia_safe::Typeface> {
|
fn ensure_cjk_typeface(&mut self) -> Option<&skia_safe::Typeface> {
|
||||||
if !self.cjk_typeface_tried {
|
if !self.cjk_typeface_tried {
|
||||||
let mgr = skia_safe::FontMgr::new();
|
self.cjk_typeface = self.font_mgr.match_family_style_character(
|
||||||
self.cjk_typeface = mgr.match_family_style_character(
|
|
||||||
"",
|
"",
|
||||||
skia_safe::FontStyle::default(),
|
skia_safe::FontStyle::default(),
|
||||||
&[],
|
&[],
|
||||||
|
|
@ -592,65 +610,6 @@ impl NativeBackend {
|
||||||
canvas.draw_round_rect(to_sk_rect(rect), radius, radius, &paint);
|
canvas.draw_round_rect(to_sk_rect(rect), radius, radius, &paint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Step 5 SVG icons: parse an SVG path `d` string, scale from
|
|
||||||
/// a 24×24 viewBox to `size × size` at `top_left`, and stroke
|
|
||||||
/// it with round caps + joins (matches lucide's visual style).
|
|
||||||
/// Falls back to a no-op when the path string fails to parse —
|
|
||||||
/// silently dropping a single icon is better than panicking
|
|
||||||
/// the paint loop.
|
|
||||||
pub fn stroke_svg_path(
|
|
||||||
&self,
|
|
||||||
canvas: &skia_safe::Canvas,
|
|
||||||
d: &str,
|
|
||||||
top_left: Point2D,
|
|
||||||
size: f32,
|
|
||||||
color: Color,
|
|
||||||
width: f32,
|
|
||||||
) {
|
|
||||||
let Some(path) = skia_safe::utils::parse_path::from_svg(d) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let s = size / 24.0;
|
|
||||||
let mut matrix = skia_safe::Matrix::new_identity();
|
|
||||||
matrix.set_scale_translate((s, s), (top_left.x, top_left.y));
|
|
||||||
let path = path.with_transform(&matrix);
|
|
||||||
let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
|
|
||||||
paint.set_stroke(true);
|
|
||||||
paint.set_stroke_width(width);
|
|
||||||
paint.set_anti_alias(true);
|
|
||||||
paint.set_stroke_cap(skia_safe::PaintCap::Round);
|
|
||||||
paint.set_stroke_join(skia_safe::PaintJoin::Round);
|
|
||||||
canvas.draw_path(&path, &paint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fill an SVG path scaled from `viewbox × viewbox` to
|
|
||||||
/// `size × size`. Brand logos ship as filled paths in their
|
|
||||||
/// own viewBox; the same parser as `stroke_svg_path` is
|
|
||||||
/// reused but paint is configured for `Fill` not `Stroke`.
|
|
||||||
pub fn fill_svg_path(
|
|
||||||
&self,
|
|
||||||
canvas: &skia_safe::Canvas,
|
|
||||||
d: &str,
|
|
||||||
top_left: Point2D,
|
|
||||||
size: f32,
|
|
||||||
viewbox: f32,
|
|
||||||
color: Color,
|
|
||||||
) {
|
|
||||||
let Some(path) = skia_safe::utils::parse_path::from_svg(d) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
let s = size / viewbox;
|
|
||||||
let mut matrix = skia_safe::Matrix::new_identity();
|
|
||||||
matrix.set_scale_translate((s, s), (top_left.x, top_left.y));
|
|
||||||
let mut path = path.with_transform(&matrix);
|
|
||||||
if d.matches(['Z', 'z']).count() > 1 {
|
|
||||||
path.set_fill_type(skia_safe::PathFillType::EvenOdd);
|
|
||||||
}
|
|
||||||
let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
|
|
||||||
paint.set_anti_alias(true);
|
|
||||||
canvas.draw_path(&path, &paint);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Filled ellipse inscribed in `bounds`. Uses skia's native
|
/// Filled ellipse inscribed in `bounds`. Uses skia's native
|
||||||
/// oval primitive so the curve is properly anti-aliased.
|
/// oval primitive so the curve is properly anti-aliased.
|
||||||
pub fn fill_oval(&self, canvas: &skia_safe::Canvas, bounds: Rect, color: Color) {
|
pub fn fill_oval(&self, canvas: &skia_safe::Canvas, bounds: Rect, color: Color) {
|
||||||
|
|
|
||||||
27
crates/op-host-native/src/backend/skia/font_script.rs
Normal file
27
crates/op-host-native/src/backend/skia/font_script.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
pub(super) fn is_east_asian_codepoint(c: char) -> bool {
|
||||||
|
let cp = c as u32;
|
||||||
|
matches!(
|
||||||
|
cp,
|
||||||
|
0x2E80..=0x2EFF
|
||||||
|
| 0x3000..=0x303F
|
||||||
|
| 0x3040..=0x30FF
|
||||||
|
| 0x3100..=0x312F
|
||||||
|
| 0x3130..=0x318F
|
||||||
|
| 0x31A0..=0x31BF
|
||||||
|
| 0x31C0..=0x31EF
|
||||||
|
| 0x31F0..=0x31FF
|
||||||
|
| 0x3400..=0x4DBF
|
||||||
|
| 0x4E00..=0x9FFF
|
||||||
|
| 0xAC00..=0xD7AF
|
||||||
|
| 0xF900..=0xFAFF
|
||||||
|
| 0xFE10..=0xFE1F
|
||||||
|
| 0xFE30..=0xFE4F
|
||||||
|
| 0xFF00..=0xFFEF
|
||||||
|
| 0x20000..=0x2A6DF
|
||||||
|
| 0x2A700..=0x2B73F
|
||||||
|
| 0x2B740..=0x2B81F
|
||||||
|
| 0x2B820..=0x2CEAF
|
||||||
|
| 0x2CEB0..=0x2EBEF
|
||||||
|
| 0x30000..=0x3134F
|
||||||
|
)
|
||||||
|
}
|
||||||
274
crates/op-host-native/src/backend/skia/path.rs
Normal file
274
crates/op-host-native/src/backend/skia/path.rs
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
use super::{jian_color_to_color4f, to_sk_rect, NativeBackend};
|
||||||
|
use op_editor_ui::{Color, Point2D, Rect};
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
const SVG_PATH_CACHE_CAP: usize = 2048;
|
||||||
|
const SVG_RASTER_CACHE_CAP: usize = 256;
|
||||||
|
const SVG_RASTER_COMPLEXITY_MIN: usize = 4096;
|
||||||
|
const SVG_RASTER_MAX_PIXELS: i32 = 2_000_000;
|
||||||
|
const SVG_RASTER_PAD: f32 = 2.0;
|
||||||
|
|
||||||
|
pub(super) struct SvgPathCacheEntry {
|
||||||
|
d: String,
|
||||||
|
path: Option<skia_safe::Path>,
|
||||||
|
even_odd: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub(super) struct SvgRasterKey {
|
||||||
|
path_key: u64,
|
||||||
|
d_len: usize,
|
||||||
|
color_rgba: u32,
|
||||||
|
size_bits: u32,
|
||||||
|
viewbox_bits: u32,
|
||||||
|
dpi_bits: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) struct SvgRasterCacheEntry {
|
||||||
|
image: skia_safe::Image,
|
||||||
|
offset: Point2D,
|
||||||
|
size: Point2D,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SvgRasterRequest<'a> {
|
||||||
|
path_key: u64,
|
||||||
|
d_len: usize,
|
||||||
|
path: &'a skia_safe::Path,
|
||||||
|
even_odd: bool,
|
||||||
|
size: f32,
|
||||||
|
viewbox: f32,
|
||||||
|
color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NativeBackend {
|
||||||
|
fn cached_svg_path(&mut self, d: &str) -> Option<(u64, skia_safe::Path, bool)> {
|
||||||
|
let key = svg_path_key(d);
|
||||||
|
if let Some(entry) = self.svg_path_cache.get(&key) {
|
||||||
|
if entry.d == d {
|
||||||
|
return entry.path.clone().map(|path| (key, path, entry.even_odd));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = skia_safe::utils::parse_path::from_svg(d);
|
||||||
|
let even_odd = path.is_some() && has_multiple_close_commands(d);
|
||||||
|
let was_present = self.svg_path_cache.contains_key(&key);
|
||||||
|
self.svg_path_cache.insert(
|
||||||
|
key,
|
||||||
|
SvgPathCacheEntry {
|
||||||
|
d: d.to_string(),
|
||||||
|
path: path.clone(),
|
||||||
|
even_odd,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !was_present {
|
||||||
|
self.svg_path_cache_order.push_back(key);
|
||||||
|
}
|
||||||
|
while self.svg_path_cache.len() > SVG_PATH_CACHE_CAP {
|
||||||
|
match self.svg_path_cache_order.pop_front() {
|
||||||
|
Some(oldest) => {
|
||||||
|
self.svg_path_cache.remove(&oldest);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path.map(|path| (key, path, even_odd))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cached_raster_svg_path(
|
||||||
|
&mut self,
|
||||||
|
req: SvgRasterRequest<'_>,
|
||||||
|
) -> Option<&SvgRasterCacheEntry> {
|
||||||
|
let SvgRasterRequest {
|
||||||
|
path_key,
|
||||||
|
d_len,
|
||||||
|
path,
|
||||||
|
even_odd,
|
||||||
|
size,
|
||||||
|
viewbox,
|
||||||
|
color,
|
||||||
|
} = req;
|
||||||
|
if d_len < SVG_RASTER_COMPLEXITY_MIN || !size.is_finite() || !viewbox.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let s = size / viewbox;
|
||||||
|
if s <= 0.0 || !s.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let dpi = if self.dpi.is_finite() && self.dpi > 0.0 {
|
||||||
|
self.dpi
|
||||||
|
} else {
|
||||||
|
1.0
|
||||||
|
};
|
||||||
|
let raster_s = s * dpi;
|
||||||
|
let pad_px = (SVG_RASTER_PAD * dpi).ceil();
|
||||||
|
let bounds = path.compute_tight_bounds();
|
||||||
|
if !bounds.is_finite() || bounds.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let width = (bounds.width() * raster_s).abs().ceil() as i32 + (pad_px as i32 * 2);
|
||||||
|
let height = (bounds.height() * raster_s).abs().ceil() as i32 + (pad_px as i32 * 2);
|
||||||
|
if width <= 0 || height <= 0 || width.saturating_mul(height) > SVG_RASTER_MAX_PIXELS {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = SvgRasterKey {
|
||||||
|
path_key,
|
||||||
|
d_len,
|
||||||
|
color_rgba: color_key(color),
|
||||||
|
size_bits: size.to_bits(),
|
||||||
|
viewbox_bits: viewbox.to_bits(),
|
||||||
|
dpi_bits: dpi.to_bits(),
|
||||||
|
};
|
||||||
|
if self.svg_raster_cache.contains_key(&key) {
|
||||||
|
return self.svg_raster_cache.get(&key);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut surface = skia_safe::surfaces::raster_n32_premul((width, height))?;
|
||||||
|
let raster_canvas = surface.canvas();
|
||||||
|
raster_canvas.clear(skia_safe::Color::TRANSPARENT);
|
||||||
|
let save = raster_canvas.save();
|
||||||
|
raster_canvas.translate((
|
||||||
|
pad_px - bounds.left() * raster_s,
|
||||||
|
pad_px - bounds.top() * raster_s,
|
||||||
|
));
|
||||||
|
raster_canvas.scale((raster_s, raster_s));
|
||||||
|
let mut raster_path = path.clone();
|
||||||
|
if even_odd {
|
||||||
|
raster_path.set_fill_type(skia_safe::PathFillType::EvenOdd);
|
||||||
|
}
|
||||||
|
let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
|
||||||
|
paint.set_anti_alias(true);
|
||||||
|
raster_canvas.draw_path(&raster_path, &paint);
|
||||||
|
raster_canvas.restore_to_count(save);
|
||||||
|
|
||||||
|
let entry = SvgRasterCacheEntry {
|
||||||
|
image: surface.image_snapshot(),
|
||||||
|
offset: Point2D::new(
|
||||||
|
bounds.left() * s - pad_px / dpi,
|
||||||
|
bounds.top() * s - pad_px / dpi,
|
||||||
|
),
|
||||||
|
size: Point2D::new(width as f32 / dpi, height as f32 / dpi),
|
||||||
|
};
|
||||||
|
self.svg_raster_cache.insert(key, entry);
|
||||||
|
self.svg_raster_cache_order.push_back(key);
|
||||||
|
while self.svg_raster_cache.len() > SVG_RASTER_CACHE_CAP {
|
||||||
|
match self.svg_raster_cache_order.pop_front() {
|
||||||
|
Some(oldest) => {
|
||||||
|
self.svg_raster_cache.remove(&oldest);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.svg_raster_cache.get(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 5 SVG icons: parse an SVG path `d` string once, scale from
|
||||||
|
/// a 24x24 viewBox to `size x size` at `top_left`, and stroke it
|
||||||
|
/// with round caps + joins.
|
||||||
|
pub fn stroke_svg_path(
|
||||||
|
&mut self,
|
||||||
|
canvas: &skia_safe::Canvas,
|
||||||
|
d: &str,
|
||||||
|
top_left: Point2D,
|
||||||
|
size: f32,
|
||||||
|
color: Color,
|
||||||
|
width: f32,
|
||||||
|
) {
|
||||||
|
let Some((_, path, _)) = self.cached_svg_path(d) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let s = size / 24.0;
|
||||||
|
let mut matrix = skia_safe::Matrix::new_identity();
|
||||||
|
matrix.set_scale_translate((s, s), (top_left.x, top_left.y));
|
||||||
|
let path = path.with_transform(&matrix);
|
||||||
|
let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
|
||||||
|
paint.set_stroke(true);
|
||||||
|
paint.set_stroke_width(width);
|
||||||
|
paint.set_anti_alias(true);
|
||||||
|
paint.set_stroke_cap(skia_safe::PaintCap::Round);
|
||||||
|
paint.set_stroke_join(skia_safe::PaintJoin::Round);
|
||||||
|
canvas.draw_path(&path, &paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill an SVG path scaled from `viewbox x viewbox` to
|
||||||
|
/// `size x size`. The parsed base path is cached so Figma imports
|
||||||
|
/// with many vector paths do not re-parse every paint frame.
|
||||||
|
pub fn fill_svg_path(
|
||||||
|
&mut self,
|
||||||
|
canvas: &skia_safe::Canvas,
|
||||||
|
d: &str,
|
||||||
|
top_left: Point2D,
|
||||||
|
size: f32,
|
||||||
|
viewbox: f32,
|
||||||
|
color: Color,
|
||||||
|
) {
|
||||||
|
let Some((path_key, path, even_odd)) = self.cached_svg_path(d) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(raster) = self.cached_raster_svg_path(SvgRasterRequest {
|
||||||
|
path_key,
|
||||||
|
d_len: d.len(),
|
||||||
|
path: &path,
|
||||||
|
even_odd,
|
||||||
|
size,
|
||||||
|
viewbox,
|
||||||
|
color,
|
||||||
|
}) {
|
||||||
|
let dst = Rect {
|
||||||
|
origin: Point2D::new(top_left.x + raster.offset.x, top_left.y + raster.offset.y),
|
||||||
|
size: raster.size,
|
||||||
|
};
|
||||||
|
let mut paint = skia_safe::Paint::default();
|
||||||
|
paint.set_anti_alias(true);
|
||||||
|
canvas.draw_image_rect(&raster.image, None, to_sk_rect(dst), &paint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let s = size / viewbox;
|
||||||
|
let mut matrix = skia_safe::Matrix::new_identity();
|
||||||
|
matrix.set_scale_translate((s, s), (top_left.x, top_left.y));
|
||||||
|
let mut path = path.with_transform(&matrix);
|
||||||
|
if even_odd {
|
||||||
|
path.set_fill_type(skia_safe::PathFillType::EvenOdd);
|
||||||
|
}
|
||||||
|
let mut paint = skia_safe::Paint::new(jian_color_to_color4f(color), None);
|
||||||
|
paint.set_anti_alias(true);
|
||||||
|
canvas.draw_path(&path, &paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn svg_path_cache_len(&self) -> usize {
|
||||||
|
self.svg_path_cache.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn svg_raster_cache_len(&self) -> usize {
|
||||||
|
self.svg_raster_cache.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn svg_path_key(d: &str) -> u64 {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
d.hash(&mut hasher);
|
||||||
|
hasher.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_multiple_close_commands(d: &str) -> bool {
|
||||||
|
let mut closes = 0;
|
||||||
|
for byte in d.bytes() {
|
||||||
|
if byte == b'Z' || byte == b'z' {
|
||||||
|
closes += 1;
|
||||||
|
if closes > 1 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_key(c: Color) -> u32 {
|
||||||
|
fn ch(v: f32) -> u32 {
|
||||||
|
(v.clamp(0.0, 1.0) * 255.0).round() as u32
|
||||||
|
}
|
||||||
|
(ch(c.r) << 24) | (ch(c.g) << 16) | (ch(c.b) << 8) | ch(c.a)
|
||||||
|
}
|
||||||
|
|
@ -149,6 +149,36 @@ fn explicit_family_typeface_lookup_is_cached() {
|
||||||
assert_eq!(be.family_typeface_cache_len(), 1);
|
assert_eq!(be.family_typeface_cache_len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn svg_path_cache_reuses_parsed_paths() {
|
||||||
|
let mut be = NativeBackend::with_dpi(1.0);
|
||||||
|
let mut surface = skia_safe::surfaces::raster_n32_premul((32, 32)).unwrap();
|
||||||
|
let canvas = surface.canvas();
|
||||||
|
let d = "M0 0 L10 0 L10 10 Z";
|
||||||
|
|
||||||
|
be.fill_svg_path(canvas, d, Point2D::ZERO, 1.0, 1.0, Color::BLACK);
|
||||||
|
assert_eq!(be.svg_path_cache_len(), 1);
|
||||||
|
|
||||||
|
be.fill_svg_path(canvas, d, Point2D::new(4.0, 4.0), 2.0, 1.0, Color::RED);
|
||||||
|
assert_eq!(be.svg_path_cache_len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complex_svg_fill_uses_raster_cache_after_first_paint() {
|
||||||
|
let mut be = NativeBackend::with_dpi(1.0);
|
||||||
|
let mut surface = skia_safe::surfaces::raster_n32_premul((128, 128)).unwrap();
|
||||||
|
let canvas = surface.canvas();
|
||||||
|
let d = format!("M0 0 L64 0 L64 64 L0 64 Z{}", " ".repeat(4096));
|
||||||
|
|
||||||
|
be.fill_svg_path(canvas, &d, Point2D::ZERO, 1.0, 1.0, Color::BLACK);
|
||||||
|
assert_eq!(be.svg_path_cache_len(), 1);
|
||||||
|
assert_eq!(be.svg_raster_cache_len(), 1);
|
||||||
|
|
||||||
|
be.fill_svg_path(canvas, &d, Point2D::new(8.0, 8.0), 1.0, 1.0, Color::BLACK);
|
||||||
|
assert_eq!(be.svg_path_cache_len(), 1);
|
||||||
|
assert_eq!(be.svg_raster_cache_len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode a solid raster surface to PNG bytes — a real image for
|
/// Encode a solid raster surface to PNG bytes — a real image for
|
||||||
/// the decode-cache test (no hardcoded blob).
|
/// the decode-cache test (no hardcoded blob).
|
||||||
fn encode_test_png(w: i32, h: i32) -> Vec<u8> {
|
fn encode_test_png(w: i32, h: i32) -> Vec<u8> {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ mod click;
|
||||||
mod color_picker_press;
|
mod color_picker_press;
|
||||||
mod component_browser_press;
|
mod component_browser_press;
|
||||||
mod design_md_press;
|
mod design_md_press;
|
||||||
|
#[cfg(test)]
|
||||||
|
mod figma_import_tests;
|
||||||
mod frame_backend;
|
mod frame_backend;
|
||||||
mod geometry;
|
mod geometry;
|
||||||
mod git_press;
|
mod git_press;
|
||||||
|
|
@ -573,6 +575,29 @@ impl WidgetHostNative {
|
||||||
self.editor_state_dirty = true;
|
self.editor_state_dirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install a Figma-imported editor state. The worker only parses
|
||||||
|
/// into canonical data; layout scene construction stays on the
|
||||||
|
/// normal host path so the worker never touches Skia / FontMgr.
|
||||||
|
pub fn install_imported_state(&mut self, mut state: op_editor_core::EditorState) {
|
||||||
|
let mut preserved = self.editor_state.editor_ui.clone();
|
||||||
|
preserved.figma_import_in_progress = false;
|
||||||
|
preserved.file_name_display = state.editor_ui.file_name_display.take();
|
||||||
|
preserved.preserve_authored_geometry = state.editor_ui.preserve_authored_geometry;
|
||||||
|
state.editor_ui = preserved;
|
||||||
|
|
||||||
|
let old_state = std::mem::replace(&mut self.editor_state, state);
|
||||||
|
let old_scene = std::mem::take(&mut self.layout_scene);
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("op-import-drop".into())
|
||||||
|
.spawn(move || {
|
||||||
|
drop(old_state);
|
||||||
|
drop(old_scene);
|
||||||
|
})
|
||||||
|
.expect("spawn op-import-drop worker");
|
||||||
|
|
||||||
|
self.editor_state_dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain a queued Component-Browser insert: place the chosen
|
/// Drain a queued Component-Browser insert: place the chosen
|
||||||
/// UIKit component at the viewport's centre (top-left = centre −
|
/// UIKit component at the viewport's centre (top-left = centre −
|
||||||
/// half the component's size) and call
|
/// half the component's size) and call
|
||||||
|
|
|
||||||
28
crates/op-host-native/src/widget_host/figma_import_tests.rs
Normal file
28
crates/op-host-native/src/widget_host/figma_import_tests.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
use super::WidgetHostNative;
|
||||||
|
use op_editor_core::{EditorState, Locale};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn install_imported_state_preserves_live_ui_and_defers_layout() {
|
||||||
|
let mut host = WidgetHostNative::new();
|
||||||
|
host.editor_state.editor_ui.locale = Locale::ZhCn;
|
||||||
|
host.editor_state.editor_ui.sidebar_open = false;
|
||||||
|
host.editor_state.editor_ui.figma_import_in_progress = true;
|
||||||
|
host.editor_state_dirty = false;
|
||||||
|
|
||||||
|
let mut imported = EditorState::new();
|
||||||
|
imported.editor_ui.file_name_display = Some("Dashboard.fig".into());
|
||||||
|
imported.editor_ui.locale = Locale::EnUs;
|
||||||
|
imported.editor_ui.sidebar_open = true;
|
||||||
|
|
||||||
|
host.install_imported_state(imported);
|
||||||
|
|
||||||
|
assert_eq!(host.editor_state.editor_ui.locale, Locale::ZhCn);
|
||||||
|
assert!(!host.editor_state.editor_ui.sidebar_open);
|
||||||
|
assert!(!host.editor_state.editor_ui.figma_import_in_progress);
|
||||||
|
assert_eq!(
|
||||||
|
host.editor_state.editor_ui.file_name_display.as_deref(),
|
||||||
|
Some("Dashboard.fig")
|
||||||
|
);
|
||||||
|
assert!(host.editor_state_dirty);
|
||||||
|
assert!(host.layout_scene.pages.is_empty());
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,33 @@ impl WidgetHostNative {
|
||||||
|
|
||||||
let dpi = frame.dpi_scale();
|
let dpi = frame.dpi_scale();
|
||||||
|
|
||||||
|
// During Figma import, keep the frame path independent from
|
||||||
|
// document layout/canvas paint. The parser can be CPU-heavy;
|
||||||
|
// rebuilding or painting the old scene here makes the loading
|
||||||
|
// overlay appear frozen.
|
||||||
|
if self.editor_state.editor_ui.figma_import_in_progress {
|
||||||
|
use op_editor_ui::widgets::figma_import_progress::FigmaImportProgressOverlay;
|
||||||
|
frame.fill_rect(
|
||||||
|
Rect {
|
||||||
|
origin: Point2D::new(0.0, 0.0),
|
||||||
|
size: Point2D::new(viewport_width, viewport_height),
|
||||||
|
},
|
||||||
|
op_editor_ui::Color {
|
||||||
|
r: 0.0,
|
||||||
|
g: 0.0,
|
||||||
|
b: 0.0,
|
||||||
|
a: 0.55,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let overlay = FigmaImportProgressOverlay::for_editor(&self.editor_state, self.now_ms);
|
||||||
|
let rect = overlay.rect(viewport_width, viewport_height);
|
||||||
|
let mut cx = PaintCx {
|
||||||
|
backend: &mut *frame,
|
||||||
|
};
|
||||||
|
overlay.paint(&mut cx, rect);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Rebuild the layout-resolved render scene ONCE for the whole
|
// Rebuild the layout-resolved render scene ONCE for the whole
|
||||||
// paint pass. Every widget builder below reads `editor_state`
|
// paint pass. Every widget builder below reads `editor_state`
|
||||||
// directly; the canvas reads `self.layout_scene`.
|
// directly; the canvas reads `self.layout_scene`.
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,42 @@ pub fn pen_document_to_payload(doc: &PenDocument) -> LoadedDoc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert a document that already carries authored absolute/parent
|
||||||
|
/// geometry into payloads without running the flex/text layout pass.
|
||||||
|
///
|
||||||
|
/// Figma `.fig` import uses this after parsing in Preserve mode: all
|
||||||
|
/// nodes have numeric sizes and parent-local positions from Figma, so
|
||||||
|
/// re-running jian layout only burns time and can visibly freeze the
|
||||||
|
/// UI after the import worker finishes.
|
||||||
|
pub fn pen_document_to_payload_preserving_geometry(doc: &PenDocument) -> LoadedDoc {
|
||||||
|
let pages: Vec<PagePayload> = if let Some(pages) = &doc.pages {
|
||||||
|
pages
|
||||||
|
.iter()
|
||||||
|
.map(|p| build_page_preserving_geometry(&p.id, &p.name, &p.children))
|
||||||
|
.collect()
|
||||||
|
} else if !doc.children.is_empty() {
|
||||||
|
vec![build_page_preserving_geometry(
|
||||||
|
"page-1",
|
||||||
|
doc.name.as_deref().unwrap_or("Page 1"),
|
||||||
|
&doc.children,
|
||||||
|
)]
|
||||||
|
} else {
|
||||||
|
vec![PagePayload {
|
||||||
|
id: "n1".to_string(),
|
||||||
|
name: "Page 1".into(),
|
||||||
|
children: Vec::new(),
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
LoadedDoc {
|
||||||
|
payload: DocPayload {
|
||||||
|
version: 1,
|
||||||
|
active_page_index: 0,
|
||||||
|
pages,
|
||||||
|
var_table: crate::variables::VarTablePayload::default(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy `PenDocument.variables` + `.themes` into a shell-core
|
/// Copy `PenDocument.variables` + `.themes` into a shell-core
|
||||||
/// `VariableTable`. Caller assigns the result to `Document.var_table`
|
/// `VariableTable`. Caller assigns the result to `Document.var_table`
|
||||||
/// AFTER `apply_payload` (which clears it via Default). Lossless on
|
/// AFTER `apply_payload` (which clears it via Default). Lossless on
|
||||||
|
|
@ -165,6 +201,15 @@ fn build_page(id: &str, name: &str, roots: &[PenNode], page_idx: usize) -> PageP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_page_preserving_geometry(id: &str, name: &str, roots: &[PenNode]) -> PagePayload {
|
||||||
|
let rects = crate::authored_geometry::rects_for_roots(roots);
|
||||||
|
PagePayload {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
children: roots.iter().map(|n| node_to_payload(n, &rects)).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run jian-core's `LayoutEngine` on `root` and harvest absolute
|
/// Run jian-core's `LayoutEngine` on `root` and harvest absolute
|
||||||
/// rects per schema id into `out`. Each page root gets its own
|
/// rects per schema id into `out`. Each page root gets its own
|
||||||
/// `LayoutEngine` instance — OpenPencil's canvas is infinite, so
|
/// `LayoutEngine` instance — OpenPencil's canvas is infinite, so
|
||||||
|
|
@ -286,6 +331,11 @@ fn node_to_payload(node: &PenNode, rects: &BTreeMap<String, [f32; 4]>) -> NodePa
|
||||||
// overwrite its hand-encoded geometry with the taffy AABB.
|
// overwrite its hand-encoded geometry with the taffy AABB.
|
||||||
if !matches!(node, PenNode::Line(_)) {
|
if !matches!(node, PenNode::Line(_)) {
|
||||||
apply_computed_rect(&mut p, rects);
|
apply_computed_rect(&mut p, rects);
|
||||||
|
} else if let Some([x, y, w, h]) = rects.get(&p.schema_id).copied() {
|
||||||
|
if w.is_nan() && h.is_nan() {
|
||||||
|
p.x = x;
|
||||||
|
p.y = y;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Canonical `PathNode.anchors` need the same transform the TS
|
// Canonical `PathNode.anchors` need the same transform the TS
|
||||||
// renderer applies in `pen-renderer/node-renderer.ts::drawPath`:
|
// renderer applies in `pen-renderer/node-renderer.ts::drawPath`:
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,38 @@ fn load(src: &str) -> LoadedDoc {
|
||||||
pen_document_to_payload(&r.value)
|
pen_document_to_payload(&r.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preserving_geometry_keeps_authored_nested_positions() {
|
||||||
|
let src = r##"{
|
||||||
|
"version":"1.0.0",
|
||||||
|
"pages":[{
|
||||||
|
"id":"p1","name":"Page 1",
|
||||||
|
"children":[{
|
||||||
|
"type":"frame","id":"root","x":100,"y":200,"width":300,"height":200,
|
||||||
|
"layout":"horizontal","gap":99,
|
||||||
|
"children":[
|
||||||
|
{"type":"rectangle","id":"r1","x":10,"y":20,"width":30,"height":40,
|
||||||
|
"fill":[{"type":"solid","color":"#000000"}]},
|
||||||
|
{"type":"line","id":"l1","x":5,"y":6,"x2":10,"y2":0}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"children":[]
|
||||||
|
}"##;
|
||||||
|
let r = jian_ops_schema::load_str(src).unwrap();
|
||||||
|
let loaded = pen_document_to_payload_preserving_geometry(&r.value);
|
||||||
|
let root = &loaded.payload.pages[0].children[0];
|
||||||
|
let rect = &root.children[0];
|
||||||
|
let line = &root.children[1];
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
(root.x, root.y, root.w, root.h),
|
||||||
|
(100.0, 200.0, 300.0, 200.0)
|
||||||
|
);
|
||||||
|
assert_eq!((rect.x, rect.y, rect.w, rect.h), (110.0, 220.0, 30.0, 40.0));
|
||||||
|
assert_eq!((line.x, line.y, line.w, line.h), (105.0, 206.0, 10.0, 0.0));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn minimal_empty_doc() {
|
fn minimal_empty_doc() {
|
||||||
let r = load(r#"{"version":"0.8.0","children":[]}"#);
|
let r = load(r#"{"version":"0.8.0","children":[]}"#);
|
||||||
|
|
|
||||||
84
crates/op-pen-loader/src/authored_geometry.rs
Normal file
84
crates/op-pen-loader/src/authored_geometry.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use jian_ops_schema::node::base::PenNodeBase;
|
||||||
|
use jian_ops_schema::node::PenNode;
|
||||||
|
use jian_ops_schema::sizing::SizingBehavior;
|
||||||
|
|
||||||
|
pub fn rects_for_roots(roots: &[PenNode]) -> BTreeMap<String, [f32; 4]> {
|
||||||
|
let mut out = BTreeMap::new();
|
||||||
|
for root in roots {
|
||||||
|
collect_rect(root, 0.0, 0.0, &mut out);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_rect(
|
||||||
|
node: &PenNode,
|
||||||
|
parent_x: f32,
|
||||||
|
parent_y: f32,
|
||||||
|
out: &mut BTreeMap<String, [f32; 4]>,
|
||||||
|
) {
|
||||||
|
let base = base_of(node);
|
||||||
|
let x = parent_x + base.x.unwrap_or(0.0) as f32;
|
||||||
|
let y = parent_y + base.y.unwrap_or(0.0) as f32;
|
||||||
|
let (w, h) = size_of(node);
|
||||||
|
out.insert(base.id.clone(), [x, y, w, h]);
|
||||||
|
for child in children_of(node) {
|
||||||
|
collect_rect(child, x, y, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn size_of(node: &PenNode) -> (f32, f32) {
|
||||||
|
match node {
|
||||||
|
PenNode::Frame(n) => numeric_size(&n.container.width, &n.container.height),
|
||||||
|
PenNode::Group(n) => numeric_size(&n.container.width, &n.container.height),
|
||||||
|
PenNode::Rectangle(n) => numeric_size(&n.container.width, &n.container.height),
|
||||||
|
PenNode::Ellipse(n) => numeric_size(&n.width, &n.height),
|
||||||
|
PenNode::Polygon(n) => numeric_size(&n.width, &n.height),
|
||||||
|
PenNode::Path(n) => numeric_size(&n.width, &n.height),
|
||||||
|
PenNode::Text(n) => numeric_size(&n.width, &n.height),
|
||||||
|
PenNode::TextInput(n) => numeric_size(&n.width, &n.height),
|
||||||
|
PenNode::Image(n) => numeric_size(&n.width, &n.height),
|
||||||
|
PenNode::IconFont(n) => numeric_size(&n.width, &n.height),
|
||||||
|
PenNode::Line(_) => (f32::NAN, f32::NAN),
|
||||||
|
PenNode::Ref(_) => (0.0, 0.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numeric_size(w: &Option<SizingBehavior>, h: &Option<SizingBehavior>) -> (f32, f32) {
|
||||||
|
(numeric(w), numeric(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn numeric(value: &Option<SizingBehavior>) -> f32 {
|
||||||
|
match value {
|
||||||
|
Some(SizingBehavior::Number(n)) => *n as f32,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn children_of(node: &PenNode) -> &[PenNode] {
|
||||||
|
match node {
|
||||||
|
PenNode::Frame(n) => n.children.as_deref().unwrap_or(&[]),
|
||||||
|
PenNode::Group(n) => n.children.as_deref().unwrap_or(&[]),
|
||||||
|
PenNode::Rectangle(n) => n.children.as_deref().unwrap_or(&[]),
|
||||||
|
PenNode::Ref(n) => n.children.as_deref().unwrap_or(&[]),
|
||||||
|
_ => &[],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_of(node: &PenNode) -> &PenNodeBase {
|
||||||
|
match node {
|
||||||
|
PenNode::Frame(n) => &n.base,
|
||||||
|
PenNode::Group(n) => &n.base,
|
||||||
|
PenNode::Rectangle(n) => &n.base,
|
||||||
|
PenNode::Ellipse(n) => &n.base,
|
||||||
|
PenNode::Line(n) => &n.base,
|
||||||
|
PenNode::Polygon(n) => &n.base,
|
||||||
|
PenNode::Path(n) => &n.base,
|
||||||
|
PenNode::Text(n) => &n.base,
|
||||||
|
PenNode::TextInput(n) => &n.base,
|
||||||
|
PenNode::Image(n) => &n.base,
|
||||||
|
PenNode::IconFont(n) => &n.base,
|
||||||
|
PenNode::Ref(n) => &n.base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,8 +23,8 @@
|
||||||
|
|
||||||
use op_editor_ui::layout_scene::NodeKind;
|
use op_editor_ui::layout_scene::NodeKind;
|
||||||
use op_editor_ui::layout_scene::{
|
use op_editor_ui::layout_scene::{
|
||||||
LayoutScene, SceneFillType, SceneGradient, SceneGradientStop, SceneImageFit, SceneNode,
|
stable_image_source_id, LayoutScene, SceneFillType, SceneGradient, SceneGradientStop,
|
||||||
ScenePage, SceneStroke, SceneTextAlign, SceneTextVerticalAlign,
|
SceneImageFit, SceneNode, ScenePage, SceneStroke, SceneTextAlign, SceneTextVerticalAlign,
|
||||||
};
|
};
|
||||||
use op_editor_ui::scene_vars::VariableTable;
|
use op_editor_ui::scene_vars::VariableTable;
|
||||||
use op_editor_ui::Color;
|
use op_editor_ui::Color;
|
||||||
|
|
@ -47,7 +47,11 @@ pub fn editor_state_to_layout_scene(state: &op_editor_core::EditorState) -> Layo
|
||||||
// every `NodePayload`'s AABB by jian-core's `LayoutEngine`. This
|
// every `NodePayload`'s AABB by jian-core's `LayoutEngine`. This
|
||||||
// is the reusable layout-resolution core; it never touches the
|
// is the reusable layout-resolution core; it never touches the
|
||||||
// shell-core `Document` model.
|
// shell-core `Document` model.
|
||||||
let payload: DocPayload = crate::adapter::pen_document_to_payload(&state.doc).payload;
|
let payload: DocPayload = if state.editor_ui.preserve_authored_geometry {
|
||||||
|
crate::adapter::pen_document_to_payload_preserving_geometry(&state.doc).payload
|
||||||
|
} else {
|
||||||
|
crate::adapter::pen_document_to_payload(&state.doc).payload
|
||||||
|
};
|
||||||
// Variables + active theme + the `fill_refs` / `stroke_refs`
|
// Variables + active theme + the `fill_refs` / `stroke_refs`
|
||||||
// caches the editor holds. `editor_state_var_table` folds the
|
// caches the editor holds. `editor_state_var_table` folds the
|
||||||
// persisted definitions and the transient `EditorState.ui`
|
// persisted definitions and the transient `EditorState.ui`
|
||||||
|
|
@ -133,6 +137,11 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene
|
||||||
arc_inner_radius: node.arc_inner_radius,
|
arc_inner_radius: node.arc_inner_radius,
|
||||||
polygon_sides: node.polygon_sides.clamp(3, 100),
|
polygon_sides: node.polygon_sides.clamp(3, 100),
|
||||||
image_src: node.image_src.clone(),
|
image_src: node.image_src.clone(),
|
||||||
|
image_src_id: node
|
||||||
|
.image_src
|
||||||
|
.as_deref()
|
||||||
|
.map(stable_image_source_id)
|
||||||
|
.unwrap_or(0),
|
||||||
image_fit: image_fit_to_scene(node.image_fit.as_deref()),
|
image_fit: image_fit_to_scene(node.image_fit.as_deref()),
|
||||||
image_adjustments: image_adjustments_to_scene(node.image_adjustments),
|
image_adjustments: image_adjustments_to_scene(node.image_adjustments),
|
||||||
effects: crate::effects::effects_from_payload_ref(&node.effects),
|
effects: crate::effects::effects_from_payload_ref(&node.effects),
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
//! dialogs stay in `openpencil-desktop/src/persistence.rs`.
|
//! dialogs stay in `openpencil-desktop/src/persistence.rs`.
|
||||||
|
|
||||||
mod adapter;
|
mod adapter;
|
||||||
|
mod authored_geometry;
|
||||||
mod effects;
|
mod effects;
|
||||||
mod layout_scene;
|
mod layout_scene;
|
||||||
mod path_bounds;
|
mod path_bounds;
|
||||||
|
|
@ -38,7 +39,10 @@ pub use layout_scene::editor_state_to_layout_scene;
|
||||||
|
|
||||||
// Re-exports so `openpencil-desktop`'s existing call sites change
|
// Re-exports so `openpencil-desktop`'s existing call sites change
|
||||||
// minimally.
|
// minimally.
|
||||||
pub use adapter::{build_var_table, pen_document_to_payload, LoadedDoc};
|
pub use adapter::{
|
||||||
|
build_var_table, pen_document_to_payload, pen_document_to_payload_preserving_geometry,
|
||||||
|
LoadedDoc,
|
||||||
|
};
|
||||||
pub use effects::{
|
pub use effects::{
|
||||||
effects_from_payload, effects_from_payload_ref, effects_to_payload, shadows_from_canonical,
|
effects_from_payload, effects_from_payload_ref, effects_to_payload, shadows_from_canonical,
|
||||||
ShadowPayload,
|
ShadowPayload,
|
||||||
|
|
|
||||||
150
scripts/bundle-macos.sh
Executable file
150
scripts/bundle-macos.sh
Executable file
|
|
@ -0,0 +1,150 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Produce a fully-wired macOS .app bundle for openpencil-desktop.
|
||||||
|
#
|
||||||
|
# `cargo-bundle` 0.10.0 ignores most of our `[package.metadata.bundle]`
|
||||||
|
# entries (CFBundleName / CFBundleIdentifier / CFBundleIconFile come
|
||||||
|
# out blank or set to the package name, Resources/ is never created,
|
||||||
|
# and the UTI-based file-association keys aren't emitted at all). On
|
||||||
|
# macOS 10.10+ the document-binding lookup is UTI-based (.fig files
|
||||||
|
# are tagged `com.figma.document`), so without `LSItemContentTypes` +
|
||||||
|
# an `UTImportedTypeDeclarations` claim the OS will not route .fig
|
||||||
|
# double-clicks to our bundle even if `CFBundleTypeExtensions`
|
||||||
|
# contains "fig".
|
||||||
|
#
|
||||||
|
# This script:
|
||||||
|
# 1. Builds the release binary.
|
||||||
|
# 2. Runs cargo-bundle.
|
||||||
|
# 3. Renames the produced `.app` to `OpenPencil.app`.
|
||||||
|
# 4. Backfills the Info.plist with CFBundleName / CFBundleIdentifier
|
||||||
|
# / CFBundleIconFile / CFBundleShortVersionString /
|
||||||
|
# CFBundleDocumentTypes (with UTI binding) /
|
||||||
|
# UTImportedTypeDeclarations.
|
||||||
|
# 5. Copies the icon into Resources/.
|
||||||
|
# 6. Re-registers the bundle with LaunchServices so Finder picks up
|
||||||
|
# the .fig binding.
|
||||||
|
#
|
||||||
|
# Idempotent: re-running on a refreshed binary overwrites the patched
|
||||||
|
# fields without compounding them (PlistBuddy `Set` replaces; `Add`
|
||||||
|
# would duplicate, so this script clears the entries it rewrites
|
||||||
|
# first).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
APP_VERSION="${OPENPENCIL_VERSION:-0.8.0}"
|
||||||
|
|
||||||
|
# Locate cargo-bundle. Tries PATH first, then a workspace-local
|
||||||
|
# fallback under `target/cargo-bundle-host/bin`. When neither
|
||||||
|
# resolves the script auto-installs into the fallback so a fresh CI
|
||||||
|
# checkout bootstraps without manual intervention. The install uses
|
||||||
|
# a script-local `CARGO_HOME` so the user's Cargo mirror config
|
||||||
|
# (the workspace pins a private USTC mirror) doesn't apply.
|
||||||
|
CARGO_BUNDLE_HOME="${CARGO_BUNDLE_HOME:-$WS_ROOT/target/cargo-bundle-host}"
|
||||||
|
if command -v cargo-bundle >/dev/null 2>&1; then
|
||||||
|
CARGO_BUNDLE=cargo-bundle
|
||||||
|
elif [ -x "$CARGO_BUNDLE_HOME/bin/cargo-bundle" ]; then
|
||||||
|
CARGO_BUNDLE="$CARGO_BUNDLE_HOME/bin/cargo-bundle"
|
||||||
|
else
|
||||||
|
echo "==> bootstrapping cargo-bundle into $CARGO_BUNDLE_HOME"
|
||||||
|
mkdir -p "$CARGO_BUNDLE_HOME"
|
||||||
|
if ! CARGO_HOME="$CARGO_BUNDLE_HOME" cargo install cargo-bundle --locked >&2; then
|
||||||
|
echo "error: failed to install cargo-bundle into $CARGO_BUNDLE_HOME" >&2
|
||||||
|
echo " try a hermetic CARGO_HOME by hand:" >&2
|
||||||
|
echo " CARGO_HOME=$CARGO_BUNDLE_HOME cargo install cargo-bundle --locked" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
CARGO_BUNDLE="$CARGO_BUNDLE_HOME/bin/cargo-bundle"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> building release binary"
|
||||||
|
( cd "$WS_ROOT" && cargo build --release --bin openpencil-desktop )
|
||||||
|
|
||||||
|
echo "==> running cargo-bundle"
|
||||||
|
( cd "$WS_ROOT/crates/op-host-desktop" \
|
||||||
|
&& "$CARGO_BUNDLE" bundle --bin openpencil-desktop --release --format osx )
|
||||||
|
|
||||||
|
OUT_DIR="$WS_ROOT/target/release/bundle/osx"
|
||||||
|
RAW_APP="$OUT_DIR/op-host-desktop.app"
|
||||||
|
APP="$OUT_DIR/OpenPencil.app"
|
||||||
|
|
||||||
|
if [ ! -d "$RAW_APP" ]; then
|
||||||
|
echo "error: cargo-bundle did not produce $RAW_APP" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> renaming to OpenPencil.app"
|
||||||
|
rm -rf "$APP"
|
||||||
|
mv "$RAW_APP" "$APP"
|
||||||
|
|
||||||
|
PLIST="$APP/Contents/Info.plist"
|
||||||
|
|
||||||
|
echo "==> patching Info.plist (name, identifier, icon, doc-types, UTI)"
|
||||||
|
# Set known fields (Set is idempotent).
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleName OpenPencil" "$PLIST"
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName OpenPencil" "$PLIST"
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier com.zseven-w.openpencil" "$PLIST"
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $APP_VERSION" "$PLIST"
|
||||||
|
|
||||||
|
# Clear-and-add the entries cargo-bundle leaves blank / wrong. Suppress
|
||||||
|
# stderr on Delete because the keys may or may not already exist.
|
||||||
|
/usr/libexec/PlistBuddy -c "Delete :CFBundleIconFile" "$PLIST" 2>/dev/null || true
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :CFBundleIconFile string icon.icns" "$PLIST"
|
||||||
|
|
||||||
|
/usr/libexec/PlistBuddy -c "Delete :CFBundleDocumentTypes" "$PLIST" 2>/dev/null || true
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :CFBundleDocumentTypes array" "$PLIST"
|
||||||
|
|
||||||
|
# Group 0: .op / .pen (canonical OpenPencil documents).
|
||||||
|
/usr/libexec/PlistBuddy \
|
||||||
|
-c "Add :CFBundleDocumentTypes:0 dict" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:0:CFBundleTypeName string 'OpenPencil Document'" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:0:CFBundleTypeRole string Editor" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:0:CFBundleTypeExtensions array" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:0:CFBundleTypeExtensions:0 string op" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:0:CFBundleTypeExtensions:1 string pen" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:0:LSHandlerRank string Owner" \
|
||||||
|
"$PLIST"
|
||||||
|
|
||||||
|
# Group 1: .fig (Figma export) — UTI-based binding so Finder routes
|
||||||
|
# double-clicks even though the OS-assigned UTI is com.figma.document.
|
||||||
|
/usr/libexec/PlistBuddy \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1 dict" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1:CFBundleTypeName string 'Figma Document'" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1:CFBundleTypeRole string Editor" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1:CFBundleTypeExtensions array" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1:CFBundleTypeExtensions:0 string fig" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1:LSItemContentTypes array" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1:LSItemContentTypes:0 string com.figma.document" \
|
||||||
|
-c "Add :CFBundleDocumentTypes:1:LSHandlerRank string Alternate" \
|
||||||
|
"$PLIST"
|
||||||
|
|
||||||
|
# UTImportedTypeDeclarations — declare we KNOW about com.figma.document
|
||||||
|
# without forcing Figma.app to be installed. Required so the
|
||||||
|
# LSItemContentTypes claim above resolves on systems without Figma.
|
||||||
|
/usr/libexec/PlistBuddy -c "Delete :UTImportedTypeDeclarations" "$PLIST" 2>/dev/null || true
|
||||||
|
/usr/libexec/PlistBuddy \
|
||||||
|
-c "Add :UTImportedTypeDeclarations array" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0 dict" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeIdentifier string com.figma.document" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeDescription string 'Figma Document'" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeConformsTo array" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeConformsTo:0 string public.data" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeTagSpecification dict" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeTagSpecification:public.filename-extension array" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeTagSpecification:public.filename-extension:0 string fig" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeTagSpecification:public.mime-type array" \
|
||||||
|
-c "Add :UTImportedTypeDeclarations:0:UTTypeTagSpecification:public.mime-type:0 string application/x-figma" \
|
||||||
|
"$PLIST"
|
||||||
|
|
||||||
|
echo "==> copying icon into Resources/"
|
||||||
|
mkdir -p "$APP/Contents/Resources"
|
||||||
|
cp "$WS_ROOT/crates/op-host-desktop/assets/icon.icns" "$APP/Contents/Resources/icon.icns"
|
||||||
|
|
||||||
|
echo "==> registering with LaunchServices"
|
||||||
|
LSREG=/System/Library/Frameworks/CoreServices.framework/Versions/A/Frameworks/LaunchServices.framework/Versions/A/Support/lsregister
|
||||||
|
"$LSREG" -u "$APP" 2>/dev/null || true
|
||||||
|
"$LSREG" -f "$APP" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Bundle ready: $APP"
|
||||||
|
echo " open '$APP' to launch"
|
||||||
|
echo " open -a '$APP' /path/to/file.fig # routes through async Figma import"
|
||||||
159
scripts/probe-fig-ts.ts
Normal file
159
scripts/probe-fig-ts.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
// Drive the TS pen-figma import on a .fig file, then dump a structural
|
||||||
|
// digest so we can diff against the Rust probe_fig output.
|
||||||
|
//
|
||||||
|
// Usage: bun run scripts/probe-fig-ts.ts <path.fig>
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { basename } from 'node:path';
|
||||||
|
import { parseFigFile } from '../packages/pen-figma/src/fig-parser';
|
||||||
|
import {
|
||||||
|
figmaAllPagesToPenDocument,
|
||||||
|
getFigmaPages,
|
||||||
|
} from '../packages/pen-figma/src/figma-node-mapper';
|
||||||
|
import { resolveImageBlobs } from '../packages/pen-figma/src/figma-image-resolver';
|
||||||
|
import type { PenNode } from '../packages/pen-types/src';
|
||||||
|
|
||||||
|
const path = process.argv[2];
|
||||||
|
if (!path) {
|
||||||
|
console.error('usage: bun run scripts/probe-fig-ts.ts <path.fig>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = readFileSync(path);
|
||||||
|
const ab = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
|
||||||
|
|
||||||
|
const decoded = parseFigFile(ab);
|
||||||
|
const pages = getFigmaPages(decoded);
|
||||||
|
const name = basename(path).replace(/\.fig$/, '');
|
||||||
|
const result = figmaAllPagesToPenDocument(decoded, name, 'openpencil');
|
||||||
|
resolveImageBlobs(result.document.pages?.flatMap((p) => p.children) ?? [], result.imageBlobs, decoded.imageFiles);
|
||||||
|
|
||||||
|
if (process.env.OP_DUMP_JSON) {
|
||||||
|
const fs = await import('node:fs');
|
||||||
|
fs.writeFileSync(process.env.OP_DUMP_JSON, JSON.stringify(result.document, null, 2));
|
||||||
|
console.error(`dumped PenDocument JSON to ${process.env.OP_DUMP_JSON}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`input: ${path} (${bytes.length} bytes)`);
|
||||||
|
console.log(`pages: ${pages.length}`);
|
||||||
|
for (let i = 0; i < pages.length; i++) {
|
||||||
|
const p = pages[i];
|
||||||
|
const root = result.document.pages?.[i];
|
||||||
|
console.log(` [${i}] name=${JSON.stringify(p.name)} children=${root?.children.length ?? 0}`);
|
||||||
|
if (root) {
|
||||||
|
const counts = tally(root.children);
|
||||||
|
if (Object.keys(counts).length > 0) {
|
||||||
|
const parts = Object.entries(counts)
|
||||||
|
.sort()
|
||||||
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join(' ');
|
||||||
|
console.log(` top-level tally: ${parts}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`warnings: ${result.warnings.length}`);
|
||||||
|
for (const w of result.warnings.slice(0, 8)) console.log(` - ${w}`);
|
||||||
|
|
||||||
|
// Walk the first page in dfs and print a hash of (id, type, x, y, w, h) so we can compare.
|
||||||
|
// Optional drill into a named subframe via env var OP_PROBE_DRILL.
|
||||||
|
const drillNeedle = process.env.OP_PROBE_DRILL;
|
||||||
|
const firstPage = result.document.pages?.[0];
|
||||||
|
if (firstPage && drillNeedle) {
|
||||||
|
for (const root of firstPage.children) {
|
||||||
|
if (!('children' in root) || !Array.isArray(root.children)) continue;
|
||||||
|
for (const c of root.children) {
|
||||||
|
if ((c.name ?? '').includes(drillNeedle)) {
|
||||||
|
console.log(`── drilling into ${JSON.stringify(c.name)} ──`);
|
||||||
|
console.log(`width=${JSON.stringify((c as any).width)} height=${JSON.stringify((c as any).height)} layout=${JSON.stringify((c as any).layout)} gap=${JSON.stringify((c as any).gap)}`);
|
||||||
|
const kids = (c as any).children ?? [];
|
||||||
|
for (let i = 0; i < kids.length; i++) {
|
||||||
|
const k = kids[i];
|
||||||
|
console.log(
|
||||||
|
` [${i}] ${(k.name ?? '').slice(0, 24).padEnd(24)} ${k.type} x=${k.x ?? 0} y=${k.y ?? 0} w=${JSON.stringify((k as any).width)} h=${JSON.stringify((k as any).height)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (firstPage) {
|
||||||
|
const digest = digestTree(firstPage.children);
|
||||||
|
console.log(`first-page digest: ${digest}`);
|
||||||
|
const total = countDeep(firstPage.children);
|
||||||
|
console.log(`first-page deep node count: ${total}`);
|
||||||
|
const textNodes = collectText(firstPage.children);
|
||||||
|
console.log(`first-page text node count: ${textNodes.length}`);
|
||||||
|
const overlaps = findCoLocatedTexts(textNodes);
|
||||||
|
console.log(`first-page co-located-text clusters (≥2 nodes sharing x,y): ${overlaps.length}`);
|
||||||
|
for (const c of overlaps.slice(0, 10)) {
|
||||||
|
console.log(` - at (${c.x}, ${c.y}): ${c.texts.slice(0, 4).map((t) => JSON.stringify(t)).join(' | ')}${c.texts.length > 4 ? ' …' : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tally(nodes: PenNode[]): Record<string, number> {
|
||||||
|
const r: Record<string, number> = {};
|
||||||
|
for (const n of nodes) r[n.type] = (r[n.type] ?? 0) + 1;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countDeep(nodes: PenNode[]): number {
|
||||||
|
let n = nodes.length;
|
||||||
|
for (const c of nodes) {
|
||||||
|
if ('children' in c && Array.isArray(c.children)) n += countDeep(c.children);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function digestTree(nodes: PenNode[]): string {
|
||||||
|
let h = 0;
|
||||||
|
function go(arr: PenNode[]) {
|
||||||
|
for (const c of arr) {
|
||||||
|
const sig = `${c.type}|${c.x ?? 0}|${c.y ?? 0}|${('width' in c ? JSON.stringify(c.width) : '')}|${('height' in c ? JSON.stringify(c.height) : '')}`;
|
||||||
|
for (let i = 0; i < sig.length; i++) {
|
||||||
|
h = (h * 31 + sig.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
if ('children' in c && Array.isArray(c.children)) go(c.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go(nodes);
|
||||||
|
return (h >>> 0).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectText(nodes: PenNode[]): Array<{ x: number; y: number; text: string }> {
|
||||||
|
const out: Array<{ x: number; y: number; text: string }> = [];
|
||||||
|
function go(arr: PenNode[]) {
|
||||||
|
for (const c of arr) {
|
||||||
|
if (c.type === 'text') {
|
||||||
|
const text = typeof c.content === 'string'
|
||||||
|
? c.content
|
||||||
|
: Array.isArray(c.content)
|
||||||
|
? c.content.map((s) => s.text).join('')
|
||||||
|
: '';
|
||||||
|
out.push({ x: c.x ?? 0, y: c.y ?? 0, text });
|
||||||
|
}
|
||||||
|
if ('children' in c && Array.isArray(c.children)) go(c.children);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go(nodes);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findCoLocatedTexts(
|
||||||
|
texts: Array<{ x: number; y: number; text: string }>,
|
||||||
|
): Array<{ x: number; y: number; texts: string[] }> {
|
||||||
|
const m = new Map<string, string[]>();
|
||||||
|
for (const t of texts) {
|
||||||
|
const k = `${Math.round(t.x)},${Math.round(t.y)}`;
|
||||||
|
if (!m.has(k)) m.set(k, []);
|
||||||
|
m.get(k)!.push(t.text);
|
||||||
|
}
|
||||||
|
const out: Array<{ x: number; y: number; texts: string[] }> = [];
|
||||||
|
for (const [k, v] of m.entries()) {
|
||||||
|
if (v.length >= 2) {
|
||||||
|
const [xs, ys] = k.split(',');
|
||||||
|
out.push({ x: parseInt(xs), y: parseInt(ys), texts: v });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
85
scripts/probe-layout.ts
Normal file
85
scripts/probe-layout.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
/* eslint-disable no-console */
|
||||||
|
// Drive `computeLayoutPositions` directly on the 5-card horizontal
|
||||||
|
// frame from /tmp/op-figma-ts.json and print the children x/y AFTER
|
||||||
|
// layout, so we can compare with authored values.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { computeLayoutPositions } from '../packages/pen-core/src/layout/engine';
|
||||||
|
import type { PenNode } from '../packages/pen-types/src';
|
||||||
|
|
||||||
|
const doc = JSON.parse(readFileSync('/tmp/op-figma-ts.json', 'utf8'));
|
||||||
|
|
||||||
|
function find5CardFrame(node: PenNode): PenNode | null {
|
||||||
|
if (
|
||||||
|
node.type === 'frame' &&
|
||||||
|
(node as any).layout === 'horizontal' &&
|
||||||
|
Array.isArray((node as any).children)
|
||||||
|
) {
|
||||||
|
const flat: string[] = [];
|
||||||
|
function go(x: any): void {
|
||||||
|
if (x?.type === 'text') {
|
||||||
|
const t =
|
||||||
|
typeof x.content === 'string'
|
||||||
|
? x.content
|
||||||
|
: Array.isArray(x.content)
|
||||||
|
? x.content.map((s: any) => s.text).join('')
|
||||||
|
: '';
|
||||||
|
flat.push(t);
|
||||||
|
}
|
||||||
|
if (x?.children) for (const c of x.children) go(c);
|
||||||
|
}
|
||||||
|
for (const c of (node as any).children) go(c);
|
||||||
|
if (flat.includes('一级高风险') && flat.includes('其他')) return node;
|
||||||
|
}
|
||||||
|
if ('children' in (node as any)) {
|
||||||
|
for (const c of (node as any).children ?? []) {
|
||||||
|
const r = find5CardFrame(c);
|
||||||
|
if (r) return r;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parent: PenNode | null = null;
|
||||||
|
for (const p of doc.pages ?? []) {
|
||||||
|
for (const c of p.children) {
|
||||||
|
if (!parent) parent = find5CardFrame(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!parent) {
|
||||||
|
console.error('not found');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const kids = (parent as any).children;
|
||||||
|
console.log('AUTHORED:');
|
||||||
|
kids.forEach((c: any, i: number) => {
|
||||||
|
const t = (() => {
|
||||||
|
const out: string[] = [];
|
||||||
|
function go(x: any): void {
|
||||||
|
if (x?.type === 'text') {
|
||||||
|
out.push(typeof x.content === 'string' ? x.content : Array.isArray(x.content) ? x.content.map((s: any) => s.text).join('') : '');
|
||||||
|
}
|
||||||
|
if (x?.children) for (const c of x.children) go(c);
|
||||||
|
}
|
||||||
|
go(c);
|
||||||
|
return out[0] ?? '';
|
||||||
|
})();
|
||||||
|
console.log(` [${i}] ${c.type} x=${c.x} y=${c.y} w=${JSON.stringify((c as any).width)} text=${JSON.stringify(t)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\nAFTER computeLayoutPositions:');
|
||||||
|
const after = computeLayoutPositions(parent, kids);
|
||||||
|
after.forEach((c: any, i: number) => {
|
||||||
|
const t = (() => {
|
||||||
|
const out: string[] = [];
|
||||||
|
function go(x: any): void {
|
||||||
|
if (x?.type === 'text') {
|
||||||
|
out.push(typeof x.content === 'string' ? x.content : Array.isArray(x.content) ? x.content.map((s: any) => s.text).join('') : '');
|
||||||
|
}
|
||||||
|
if (x?.children) for (const c of x.children) go(c);
|
||||||
|
}
|
||||||
|
go(c);
|
||||||
|
return out[0] ?? '';
|
||||||
|
})();
|
||||||
|
console.log(` [${i}] ${c.type} x=${c.x} y=${c.y} w=${JSON.stringify((c as any).width)} text=${JSON.stringify(t)}`);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue