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