mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 03:01:11 +00:00
Merge main into #2304
This commit is contained in:
@@ -2,15 +2,21 @@
|
||||
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
|
||||
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// The result of one `figcraft-cli run` step (script file `name`); `opt_out` sets the environment
|
||||
/// variable.
|
||||
fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
|
||||
static NEXT_SCRIPT: AtomicU64 = AtomicU64::new(0);
|
||||
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let script = d.join(format!("{name}.json"));
|
||||
// Tests in this binary run concurrently. `sketch_imports_keep_to_no_system_fonts` also calls
|
||||
// `families`, so a shared `families.json` could be truncated while another child reads it;
|
||||
// an empty script is valid and makes the CLI exit successfully without printing result JSON.
|
||||
let sequence = NEXT_SCRIPT.fetch_add(1, Ordering::Relaxed);
|
||||
let script = d.join(format!("{name}-{sequence}.json"));
|
||||
std::fs::write(&script, json!([step]).to_string()).unwrap();
|
||||
let mut cmd = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"));
|
||||
cmd.arg("run").arg(&script).args(args);
|
||||
@@ -20,8 +26,21 @@ fn run_one(name: &str, step: Value, args: &[&str], opt_out: bool) -> Value {
|
||||
cmd.env_remove("FIGCRAFT_NO_SYSTEM_FONTS");
|
||||
}
|
||||
let out = cmd.output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let line: Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"{name}: CLI status {:?}; stdout={:?}; stderr={}",
|
||||
out.status.code(),
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
);
|
||||
let line: Value = serde_json::from_slice(&out.stdout).unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"{name} {:?}: CLI returned success without JSON: {error}; stdout={:?}; stderr={}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr),
|
||||
)
|
||||
});
|
||||
line["result"].clone()
|
||||
}
|
||||
|
||||
|
||||
@@ -329,14 +329,22 @@ pub(super) fn paste_over(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let sel = s.selection();
|
||||
let page = s.page()?;
|
||||
let doc = s.doc()?;
|
||||
let (parent, at) = match sel.first() {
|
||||
let (parent, at, above) = match sel.first() {
|
||||
Some(i) => {
|
||||
let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page);
|
||||
(parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0)))
|
||||
(parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0)), doc.index_in_parent(*i).map(|ix| ix + 1))
|
||||
}
|
||||
None => (page, None),
|
||||
None => (page, None, None),
|
||||
};
|
||||
let ids = paste_into(s, parent, at)?;
|
||||
if let Some(above) = above {
|
||||
let doc = s.doc_mut()?;
|
||||
for (k, i) in
|
||||
ids.iter().filter(|i| doc.node(**i).is_some_and(|n| n.parent == Some(parent))).copied().collect::<Vec<_>>().into_iter().enumerate()
|
||||
{
|
||||
doc.reorder(i, above + k)?;
|
||||
}
|
||||
}
|
||||
s.set_selection(ids.clone());
|
||||
Ok(json!({"ids": ids}))
|
||||
}
|
||||
|
||||
@@ -283,25 +283,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| {
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.bounds_of(&ds.selection) {
|
||||
ds.view.fit(b, 256.0);
|
||||
ds.view.fit_selection(b, 256.0);
|
||||
}
|
||||
Ok(json!({"zoom": ds.view.zoom}))
|
||||
}),
|
||||
spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| {
|
||||
let v = view(s)?;
|
||||
if let Some(z) = num(p, "zoom") {
|
||||
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
||||
spec!(
|
||||
"view.set",
|
||||
"Set view",
|
||||
"{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size), bottomInset? (height of the strip the bottom toolbar covers)}",
|
||||
false,
|
||||
|s, p| {
|
||||
let v = view(s)?;
|
||||
if let Some(z) = num(p, "zoom") {
|
||||
v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM);
|
||||
}
|
||||
v.cx = num_or(p, "cx", v.cx);
|
||||
v.cy = num_or(p, "cy", v.cy);
|
||||
if let Some(w) = num(p, "vw") {
|
||||
v.vw = w.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(h) = num(p, "vh") {
|
||||
v.vh = h.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(i) = num(p, "bottomInset") {
|
||||
v.bottom_inset = i.clamp(0.0, 20000.0);
|
||||
}
|
||||
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
||||
}
|
||||
v.cx = num_or(p, "cx", v.cx);
|
||||
v.cy = num_or(p, "cy", v.cy);
|
||||
if let Some(w) = num(p, "vw") {
|
||||
v.vw = w.clamp(10.0, 20000.0);
|
||||
}
|
||||
if let Some(h) = num(p, "vh") {
|
||||
v.vh = h.clamp(10.0, 20000.0);
|
||||
}
|
||||
Ok(serde_json::to_value(&*v).unwrap_or(Value::Null))
|
||||
}),
|
||||
),
|
||||
spec!(
|
||||
"view.zoomAt",
|
||||
"Zoom tool",
|
||||
|
||||
@@ -345,6 +345,24 @@ fn copy_paste_and_duplicate() {
|
||||
assert_eq!(d["ids"].as_array().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_over_puts_the_copies_just_above_the_selected_layer() {
|
||||
let mut s = sess();
|
||||
let frame = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap());
|
||||
let layer =
|
||||
|s: &mut Session, x: f64| id(&s.execute("shape.rectangle", &json!({"x": x, "y": 10, "width": 10, "height": 10, "parent": frame})).unwrap());
|
||||
let (a, b, c) = (layer(&mut s, 10.0), layer(&mut s, 30.0), layer(&mut s, 50.0));
|
||||
let kids = |s: &Session| s.doc().unwrap().node(frame).unwrap().children.clone();
|
||||
assert_eq!(kids(&s), vec![a, b, c]);
|
||||
s.execute("edit.copy", &json!({"ids": [b, c]})).unwrap();
|
||||
s.set_selection(vec![a]);
|
||||
let pasted: Vec<NodeId> = s.execute("edit.pasteOver", &json!({})).unwrap()["ids"].as_array().unwrap().iter().filter_map(params::id_of).collect();
|
||||
assert_eq!(pasted.len(), 2);
|
||||
assert_eq!(kids(&s), vec![a, pasted[0], pasted[1], b, c], "in the selected layer's frame, just above it, in the copied order");
|
||||
assert_eq!(s.doc().unwrap().world_bounds(pasted[0]).unwrap().x0, 10.0, "at the selected layer's position");
|
||||
assert_eq!(s.selection(), pasted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boolean_and_flatten() {
|
||||
let mut s = sess();
|
||||
|
||||
@@ -131,7 +131,7 @@ fn zoom_to_fit_selection_and_frames() {
|
||||
// (command, selection before, zoom, centre, selection after, derivation)
|
||||
let rows: Vec<(&str, Vec<NodeId>, f64, (f64, f64), Vec<NodeId>, &str)> = vec![
|
||||
("view.zoomFit", vec![], fit(content), (470.0, 300.0), vec![], "Shift+1 fits everything on the page into the view"),
|
||||
("view.zoomSelection", vec![r], fit(Rect::new(0.0, 0.0, 40.0, 40.0)), (920.0, 20.0), vec![r], "Shift+2 zooms to the selection"),
|
||||
("view.zoomSelection", vec![r], (800.0 - 80.0) / 40.0, (920.0, 20.0), vec![r], "Shift+2 zooms to the selection, with a 40-point margin"),
|
||||
(
|
||||
"view.nextFrame",
|
||||
vec![],
|
||||
@@ -225,7 +225,7 @@ fn view_shortcuts_follow_the_documented_keys() {
|
||||
(Platform::Other, "-", m(true, false, false, false), "view.zoomOut", "Shift and - zooms out"),
|
||||
(Platform::Mac, "0", m(true, false, false, false), "view.zoom100", "Shift+0 zooms to 100%"),
|
||||
(Platform::Other, "1", m(true, false, false, false), "view.zoomFit", "Shift+1 zooms to fit"),
|
||||
(Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection"),
|
||||
(Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection, with a 40-point margin"),
|
||||
(Platform::Mac, "N", m(false, false, false, false), "view.nextFrame", "N zooms to the next frame"),
|
||||
(Platform::Other, "N", m(true, false, false, false), "view.prevFrame", "Shift+N zooms to the previous frame"),
|
||||
(Platform::Mac, "R", m(true, false, false, false), "view.rulers", "Shift+R shows and hides the rulers"),
|
||||
@@ -771,3 +771,151 @@ fn view_toggles_are_kept_for_the_next_launch() {
|
||||
}
|
||||
assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}");
|
||||
}
|
||||
|
||||
/// Select a fresh rectangle at the origin, set the view and run `cmd`; the rect's screen box is
|
||||
/// returned as (left, top, right, bottom).
|
||||
fn zoom_on(cmd: &str, inset: f64, w: f64, h: f64) -> (ViewState, (f64, f64, f64, f64)) {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, w, h, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": inset}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, cmd, json!({}));
|
||||
let v = view(&s);
|
||||
let (l, t) = v.page_to_screen(0.0, 0.0);
|
||||
let (rr, b) = v.page_to_screen(w, h);
|
||||
(v, (l, t, rr, b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_leaves_forty_points_and_clears_the_toolbar() {
|
||||
// Measured in Figma's web app, 2026-11-09: 40 points on every side of the canvas above the toolbar.
|
||||
let (v, (l, t, r, b)) = zoom_on("view.zoomSelection", 58.0, 100.0, 100.0);
|
||||
assert!((v.zoom - 7.65).abs() < 0.01, "{}", v.zoom);
|
||||
assert!((t - 40.0).abs() < 0.5 && (b - (845.0 - 40.0)).abs() < 0.5, "top {t}, bottom {b}");
|
||||
assert!((l - (1389.0 - r)).abs() < 0.5, "left {l}, right {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_on_a_wide_layer_is_limited_by_the_width() {
|
||||
let (v, (l, _, r, _)) = zoom_on("view.zoomSelection", 58.0, 1000.0, 200.0);
|
||||
assert!((v.zoom - (1389.0 - 80.0) / 1000.0).abs() < 0.001, "{}", v.zoom);
|
||||
assert!((l - 40.0).abs() < 0.5 && ((1389.0 - r) - 40.0).abs() < 0.5, "left {l}, right {r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_without_a_toolbar_inset_uses_the_whole_height() {
|
||||
let (v, _) = zoom_on("view.zoomSelection", 0.0, 100.0, 100.0);
|
||||
assert!((v.zoom - (903.0 - 80.0) / 100.0).abs() < 1e-6, "{}", v.zoom);
|
||||
let (_, top) = v.page_to_screen(0.0, 0.0);
|
||||
let (_, bottom) = v.page_to_screen(100.0, 100.0);
|
||||
assert!((top - 40.0).abs() < 1e-6 && (bottom - 863.0).abs() < 1e-6, "top {top}, bottom {bottom}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_selection_in_a_short_canvas_still_ends_above_the_toolbar() {
|
||||
// A canvas 130 points high with the 60-point toolbar strip leaves 70 above it: too short for
|
||||
// 40 points a side, so the margin shrinks and the selection stays clear of the toolbar.
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 130, "bottomInset": 60}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
let (_, top) = v.page_to_screen(0.0, 0.0);
|
||||
let (_, bottom) = v.page_to_screen(100.0, 100.0);
|
||||
assert!(top >= 0.0 && bottom <= 70.0 + 1e-6, "top {top}, bottom {bottom}");
|
||||
assert!((top - (70.0 - bottom)).abs() < 1e-6, "centred above the toolbar: top {top}, bottom {bottom}");
|
||||
// The margin shrinks to a quarter of the 70 points (17.5) and the zoom is the exact fit, not the floor.
|
||||
assert!((v.zoom - 0.35).abs() < 1e-9, "{}", v.zoom);
|
||||
assert!((top - 17.5).abs() < 1e-6 && (bottom - 52.5).abs() < 1e-6, "top {top}, bottom {bottom}");
|
||||
}
|
||||
|
||||
/// A canvas narrower than 160 points keeps a quarter of its width on each side.
|
||||
#[test]
|
||||
fn zoom_to_selection_in_a_narrow_canvas_shrinks_the_side_margins() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 100, "vh": 903, "bottomInset": 0}));
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
let (left, _) = v.page_to_screen(0.0, 0.0);
|
||||
let (right, _) = v.page_to_screen(100.0, 100.0);
|
||||
assert!((v.zoom - 0.5).abs() < 1e-9, "{}", v.zoom);
|
||||
assert!((left - 25.0).abs() < 1e-6 && (right - 75.0).abs() < 1e-6, "left {left}, right {right}");
|
||||
}
|
||||
|
||||
/// A view 1000 by 800 with a 100-point toolbar strip, for the edge cases of the fit.
|
||||
fn strip_view() -> ViewState {
|
||||
ViewState { vw: 1000.0, vh: 800.0, bottom_inset: 100.0, ..ViewState::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_a_selection_survives_points_lines_extremes_and_junk() {
|
||||
// A point: the zoom stays, and it lands in the middle of the area above the toolbar (y = 350).
|
||||
let mut v = strip_view();
|
||||
v.zoom = 2.0;
|
||||
v.fit_selection(Rect::new(10.0, 20.0, 10.0, 20.0), 32.0);
|
||||
assert_eq!(v.zoom, 2.0);
|
||||
let (x, y) = v.page_to_screen(10.0, 20.0);
|
||||
assert!((x - 500.0).abs() < 1e-9 && (y - 350.0).abs() < 1e-9, "{x}, {y}");
|
||||
// A zero-width line is limited by its height, a zero-height one by its width.
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 0.0, 100.0), 32.0);
|
||||
assert!((v.zoom - (700.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom);
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 100.0, 0.0), 32.0);
|
||||
assert!((v.zoom - (1000.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom);
|
||||
// A tiny one stops at the maximum zoom, a huge one at the minimum.
|
||||
let mut v = strip_view();
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 0.001, 0.001), 32.0);
|
||||
assert_eq!(v.zoom, 32.0);
|
||||
v.fit_selection(Rect::new(0.0, 0.0, 1e12, 1e12), 32.0);
|
||||
assert_eq!(v.zoom, ViewState::MIN_ZOOM);
|
||||
// A rectangle with any coordinate that is not a finite number changes nothing.
|
||||
for bad in [
|
||||
Rect::new(0.0, f64::NAN, 10.0, 10.0),
|
||||
Rect::new(0.0, 0.0, f64::INFINITY, 10.0),
|
||||
Rect::new(0.0, 0.0, 10.0, f64::NAN),
|
||||
Rect::new(f64::NAN, 0.0, 10.0, 10.0),
|
||||
] {
|
||||
let mut v = strip_view();
|
||||
let before = (v.zoom, v.cx, v.cy);
|
||||
v.fit_selection(bad, 32.0);
|
||||
assert_eq!((v.zoom, v.cx, v.cy), before, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bottom_inset_is_clamped_and_never_saved() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "view.set", json!({"bottomInset": -5}));
|
||||
assert_eq!(view(&s).bottom_inset, 0.0);
|
||||
ex(&mut s, "view.set", json!({"bottomInset": 1e12}));
|
||||
assert_eq!(view(&s).bottom_inset, 20000.0);
|
||||
// Larger than the viewport: the fit still leaves a sliver of canvas and a finite centre.
|
||||
ex(&mut s, "view.set", json!({"vw": 400, "vh": 300, "bottomInset": 5000}));
|
||||
let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, None);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
ex(&mut s, "view.zoomSelection", json!({}));
|
||||
let v = view(&s);
|
||||
assert!(v.zoom.is_finite() && v.cx.is_finite() && v.cy.is_finite(), "{v:?}");
|
||||
// It is not part of what the view saves.
|
||||
let saved = serde_json::to_string(&v).unwrap();
|
||||
assert!(!saved.to_lowercase().contains("inset"), "{saved}");
|
||||
let back: ViewState = serde_json::from_str(&saved).unwrap();
|
||||
assert_eq!(back.bottom_inset, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_to_fit_is_unchanged() {
|
||||
// Left alone on purpose: its 64-point margin is not measured against Figma yet.
|
||||
let mut s = Session::new();
|
||||
rect(&mut s, 0.0, 0.0, 100.0, 100.0, None);
|
||||
ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": 58}));
|
||||
ex(&mut s, "view.zoomFit", json!({}));
|
||||
let v = view(&s);
|
||||
assert!((v.zoom - (903.0 - 128.0) / 100.0).abs() < 1e-6);
|
||||
// No toolbar offset: the rectangle's own centre stays in the middle of the whole viewport.
|
||||
assert_eq!((v.cx, v.cy), (50.0, 50.0));
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ pub struct ViewState {
|
||||
/// Viewport size in logical pixels (set by the UI; used by zoom-to-fit).
|
||||
pub vw: f64,
|
||||
pub vh: f64,
|
||||
/// Height in screen points of the strip the floating bottom toolbar covers (from its top edge to
|
||||
/// the canvas's bottom edge); set by the UI every frame, 0 when it's hidden. Never saved.
|
||||
#[serde(skip)]
|
||||
pub bottom_inset: f64,
|
||||
pub pixel_grid: bool,
|
||||
pub layout_grids: bool,
|
||||
pub rulers: bool,
|
||||
@@ -51,6 +55,7 @@ impl Default for ViewState {
|
||||
zoom: 1.0,
|
||||
vw: 1200.0,
|
||||
vh: 800.0,
|
||||
bottom_inset: 0.0,
|
||||
pixel_grid: true,
|
||||
layout_grids: true,
|
||||
rulers: false,
|
||||
@@ -105,6 +110,32 @@ impl ViewState {
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y;
|
||||
}
|
||||
/// Fit a page rectangle for Zoom to selection: a 40 point margin on all four sides of the area
|
||||
/// above the bottom toolbar (`vw` by `vh - bottom_inset`), centred in that area.
|
||||
pub fn fit_selection(&mut self, r: figcraft_geom::Rect, max_zoom: f64) {
|
||||
// A rectangle that isn't all finite numbers leaves the view as it is.
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let inset = if self.bottom_inset.is_finite() { self.bottom_inset.clamp(0.0, (self.vh - 1.0).max(0.0)) } else { 0.0 };
|
||||
let area_h = (self.vh - inset).max(1.0);
|
||||
// A point (nothing to fit) is centred in the area above the toolbar at the zoom it has.
|
||||
if !(r.width() > 0.0 || r.height() > 0.0) {
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom.max(1e-6);
|
||||
return;
|
||||
}
|
||||
// 40 points as measured; a canvas too small for that keeps a quarter of its size on each
|
||||
// side instead, so the selection still ends above the toolbar.
|
||||
let mx = (self.vw / 4.0).clamp(0.0, 40.0);
|
||||
let my = (area_h / 4.0).clamp(0.0, 40.0);
|
||||
let zx = (self.vw - mx * 2.0).max(1.0) / r.width().max(1e-6);
|
||||
let zy = (area_h - my * 2.0).max(1.0) / r.height().max(1e-6);
|
||||
self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom);
|
||||
// The rect's centre lands at screen y = area_h / 2, the page centre `(vh - area_h) / 2` below it.
|
||||
self.cx = r.center().x;
|
||||
self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom;
|
||||
}
|
||||
/// The zoom from which the pixel grid shows (400%).
|
||||
pub const PIXEL_GRID_ZOOM: f64 = 4.0;
|
||||
/// Whether the pixel grid draws: it's on and the canvas is zoomed in to 400% or more.
|
||||
|
||||
@@ -72,7 +72,7 @@ const AUX: u32 = 2;
|
||||
pub(super) const SCHEMA: &str = "
|
||||
enum MessageType JOIN_START=0 NODE_CHANGES=1
|
||||
enum NodePhase CREATED=0 REMOVED=1
|
||||
enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25 VARIABLE=28 VARIABLE_SET=31
|
||||
enum NodeType NONE=0 DOCUMENT=1 CANVAS=2 GROUP=3 FRAME=4 BOOLEAN_OPERATION=5 VECTOR=6 STAR=7 LINE=8 ELLIPSE=9 RECTANGLE=10 REGULAR_POLYGON=11 ROUNDED_RECTANGLE=12 TEXT=13 SLICE=14 SYMBOL=15 INSTANCE=16 SECTION=25 VARIABLE=28 VARIABLE_SET=31 TEXT_PATH=41
|
||||
enum BlendMode PASS_THROUGH=0 NORMAL=1 DARKEN=2 MULTIPLY=3 LINEAR_BURN=4 COLOR_BURN=5 LIGHTEN=6 SCREEN=7 LINEAR_DODGE=8 COLOR_DODGE=9 OVERLAY=10 SOFT_LIGHT=11 HARD_LIGHT=12 DIFFERENCE=13 EXCLUSION=14 HUE=15 SATURATION=16 COLOR=17 LUMINOSITY=18
|
||||
enum PaintType SOLID=0 GRADIENT_LINEAR=1 GRADIENT_RADIAL=2 GRADIENT_ANGULAR=3 GRADIENT_DIAMOND=4 IMAGE=5
|
||||
enum ImageScaleMode STRETCH=0 FIT=1 FILL=2 TILE=3
|
||||
@@ -152,6 +152,7 @@ message TextLineData LineType lineType=1, int styleId=10, int indentationLevel=2
|
||||
message TextData string characters=1, uint[] characterStyleIDs=2, NodeChange[] styleOverrideTable=3, TextLineData[] lines=12
|
||||
message Path WindingRule windingRule=1, uint commandsBlob=2, uint styleID=3
|
||||
message VectorData uint vectorNetworkBlob=1, Vector normalizedSize=2, NodeChange[] styleOverrideTable=3
|
||||
message TextPathStart float tValue=1, bool forward=2
|
||||
message ArcData float startingAngle=1, float endingAngle=2, float innerRadius=3
|
||||
message OptionalVector Vector value=1
|
||||
message LayoutGrid LayoutGridType type=1, Axis axis=2, bool visible=3, int numSections=4, float offset=5, float sectionSize=6, float gutterSize=7, Color color=8, LayoutGridPattern pattern=9, VariableData numSectionsVar=10, VariableData offsetVar=11, VariableData sectionSizeVar=12, VariableData gutterSizeVar=13
|
||||
@@ -191,7 +192,7 @@ message GridTrackSizingFunction GridTrackSizingType type=1, float value=2
|
||||
message GridTrackSize GridTrackSizingFunction minSizing=1, GridTrackSizingFunction maxSizing=2
|
||||
message GUIDGridTrackSizeMapEntry GUID id=1, GridTrackSize trackSize=2
|
||||
message GUIDGridTrackSizeMap GUIDGridTrackSizeMapEntry[] entries=1
|
||||
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, float listSpacing=264, LeadingTrim leadingTrim=322, bool hangingPunctuation=337, bool hangingList=339, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201, Color prototypeBackgroundColor=141
|
||||
message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, StateGroupPropertyValueOrder[] stateGroupPropertyValueOrders=238, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GUIDGridTrackSizeMap gridColumnsSizing=474, GUIDGridTrackSizeMap gridRowsSizing=475, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, float listSpacing=264, LeadingTrim leadingTrim=322, bool hangingPunctuation=337, bool hangingList=339, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, TextPathStart textPathStart=432, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropRef[] componentPropRefs=267, ComponentPropAssignment[] componentPropAssignments=268, VariantPropSpec[] variantPropSpecs=483, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201, Color prototypeBackgroundColor=141
|
||||
struct Blob byte[] bytes
|
||||
message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6
|
||||
";
|
||||
|
||||
@@ -74,6 +74,11 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// A text on a path is its own layer kind, written only when its path fields were built.
|
||||
let on_path = match &n.kind {
|
||||
NodeKind::Text(td) => self.text_path(n, td, nested),
|
||||
_ => None,
|
||||
};
|
||||
let ty = match &n.kind {
|
||||
NodeKind::Page { .. } | NodeKind::Frame | NodeKind::Group | NodeKind::ComponentSet { .. } => "FRAME",
|
||||
NodeKind::Instance { .. } if main.is_none() => "FRAME",
|
||||
@@ -84,6 +89,7 @@ impl<'d> Exporter<'d> {
|
||||
NodeKind::Star { .. } => "STAR",
|
||||
NodeKind::Line => "LINE",
|
||||
NodeKind::Vector { .. } => "VECTOR",
|
||||
NodeKind::Text(_) if on_path.is_some() => "TEXT_PATH",
|
||||
NodeKind::Text(_) => "TEXT",
|
||||
NodeKind::BooleanOp { .. } => "BOOLEAN_OPERATION",
|
||||
NodeKind::Component { .. } => "SYMBOL",
|
||||
@@ -107,7 +113,16 @@ impl<'d> Exporter<'d> {
|
||||
NodeKind::Polygon { count } => f.push(("count", u(*count))),
|
||||
NodeKind::Star { count, inner_ratio } => f.extend([("count", u(*count)), ("starInnerScale", fl(*inner_ratio))]),
|
||||
NodeKind::Vector { network } => self.vector(n, network, f),
|
||||
NodeKind::Text(td) => self.text(td, &n.fills, f),
|
||||
NodeKind::Text(td) => {
|
||||
self.text(td, &n.fills, f);
|
||||
if let Some(path) = on_path {
|
||||
// Text on a path has no box to align in: it is written as top, which is how it draws.
|
||||
if let Some(entry) = f.iter_mut().find(|(k, _)| *k == "textAlignVertical") {
|
||||
entry.1 = e("TOP");
|
||||
}
|
||||
f.extend(path);
|
||||
}
|
||||
}
|
||||
NodeKind::BooleanOp { op } => {
|
||||
let name = match op {
|
||||
BoolOp::Union => "UNION",
|
||||
|
||||
@@ -173,9 +173,6 @@ impl<'d> Exporter<'d> {
|
||||
if styles.clone().any(|st| unsupported_decoration_look(st) || !st.variations.is_empty()) {
|
||||
self.warn("text decoration looks and variable font settings were left out");
|
||||
}
|
||||
if td.path.is_some() {
|
||||
self.warn("text on a path was written as plain text");
|
||||
}
|
||||
if !td.features.is_empty() {
|
||||
self.warn("OpenType features were left out");
|
||||
}
|
||||
@@ -382,4 +379,236 @@ impl<'d> Exporter<'d> {
|
||||
self.blobs.insert(bytes, i);
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// The fields of a text on a path (its start and its path as a vector network), or `None` when the text stays plain text
|
||||
/// (with the warning that says why). `nested`: for an instance's override row, which follows its main's layer.
|
||||
pub(super) fn text_path(&mut self, n: &Node, td: &TextData, nested: bool) -> Option<Fields> {
|
||||
let doc = self.doc;
|
||||
let own = text_path_net(td, n.width, n.height);
|
||||
let main = if nested { n.source.and_then(|s| doc.node(s)) } else { None };
|
||||
if let Some(main) = main {
|
||||
let main_ok = matches!(&main.kind, NodeKind::Text(m) if text_path_net(m, main.width, main.height).is_ok());
|
||||
if main_ok != own.is_ok() {
|
||||
// An override can't take its layer off the main's path. So an instance text of
|
||||
// more than one line, on a main that is on a path, stays on that path in the file:
|
||||
// say so rather than the general mismatch (FigCraft's import reads it as plain text).
|
||||
let lines = main_ok && matches!(own, Err(TEXT_PATH_LINES));
|
||||
self.warn(if lines { TEXT_PATH_INSTANCE_LINES } else { TEXT_PATH_INSTANCE });
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let tp = td.path.as_deref()?;
|
||||
let made = match own {
|
||||
Ok(made) => made,
|
||||
Err(why) => {
|
||||
self.warn(why);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let Some(index) = self.blob(network::encode(&made.net)) else {
|
||||
self.warn(TEXT_PATH_CANT);
|
||||
return None;
|
||||
};
|
||||
if made.later_parts {
|
||||
self.warn(TEXT_PATH_PARTS);
|
||||
}
|
||||
Some(vec![
|
||||
("textPathStart", o(vec![("tValue", fl(made.t)), ("forward", b(!tp.flip))])),
|
||||
("vectorData", o(vec![("vectorNetworkBlob", u(index)), ("normalizedSize", xy(n.width, n.height))])),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// The style id the format gives the segment a text on a path starts on.
|
||||
const TEXT_PATH_MARK: u32 = 0x01ff_ffff;
|
||||
const TEXT_PATH_LINES: &str = "text on a path with more than one line was written as plain text";
|
||||
const TEXT_PATH_CANT: &str = "text on a path the format can't hold was written as plain text";
|
||||
const TEXT_PATH_PARTS: &str = "the parts of a text's path after the first were left out";
|
||||
const TEXT_PATH_INSTANCE: &str =
|
||||
"text on a path in an instance but not in its main component, or the other way round, was written as the main has it";
|
||||
const TEXT_PATH_INSTANCE_LINES: &str =
|
||||
"an instance's text of more than one line stays on its main component's text path in the file, as an override can't take it off";
|
||||
|
||||
/// A text's path as the format holds it: one chain (or cycle) of a vector network with the
|
||||
/// segment the text starts on marked, and the start as that segment's own parameter.
|
||||
struct PathNet {
|
||||
net: network::Network,
|
||||
t: f64,
|
||||
later_parts: bool,
|
||||
}
|
||||
|
||||
/// The tangent-to-control-point cubic of a network segment, as the import reads it.
|
||||
fn net_cubic(net: &network::Network, s: &network::Segment) -> Option<(figcraft_doc::geom::kurbo::CubicBez, bool)> {
|
||||
use figcraft_doc::geom::kurbo::CubicBez;
|
||||
let (a, b) = (net.vertices.get(s.a)?, net.vertices.get(s.b)?);
|
||||
let line = s.ta == (0.0, 0.0) && s.tb == (0.0, 0.0);
|
||||
let cubic = CubicBez::new((a.x, a.y), (a.x + s.ta.0, a.y + s.ta.1), (b.x + s.tb.0, b.y + s.tb.1), (b.x, b.y));
|
||||
Some((cubic, line))
|
||||
}
|
||||
|
||||
/// The length of a segment, as the import measures it.
|
||||
fn net_length(net: &network::Network, s: &network::Segment) -> f64 {
|
||||
use figcraft_doc::geom::kurbo::{Line, ParamCurveArclen};
|
||||
match net_cubic(net, s) {
|
||||
Some((c, true)) => Line::new(c.p0, c.p3).arclen(1e-9),
|
||||
Some((c, false)) => c.arclen(1e-9),
|
||||
None => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The part of a path (a move and what follows it) as a chain of the network, and whether it is
|
||||
/// a cycle; `None` without a segment longer than a billionth.
|
||||
fn path_chain(part: &[figcraft_doc::geom::kurbo::PathEl]) -> Option<(network::Network, bool)> {
|
||||
use figcraft_doc::geom::kurbo::{PathEl, Point, QuadBez};
|
||||
let r = |x: f64| f64::from(finite(x) as f32);
|
||||
let rp = |p: Point| (r(p.x), r(p.y));
|
||||
let mut net = network::Network::default();
|
||||
let mut closed = false;
|
||||
let mut cur = (0.0, 0.0);
|
||||
let add = |net: &mut network::Network, c1: Option<(f64, f64)>, c2: Option<(f64, f64)>, end: (f64, f64)| {
|
||||
let Some(last) = net.vertices.last() else { return };
|
||||
let (a, index) = ((last.x, last.y), net.vertices.len() - 1);
|
||||
let tangent = |c: Option<(f64, f64)>, from: (f64, f64)| c.map_or((0.0, 0.0), |c| (r(c.0 - from.0), r(c.1 - from.1)));
|
||||
let seg = network::Segment { style: 0, a: index, ta: tangent(c1, a), b: index + 1, tb: tangent(c2, end) };
|
||||
net.vertices.push(network::Vertex { style: 0, x: end.0, y: end.1 });
|
||||
if net_length(net, &seg) > 1e-9 {
|
||||
net.segments.push(seg);
|
||||
} else {
|
||||
net.vertices.pop();
|
||||
}
|
||||
};
|
||||
for el in part {
|
||||
match el {
|
||||
PathEl::MoveTo(p) => {
|
||||
cur = rp(*p);
|
||||
net.vertices.push(network::Vertex { style: 0, x: cur.0, y: cur.1 });
|
||||
}
|
||||
PathEl::LineTo(p) => {
|
||||
cur = rp(*p);
|
||||
add(&mut net, None, None, cur);
|
||||
}
|
||||
PathEl::QuadTo(c, p) => {
|
||||
let q = QuadBez::new(Point::new(cur.0, cur.1), Point::new(r(c.x), r(c.y)), Point::new(r(p.x), r(p.y))).raise();
|
||||
cur = rp(*p);
|
||||
add(&mut net, Some((q.p1.x, q.p1.y)), Some((q.p2.x, q.p2.y)), cur);
|
||||
}
|
||||
PathEl::CurveTo(a, b, p) => {
|
||||
cur = rp(*p);
|
||||
// The control points are not rounded on their own: the format holds each as its offset from the end
|
||||
// point, and taken from the point as read that is the offset the file had.
|
||||
add(&mut net, Some((finite(a.x), finite(a.y))), Some((finite(b.x), finite(b.y))), cur);
|
||||
}
|
||||
PathEl::ClosePath => closed = true,
|
||||
}
|
||||
}
|
||||
if net.segments.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let first = net.vertices.first().map(|v| (v.x, v.y))?;
|
||||
let last = net.vertices.last().map(|v| (v.x, v.y))?;
|
||||
let near = |p: (f64, f64), q: (f64, f64)| (p.0 - q.0).hypot(p.1 - q.1) <= 1e-6;
|
||||
let mut cycle = false;
|
||||
if closed || near(last, first) {
|
||||
if near(last, first) {
|
||||
// The last segment ends on the first vertex. A single segment stays an open chain of
|
||||
// two vertices at one place: the import refuses a segment from a vertex to itself.
|
||||
if net.segments.len() > 1 {
|
||||
net.vertices.pop();
|
||||
if let Some(s) = net.segments.last_mut() {
|
||||
s.b = 0;
|
||||
s.tb = (r(last.0 + s.tb.0 - first.0), r(last.1 + s.tb.1 - first.1));
|
||||
cycle = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
net.segments.push(network::Segment { style: 0, a: net.vertices.len() - 1, ta: (0.0, 0.0), b: 0, tb: (0.0, 0.0) });
|
||||
cycle = true;
|
||||
}
|
||||
}
|
||||
Some((net, cycle))
|
||||
}
|
||||
|
||||
/// A text on a path as the format holds it, or the warning for why it stays plain text.
|
||||
fn text_path_net(td: &TextData, w: f64, h: f64) -> Result<PathNet, &'static str> {
|
||||
use figcraft_doc::geom::kurbo::{ParamCurveArclen, PathEl};
|
||||
let Some(tp) = td.path.as_deref() else { return Err(TEXT_PATH_CANT) };
|
||||
if td.characters.contains(['\n', '\r']) {
|
||||
return Err(TEXT_PATH_LINES);
|
||||
}
|
||||
let path = tp.path();
|
||||
let mut parts: Vec<Vec<PathEl>> = Vec::new();
|
||||
for el in path.elements() {
|
||||
if matches!(el, PathEl::MoveTo(_)) {
|
||||
parts.push(Vec::new());
|
||||
}
|
||||
if let Some(part) = parts.last_mut() {
|
||||
part.push(*el);
|
||||
}
|
||||
}
|
||||
let mut chains = parts.iter().filter_map(|p| path_chain(p));
|
||||
let (mut net, cycle) = chains.next().ok_or(TEXT_PATH_CANT)?;
|
||||
let later_parts = chains.next().is_some();
|
||||
if net.vertices.len() + net.segments.len() > MAX_NETWORK {
|
||||
return Err(TEXT_PATH_CANT);
|
||||
}
|
||||
// The import scales the coordinates by size over normalized size, which are the same number
|
||||
// here; an axis of no extent holds nothing but zeros.
|
||||
let size = |x: f64| f64::from(finite(x) as f32);
|
||||
for (extent, axis) in [(size(w), 0), (size(h), 1)] {
|
||||
let at = |p: (f64, f64)| if axis == 0 { p.0 } else { p.1 };
|
||||
let flat = net.vertices.iter().all(|v| at((v.x, v.y)) == 0.0) && net.segments.iter().all(|s| at(s.ta) == 0.0 && at(s.tb) == 0.0);
|
||||
if extent < 0.0 || (extent <= 1e-9 && (extent != 0.0 || !flat)) {
|
||||
return Err(TEXT_PATH_CANT);
|
||||
}
|
||||
}
|
||||
let lengths: Vec<f64> = net.segments.iter().map(|s| net_length(&net, s)).collect();
|
||||
let total: f64 = lengths.iter().sum();
|
||||
if !total.is_finite() || total <= 1e-10 {
|
||||
return Err(TEXT_PATH_CANT);
|
||||
}
|
||||
// The path the import builds from it must be one the text renderer can measure.
|
||||
let mut shape = figcraft_doc::geom::BezPath::new();
|
||||
for (i, s) in net.segments.iter().enumerate() {
|
||||
let Some((c, line)) = net_cubic(&net, s) else { return Err(TEXT_PATH_CANT) };
|
||||
if i == 0 {
|
||||
shape.move_to(c.p0);
|
||||
}
|
||||
if line {
|
||||
shape.line_to(c.p3);
|
||||
} else {
|
||||
shape.curve_to(c.p1, c.p2, c.p3);
|
||||
}
|
||||
}
|
||||
if cycle {
|
||||
shape.close_path();
|
||||
}
|
||||
let measured = figcraft_doc::geom::measure::polylines(&shape);
|
||||
if measured.as_ref().and_then(|l| l.first()).is_none_or(|l| l.length() <= 1e-9) {
|
||||
return Err(TEXT_PATH_CANT);
|
||||
}
|
||||
// The start, by length, as a parameter of the segment it falls on.
|
||||
let start = tp.start();
|
||||
let at = (if tp.flip { 1.0 - start } else { start }) * total;
|
||||
let mut before = 0.0;
|
||||
let mut k = lengths.len().saturating_sub(1);
|
||||
for (i, len) in lengths.iter().enumerate() {
|
||||
if before + len >= at {
|
||||
k = i;
|
||||
break;
|
||||
}
|
||||
before += len;
|
||||
}
|
||||
if k + 1 == lengths.len() && before + lengths[k] < at {
|
||||
before = total - lengths[k];
|
||||
}
|
||||
let local = (at - before).clamp(0.0, lengths[k]);
|
||||
let t = match net.segments.get(k).and_then(|s| net_cubic(&net, s)) {
|
||||
Some((_, true)) | None => local / lengths[k],
|
||||
Some((c, false)) => c.inv_arclen(local, 1e-9),
|
||||
};
|
||||
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
|
||||
if let Some(s) = net.segments.get_mut(k) {
|
||||
s.style = TEXT_PATH_MARK;
|
||||
}
|
||||
Ok(PathNet { net, t, later_parts })
|
||||
}
|
||||
|
||||
@@ -83,8 +83,11 @@ fn blob() -> Vec<u8> {
|
||||
}
|
||||
|
||||
fn canvas() -> Vec<u8> {
|
||||
canvas_of(message(true))
|
||||
}
|
||||
|
||||
fn canvas_of(message: Kv<'static>) -> Vec<u8> {
|
||||
let schema = super::kiwi::Schema::parse(SCHEMA).unwrap();
|
||||
let message = message(true);
|
||||
let message = super::kiwi::encode::message(&schema, schema.find("Message").unwrap(), &message).unwrap();
|
||||
let mut out = b"fig-kiwi\0\0\0\0".to_vec();
|
||||
for raw in [super::kiwi::encode::schema(&schema), message] {
|
||||
@@ -262,3 +265,379 @@ fn a_text_on_a_path_in_a_scaled_instance_has_its_path_scaled() {
|
||||
assert_eq!(path_of("Main"), "M0,0 L100,0");
|
||||
assert_eq!(path_of("Half"), "M0,0 L50,0");
|
||||
}
|
||||
|
||||
// The `.fig` export writes text on a path.
|
||||
|
||||
use figcraft_doc::geom::kurbo::PathEl;
|
||||
use figcraft_doc::{Document, NodeId, TextAlignV};
|
||||
|
||||
const W1: &str = "text on a path with more than one line was written as plain text";
|
||||
const W2: &str = "text on a path the format can't hold was written as plain text";
|
||||
const W3: &str = "the parts of a text's path after the first were left out";
|
||||
const W4: &str = "text on a path in an instance but not in its main component, or the other way round, was written as the main has it";
|
||||
const W4_LINES: &str =
|
||||
"an instance's text of more than one line stays on its main component's text path in the file, as an override can't take it off";
|
||||
const MARK: u32 = 0x01ff_ffff;
|
||||
|
||||
/// What an export wrote: its warnings, the layer records and the blobs.
|
||||
struct Written {
|
||||
warnings: Vec<String>,
|
||||
records: Vec<Kv<'static>>,
|
||||
blobs: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Written {
|
||||
fn of(doc: &Document) -> (Vec<u8>, Written) {
|
||||
let (bytes, warnings) = super::export(doc, None).unwrap();
|
||||
let archive = super::Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = super::decode_canvas(&archive.canvas).unwrap();
|
||||
let schema: &'static super::kiwi::Schema = Box::leak(Box::new(schema));
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let records = msg.get("nodeChanges").unwrap().list().to_vec();
|
||||
let blobs = msg
|
||||
.get("blobs")
|
||||
.map(|b| b.list().iter().filter_map(|x| x.get("bytes").and_then(Kv::bytes).map(<[u8]>::to_vec)).collect())
|
||||
.unwrap_or_default();
|
||||
(bytes, Written { warnings, records, blobs })
|
||||
}
|
||||
|
||||
fn record(&self, name: &str) -> &Kv<'static> {
|
||||
self.records.iter().find(|r| r.get("name").and_then(Kv::str) == Some(name)).unwrap_or_else(|| panic!("no record {name}"))
|
||||
}
|
||||
|
||||
/// The network in the blob a record's `vectorData` names.
|
||||
fn network(&self, record: &Kv<'static>) -> super::network::Network {
|
||||
let i = record.get("vectorData").unwrap().get("vectorNetworkBlob").and_then(Kv::f64).unwrap() as usize;
|
||||
super::network::decode(self.blobs.get(i).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn warned(&self, text: &str) -> bool {
|
||||
self.warnings.iter().any(|w| w == text)
|
||||
}
|
||||
}
|
||||
|
||||
fn ty<'a>(r: &'a Kv<'static>) -> &'a str {
|
||||
r.get("type").and_then(Kv::str).unwrap()
|
||||
}
|
||||
|
||||
fn start_of(r: &Kv<'static>) -> (f64, bool) {
|
||||
let s = r.get("textPathStart").unwrap_or_else(|| panic!("no textPathStart"));
|
||||
(s.get("tValue").and_then(Kv::f64).unwrap(), s.get("forward").and_then(Kv::bool).unwrap())
|
||||
}
|
||||
|
||||
/// A page holding one text per `(name, characters, d, start, flip, width, height)`.
|
||||
fn doc_of(texts: &[(&str, &str, &str, f64, bool, f64, f64)]) -> Document {
|
||||
let mut doc = Document::new("paths");
|
||||
let page = doc.first_page().unwrap();
|
||||
for (name, chars, d, start, flip, w, h) in texts {
|
||||
let id = doc.create(page, NodeKind::Text(Default::default()), name, 0.0, 0.0, *w, *h).unwrap();
|
||||
let td = doc.node_mut(id).unwrap().kind.text_mut().unwrap();
|
||||
td.characters = (*chars).into();
|
||||
if !d.is_empty() || !chars.is_empty() {
|
||||
td.path = Some(Box::new(TextPath { d: (*d).into(), start: *start, flip: *flip }));
|
||||
}
|
||||
}
|
||||
doc
|
||||
}
|
||||
|
||||
fn text_of<'a>(doc: &'a Document, name: &str) -> &'a figcraft_doc::TextData {
|
||||
let node = doc.nodes().find(|n| n.name == name).unwrap_or_else(|| panic!("no layer {name}"));
|
||||
let NodeKind::Text(td) = &node.kind else { panic!("{name} is not text") };
|
||||
td
|
||||
}
|
||||
|
||||
fn elements(d: &str) -> Vec<(char, Vec<(f64, f64)>)> {
|
||||
figcraft_doc::geom::svg_path(d)
|
||||
.unwrap()
|
||||
.elements()
|
||||
.iter()
|
||||
.map(|el| match el {
|
||||
PathEl::MoveTo(p) => ('M', vec![(p.x, p.y)]),
|
||||
PathEl::LineTo(p) => ('L', vec![(p.x, p.y)]),
|
||||
PathEl::QuadTo(a, b) => ('Q', vec![(a.x, a.y), (b.x, b.y)]),
|
||||
PathEl::CurveTo(a, b, c) => ('C', vec![(a.x, a.y), (b.x, b.y), (c.x, c.y)]),
|
||||
PathEl::ClosePath => ('Z', vec![]),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn same_path(a: &str, b: &str) {
|
||||
let (x, y) = (elements(a), elements(b));
|
||||
assert_eq!(x.len(), y.len(), "{a} / {b}");
|
||||
for ((ka, pa), (kb, pb)) in x.iter().zip(&y) {
|
||||
assert_eq!(ka, kb, "{a} / {b}");
|
||||
for (p, q) in pa.iter().zip(pb) {
|
||||
assert!((p.0 - q.0).abs() < 1e-3 && (p.1 - q.1).abs() < 1e-3, "{a} / {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn no_text_path_warning(warnings: &[String]) {
|
||||
assert!(!warnings.iter().any(|w| w.contains("text path") || w.contains("text off its path")), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_text_on_a_path_survives_a_fig_export() {
|
||||
let (doc, _) = super::import(&canvas(), "Text path").unwrap();
|
||||
let (bytes, warnings) = super::export(&doc, None).unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("text on a path")), "{warnings:?}");
|
||||
let (_, w) = Written::of(&doc);
|
||||
let r = w.record("alpha path");
|
||||
assert_eq!(ty(r), "TEXT_PATH");
|
||||
assert_eq!(start_of(r), (0.5, true));
|
||||
assert_eq!(r.get("textAlignVertical").and_then(Kv::str), Some("TOP"));
|
||||
let normalized = r.get("vectorData").unwrap().get("normalizedSize").unwrap();
|
||||
let size = r.get("size").unwrap();
|
||||
for k in ["x", "y"] {
|
||||
assert_eq!(normalized.get(k).and_then(Kv::f64), size.get(k).and_then(Kv::f64));
|
||||
}
|
||||
assert_eq!((size.get("x").and_then(Kv::f64), size.get("y").and_then(Kv::f64)), (Some(100.0), Some(0.0)));
|
||||
let net = w.network(r);
|
||||
assert_eq!(net.vertices.iter().map(|v| (v.x, v.y)).collect::<Vec<_>>(), [(0.0, 0.0), (100.0, 0.0)]);
|
||||
assert_eq!(net.segments.len(), 1);
|
||||
assert_eq!((net.segments[0].style, net.segments[0].ta, net.segments[0].tb), (MARK, (0.0, 0.0), (0.0, 0.0)));
|
||||
assert!(net.regions.is_empty());
|
||||
let (back, import_warnings) = super::import(&bytes, "Back").unwrap();
|
||||
no_text_path_warning(&import_warnings);
|
||||
let td = text_of(&back, "alpha path");
|
||||
assert_eq!(td.path.as_deref(), Some(&TextPath { d: "M0,0 L100,0".into(), start: 0.5, flip: false }));
|
||||
assert_eq!(td.characters, "alpha");
|
||||
assert_eq!((td.runs.len(), td.runs[0].start, td.runs[0].end), (1, 3, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_start_is_written_by_length_from_either_side() {
|
||||
const CURVE: &str = "M0,82.5 C70,-27.5 230,-27.5 300,82.5 C370,192.5 530,192.5 600,82.5";
|
||||
let mut doc = doc_of(&[
|
||||
("far", "alpha", CURVE, 0.7490379757, false, 600.0, 165.0),
|
||||
("near", "alpha", CURVE, 0.004744078, false, 600.0, 165.0),
|
||||
("flipped", "alpha", "M0,0 L600,0", 0.16013986, true, 600.0, 0.0),
|
||||
]);
|
||||
let page = doc.first_page().unwrap();
|
||||
doc.create(page, NodeKind::Text(Default::default()), "plain", 0.0, 0.0, 50.0, 20.0).unwrap();
|
||||
let (bytes, w) = Written::of(&doc);
|
||||
let marked = |name: &str| -> (usize, f64, bool) {
|
||||
let r = w.record(name);
|
||||
let net = w.network(r);
|
||||
let at: Vec<usize> = net.segments.iter().enumerate().filter(|(_, s)| s.style == MARK).map(|(i, _)| i).collect();
|
||||
assert_eq!(at.len(), 1, "{name}");
|
||||
let (t, forward) = start_of(r);
|
||||
(at[0], t, forward)
|
||||
};
|
||||
let (i, t, forward) = marked("far");
|
||||
assert_eq!((i, forward), (1, true));
|
||||
assert!((t - 0.49800667).abs() < 1e-5, "{t}");
|
||||
let (i, t, forward) = marked("near");
|
||||
assert_eq!((i, forward), (0, true));
|
||||
assert!((t - 0.0086954795).abs() < 1e-5, "{t}");
|
||||
assert!((t - 0.009488156).abs() > 5e-4, "{t}");
|
||||
let (i, t, forward) = marked("flipped");
|
||||
assert_eq!((i, forward), (0, false));
|
||||
assert!((t - 0.83986014).abs() < 1e-6, "{t}");
|
||||
let plain = w.record("plain");
|
||||
assert_eq!(ty(plain), "TEXT");
|
||||
assert!(plain.get("textPathStart").is_none() && plain.get("vectorData").is_none());
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
for (name, d, start, flip) in
|
||||
[("far", CURVE, 0.7490379757, false), ("near", CURVE, 0.004744078, false), ("flipped", "M0,0 L600,0", 0.16013986, true)]
|
||||
{
|
||||
let path = text_of(&back, name).path.as_deref().unwrap_or_else(|| panic!("{name} lost its path"));
|
||||
same_path(&path.d, d);
|
||||
assert!((path.start - start).abs() < 1e-6, "{name}: {}", path.start);
|
||||
assert_eq!(path.flip, flip, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_path_is_written_as_a_cycle() {
|
||||
let doc = doc_of(&[("square", "alpha", "M0,0 L100,0 L100,100 L0,100 Z", 0.3, false, 100.0, 100.0)]);
|
||||
let (bytes, w) = Written::of(&doc);
|
||||
let r = w.record("square");
|
||||
let net = w.network(r);
|
||||
assert_eq!((net.vertices.len(), net.segments.len()), (4, 4));
|
||||
assert_eq!(net.segments.iter().map(|s| (s.a, s.b)).collect::<Vec<_>>(), [(0, 1), (1, 2), (2, 3), (3, 0)]);
|
||||
assert_eq!(net.segments.iter().map(|s| s.style).collect::<Vec<_>>(), [0, MARK, 0, 0]);
|
||||
assert!((start_of(r).0 - 0.2).abs() < 1e-6);
|
||||
let come_back = |bytes: &[u8]| {
|
||||
let (back, warnings) = super::import(bytes, "Back").unwrap();
|
||||
no_text_path_warning(&warnings);
|
||||
text_of(&back, "square").path.as_deref().unwrap().clone()
|
||||
};
|
||||
let once = come_back(&bytes);
|
||||
same_path(&once.d, "M100,0 L100,100 L0,100 L0,0 L100,0 Z");
|
||||
assert!((once.start - 0.05).abs() < 1e-6, "{}", once.start);
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
let (again, _) = super::export(&back, None).unwrap();
|
||||
let twice = come_back(&again);
|
||||
same_path(&twice.d, &once.d);
|
||||
assert!((twice.start - once.start).abs() < 1e-6);
|
||||
|
||||
let open_end = doc_of(&[("tri", "alpha", "M0,0 L100,0 L100,100 L0,0", 0.5, false, 100.0, 100.0)]);
|
||||
let (_, w) = Written::of(&open_end);
|
||||
let net = w.network(w.record("tri"));
|
||||
assert_eq!(net.vertices.len(), 3);
|
||||
assert_eq!(net.segments.iter().map(|s| (s.a, s.b)).collect::<Vec<_>>(), [(0, 1), (1, 2), (2, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_the_format_cant_hold_fall_back_with_their_own_warning() {
|
||||
let plain = |doc: &Document, warning: &str| {
|
||||
let (bytes, w) = Written::of(doc);
|
||||
assert!(w.warned(warning), "{:?}", w.warnings);
|
||||
let r = w.record("t");
|
||||
assert_eq!(ty(r), "TEXT");
|
||||
assert!(r.get("textPathStart").is_none() && r.get("vectorData").is_none());
|
||||
let (back, warnings) = super::import(&bytes, "Back").unwrap();
|
||||
assert!(text_of(&back, "t").path.is_none());
|
||||
assert!(!warnings.iter().any(|x| x.contains("text path")), "{warnings:?}");
|
||||
};
|
||||
plain(&doc_of(&[("t", "al\npha", "M0,0 L100,0", 0.5, false, 100.0, 0.0)]), W1);
|
||||
plain(&doc_of(&[("t", "alpha", "M0,5 L100,5", 0.5, false, 100.0, 0.0)]), W2);
|
||||
let mut empty = doc_of(&[("t", "alpha", "", 0.5, false, 100.0, 20.0)]);
|
||||
let id = empty.nodes().find(|n| n.name == "t").unwrap().id;
|
||||
empty.node_mut(id).unwrap().kind.text_mut().unwrap().path = Some(Box::new(TextPath { d: String::new(), start: 0.5, flip: false }));
|
||||
plain(&empty, W2);
|
||||
|
||||
// Vertical alignment: written as top, still on its path.
|
||||
let mut middle = doc_of(&[("t", "alpha", "M0,0 L100,0", 0.5, false, 100.0, 0.0)]);
|
||||
let id = middle.nodes().find(|n| n.name == "t").unwrap().id;
|
||||
middle.node_mut(id).unwrap().kind.text_mut().unwrap().align_v = TextAlignV::Middle;
|
||||
let (bytes, w) = Written::of(&middle);
|
||||
assert!(!w.warnings.iter().any(|x| x.contains("text on a path")), "{:?}", w.warnings);
|
||||
assert_eq!(w.record("t").get("textAlignVertical").and_then(Kv::str), Some("TOP"));
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
assert!(text_of(&back, "t").path.is_some());
|
||||
|
||||
// Several parts: the first.
|
||||
let (bytes, w) = Written::of(&doc_of(&[("t", "alpha", "M0,0 L100,0 M0,50 L100,50", 0.5, false, 100.0, 50.0)]));
|
||||
assert!(w.warned(W3), "{:?}", w.warnings);
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
let path = text_of(&back, "t").path.as_deref().unwrap();
|
||||
same_path(&path.d, "M0,0 L100,0");
|
||||
assert!((path.start - 0.5).abs() < 1e-6);
|
||||
|
||||
// A zero-length segment is dropped.
|
||||
let (bytes, w) = Written::of(&doc_of(&[("t", "alpha", "M0,0 L0,0 L100,0", 0.5, false, 100.0, 0.0)]));
|
||||
assert!(!w.warnings.iter().any(|x| x.contains("text on a path")), "{:?}", w.warnings);
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
same_path(&text_of(&back, "t").path.as_deref().unwrap().d, "M0,0 L100,0");
|
||||
|
||||
// A closed path of one segment.
|
||||
let (bytes, w) = Written::of(&doc_of(&[("t", "alpha", "M0,0 C100,-100 -100,-100 0,0 Z", 0.5, false, 100.0, 100.0)]));
|
||||
assert!(!w.warnings.iter().any(|x| x.contains("text on a path")), "{:?}", w.warnings);
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
let els = elements(&text_of(&back, "t").path.as_deref().unwrap().d);
|
||||
assert_eq!(els.first().map(|e| e.1[0]), Some((0.0, 0.0)));
|
||||
let last = els.iter().rev().find(|e| e.0 != 'Z').unwrap();
|
||||
assert_eq!(last.1.last().copied(), Some((0.0, 0.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_instance_layer_on_a_path_survives_a_fig_export() {
|
||||
let (doc, _) = super::import(&canvas_of(scaled_instance_message()), "Scaled path").unwrap();
|
||||
let (bytes, warnings) = super::export(&doc, None).unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("text on a path")), "{warnings:?}");
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
let path_of = |doc: &Document, parent: &str| {
|
||||
let parent = doc.nodes().find(|n| n.name == parent).unwrap();
|
||||
let node = doc.node(parent.children[0]).unwrap();
|
||||
let NodeKind::Text(text) = &node.kind else { panic!("expected text") };
|
||||
text.path.as_deref().map(|p| p.d.clone())
|
||||
};
|
||||
assert_eq!(path_of(&back, "Main").as_deref(), Some("M0,0 L100,0"));
|
||||
assert_eq!(path_of(&back, "Half").as_deref(), Some("M0,0 L50,0"));
|
||||
|
||||
// A layer in an instance that isn't on a path where its main's layer is: it comes back on the main's path.
|
||||
let off = |parent: &str| {
|
||||
let mut doc = doc.clone();
|
||||
let id: NodeId = doc.nodes().find(|n| n.name == parent).unwrap().children[0];
|
||||
doc.node_mut(id).unwrap().kind.text_mut().unwrap().path = None;
|
||||
doc
|
||||
};
|
||||
let (bytes, w) = Written::of(&off("Half"));
|
||||
assert!(w.warned(W4), "{:?}", w.warnings);
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
assert_eq!(path_of(&back, "Half").as_deref(), Some("M0,0 L50,0"));
|
||||
|
||||
// An instance's text of two lines on a main that is on a path: the override can't leave the
|
||||
// path, so the warning names that case, not the general mismatch.
|
||||
let mut two = doc.clone();
|
||||
let id: NodeId = two.nodes().find(|n| n.name == "Half").unwrap().children[0];
|
||||
two.node_mut(id).unwrap().kind.text_mut().unwrap().characters = "al\npha".into();
|
||||
let (_, w) = Written::of(&two);
|
||||
assert!(w.warned(W4_LINES), "{:?}", w.warnings);
|
||||
assert!(!w.warned(W4), "{:?}", w.warnings);
|
||||
|
||||
// The other way round: the main's layer is plain text, and so is the instance's.
|
||||
let (bytes, w) = Written::of(&off("Main"));
|
||||
assert!(w.warned(W4), "{:?}", w.warnings);
|
||||
assert_eq!(ty(w.record("arc")), "TEXT");
|
||||
let (back, _) = super::import(&bytes, "Back").unwrap();
|
||||
assert_eq!((path_of(&back, "Main"), path_of(&back, "Half")), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_schema_numbers_text_on_a_path() {
|
||||
let s = super::kiwi::Schema::parse(super::export::SCHEMA).unwrap();
|
||||
let def = |n: &str| s.defs.iter().find(|d| d.name == n).unwrap();
|
||||
assert_eq!(def("NodeType").fields.iter().find(|x| x.name == "TEXT_PATH").map(|x| x.value), Some(41));
|
||||
assert_eq!(def("NodeChange").fields.iter().find(|x| x.name == "textPathStart").map(|x| x.value), Some(432));
|
||||
let start = def("TextPathStart");
|
||||
assert!(matches!(start.kind, super::kiwi::Kind::Message));
|
||||
let fields: Vec<_> = start.fields.iter().map(|x| (x.name.as_str(), x.value)).collect();
|
||||
assert_eq!(fields, [("tValue", 1), ("forward", 2)]);
|
||||
}
|
||||
|
||||
/// A text on a path of two curves whose coordinates 32-bit floats hold while their control points (a point plus its
|
||||
/// tangent) need more digits, as the curves of a saved file do.
|
||||
fn uneven_curve_canvas() -> Vec<u8> {
|
||||
let mut blob = Vec::new();
|
||||
let u32le = |out: &mut Vec<u8>, n: u32| out.extend_from_slice(&n.to_le_bytes());
|
||||
let f32le = |out: &mut Vec<u8>, n: f32| out.extend_from_slice(&n.to_le_bytes());
|
||||
for n in [3, 2, 0] {
|
||||
u32le(&mut blob, n);
|
||||
}
|
||||
for (x, y) in [(0.1, 82.3), (300.7, 82.9), (600.3, 81.7)] {
|
||||
u32le(&mut blob, 0);
|
||||
f32le(&mut blob, x);
|
||||
f32le(&mut blob, y);
|
||||
}
|
||||
for (style, a, ta, b, tb) in [(0, 0, (70.7, -110.3), 1, (-70.9, -110.1)), (MARK, 1, (70.3, 110.7), 2, (-70.1, 110.9))] {
|
||||
u32le(&mut blob, style);
|
||||
u32le(&mut blob, a);
|
||||
f32le(&mut blob, ta.0);
|
||||
f32le(&mut blob, ta.1);
|
||||
u32le(&mut blob, b);
|
||||
f32le(&mut blob, tb.0);
|
||||
f32le(&mut blob, tb.1);
|
||||
}
|
||||
let identity = o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(0.0)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(0.0))]);
|
||||
let text = node(
|
||||
2,
|
||||
Some(1),
|
||||
"TEXT_PATH",
|
||||
vec![
|
||||
("name", s("curve")),
|
||||
("size", point(600.5, 221.25)),
|
||||
("transform", identity),
|
||||
("textData", o(vec![("characters", s("alpha"))])),
|
||||
("fontSize", f(24.0)),
|
||||
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
|
||||
("textPathStart", o(vec![("tValue", f(0.4)), ("forward", Kv::Bool(true))])),
|
||||
("vectorData", o(vec![("vectorNetworkBlob", u(0)), ("normalizedSize", point(600.5, 221.25))])),
|
||||
],
|
||||
);
|
||||
let nodes = vec![node(0, None, "DOCUMENT", vec![]), node(1, Some(0), "CANVAS", vec![]), text];
|
||||
canvas_of(o(vec![("nodeChanges", a(nodes)), ("blobs", a(vec![o(vec![("bytes", Kv::Bytes(blob))])]))]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_curve_read_from_a_file_is_written_back_as_it_was() {
|
||||
let (doc, _) = super::import(&uneven_curve_canvas(), "Curve").unwrap();
|
||||
let read = text_of(&doc, "curve").path.as_deref().unwrap().clone();
|
||||
let (bytes, _) = super::export(&doc, None).unwrap();
|
||||
let (back, warnings) = super::import(&bytes, "Back").unwrap();
|
||||
no_text_path_warning(&warnings);
|
||||
// The file holds a control point as its offset from the end point: taken from the point as read, it is the same offset.
|
||||
assert_eq!(text_of(&back, "curve").path.as_deref(), Some(&read));
|
||||
}
|
||||
|
||||
@@ -376,9 +376,19 @@ fn font_bytes_load_into_the_db() {
|
||||
let families = load_font_bytes(f.bytes.to_vec());
|
||||
assert_eq!(families.len(), 1);
|
||||
assert!(families.iter().all(|f| has_family(f)));
|
||||
let faces = db().read().unwrap().face_count();
|
||||
// The global face count can grow here when another test finishes the concurrent system
|
||||
// font scan. Count explicitly loaded faces from this family so the idempotency assertion
|
||||
// stays isolated from unrelated installed fonts.
|
||||
let loaded_faces = || {
|
||||
let db = db().read().unwrap();
|
||||
families
|
||||
.iter()
|
||||
.map(|family| db.faces.iter().filter(|face| face.loaded > 0 && face.family.eq_ignore_ascii_case(family)).count())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let faces = loaded_faces();
|
||||
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
|
||||
assert_eq!(db().read().unwrap().face_count(), faces);
|
||||
assert_eq!(loaded_faces(), faces);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -379,6 +379,12 @@ impl FigApp {
|
||||
let worker = self.worker.take();
|
||||
let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone()));
|
||||
canvas::show(self, ui, &mut worker);
|
||||
// The strip the floating bottom toolbar covers, for Zoom to selection (0 when it's hidden).
|
||||
let inset =
|
||||
if !hidden && mobile.is_none() { f64::from(self.canvas.rect.max.y - toolbar::pill_rect(self.canvas.rect, 0.0).min.y) } else { 0.0 };
|
||||
if let Ok(ds) = self.session.ds_mut() {
|
||||
ds.view.bottom_inset = inset;
|
||||
}
|
||||
self.worker = Some(worker);
|
||||
if !hidden && mobile.is_none() {
|
||||
ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| {
|
||||
|
||||
@@ -90,7 +90,14 @@ fn shift_digits_zoom() {
|
||||
press(&mut u, Key::Num2, Modifiers::SHIFT);
|
||||
assert!(zoom(&u) > fit, "Shift+2 fits the smaller selection");
|
||||
let v = &u.app.session.ds().unwrap().view;
|
||||
assert_eq!((v.cx, v.cy), (110.0, 40.0), "centred on it");
|
||||
// Centred in the canvas above the bottom toolbar: the page centre sits half the toolbar's strip lower.
|
||||
// The strip is the canvas's bottom edge less the pill's top, worked out here, not read back.
|
||||
let strip = f64::from(u.app.canvas.rect.max.y - crate::toolbar::pill_rect(u.app.canvas.rect, 0.0).min.y);
|
||||
assert!(strip > 20.0 && strip < 200.0, "the pill is a toolbar's height: {strip}");
|
||||
assert!((v.bottom_inset - strip).abs() < 1e-3, "the app gives the engine the pill's strip: {} vs {strip}", v.bottom_inset);
|
||||
let expect_cy = 40.0 + strip / 2.0 / v.zoom;
|
||||
assert_eq!(v.cx, 110.0, "centred on it");
|
||||
assert!((v.cy - expect_cy).abs() < 1e-9, "centred above the toolbar: {} vs {expect_cy}", v.cy);
|
||||
// `+` and `-` step the zoom.
|
||||
let z = zoom(&u);
|
||||
press(&mut u, Key::Minus, Modifiers::NONE);
|
||||
@@ -201,3 +208,16 @@ fn shortcuts_panel_does_not_show_on_the_home_screen() {
|
||||
u.frame(vec![]);
|
||||
assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen");
|
||||
}
|
||||
|
||||
/// The strip the bottom toolbar covers is given to the engine while the toolbar shows and is zero
|
||||
/// when the interface is hidden.
|
||||
#[test]
|
||||
fn the_toolbar_strip_is_zero_when_the_interface_is_hidden() {
|
||||
let (mut u, _, _) = setup();
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.session.ds().unwrap().view.bottom_inset > 0.0, "shown");
|
||||
u.app.session.ds_mut().unwrap().view.ui_hidden = true;
|
||||
u.frame(vec![]);
|
||||
assert_eq!(u.app.session.ds().unwrap().view.bottom_inset, 0.0, "hidden");
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
+4
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user