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

This commit is contained in:
Kayshen-X 2026-05-27 21:51:57 +08:00
parent f547fe1737
commit 2ab2a86fdc
39 changed files with 4364 additions and 294 deletions

View file

@ -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 {

View file

@ -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,

View file

@ -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;

View 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);
}
}

View file

@ -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,

View 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))
}

View file

@ -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(

View file

@ -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;

View 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
}

View 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, "&lt;!--(figmeta)--&gt;", "&lt;!--(figmeta)--&gt;"),
scan_between(html, "&lt;!--(figma)--&gt;", "&lt;!--(figma)--&gt;"),
) {
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 (`&amp;`, `&lt;`, `&gt;`, `&quot;`, `&nbsp;`).
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 [
("&amp;", '&'),
("&lt;", '<'),
("&gt;", '>'),
("&quot;", '"'),
("&nbsp;", ' '),
] {
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;

View 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("&amp;&lt;&gt;&quot;&nbsp;"), "&<>\" ");
assert_eq!(decode_html_entities("&#65;&#x42;"), "AB");
assert_eq!(
decode_html_entities("plain text with &amp; 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"),
}
}
}

View file

@ -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.

View file

@ -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;

View 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();
}

View file

@ -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 13 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());
} }
} }

View 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;

View file

@ -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;

View file

@ -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")

View file

@ -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,
} }

View file

@ -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

View file

@ -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 => {}
} }

View 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);
}

View file

@ -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

View file

@ -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 23 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::*;

View file

@ -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) {

View 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
)
}

View 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)
}

View file

@ -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> {

View file

@ -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

View 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());
}

View file

@ -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`.

View file

@ -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`:

View file

@ -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":[]}"#);

View 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,
}
}

View file

@ -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),

View file

@ -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
View 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
View 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
View 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)}`);
});