Merge upstream/main into feat/web-embed-scripts

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Trevor BrownandClaude Fable 5.1 committed 2026-11-06 21:40:10 -05:00
commit 38b2b78ebc
153 files changed
+3625 -460

No files matched your search

+6 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not eachLine truncated
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (Line truncated
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
| Stability | 55% (damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, #1125) | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts; the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian, #2014); Arabic draft: 2,508 entries, native review and panel mirroring pending | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
@@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-11-05** · M10, #2014: the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic (`bidi::script_fallbacks`: one sample letter per script, the system's families for each; Bengali and Thai keep `crate::cjk`'s choice), so a layer name or a String variable's Amharic value draws in the panels instead of boxes. Test: `bidi::tests::other_scripts_take_an_installed_system_face`.
- **2026-11-05** · M4, #1830: a paragraph's base direction can be forced (Text ▸ Text direction: Auto, Left to right, Right to left) as Add right-to-left text says ("controls are available to override text direction per paragraph"). `ParaList::direction`, `text.direction {direction, ids?}` and the three menu commands (the edited paragraphs while editing, else the layer's), `node.set textDirection`, the Type settings control once a right-to-left script is in the text, `textDirection` / `lineDirections` in inspect, the CSS `direction`, and the `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` per paragraph). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests`.
- **2026-11-05** · M1, #2003: the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code from rectangles and polygons (two offset squares, the result filled at reduced alpha); each entry still runs its command.
- **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged.
- **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged.
@@ -359,6 +361,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes asks Don't Save / Cancel / Save, as closing the window does, instead of a toast that left the tab open; Alt+close still discards at once. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Numbers unchanged |
| 2026-11-06 | patch | M8, #1906: under Wayland a minimised window answers control-channel and attached-MCP requests from its logic passes once it has drawn nothing for 0.2 s while a request waits (about half a second each; every request timed out after 60 s before). Test: `idle_tests::a_window_that_stopped_drawing_answers_from_its_logic_passes`. Numbers unchanged |
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged |
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged |
| 2026-11-06 | patch | M9, #2026: a branch merge counts one conflict per auto layout field changed both ways, not one for every size and place auto layout works out from it; values an instance takes from its main no longer conflict, and auto layout settings changed one way survive a conflict on another field. Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`. Numbers unchanged |
+3
View File
@@ -112,3 +112,6 @@ display_name = "Denis Gomes"
[people.Hamza0101]
real_name = "Hamza Naveed"
display_name = "Hamza0101"
[people.ashokram-si]
real_name = "Ashok Ram"
+3
View File
@@ -737,6 +737,9 @@ impl<B: Backend> Hub<B> {
continue;
}
let Some(&conn) = room.ids.get(&id) else { continue };
if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) {
continue;
}
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) {
stuck.push(conn);
@@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
let out = Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: 32,
block: Arc::new(AtomicUsize::new(0)),
all: Arc::new(AtomicUsize::new(0)),
max: 1 << 20,
+2
View File
@@ -235,6 +235,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
let out = Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: limits.queue.max(4) / 2,
block: block.queued.clone(),
all: shared.queued.clone(),
max: limits.max_queued_bytes,
+17 -1
View File
@@ -48,6 +48,7 @@ pub(crate) struct Charges {
pub(super) struct Charge {
n: usize,
mine: Arc<AtomicUsize>,
waiting: Arc<AtomicUsize>,
_all: Arc<Held>,
_block: Option<Arc<Held>>,
}
@@ -55,6 +56,7 @@ pub(super) struct Charge {
impl Drop for Charge {
fn drop(&mut self) {
self.mine.fetch_sub(self.n, Ordering::Relaxed);
self.waiting.fetch_sub(1, Ordering::Relaxed);
}
}
@@ -76,6 +78,10 @@ pub(crate) enum Push {
pub(crate) struct Line {
pub(crate) tx: SyncSender<Out>,
pub(crate) queued: Arc<AtomicUsize>,
/// Messages waiting in the queue.
pub(crate) waiting: Arc<AtomicUsize>,
/// With this many waiting, presence is left out (see [`Line::takes_presence`]).
pub(crate) max_presence: usize,
pub(crate) block: Arc<AtomicUsize>,
pub(crate) all: Arc<AtomicUsize>,
pub(crate) max: usize,
@@ -84,6 +90,15 @@ pub(crate) struct Line {
}
impl Line {
/// Whether presence goes in the queue now. A client still taking in a long message (the welcome
/// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors
/// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up
/// for by the next update, and this keeps room for what can't be: other people's edits. A client
/// that reads nothing at all still ends at the write timeout.
pub(crate) fn takes_presence(&self) -> bool {
self.waiting.load(Ordering::Relaxed) < self.max_presence
}
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
/// gets out). `charges` is what the message counts against, shared by its recipients;
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
@@ -118,7 +133,8 @@ impl Line {
}
};
self.queued.fetch_add(n, Ordering::Relaxed);
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
self.waiting.fetch_add(1, Ordering::Relaxed);
let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block };
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
TrySendError::Full(_) => Push::Full,
TrySendError::Disconnected(_) => Push::Gone,
+38 -2
View File
@@ -1,7 +1,8 @@
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
//! broadcast counts once against the server's budget however many copies are queued, a block of
//! addresses can't take more than its share, and when the server's budget is used up the
//! connection holding the most is shed, not the next client that needs a little.
//! connection holding the most is shed, not the next client that needs a little. Presence stays out
//! of a busy queue.
use std::collections::HashMap;
use std::net::{IpAddr, TcpListener, TcpStream};
@@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
/// A line with its own queue counter, the given block counter and the server's.
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
let (tx, rx) = sync_channel(64);
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
(
Line {
tx,
queued: Arc::default(),
waiting: Arc::default(),
max_presence: 32,
block: block.clone(),
all: all.clone(),
max: 1 << 20,
max_block,
max_all,
},
rx,
)
}
fn socket() -> TcpStream {
@@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() {
let _c = blocks.get("203.0.113.3".parse().unwrap());
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
}
#[test]
fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() {
let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX);
let msg = Bytes::from_static(b"x");
for _ in 0..32 {
assert!(l.takes_presence());
l.push(&msg, &mut Charges::default(), false).unwrap();
}
assert!(!l.takes_presence(), "half full");
// Edits still fit in the half kept for them.
for _ in 0..32 {
l.push(&msg, &mut Charges::default(), false).unwrap();
}
assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends");
// It catches up: presence goes in again.
for _ in 0..33 {
drop(rx.recv().unwrap());
}
assert!(l.takes_presence());
assert_eq!(l.waiting.load(Ordering::Relaxed), 31);
}
+4 -1
View File
@@ -73,7 +73,10 @@ fn css_hue_units_and_none() {
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
}
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
// A missing alpha is zero like any other missing component (CSS Color 4 §4.4), so it is
// transparent; only an *omitted* alpha defaults to opaque.
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 0]);
assert_eq!(Color::parse_css("oklch(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
}
#[test]
+55 -24
View File
@@ -158,14 +158,9 @@ impl Color {
}
if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) {
let parts = css_args(inner)?;
let num = |i: usize| -> Option<f32> {
let p = parts.get(i)?;
if let Some(pc) = p.strip_suffix('%') {
pc.trim().parse::<f32>().ok().map(|v| v / 100.0)
} else {
p.parse::<f32>().ok().map(|v| v / 255.0)
}
};
// A channel is a number of 255, a percentage or `none`; out-of-range values (a number
// too big for f32 included) clamp in `Color::new`, as CSS Color 4 §5.1 asks.
let num = |i: usize| css_fraction(parts.get(i)?, 255.0);
let alpha = match parts.get(3) {
Some(a) => parse_alpha(a)?,
None => 1.0,
@@ -177,10 +172,12 @@ impl Color {
}
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
let parts = css_args(inner)?;
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
if !(h.is_finite() && s.is_finite() && l.is_finite()) {
// The hue takes every CSS angle unit; saturation and lightness are percentages, numbers
// read as percentages (CSS Color 4 §7) or `none`.
let h = css_hue(parts.first()?)?;
let s = css_fraction(parts.get(1)?, 100.0)?;
let l = css_fraction(parts.get(2)?, 100.0)?;
if !(s.is_finite() && l.is_finite()) {
return None;
}
let alpha = match parts.get(3) {
@@ -209,14 +206,23 @@ impl Color {
/// CSS `rgba(...)` / hex text for copy-as-CSS.
pub fn to_css(self) -> String {
let [r, g, b, _] = self.to_rgba8();
if self.a >= 0.999 {
let [r, g, b, a8] = self.to_rgba8();
let a = clamp01(self.a);
if a >= 0.999 {
return self.to_css_hex();
}
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
let short = format!("{:.2}", self.a);
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
// The fewest decimals that read back as the same 8-bit alpha: two, else three, else three
// of the 8-bit value itself (within half a step of it, so it always reads back).
let keeps = |s: &str| s.parse::<f32>().is_ok_and(|v| (v * 255.0).round() as u8 == a8);
let two = format!("{a:.2}");
let three = format!("{a:.3}");
let alpha = if keeps(&two) {
two
} else if keeps(&three) {
three
} else {
format!("{:.3}", f32::from(a8) / 255.0)
};
format!("rgba({r}, {g}, {b}, {alpha})")
}
@@ -268,19 +274,42 @@ fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
let l = css_number(parts.first()?, l_full)?;
let c = css_number(parts.get(1)?, c_full)?;
let h = css_hue(parts.get(2)?)?;
let alpha = match parts.get(3).map(String::as_str) {
None | Some("none") => 1.0,
let alpha = match parts.get(3) {
None => 1.0,
Some(a) => parse_alpha(a)?,
};
Some(([l, c, h], alpha))
}
/// A CSS `<number>` as f32: a sign, digits, a decimal point and an exponent only. Rust's float
/// parser also reads `inf`, `infinity` and `nan`, which are identifiers in CSS, not numbers. A
/// value too big for f32 comes back as an infinity for the caller to clamp or refuse.
fn css_f32(s: &str) -> Option<f32> {
if !s.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) {
return None;
}
s.parse::<f32>().ok()
}
/// A CSS number, percentage or `none` as a fraction of one: a percentage is of 100, a bare number
/// of `number_full` (255 for an rgb() channel, 100 for hsl() saturation and lightness, 1 for
/// alpha) and `none` is 0 (CSS Color 4 §4.4).
fn css_fraction(p: &str, number_full: f32) -> Option<f32> {
if p == "none" {
return Some(0.0);
}
match p.strip_suffix('%') {
Some(pc) => Some(css_f32(pc)? / 100.0),
None => Some(css_f32(p)? / number_full),
}
}
/// A CSS number, a percentage of `full` or `none` (0); finite only.
fn css_number(p: &str, full: f32) -> Option<f32> {
let v = match p.strip_suffix('%') {
_ if p == "none" => 0.0,
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
None => p.parse::<f32>().ok()?,
Some(pc) => css_f32(pc)? / 100.0 * full,
None => css_f32(p)?,
};
v.is_finite().then_some(v)
}
@@ -294,12 +323,14 @@ fn css_hue(p: &str) -> Option<f32> {
.into_iter()
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
.unwrap_or((p, 1.0));
let v = num.parse::<f32>().ok()? / per_degree;
let v = css_f32(num)? / per_degree;
v.is_finite().then_some(v)
}
/// A CSS `<alpha-value>`: a number, a percentage or `none` (0: a missing alpha is transparent, CSS
/// Color 4 §4.4); finite only. An *omitted* alpha is the caller's default of 1.
fn parse_alpha(a: &str) -> Option<f32> {
let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }?;
let v = css_fraction(a, 1.0)?;
v.is_finite().then_some(v)
}
+99
View File
@@ -195,3 +195,102 @@ fn css_rejects_empty_arguments() {
assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some());
assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some());
}
#[test]
fn css_rgb_rejects_non_numeric_channels() {
// `nan`, `inf` and `infinity` are identifiers, not CSS numbers; the hsl(), lch() and oklch()
// branches and the alpha already refuse them, and so must rgb() channels (which previously read
// `rgb(nan, 0, 0)` as black and `rgb(inf, 0, 0)` as red).
for s in ["rgb(nan, 0, 0)", "rgb(inf, 0, 0)", "rgb(0, 0, infinity)", "rgb(-inf, 0, 0)", "rgb(nan%, 0, 0)", "rgb(NaN 0 0)", "rgba(0, 0, 0, inf)"] {
assert_eq!(Color::parse_css(s), None, "{s}");
}
// A genuine number that overflows f32 is still a number and clamps (CSS Color 4 §5.1).
assert_eq!(Color::parse_css("rgb(1e40, -1e40, 0)").unwrap().to_rgba8(), [255, 0, 0, 255]);
assert_eq!(Color::parse_css("rgb(+255, .5%, 1E2)").unwrap().to_rgba8(), [255, 1, 100, 255]);
}
#[test]
fn css_hsl_takes_every_hue_unit() {
// CSS Color 4 §4.3: `<hue> = <number> | <angle>`, so turn, grad and rad are as valid as deg.
let cyan = Color::parse_css("hsl(180, 100%, 50%)").unwrap();
assert_eq!(cyan.to_hex(), "00FFFF");
let units =
["hsl(0.5turn, 100%, 50%)", "hsl(200grad 100% 50%)", "hsl(3.1415927rad 100% 50%)", "hsl(180deg 100% 50%)", "hsla(-0.5turn, 100%, 50%, 1)"];
for s in units {
assert_eq!(Color::parse_css(s).map(|c| c.to_hex()), Some(cyan.to_hex()), "{s}");
}
// One unit only: a repeated or stray unit is not a dimension.
for s in ["hsl(120degdeg, 100%, 50%)", "hsl(120, 100%%, 50%)", "hsl(0.5turndeg 100% 50%)", "hsl(deg 100% 50%)"] {
assert_eq!(Color::parse_css(s), None, "{s}");
}
}
#[test]
fn css_none_is_zero_in_every_function() {
// CSS Color 4 §4.4: every modern-syntax component may be `none`, and outside interpolation a
// missing component "behaves as a zero value" -- including a missing alpha, which renders
// fully transparent.
assert_eq!(Color::parse_css("rgb(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
assert_eq!(Color::parse_css("rgb(255 none none)").unwrap().to_rgba8(), [255, 0, 0, 255]);
assert_eq!(Color::parse_css("rgb(255 0 0 / none)").unwrap().to_rgba8(), [255, 0, 0, 0]);
assert_eq!(Color::parse_css("hsl(none 100% 50%)").unwrap().to_hex(), "FF0000");
assert_eq!(Color::parse_css("hsl(120 none 50%)").unwrap().to_hex(), "808080");
assert_eq!(Color::parse_css("hsl(120 100% none)").unwrap().to_hex(), "000000");
assert_eq!(Color::parse_css("hsl(120 100% 50% / none)").unwrap().to_rgba8(), [0, 255, 0, 0]);
assert_eq!(Color::parse_css("oklch(0.5 0.1 20 / none)").unwrap().a, 0.0);
assert_eq!(Color::parse_css("lch(50 50 50 / none)").unwrap().a, 0.0);
// An omitted alpha still defaults to opaque.
assert_eq!(Color::parse_css("rgb(none none none)").unwrap().a, 1.0);
}
#[test]
fn to_css_keeps_the_8_bit_alpha_for_any_alpha() {
// Three decimals of the alpha can still round across an 8-bit step (0.0055 -> "0.006" reads
// back as 2/255, not 1/255); the text must always read back to the same 8-bit alpha.
for i in 0..=10_000u32 {
let a = i as f32 / 10_000.0;
let c = Color::new(0.1, 0.2, 0.3, a);
let css = c.to_css();
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some(c.to_rgba8()), "alpha {a}: {css}");
}
let c = Color::new(0.1, 0.2, 0.3, 0.0055);
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8()[3], 1, "{}", c.to_css());
// Still the short forms where they are exact.
assert_eq!(Color::new(1.0, 0.0, 0.0, 0.5).to_css(), "rgba(255, 0, 0, 0.50)");
assert_eq!(Color::from_rgba8(255, 0, 0, 85).to_css(), "rgba(255, 0, 0, 0.333)");
}
#[test]
fn to_css_sanitizes_an_unclamped_alpha() {
// The RGB channels go through `to_rgba8` and are sanitized; the alpha must be too, or a colour
// built field-by-field prints `rgba(0, 0, 0, NaN)` / `rgba(0, 0, 0, -0.50)`, which is not CSS.
for a in [f32::NAN, -0.5, f32::NEG_INFINITY] {
let c = Color { r: 0.0, g: 0.0, b: 0.0, a };
let css = c.to_css();
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some([0, 0, 0, 0]), "alpha {a}: {css}");
}
assert_eq!(Color { r: 1.0, g: 0.0, b: 0.0, a: f32::INFINITY }.to_css(), "#FF0000");
}
#[test]
fn css_hue_wraps_a_whole_turn_and_calc_is_not_a_number() {
// A hue is an angle (CSS Color 4 §4.3): a whole turn (360, 1turn, 400grad, 2 pi rad) is 0 and a
// negative hue counts back from it, in hsl() and in the polar spaces alike. `calc()` is not
// read, so `calc(NaN)` and `calc(infinity)` are refused like any other non-number rather than
// read as 0 or clamped.
let red = Color::parse_css("hsl(0 100% 50%)").unwrap().to_rgba8();
for s in ["hsl(360 100% 50%)", "hsl(1turn 100% 50%)", "hsl(400grad 100% 50%)", "hsl(6.2831855rad 100% 50%)", "hsl(-1turn 100% 50%)"] {
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(red), "{s}");
}
let violet = Color::parse_css("hsl(270 100% 50%)").unwrap().to_rgba8();
for s in ["hsl(-90deg 100% 50%)", "hsl(-0.25turn 100% 50%)", "hsl(-100grad 100% 50%)", "hsl(630 100% 50%)"] {
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(violet), "{s}");
}
assert_eq!(Color::parse_css("oklch(0.5 0.1 1turn)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 0)").map(|c| c.to_rgba8()));
assert_eq!(Color::parse_css("lch(50 50 400grad)").map(|c| c.to_rgba8()), Color::parse_css("lch(50 50 0)").map(|c| c.to_rgba8()));
assert_eq!(Color::parse_css("oklch(0.5 0.1 -90deg)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 270)").map(|c| c.to_rgba8()));
for s in ["hsl(calc(NaN) 100% 50%)", "rgb(calc(infinity) 0 0)", "rgb(0 0 0 / calc(1))", "oklch(0.5 0.1 calc(20deg))", "hsl(calc(1turn) 100% 50%)"]
{
assert_eq!(Color::parse_css(s), None, "{s}");
}
}
+1 -1
View File
@@ -79,7 +79,7 @@ impl Style {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
match &mut self.kind {
StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)),
StyleKind::Text { style } => style.for_each_color_mut(f),
StyleKind::Text { style, .. } => style.for_each_color_mut(f),
StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)),
StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)),
}
+19 -7
View File
@@ -28,8 +28,9 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
p
}
NodeKind::Vector { network } => {
let f = network.fill_path();
if f.elements().is_empty() { network.stroke_path() } else { f }
let net = drawn_network(network, c.smoothing);
let f = net.fill_path();
if f.elements().is_empty() { net.stroke_path() } else { f }
}
NodeKind::Text(_) => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
NodeKind::Page { .. } | NodeKind::Group | NodeKind::BooleanOp { .. } => return None,
@@ -39,11 +40,21 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
/// The open/closed path strokes follow (vector networks stroke every segment, filled or not).
pub fn stroke_geometry(n: &Node) -> Option<BezPath> {
match &n.kind {
NodeKind::Vector { network } => Some(network.stroke_path()),
NodeKind::Vector { network } => Some(drawn_network(network, n.corners.smoothing).stroke_path()),
_ => shape_path(n),
}
}
/// A vector's `network` as the layer draws it: rounded points drawn in with the layer's corner
/// `smoothing` (see [`VectorNetwork::rounded_smoothed`](crate::VectorNetwork::rounded_smoothed)),
/// or the network itself when no point is rounded.
pub fn drawn_network(network: &crate::VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, crate::VectorNetwork> {
match network.rounded_smoothed(smoothing) {
Some(r) => std::borrow::Cow::Owned(r),
None => std::borrow::Cow::Borrowed(network),
}
}
/// Whether the shape's path is closed (fills apply).
pub fn is_closed(n: &Node) -> bool {
match &n.kind {
@@ -75,8 +86,8 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
if !outline {
for (i, region) in net.regions.iter().enumerate().rev() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
@@ -96,10 +107,11 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
}
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains) on the network as drawn: even-odd when any
/// region asks for it).
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
match &n.kind {
NodeKind::Vector { network } => network.fill_contains(lp),
NodeKind::Vector { network } => drawn_network(network, n.corners.smoothing).fill_contains(lp),
_ => path.winding(lp) != 0,
}
}
+23 -4
View File
@@ -14,6 +14,8 @@
//! space 0. The session server accepts a new object only with an id in its creator's space or
//! space 0, so two people never make the same id.
use std::collections::BTreeSet;
use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId};
/// What style ids start with.
@@ -74,25 +76,42 @@ impl Document {
/// A new style id.
pub fn new_style_id(&mut self) -> StyleId {
let taken: Vec<u64> = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect();
self.new_style_id_avoiding(&BTreeSet::new())
}
/// A new style id that is none of `avoid` either: ids of styles about to come in with a file
/// being imported.
pub fn new_style_id_avoiding(&mut self, avoid: &BTreeSet<StyleId>) -> StyleId {
let taken: Vec<u64> = self.styles.iter().map(|s| &s.id).chain(avoid).filter_map(|id| id_number(id, STYLE_ID_PREFIX)).collect();
if let Some(n) = self.session_number(taken) {
return format!("{STYLE_ID_PREFIX}{n}");
}
(self.styles.len() + 1..)
.map(|k| format!("{STYLE_ID_PREFIX}{k}"))
.find(|id| self.style(id).is_none())
.find(|id| self.style(id).is_none() && !avoid.contains(id))
.unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0"))
}
/// A new variable id.
pub fn new_variable_id(&mut self) -> VariableId {
let taken: Vec<u64> = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect();
self.new_variable_id_avoiding(&BTreeSet::new())
}
/// A new variable id that is none of `avoid` either (see [`Self::new_style_id_avoiding`]).
pub fn new_variable_id_avoiding(&mut self, avoid: &BTreeSet<VariableId>) -> VariableId {
let taken = self.variables.variables.iter().map(|v| &v.id).chain(avoid).filter_map(|id| id_number(id, VARIABLE_ID_PREFIX)).collect();
self.new_numbered_id(VARIABLE_ID_PREFIX, taken)
}
/// A new variable collection id.
pub fn new_collection_id(&mut self) -> CollectionId {
let taken: Vec<u64> = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect();
self.new_collection_id_avoiding(&BTreeSet::new())
}
/// A new variable collection id that is none of `avoid` either (see
/// [`Self::new_style_id_avoiding`]).
pub fn new_collection_id_avoiding(&mut self, avoid: &BTreeSet<CollectionId>) -> CollectionId {
let taken = self.variables.collections.iter().map(|c| &c.id).chain(avoid).filter_map(|id| id_number(id, COLLECTION_ID_PREFIX)).collect();
self.new_numbered_id(COLLECTION_ID_PREFIX, taken)
}
+16
View File
@@ -1,6 +1,8 @@
//! Ids of styles, variables, collections, modes and comments: unchanged outside a live session,
//! from the client's own space during one.
use std::collections::BTreeSet;
use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of};
use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind};
@@ -93,6 +95,20 @@ fn ids_of_the_space_already_in_the_document_stay_taken() {
assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1));
}
#[test]
fn new_ids_can_avoid_ids_about_to_come_in() {
let mut d = Document::new("a");
d.styles.push(style("S:1"));
let avoid: BTreeSet<String> = ["S:2", "S:3", "VariableID:4", "VariableCollectionId:2", "lib:VariableID:9"].map(String::from).into();
assert_eq!(d.new_style_id_avoiding(&avoid), "S:4");
assert_eq!(d.new_variable_id_avoiding(&avoid), "VariableID:5");
assert_eq!(d.new_collection_id_avoiding(&avoid), "VariableCollectionId:3");
// In a session the numbers come from the client's space, above those of it to avoid.
let avoid: BTreeSet<String> = [format!("VariableID:{}", C3 | 7)].into();
d.set_client_id(3);
assert_eq!(d.new_variable_id_avoiding(&avoid), format!("VariableID:{}", C3 | 8));
}
#[test]
fn the_highest_space_counts_every_kind_of_id() {
let mut d = Document::new("a");
+1 -1
View File
@@ -1230,7 +1230,7 @@ impl Document {
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
_ => None,
})
.collect();
+6 -2
View File
@@ -416,9 +416,13 @@ impl NodeKind {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
/// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network
/// has a closed region (an open path's rounded points are drawn without it).
pub fn has_corner_smoothing(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
match self {
NodeKind::Vector { network } => !network.regions.is_empty(),
_ => self.has_corner_radius(),
}
}
pub fn text(&self) -> Option<&TextData> {
+17 -5
View File
@@ -1,7 +1,7 @@
//! Shared styles and variables.
use crate::props::{Effect, LayoutGrid};
use crate::text::TextStyle;
use crate::text::{ParagraphStyle, TextStyle};
use figcraft_color::{Color, Paint};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
@@ -14,10 +14,22 @@ pub type ModeId = String;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum StyleKind {
Paint { paints: Vec<Paint> },
Text { style: TextStyle },
Effect { effects: Vec<Effect> },
Grid { grids: Vec<LayoutGrid> },
Paint {
paints: Vec<Paint>,
},
Text {
style: TextStyle,
/// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum
/// small.
#[serde(default, skip_serializing_if = "crate::node::is_default")]
paragraph: Box<ParagraphStyle>,
},
Effect {
effects: Vec<Effect>,
},
Grid {
grids: Vec<LayoutGrid>,
},
}
impl StyleKind {
+204
View File
@@ -679,6 +679,210 @@ fn two_segment_closed_loop_rounded_corners() {
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
let path = net.fill_path();
assert!(!path.elements().is_empty());
// Both points sit next to the curved segment, so smoothing keeps their plain arcs.
assert_eq!(net.rounded_smoothed(0.6), net.rounded(), "a corner next to a curve keeps the plain arc");
}
/// A closed 100 × 100 square whose corners (top left, top right, bottom right, bottom left) have
/// radii `radii`.
fn rounded_square(radii: [f64; 4]) -> VectorNetwork {
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let mut net = VectorNetwork::from_points(&pts, true);
for (v, r) in net.vertices.iter_mut().zip(radii) {
v.radius = r;
}
net
}
/// The area a closed path encloses, whichever way it runs.
fn area_of(p: &figcraft_geom::BezPath) -> f64 {
use figcraft_geom::Shape;
p.area().abs()
}
#[test]
fn closed_vectors_smooth_each_rounded_point_with_its_own_radius() {
use figcraft_geom::Shape;
use figcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
// (point radii, smoothing, derivation): a closed square draws the smoothed rectangle's corners,
// each point keeping its own radius and a point without one staying sharp.
let rows: Vec<([f64; 4], f64, &str)> = vec![
([20.0; 4], 0.6, "one radius on every point"),
([30.0, 0.0, 10.0, 0.0], 0.6, "each point keeps its own radius; points without one stay sharp"),
([12.0; 4], 1.0, "full smoothing"),
([40.0; 4], 0.6, "a corner too large for its edges loses smoothing first"),
];
for (radii, s, why) in rows {
let net = rounded_square(radii);
let smoothed = net.rounded_smoothed(s).unwrap().fill_path();
// The rectangle's corners take [top left, top right, bottom right, bottom left] radii.
let want = area_of(&figcraft_geom::rounded_rect(100.0, 100.0, radii, s));
let got = area_of(&smoothed);
assert!((got - want).abs() < 0.1, "{why}: {got} vs the smoothed rectangle's {want}");
assert!((got - area_of(&net.fill_path())).abs() > 1.0, "{why}: smoothing changes the outline");
let b = smoothed.bounding_box();
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
}
// The sharp points stay on the outline, and the outline has no kinks at the rounded points.
let smoothed = rounded_square([30.0, 0.0, 10.0, 0.0]).rounded_smoothed(0.6).unwrap();
let path = smoothed.fill_path();
for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0)] {
let d = path.segments().map(|seg| seg.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min);
assert!(d < 1e-12, "the sharp point {p:?} stays a corner");
}
let segs: Vec<_> = path.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect();
let mut kinks = 0;
for (i, seg) in segs.iter().enumerate() {
let next = segs[(i + 1) % segs.len()];
let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize();
let into = (next.eval(1e-6) - next.eval(0.0)).normalize();
if out.dot(into) < 0.999 {
kinks += 1;
}
}
assert_eq!(kinks, 2, "only the two sharp points turn without a curve");
assert_eq!(smoothed.regions[0].loops[0].len(), smoothed.segments.len(), "the loop walks every curve once");
}
#[test]
fn corner_smoothing_leaves_open_paths_and_zero_alone() {
let square = rounded_square([20.0; 4]);
assert_eq!(square.rounded_smoothed(0.0), square.rounded(), "no smoothing draws the plain rounded points");
assert_eq!(square.rounded_smoothed(f64::NAN), square.rounded(), "an unusable smoothing draws no smoothing");
// An open path (no closed region) keeps its plain arcs.
let pts = [Point::new(200.0, 0.0), Point::new(250.0, 0.0), Point::new(250.0, 50.0)];
let mut open = VectorNetwork::from_points(&pts, false);
for v in &mut open.vertices {
v.radius = 20.0;
}
assert!(open.regions.is_empty());
assert_eq!(open.rounded_smoothed(0.6), open.rounded(), "an open path takes no smoothing");
// In a network with a closed square and a separate open branch, only the square smooths.
let mut mixed = square.clone();
let base = mixed.vertices.len();
mixed.vertices.extend(open.vertices.iter().copied());
mixed.segments.extend(open.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
let runs = mixed.rounded_smoothed(0.6).unwrap().stroke_runs();
let open_run = runs.iter().find(|r| !r.closed).expect("the open branch");
assert_eq!(open_run.path, open.rounded().unwrap().stroke_runs()[0].path, "the open branch keeps its plain arc");
let closed_run = runs.iter().find(|r| r.closed).expect("the square");
assert_eq!(closed_run.path, square.rounded_smoothed(0.6).unwrap().stroke_runs()[0].path, "the square smooths");
assert_ne!(closed_run.path, square.rounded().unwrap().stroke_runs()[0].path, "the square's smoothed corners differ from its arcs");
}
#[test]
fn smoothed_vector_corners_stay_finite_on_awkward_networks() {
use figcraft_geom::Shape;
// A concave L, a square with a square hole, points with unusable radii and a loop that names a
// missing segment.
let l = [(0.0, 0.0), (100.0, 0.0), (100.0, 40.0), (40.0, 40.0), (40.0, 100.0), (0.0, 100.0)];
let mut concave = VectorNetwork::from_points(&l.map(|(x, y)| Point::new(x, y)), true);
for v in &mut concave.vertices {
v.radius = 1000.0;
}
let mut holed = rounded_square([10.0; 4]);
let hole = rounded_square([5.0; 4]);
let (base, seg_base) = (holed.vertices.len(), holed.segments.len());
holed.vertices.extend(hole.vertices.iter().map(|v| Vertex { x: 25.0 + v.x / 2.0, y: 25.0 + v.y / 2.0, ..*v }));
holed.segments.extend(hole.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
holed.regions[0].loops.push((seg_base..seg_base + hole.segments.len()).collect());
holed.regions[0].winding = WindingRule::EvenOdd;
let mut odd = rounded_square([f64::INFINITY, 10.0, f64::NAN, 10.0]);
odd.regions[0].loops[0].push(99);
for (net, s, why) in [(&concave, 1.0, "concave"), (&holed, 0.6, "holed"), (&odd, 0.6, "odd radii")] {
let drawn = net.rounded_smoothed(s).unwrap();
let b = drawn.fill_path().bounding_box();
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{why}: {b:?}");
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
assert!(!drawn.stroke_path().elements().is_empty(), "{why}");
}
let concave_path = concave.rounded_smoothed(1.0).unwrap().fill_path();
assert_ne!(concave_path, concave.fill_path(), "the concave L smooths");
assert!(concave_path.winding(Point::new(20.0, 60.0)) != 0, "the L stays filled");
let holed_net = holed.rounded_smoothed(0.6).unwrap();
assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole");
}
#[test]
fn clicks_on_a_smoothed_vector_corner_follow_the_drawn_fill() {
// A closed square with rounded points and smoothing: a click between the plain arc and the
// smoothed corner selects the layer only where the smoothed fill is drawn.
let pts = [Point::new(0.0, 0.0), Point::new(1000.0, 0.0), Point::new(1000.0, 1000.0), Point::new(0.0, 1000.0)];
let mut net = VectorNetwork::from_points(&pts, true);
for v in &mut net.vertices {
v.radius = 200.0;
}
let smoothed = net.rounded_smoothed(1.0).unwrap();
let mut d = Document::new("x");
let p = page(&d);
let v = d.create(p, NodeKind::Vector { network: net.clone() }, "V", 0.0, 0.0, 1000.0, 1000.0).unwrap();
let n = d.node_mut(v).unwrap();
n.corners.smoothing = 1.0;
n.strokes.clear();
// Walk the row 20 units below the top edge inward to where each outline starts covering it.
let first_inside = |inside: &dyn Fn(Point) -> bool| (0..5_000).map(|i| f64::from(i) * 0.1).find(|&x| inside(Point::new(x, 20.0)));
let plain = first_inside(&|pt| net.fill_contains(pt)).unwrap();
let drawn = first_inside(&|pt| smoothed.fill_contains(pt)).unwrap();
assert!((plain - drawn).abs() > 5.0, "smoothing moves the corner: plain {plain}, smoothed {drawn}");
let pt = Point::new((plain + drawn) / 2.0, 20.0);
assert_ne!(net.fill_contains(pt), smoothed.fill_contains(pt));
assert_eq!(d.hit_test(p, pt, 0.0, None).is_some(), smoothed.fill_contains(pt), "the click follows the smoothed corner at {pt:?}");
assert_eq!(d.hit_test(p, Point::new(500.0, 500.0), 0.0, None), Some(v));
}
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the
/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop
/// lists its segments and whichever way each one runs (#1951, #1506).
#[test]
fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
for first in 0..corners.len() {
for backwards in [false, true] {
for flipped in 0..(1u32 << corners.len()) {
let mut net = VectorNetwork::from_points(&corners, true);
for v in &mut net.vertices {
v.radius = 43.0;
}
for (i, s) in net.segments.iter_mut().enumerate() {
if flipped & (1 << i) != 0 {
std::mem::swap(&mut s.a, &mut s.b);
std::mem::swap(&mut s.ta, &mut s.tb);
}
}
let lp = &mut net.regions[0].loops[0];
lp.rotate_left(first);
if backwards {
lp.reverse();
}
let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" });
let stroke = net.stroke_path();
let fill = net.fill_path();
let region = net.rounded().unwrap().region_path(0).unwrap();
for (i, p) in corners.iter().enumerate() {
assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p));
assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
assert!(keeps_off(&region, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(&region, *p));
}
assert!(
(length(&fill) - length(&stroke)).abs() < 1e-6,
"{why}: fill {} and stroke {} trace the same outline",
length(&fill),
length(&stroke)
);
assert!(
(length(&region) - length(&stroke)).abs() < 1e-6,
"{why}: region {} and stroke {} trace the same outline",
length(&region),
length(&stroke)
);
}
}
}
}
#[test]
+119 -6
View File
@@ -259,10 +259,24 @@ pub enum ListStyle {
Numbered,
}
/// A paragraph's base direction: by its letters, or forced ("controls are available to override
/// text direction per paragraph", Add right-to-left text).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub enum TextDirection {
/// The first letter with a direction decides (Unicode rules P2 and P3).
#[default]
Auto,
/// Left to right, whatever the letters.
Ltr,
/// Right to left, whatever the letters.
Rtl,
}
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
pub const MAX_LIST_INDENT: u8 = 8;
/// One paragraph's list settings.
/// One paragraph's list settings and base direction.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ParaList {
@@ -276,6 +290,9 @@ pub struct ParaList {
/// (items that go on counting ignore it; other paragraphs are always 0).
#[serde(skip_serializing_if = "crate::node::is_default")]
pub start_offset: u32,
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
#[serde(skip_serializing_if = "crate::node::is_default")]
pub direction: TextDirection,
}
/// Character style. In runs, every field is an override; `None` means "inherit the base".
@@ -307,6 +324,23 @@ pub struct TextStyle {
pub style_id: Option<String>,
}
/// What a text style sets at the paragraph and layer level, beside its character style
/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features
/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType
/// features", Create and apply text styles).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
#[serde(default)]
pub struct ParagraphStyle {
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_spacing: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub paragraph_indent: f64,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub list: ListStyle,
#[serde(skip_serializing_if = "crate::node::is_default")]
pub features: std::collections::BTreeMap<String, bool>,
}
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
@@ -835,6 +869,11 @@ impl TextData {
fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
let (a, b) = self.para_span(start, end);
self.set_para_lists_between(a, b, keep_levels, f);
}
/// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly.
fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
self.fill_paragraphs();
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
f(pl);
@@ -863,6 +902,61 @@ impl TextData {
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
}
/// What a text style made from this text sets at the paragraph and layer level: the paragraph
/// spacing and indent, the list style (the one every paragraph shares; the base one when they
/// differ) and the OpenType features.
pub fn paragraph_style(&self) -> ParagraphStyle {
let list = match self.paragraphs.first() {
Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list,
_ => self.list,
};
ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() }
}
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
/// features on the layer, the list style on every paragraph.
pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) {
self.paragraph_spacing = ps.paragraph_spacing;
self.paragraph_indent = ps.paragraph_indent;
self.features.clone_from(&ps.features);
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
let last = self.paragraph_count() - 1;
self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list);
}
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
/// paragraphs chars `start..end` touch only; the spacing, indent and features are the
/// layer's, as in Figma.
pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) {
self.paragraph_spacing = ps.paragraph_spacing;
self.paragraph_indent = ps.paragraph_indent;
self.features.clone_from(&ps.features);
self.set_para_lists(start, end, |pl| pl.list = ps.list);
}
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) {
let linked: Vec<(usize, usize)> =
self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect();
for (a, b) in linked {
self.take_paragraph_style_in(a, b, ps);
}
}
/// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer
/// level: the layer's spacing, indent and features, and their paragraphs' list style.
pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool {
self.paragraph_spacing == ps.paragraph_spacing
&& self.paragraph_indent == ps.paragraph_indent
&& self.features == ps.features
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
}
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool {
self.shows_paragraph_style_in(0, self.char_count(), ps)
}
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
/// style `id`, defined as `def`.
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
@@ -991,20 +1085,20 @@ mod case_tests {
// Files written before start numbers load with none, and write the same bytes.
let old = r#"{"list":"numbered","indent":1}"#;
let pl: ParaList = serde_json::from_str(old).unwrap();
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0, ..ParaList::default() });
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
let mut t = TextData { characters: "Next".into(), ..Default::default() };
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
// Enter copies it to the new item; different starts don't make the list mixed.
t.replace_range(4, 4, "\nMore");
assert_eq!(t.para_list(1).start_offset, 1);
t.set_para_lists(5, 5, |p| p.start_offset = 0);
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
// Bullets have no number, so no start.
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4, ..ParaList::default() });
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() });
// A plain paragraph has none.
t.set_para_lists(0, 0, |p| p.list = P);
assert_eq!(t.para_list(0), ParaList::default());
@@ -1039,3 +1133,22 @@ mod title_case_tests {
assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s");
}
}
#[cfg(test)]
mod whole_text_paragraph_style_tests {
use super::*;
#[test]
fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() {
let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() };
let mut t = TextData::new("a\nb\n");
t.take_paragraph_style(&ps);
assert_eq!(t.paragraph_count(), 3);
assert_eq!(t.para_list(2).list, ListStyle::Bulleted);
assert_eq!(t.list, ListStyle::Bulleted);
assert!(t.paragraphs.is_empty());
let mut plain = TextData::new("a\nb");
plain.take_paragraph_style(&ps);
assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len()));
}
}
+76 -25
View File
@@ -129,9 +129,10 @@ impl Region {
}
}
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
/// at `b`, and an arc with handles `ta`/`tb` joins them. `cut1`/`cut2`: where each segment is cut,
/// in its own parameter (curved segments keep the rest of their curve).
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), and the curves of `chain`
/// lead from there to where segment `s2` starts again (one arc, or a smoothed corner's easing
/// curves and arc). `cut1`/`cut2`: where each segment is cut, in its own parameter (curved
/// segments keep the rest of their curve).
struct Corner {
v: usize,
s1: usize,
@@ -139,13 +140,19 @@ struct Corner {
s2: usize,
s2_at_a: bool,
a: Point,
b: Point,
ta: (f64, f64),
tb: (f64, f64),
chain: Vec<CornerCurve>,
cut1: f64,
cut2: f64,
}
/// One curve of a rounded point: its handles at its start (`ta`) and its end (`tb`), relative to
/// those ends, and the point it ends at.
struct CornerCurve {
ta: (f64, f64),
tb: (f64, f64),
end: Point,
}
/// Accuracy of arc lengths along curved segments (local units).
const ARCLEN_ACC: f64 = 1e-6;
@@ -382,9 +389,19 @@ impl VectorNetwork {
/// the rest of its curve, the arc leaving along its direction there. `None` when no point is
/// rounded.
pub fn rounded(&self) -> Option<VectorNetwork> {
self.rounded_smoothed(0.0)
}
/// [`Self::rounded`] with the layer's corner smoothing (0..=1): a rounded point between two
/// straight segments that both belong to a region's loops gets a smoothed corner, drawn with
/// the profile of a smoothed polygon's corners (`figcraft_geom::smoothed_corner`). Other
/// rounded points (on open branches, or next to a curved segment) keep the plain arc, and a
/// point without a radius stays sharp. Without smoothing this is [`Self::rounded`].
pub fn rounded_smoothed(&self, smoothing: f64) -> Option<VectorNetwork> {
if !self.vertices.iter().any(|v| v.radius > 0.0) {
return None;
}
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
let mut incident: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
for (i, s) in self.segments.iter().enumerate() {
for (v, at_a) in [(s.a, true), (s.b, false)] {
@@ -393,7 +410,14 @@ impl VectorNetwork {
}
}
}
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?)).collect();
// The segments that bound a closed region, where corners take smoothing.
let mut on_loop = vec![false; if smoothing > 0.0 { self.segments.len() } else { 0 }];
for &i in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
if let Some(x) = on_loop.get_mut(i) {
*x = true;
}
}
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?, smoothing, &on_loop)).collect();
let mut out = self.clone();
for v in &mut out.vertices {
v.radius = 0.0;
@@ -420,26 +444,33 @@ impl VectorNetwork {
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
}
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
// Each rounded point's curves in order from `s1`'s side, by point.
let mut by_vertex: std::collections::BTreeMap<usize, (usize, Vec<usize>)> = std::collections::BTreeMap::new();
for c in corners {
if let Some(v) = out.vertices.get_mut(c.v) {
(v.x, v.y) = (c.a.x, c.a.y);
}
out.vertices.push(Vertex::new(c.b.x, c.b.y));
let nb = out.vertices.len() - 1;
let mut from = c.v;
let mut arcs = Vec::with_capacity(c.chain.len());
for CornerCurve { ta, tb, end } in c.chain {
out.vertices.push(Vertex::new(end.x, end.y));
let to = out.vertices.len() - 1;
out.segments.push(Segment { a: from, b: to, ta, tb });
arcs.push(out.segments.len() - 1);
from = to;
}
if let Some(s) = out.segments.get_mut(c.s2) {
if c.s2_at_a {
s.a = nb;
s.a = from;
} else {
s.b = nb;
s.b = from;
}
}
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
let arc = out.segments.len() - 1;
by_vertex.insert(c.v, arc);
by_vertex.insert(c.v, (c.s1, arcs));
}
// The arc goes between the two segments wherever a loop walks through the point. Each loop
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
// The curves go between the two segments wherever a loop walks through the point, in the
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
// rounded corners isn't quadratic.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
@@ -453,8 +484,12 @@ impl VectorNetwork {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
if let Some(&arc) = by_vertex.get(&cur) {
rebuilt.push(arc);
if let Some((s1, arcs)) = by_vertex.get(&cur) {
if x == *s1 {
rebuilt.extend(arcs.iter().copied());
} else {
rebuilt.extend(arcs.iter().rev().copied());
}
}
}
*lp = rebuilt;
@@ -463,8 +498,9 @@ impl VectorNetwork {
}
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
/// run on smoothly or fold back.
fn corner(&self, v: usize, incident: &[(usize, bool)]) -> Option<Corner> {
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
/// are both `on_loop` gets a smoothed corner.
fn corner(&self, v: usize, incident: &[(usize, bool)], smoothing: f64, on_loop: &[bool]) -> Option<Corner> {
let r = self.vertices.get(v)?.radius;
let [(s1, a1), (s2, a2)] = *incident else { return None };
if !(r > 0.0 && r.is_finite()) || s1 == s2 {
@@ -480,6 +516,24 @@ impl VectorNetwork {
if !(l1 > 1e-9 && l2 > 1e-9) {
return None;
}
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
let closed = |s: usize| on_loop.get(s).copied().unwrap_or(false);
if smoothing > 0.0
&& is_line(&c1)
&& is_line(&c2)
&& closed(s1)
&& closed(s2)
&& let Some(curves) = figcraft_geom::smoothed_corner(c1.p0, u1, u2, l1.min(l2) / 2.0, r, smoothing)
&& let (Some(first), Some(last)) = (curves.first(), curves.last())
{
let (a, b) = (first.p0, last.p3);
let chain = curves
.iter()
.map(|q| CornerCurve { ta: (q.p1.x - q.p0.x, q.p1.y - q.p0.y), tb: (q.p2.x - q.p3.x, q.p2.y - q.p3.y), end: q.p3 })
.collect();
let (cut1, cut2) = ((a - c1.p0).hypot() / l1, (b - c2.p0).hypot() / l2);
return Some(Corner { v, s1, s1_at_a: a1, s2, s2_at_a: a2, a, chain, cut1: own(a1, cut1), cut2: own(a2, cut2) });
}
let t = (r / (angle / 2.0).tan()).min(l1.min(l2) / 2.0);
let ((a, d1, k1), (b, d2, k2)) = (trim(&c1, t, l1), trim(&c2, t, l2));
// A cubic arc: handles of 4/3·tan(φ/4)·ρ along each end's direction, φ the turn between
@@ -490,7 +544,6 @@ impl VectorNetwork {
if !(k.is_finite() && a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
return None;
}
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
Some(Corner {
v,
s1,
@@ -498,9 +551,7 @@ impl VectorNetwork {
s2,
s2_at_a: a2,
a,
b,
ta: (d1.x * k, d1.y * k),
tb: (d2.x * k, d2.y * k),
chain: vec![CornerCurve { ta: (d1.x * k, d1.y * k), tb: (d2.x * k, d2.y * k), end: b }],
cut1: own(a1, k1),
cut2: own(a2, k2),
})
+2 -2
View File
@@ -121,7 +121,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => {
crate::cmd::lists::set_grid_field(grids, i, field, *x);
}
(StyleKind::Text { style }, None, _) => set_text_field(style, key, &val),
(StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val),
_ => {}
}
}
@@ -214,7 +214,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre
&& was.is_none_or(|w| w.kind == n.kind)
{
for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) {
if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() {
if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() {
t.update_text_style(&st.id, style);
}
}
+12
View File
@@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[
"layout",
"leadingTrim",
"letterSpacing",
"lineDirections",
"lineHeight",
"lineIndentations",
"lineTypes",
@@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[
"textDecorationSkipInk",
"textDecorationStyle",
"textDecorationThickness",
"textDirection",
"textPath",
"textStyleRanges",
"type",
@@ -432,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
}
}
// The paragraphs' base direction: one for the layer when they agree, else each one's (Auto
// throughout says nothing).
let dirs: Vec<figcraft_doc::TextDirection> = t.paragraphs.iter().map(|p| p.direction).collect();
if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto)
&& dirs.iter().all(|d| *d == first)
{
insert_prop(o, options, "textDirection", || json!(first));
} else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) {
insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect());
}
if t.list_spacing != 0.0 {
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
}
+2 -2
View File
@@ -134,8 +134,8 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
}
match (&n.kind, part.region) {
(NodeKind::Vector { network }, Some(i)) => {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
let region = net.regions.get(i)?;
let path = net.region_path(i)?;
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
+8
View File
@@ -201,6 +201,7 @@ impl ToolId {
| ToolId::Star
| ToolId::Line
| ToolId::Arrow
| ToolId::BoardShape
)
}
}
@@ -591,6 +592,13 @@ pub struct ToolState {
pub picked: Option<figcraft_color::Color>,
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
pub(crate) widget_hover: Vec<NodeId>,
/// The placement crosshair: where Enter puts the next shape or frame once the arrows have
/// shown it (in the middle of the view) and moved it (`tool.crosshair`), in page coordinates
/// of the document and page in `place_on` (elsewhere it isn't shown: `Session::crosshair`).
/// Placing, Escape and a change of tool take it away.
pub place_at: Option<Point>,
/// The document (uid) and page the crosshair was shown on.
pub(crate) place_on: Option<(u64, NodeId)>,
}
impl ToolState {
+2
View File
@@ -212,6 +212,8 @@ impl Session {
crate::normalize(ds.doc_mut(), &before);
}
self.commit_interaction()?;
// A placement by the pointer takes the placement crosshair with it, as Enter does.
self.tool.place_at = None;
if !self.prefs.keep_tool {
self.set_tool(ToolId::Move);
}
+26 -3
View File
@@ -84,9 +84,14 @@ impl Session {
if self.tool.busy() {
return refuse("finish the drag first");
}
// The crosshair the arrows moved goes with the placement, which lands on it when no point
// is given.
let crosshair = self.crosshair();
self.tool.place_at = None;
let v = &self.ds()?.view;
let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
let (w, h) = tool.click_size();
let (c, zoom) = (at.or(crosshair).unwrap_or(Point::new(v.cx, v.cy)), v.zoom);
// The board Shapes tool centres its own shape on the press; the others get half a click's size.
let (w, h) = if matches!(tool, ToolId::BoardShape) { (0.0, 0.0) } else { tool.click_size() };
let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom);
let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up)));
if r.is_err() {
@@ -111,6 +116,16 @@ impl Session {
self.tool.overlay = Overlay::default();
return self.cancel_interaction();
}
// The placement crosshair goes first; the tool stays. One left on another page or
// document goes quietly.
let shown = self.crosshair().is_some();
self.tool.place_at = None;
if shown {
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
return Ok(());
}
if self.end_origin_edit() {
return Ok(());
}
@@ -174,6 +189,14 @@ impl Session {
/// Snapshot of tool state for the UI / inspect.
pub fn tool_info(&self) -> serde_json::Value {
let shapes: Vec<serde_json::Value> = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect();
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes})
let crosshair = self.crosshair().map(|p| [p.x, p.y]);
json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes, "crosshair": crosshair})
}
/// The placement crosshair, while the document and page it was shown on are the ones in
/// front (`ToolState::place_at`).
pub fn crosshair(&self) -> Option<Point> {
let here = self.ds().ok().map(|d| (d.uid, d.page));
self.tool.place_at.filter(|_| here.is_some() && here == self.tool.place_on)
}
}
@@ -76,6 +76,10 @@ impl Session {
if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) {
let _ = crate::vectoredit::end(self);
}
// The placement crosshair goes with a change of tool, not with the same tool chosen again.
if self.tool.tool != t {
self.tool.place_at = None;
}
self.tool.tool = t;
self.tool.gesture = Gesture::None;
self.tool.pick_only = false;
+65 -2
View File
@@ -658,8 +658,28 @@ pub fn connect(net: &mut VectorNetwork, a: usize, b: usize, ta: (f64, f64), tb:
net.segments.push(Segment { a, b, ta: finite_t(ta), tb: finite_t(tb) });
let si = net.segments.len() - 1;
if ends && let Some(mut lp) = path_between(net, b, a, si) {
let unfilled = !net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if unfilled {
let filled = net.regions.iter().any(|r| r.loops.iter().flatten().any(|s| lp.contains(s)));
if filled {
// A loop broken open at these ends (a point of it deleted, say) closes through the new
// segment, so the fill follows it instead of a straight line across the gap (#1951). The
// segment goes in at the gap, in the loop's own order, so the loop keeps its direction: a
// hole wound against its outline stays a hole whichever end was clicked first. Only a
// loop the segment makes one continuous chain is closed; one listed out of walking order
// is left as it was.
let segs = &net.segments;
let shares = |x: usize, y: usize| segs.get(x).zip(segs.get(y)).is_some_and(|(p, q)| p.a == q.a || p.a == q.b || p.b == q.a || p.b == q.b);
for l in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
if l.len() == lp.len() && l.iter().all(|s| lp.contains(s)) {
let gap = (1..l.len()).find(|&k| l.get(k - 1).zip(l.get(k)).is_some_and(|(&x, &y)| !shares(x, y))).unwrap_or(l.len());
let mut closed = l.clone();
closed.insert(gap, si);
let n = closed.len();
if (0..n).all(|k| closed.get(k).zip(closed.get((k + 1) % n)).is_some_and(|(&x, &y)| shares(x, y))) {
*l = closed;
}
}
}
} else {
lp.insert(0, si);
net.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() });
}
@@ -997,6 +1017,49 @@ mod tests {
assert!(connect(&mut n, 1, 99, (0.0, 0.0), (0.0, 0.0)).is_none());
}
#[test]
fn connecting_the_ends_of_a_hole_keeps_its_winding() {
// A ring: the hole's loop walks the other way round from the outline's, so the non-zero
// rule leaves it empty.
let mut n = square();
for (x, y) in [(25.0, 25.0), (75.0, 25.0), (75.0, 75.0), (25.0, 75.0)] {
n.vertices.push(Vertex::new(x, y));
}
for (a, b) in [(4, 5), (5, 6), (6, 7), (7, 4)] {
n.segments.push(Segment::line(a, b));
}
n.regions[0].loops.push(vec![7, 6, 5, 4]);
let (hole, ring, cut) = (Point::new(40.0, 60.0), Point::new(10.0, 50.0), Point::new(70.0, 30.0));
assert!(!n.fill_contains(hole) && n.fill_contains(ring) && !n.fill_contains(cut), "a ring to start with");
// Delete a corner of the hole (the path opens; its fill closes across the gap, so the cut-off
// corner is filled), then connect the two open ends again, either end first: the hole is a
// triangle, still empty.
let at = |m: &VectorNetwork, (x, y): (f64, f64)| m.vertices.iter().position(|v| v.x == x && v.y == y).unwrap();
for (first, second) in [((25.0, 25.0), (75.0, 75.0)), ((75.0, 75.0), (25.0, 25.0))] {
let mut m = n.clone();
delete_vertices(&mut m, &[5], false);
assert!(!m.fill_contains(hole) && m.fill_contains(cut), "opened: {:?}", m.regions);
let (a, b) = (at(&m, first), at(&m, second));
connect(&mut m, a, b, (0.0, 0.0), (0.0, 0.0)).unwrap();
let loops = &m.regions[0].loops;
assert_eq!((m.regions.len(), loops.len(), loops[1].len()), (1, 2, 3), "{first:?} first: {:?}", m.regions);
assert!(!m.fill_contains(hole), "{first:?} first: the hole is filled, {loops:?}");
assert!(m.fill_contains(ring) && m.fill_contains(cut), "{first:?} first: the ring is filled");
}
}
#[test]
fn connecting_leaves_a_loop_out_of_walking_order_alone() {
// The open chain A-B-C-D-E with its loop listed out of walking order: no place for the new
// segment makes it one continuous chain, so the loop is left as it was, and no region is added.
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0), Point::new(-50.0, 50.0)];
let mut n = VectorNetwork::from_points(&pts, false);
n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() });
let s = connect(&mut n, 0, 4, (0.0, 0.0), (0.0, 0.0)).unwrap();
assert_eq!(n.regions.len(), 1);
assert_eq!(n.regions[0].loops, vec![vec![0, 2, 1, 3]], "left as it was, without segment {s}");
}
#[test]
fn join_merges_overlapping_points_and_connects_ends() {
let mut n = open_line();
@@ -56,12 +56,10 @@ fn region_filled(n: &figcraft_doc::Node, net: &VectorNetwork, i: usize) -> bool
net.regions.get(i).is_some_and(|r| r.fills.as_deref().unwrap_or(&n.fills).iter().any(|f| f.visible))
}
/// The network as its fill draws it: rounded points drawn in (region indexes are kept).
fn drawn(net: &VectorNetwork) -> std::borrow::Cow<'_, VectorNetwork> {
match net.rounded() {
Some(r) => std::borrow::Cow::Owned(r),
None => std::borrow::Cow::Borrowed(net),
}
/// The network as its fill draws it: rounded points drawn in with the layer's corner `smoothing`
/// (region indexes are kept).
fn drawn(net: &VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, VectorNetwork> {
figcraft_doc::geometry::drawn_network(net, smoothing)
}
/// The region of the active layer under page point `p` at `zoom`: none when a point is within
@@ -90,7 +88,7 @@ pub fn region_hit(doc: &Document, ve: &VectorEdit, p: Point, zoom: f64) -> Optio
if (0..net.segments.len()).filter_map(|i| net.cubic(i)).any(|c| c.nearest(lp, 0.01).distance_sq.sqrt() <= sc) {
return None;
}
let drawn = drawn(net);
let drawn = drawn(net, node.corners.smoothing);
(0..drawn.regions.len()).rev().find(|i| region_filled(node, &drawn, *i) && drawn.region_contains(*i, lp))
}
@@ -221,7 +219,7 @@ pub fn region_hatch(doc: &Document, ve: &VectorEdit, regions: &[usize], view: Af
let zoom = view.determinant().abs().sqrt();
let Some(spacing) = hatch_spacing(zoom).filter(|_| !regions.is_empty()) else { return Vec::new() };
let Some(net) = network(doc, ve.id) else { return Vec::new() };
let net = drawn(net);
let net = drawn(net, doc.node(ve.id).map_or(0.0, |n| n.corners.smoothing));
let m = view * doc.world_transform(ve.id);
let anchor = m * Point::ZERO;
let mut out = Vec::new();
@@ -597,3 +597,85 @@ fn the_pen_shows_its_ghost_anchor_on_the_segment_under_it() {
assert_eq!(net(&s, id).vertices.len(), 5, "the press added the point");
assert_eq!(ve(&s).anchor, None, "the click ended the pen's path");
}
/// A rectangle's corner cut off in the editor (a point added on each edge next to it, the corner
/// deleted with Shift+Delete so the two join) and the shape rounded: the fill, which the fill and
/// the selection outline draw, traces the stroke's outline, cut at both ends of the diagonal (#1951).
#[test]
fn a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs() {
for top_first in [true, false] {
let mut s = Session::new();
let id = rect(&mut s);
// Splitting keeps the split edge's index, so the other edge's stays: top 0, right 1.
let (first, second) = if top_first { (0, 1) } else { (1, 0) };
ex(&mut s, "vector.edit.addVertex", json!({"segment": first, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": second, "t": 0.5}));
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.deleteHeal", json!({}));
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "top first {top_first}: a pentagon");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, &format!("top first {top_first}"));
// A region with a fill of its own is drawn through the region path and the planar normaliser.
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(id).unwrap().kind {
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
fill_traces_stroke(&s, id, &format!("top first {top_first}, region fill"));
}
}
/// The chamfer made other ways: the corner deleted plainly, which opens the path between the two
/// new points, and the two connected again; or the pentagon drawn with the pen (#1951).
#[test]
fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() {
let mut s = Session::new();
let id = rect(&mut s);
ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5}));
ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5}));
let corner = vertex_at(&net(&s, id), 100.0, 0.0);
ex(&mut s, "vector.edit.select", json!({"vertices": [corner]}));
ex(&mut s, "vector.edit.delete", json!({}));
let n = net(&s, id);
assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (4, 4), "the path opened: {:?}", n.regions);
let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0));
ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right}));
assert_eq!(net(&s, id).segments.len(), 5, "connected again");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "deleted and connected");
// Drawn with the pen: continue from the end of an open two-point path and close on its start.
let mut s = Session::new();
let r = ex(&mut s, "vector.create", json!({"points": [[0, 0], [50, 0]], "closed": false}));
let id = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "vector.edit.begin", json!({"id": id}));
ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
click(&mut s, 50.0, 0.0);
for (x, y) in [(100.0, 50.0), (100.0, 100.0), (0.0, 100.0), (0.0, 0.0)] {
click(&mut s, x, y);
}
let n = net(&s, id);
assert_eq!((n.vertices.len(), n.segments.len(), n.regions.len()), (5, 5, 1), "closed by the pen");
ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]}));
fill_traces_stroke(&s, id, "drawn with the pen");
}
/// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right
/// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a
/// fill that keeps off every sharp point and traces the same outline as the stroke.
fn fill_traces_stroke(s: &Session, id: NodeId, why: &str) {
use figcraft_geom::BezPath;
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
let n = net(s, id);
assert_eq!(n.regions.len(), 1, "{why}: one region, {:?}", n.regions);
assert_eq!(n.regions[0].loops[0].len(), n.segments.len(), "{why}: every segment in the loop, {:?}", n.regions[0].loops);
let doc = s.doc().unwrap();
let (fill, _) = figcraft_render::node_outline(doc, doc.node(id).unwrap()).unwrap();
let stroke = n.stroke_path();
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
for p in [pt(50.0, 0.0), pt(100.0, 50.0), pt(0.0, 0.0), pt(100.0, 100.0), pt(0.0, 100.0)] {
assert!(keeps_off(&stroke, p) > 2.0, "{why}: the stroke is cut at {p:?} ({:.2} from it)", keeps_off(&stroke, p));
assert!(keeps_off(&fill, p) > 2.0, "{why}: the fill runs into {p:?} ({:.2} from it)", keeps_off(&fill, p));
}
assert!((length(&fill) - length(&stroke)).abs() < 1e-3, "{why}: fill {} and stroke {} trace the same outline", length(&fill), length(&stroke));
}
+6 -1
View File
@@ -216,6 +216,11 @@ pub static MENU: &[MenuItem] = &[
m!("Text/Case", "Uppercase", "text.caseUpper"),
m!("Text/Case", "Lowercase", "text.caseLower"),
m!("Text/Case", "Title case", "text.caseTitle"),
// "Go to Text > Text direction. Select Left to right or Right to left." (Add right-to-left
// text); Auto, the way back to detection, is FigCraft's own (in `OWN`).
m!("Text/Text direction", "Auto", "text.directionAuto"),
m!("Text/Text direction", "Left to right", "text.directionLtr"),
m!("Text/Text direction", "Right to left", "text.directionRtl"),
m!("Text", SEP, ""),
m!("Text", "Missing fonts…", "text.missingFonts"),
// Arrange
@@ -292,7 +297,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
}
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
const OWN: [&str; 5] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop"];
const OWN: [&str; 6] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts", "image.crop", "text.directionAuto"];
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
+1
View File
@@ -15,6 +15,7 @@ use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_strin
use crate::{DocState, EngineError, Result, Session, bad, spec};
mod export;
mod import_ids;
mod load;
mod open;
mod save;
+254
View File
@@ -0,0 +1,254 @@
//! Ids a file being imported brings that the document already uses for something else.
//!
//! Style, variable and collection ids are numbered per file (`S:1`, `VariableID:1`,
//! `VariableCollectionId:1`; see [`figcraft_doc::ids`]), so two files made apart use the same ones
//! for unrelated things. Kept as they were, the imported layers would show the document's
//! variables and styles instead of their own.
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::ids::MODE_ID_INFIX;
use figcraft_doc::proto::{Action, Expr, MAX_EXPR_DEPTH, walk_mut};
use figcraft_doc::{Document, ModeId, NodeId, NodeKind, VariableValue, Variables};
/// A style, variable or collection of the file being imported.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
enum Asset<'a> {
Style(&'a str),
Variable(&'a str),
Collection(&'a str),
}
/// New ids for styles, variables and collections of a file being imported (its id → the new one).
#[derive(Debug, Default)]
pub(super) struct Renames {
styles: BTreeMap<String, String>,
variables: BTreeMap<String, String>,
collections: BTreeMap<String, String>,
}
impl Renames {
/// The new ids `other`'s styles, variables and collections take when appended to `doc`.
///
/// One whose id `doc` doesn't use keeps it. One `doc` already has is shared and keeps its id:
/// the same content under the same id, naming only things that are shared too (the same file
/// imported again). A collection and its variables are shared only all together, so variables
/// of one file never join a collection of another. Library copies keep their ids and are shared
/// with the document's copies, as before (`merge_store` records them). Every other id `doc`
/// already uses is replaced by a new one from `doc`'s allocators, clear of `other`'s ids too.
pub(super) fn new(doc: &mut Document, other: &Document) -> Renames {
let (theirs, libs) = (&other.variables, &other.libraries);
// Each with whether `doc` has the same under its id (None: nothing there), and whether
// it's a library copy.
let styles = other.styles.iter().map(|s| {
let here = doc.styles.iter().find(|m| m.id == s.id);
(Asset::Style(&s.id), here.map(|m| m == s), libs.style_import(&s.id).is_some())
});
let colls = theirs.collections.iter().map(|c| {
let here = doc.variables.collection_by_id(&c.id);
(Asset::Collection(&c.id), here.map(|m| m == c), libs.collection_import(&c.id).is_some())
});
let vars = theirs.variables.iter().map(|v| {
let here = doc.variables.get_by_id(&v.id);
(Asset::Variable(&v.id), here.map(|m| m == v), libs.variable_import(&v.id).is_some())
});
// `taken`: ids `doc` uses; `differs`: what `doc` doesn't have the same of.
let (mut taken, mut copies, mut differs) = (BTreeSet::new(), BTreeSet::new(), BTreeSet::new());
for (a, same, copy) in styles.chain(colls).chain(vars) {
match same {
None => {
differs.insert(a);
}
Some(same) => {
taken.insert(a);
if copy {
copies.insert(a);
} else if !same {
differs.insert(a);
}
}
}
}
// What differs makes what names it differ (library copies aside), and a collection and its
// variables differ together.
let mut named_by: BTreeMap<Asset, Vec<Asset>> = BTreeMap::new();
for v in &theirs.variables {
let (var, coll) = (Asset::Variable(&v.id), Asset::Collection(&v.collection));
named_by.entry(coll).or_default().push(var);
named_by.entry(var).or_default().push(coll);
for r in v.values.values().flat_map(VariableValue::references).chain(&v.extends) {
named_by.entry(Asset::Variable(r)).or_default().push(var);
}
}
for c in &theirs.collections {
if let Some(e) = &c.extends {
named_by.entry(Asset::Collection(e)).or_default().push(Asset::Collection(&c.id));
}
}
for s in &other.styles {
for v in s.bound.values() {
named_by.entry(Asset::Variable(v)).or_default().push(Asset::Style(&s.id));
}
}
let mut todo: Vec<Asset> = differs.iter().copied().collect();
while let Some(a) = todo.pop() {
for &n in named_by.get(&a).into_iter().flatten() {
if !copies.contains(&n) && differs.insert(n) {
todo.push(n);
}
}
}
let renamed = |a: Asset| taken.contains(&a) && differs.contains(&a);
Renames {
collections: fresh(
theirs.collections.iter().map(|c| &c.id),
|id| renamed(Asset::Collection(id)),
|avoid| doc.new_collection_id_avoiding(avoid),
),
variables: fresh(theirs.variables.iter().map(|v| &v.id), |id| renamed(Asset::Variable(id)), |avoid| doc.new_variable_id_avoiding(avoid)),
styles: fresh(other.styles.iter().map(|s| &s.id), |id| renamed(Asset::Style(id)), |avoid| doc.new_style_id_avoiding(avoid)),
}
}
pub(super) fn is_empty(&self) -> bool {
self.styles.is_empty() && self.variables.is_empty() && self.collections.is_empty()
}
/// `other` with the renamed styles, variables and collections under their new ids, and what
/// names them following: layers' style links (text ranges too), bindings, explicit modes and
/// prototype actions; styles' bindings; variables' collections, aliases and calculations; what
/// extended collections and their variables extend; and the ids of the renamed collections'
/// modes, which name their collection (`<collection>/mode<k>`).
pub(super) fn applied(&self, other: &Document) -> Document {
let theirs = &other.variables;
let mut doc = other.clone();
let (styles, vars, colls) = (&self.styles, &self.variables, &self.collections);
for s in &mut doc.styles {
rename(styles, &mut s.id);
s.bound.values_mut().for_each(|v| rename(vars, v));
}
for c in &mut doc.variables.collections {
for m in &mut c.modes {
self.mode(theirs, &c.id, &mut m.id);
}
for id in std::iter::once(&mut c.id).chain(&mut c.extends) {
rename(colls, id);
}
}
for v in &mut doc.variables.variables {
let values = std::mem::take(&mut v.values).into_iter().map(|(mut mode, mut val)| {
self.mode(theirs, &v.collection, &mut mode);
val.references_mut().into_iter().for_each(|r| rename(vars, r));
(mode, val)
});
v.values = values.collect();
let overrides = std::mem::take(&mut v.overrides).into_iter().map(|mut mode| {
self.mode(theirs, &v.collection, &mut mode);
mode
});
v.overrides = overrides.collect();
rename(colls, &mut v.collection);
for id in std::iter::once(&mut v.id).chain(&mut v.extends) {
rename(vars, id);
}
}
let ids: Vec<NodeId> = doc.ids().collect();
for id in ids {
let Some(n) = doc.node_mut(id) else { continue };
let links = [&mut n.styles.fill, &mut n.styles.stroke, &mut n.styles.effect, &mut n.styles.grid, &mut n.styles.text];
for s in links.into_iter().flatten() {
rename(styles, s);
}
if let NodeKind::Text(t) = &mut n.kind {
t.map_text_styles(|s| Some(styles.get(s).cloned().unwrap_or_else(|| s.to_string())));
}
n.bound.values_mut().for_each(|v| rename(vars, v));
n.var_modes = std::mem::take(&mut n.var_modes)
.into_iter()
.map(|(mut c, mut mode)| {
self.mode(theirs, &c, &mut mode);
rename(colls, &mut c);
(c, mode)
})
.collect();
for it in &mut n.interactions {
walk_mut(&mut it.actions, &mut |a| self.action(theirs, a));
}
}
doc
}
/// Rename mode `mode` of collection `of` (`theirs`: the imported file's variables as they were).
/// A mode id names the collection it was made in: `of`, or one `of` extends (an extended
/// collection has the modes of the one it extends). Library copies keep their library's mode
/// ids, which may name one of the file's own collections.
fn mode(&self, theirs: &Variables, of: &str, mode: &mut ModeId) {
let mut at = Some(of);
for _ in 0..=theirs.collections.len() {
let Some(c) = at else { return };
if let Some(k) = mode.strip_prefix(c).and_then(|rest| rest.strip_prefix(MODE_ID_INFIX)) {
if let Some(new) = self.collections.get(c) {
*mode = format!("{new}{MODE_ID_INFIX}{k}");
}
return;
}
at = theirs.collection_by_id(c).and_then(|c| c.extends.as_deref());
}
}
/// Rename what one prototype action names (not the actions nested in it).
fn action(&self, theirs: &Variables, a: &mut Action) {
match a {
Action::SetVariable { variable, expr, .. } => {
rename(&self.variables, variable);
if let Some(e) = expr {
self.expr(theirs, e, 0);
}
}
Action::SetVariableMode { collection, mode } => {
self.mode(theirs, collection, mode);
rename(&self.collections, collection);
}
Action::Conditional { blocks } => blocks.iter_mut().filter_map(|b| b.condition.as_mut()).for_each(|c| self.expr(theirs, c, 0)),
_ => {}
}
}
fn expr(&self, theirs: &Variables, e: &mut Expr, depth: usize) {
match e {
Expr::Value(v) => v.references_mut().into_iter().for_each(|r| rename(&self.variables, r)),
Expr::Op { args, .. } if depth < MAX_EXPR_DEPTH => args.iter_mut().for_each(|a| self.expr(theirs, a, depth + 1)),
Expr::Op { .. } => {}
Expr::Mode { collection } => rename(&self.collections, collection),
Expr::InMode { variable, mode } => {
if let Some(v) = theirs.get_by_id(variable) {
self.mode(theirs, &v.collection, mode);
}
rename(&self.variables, variable);
}
}
}
}
/// New ids, from `new_id`, for those of `ids` that `pick` picks; each clear of all of `ids`.
fn fresh<'a>(
ids: impl Iterator<Item = &'a String> + Clone,
pick: impl Fn(&str) -> bool,
mut new_id: impl FnMut(&BTreeSet<String>) -> String,
) -> BTreeMap<String, String> {
let mut avoid: BTreeSet<String> = ids.clone().cloned().collect();
let mut out = BTreeMap::new();
for id in ids.filter(|id| pick(id)) {
let new = new_id(&avoid);
avoid.insert(new.clone());
out.insert(id.clone(), new);
}
out
}
/// `id` as `map` renames it.
fn rename(map: &BTreeMap<String, String>, id: &mut String) {
if let Some(new) = map.get(id.as_str()) {
*id = new.clone();
}
}
+3 -3
View File
@@ -250,7 +250,7 @@ fn match_font_families(doc: &mut Document) {
}
// Shared text styles too, so they stay like the layers linked to them.
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
fix(style);
}
}
@@ -294,7 +294,7 @@ fn platform_tracking(doc: &mut Document) {
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
track(style);
}
}
@@ -365,7 +365,7 @@ fn sketch_line_heights(doc: &mut Document) {
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
if let StyleKind::Text { style, .. } = &mut st.kind {
set(style);
}
}
+12 -1
View File
@@ -1,3 +1,4 @@
use super::import_ids::Renames;
use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param};
use super::*;
@@ -218,8 +219,18 @@ pub(super) fn import(s: &mut Session, p: &Value) -> Result<Value> {
/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to
/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers
/// their source and interactions and flows their targets wherever those are. Styles, variables and
/// collections whose id the document already has are kept as they are.
/// collections the document already has (the same under the same id, as when a file comes in
/// again) and library copies are shared; others whose id the document uses for something else get
/// new ids, and what comes in names them by those ([`Renames`]).
pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result<Vec<NodeId>> {
let renames = Renames::new(doc, other);
let renamed;
let other = if renames.is_empty() {
other
} else {
renamed = renames.applied(other);
&renamed
};
for (k, v) in &other.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
}
+7 -1
View File
@@ -983,6 +983,12 @@ fn apply_text(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Val
t.set_para_lists(start, end, |p| p.indent = d.clamp(0.0, f64::from(MAX_LIST_INDENT)) as u8);
}
}
"textDirection" | "direction" => {
let d: figcraft_doc::TextDirection = enum_of(v).ok_or_else(|| bad(cmd, "textDirection: auto|ltr|rtl"))?;
let t = text_mut(cmd, doc.get_mut(id)?)?;
let (start, end) = range_of(props, t.char_count());
t.set_para_lists(start, end, |p| p.direction = d);
}
"listSpacing" => text_mut(cmd, doc.get_mut(id)?)?.list_spacing = f64_of(v).filter(|s| s.is_finite()).unwrap_or(0.0).clamp(0.0, 10_000.0),
"hangingPunctuation" | "hangingList" => {
let on = v.as_bool().ok_or_else(|| bad(cmd, format!("`{k}` must be true or false")))?;
@@ -1237,7 +1243,7 @@ pub fn override_group(k: &str) -> &'static str {
"name" => "name",
"characters" | "text" => "characters",
"features" | "fontFeatures" | "openTypeFeatures" | "listStyle" | "indentation" | "listSpacing" | "maxLines" | "variationSettings"
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" => "textStyle",
| "hangingPunctuation" | "hangingList" | "wrapStyle" | "textWrap" | "textDirection" | "direction" => "textStyle",
"cornerRadius" | "cornerSmoothing" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => "corners",
"width" | "height" | "w" | "h" => "size",
"sizingH" | "sizingV" | "layoutSizingHorizontal" | "layoutSizingVertical" | "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => "resizing",
+9 -4
View File
@@ -777,8 +777,11 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
for i in req_targets("vector.simplify", s, p)? {
let doc = s.doc_mut()?;
let n = doc.get_mut(i)?;
let smoothing = n.corners.smoothing;
if let NodeKind::Vector { network } = &mut n.kind {
let source = network.clone();
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
let drawn = figcraft_doc::geometry::drawn_network(&source, smoothing);
let mut net = VectorNetwork::default();
let legacy = source.regions.iter().all(|region| region.fills.is_none() && region.paint_space.is_none());
if legacy && !source.regions.is_empty() {
@@ -787,7 +790,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
} else {
WindingRule::NonZero
};
let path = source.fill_path();
let path = drawn.fill_path();
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let (path, output_winding) = match figcraft_pathops::simplify_with_rule(&path, winding == WindingRule::EvenOdd) {
Ok(path) => (path, WindingRule::EvenOdd),
@@ -797,7 +800,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
append_region_path(&mut net, &path, &combined, output_winding);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let Some(path) = drawn.region_path(index) else { continue };
let path = figcraft_geom::kurbo::simplify::simplify_bezpath(path.iter(), tol, &Default::default());
let path = figcraft_pathops::simplify_with_rule(&path, region.winding == WindingRule::EvenOdd).unwrap_or(path);
append_region_path(&mut net, &path, region, region.winding);
@@ -885,8 +888,10 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
nm.height = bb.height();
continue;
}
// The regions as the layer draws them: rounded points with the layer's corner smoothing.
let drawn = figcraft_doc::geometry::drawn_network(source, n.corners.smoothing);
if legacy {
let path = source.fill_path();
let path = drawn.fill_path();
let even_odd = source.regions.iter().any(|region| region.winding == WindingRule::EvenOdd);
let path = figcraft_pathops::simplify_with_rule(&path, even_odd).map_err(|error| bad("vector.offset", error.to_string()))?;
let ring = figcraft_pathops::stroke_center(&path, d.abs() * 2.0, &props);
@@ -906,7 +911,7 @@ fn offset_path(s: &mut Session, p: &Value) -> Result<Value> {
append_network(&mut net, part);
} else {
for (index, region) in source.regions.iter().enumerate() {
let Some(path) = source.region_path(index) else { continue };
let Some(path) = drawn.region_path(index) else { continue };
let mut paint_region = region.clone();
let (paint_width, paint_height, paint_transform) = region.paint_frame(n.width, n.height);
paint_region.paint_space = Some(RegionPaintSpace { width: paint_width, height: paint_height, transform: paint_transform });
+85 -14
View File
@@ -3,6 +3,7 @@
use std::collections::BTreeMap;
use figcraft_color::{Color, Paint, PaintKind};
use figcraft_doc::ParagraphStyle;
use figcraft_doc::{
Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle,
Variable, VariableId, VariableType, VariableValue, Variables, trim_num,
@@ -261,6 +262,41 @@ fn text_style_of(n: &Node, at: Option<usize>) -> Option<TextStyle> {
Some(at.map_or(&t.style, |a| t.style_at(a)).as_text_style())
}
/// What a text style definition from JSON sets at the paragraph level, in the `node.set`
/// spellings (`paragraphSpacing`, `paragraphIndent`, `listStyle`: none|bulleted|numbered,
/// `features` or `openTypeFeatures`: `{tag: bool}`); keys left out are the defaults.
fn paragraph_style_json(cmd: &str, v: &Value) -> Result<ParagraphStyle> {
let Value::Object(m) = v else { return Ok(ParagraphStyle::default()) };
let mut ps = ParagraphStyle::default();
let length =
|k: &str, x: &Value| f64_of(x).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 10_000.0)).ok_or_else(|| bad(cmd, format!("{k}: a number")));
for (k, x) in m {
match k.as_str() {
"paragraphSpacing" | "paragraph_spacing" => ps.paragraph_spacing = length(k, x)?,
"paragraphIndent" | "paragraph_indent" => ps.paragraph_indent = length(k, x)?,
"listStyle" | "list" => ps.list = super::props::enum_of(x).ok_or_else(|| bad(cmd, "listStyle: none|bulleted|numbered"))?,
"features" | "openTypeFeatures" | "fontFeatures" | "textFeatures" => {
ps.features = super::props::features_of(cmd, x)?.into_iter().filter_map(|(tag, on)| Some((tag, on?))).collect();
}
_ => {}
}
}
Ok(ps)
}
/// What a layer's text sets at the paragraph level, the list style being that of the paragraphs
/// chars `range` touch when given (the layer's when they differ).
fn paragraph_style_of(n: &Node, range: Option<(usize, usize)>) -> Option<ParagraphStyle> {
let t = n.kind.text()?;
let mut ps = t.paragraph_style();
if let Some((a, b)) = range
&& let Some(pl) = t.common_para_list(a, b)
{
ps.list = pl.list;
}
Some(ps)
}
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "style.detach";
let which = req_string(cmd, p, "which")?;
@@ -295,7 +331,7 @@ fn kind_from_node(t: &str, n: &Node) -> Option<StyleKind> {
Some(match t {
"paint" | "color" | "fill" => StyleKind::Paint { paints: n.fills.clone() },
"stroke" => StyleKind::Paint { paints: n.strokes.clone() },
"text" => StyleKind::Text { style: text_style_of(n, None)? },
"text" => StyleKind::Text { style: text_style_of(n, None)?, paragraph: Box::new(paragraph_style_of(n, None)?) },
"effect" => StyleKind::Effect { effects: n.effects.clone() },
"grid" => StyleKind::Grid { grids: n.grids.clone() },
_ => return None,
@@ -317,13 +353,17 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
let kind = if let Some(v) = get(p, "value") {
match t.as_str() {
"paint" | "color" | "fill" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
"grid" => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => return Err(bad(cmd, "type: paint|text|effect|grid")),
}
} else if let Some((tid, a, _)) = range {
StyleKind::Text { style: doc.node(tid).and_then(|n| text_style_of(n, Some(a))).ok_or_else(|| bad(cmd, "not a text layer"))? }
} else if let Some((tid, a, b)) = range {
let n = doc.node(tid).ok_or_else(|| bad(cmd, "not a text layer"))?;
StyleKind::Text {
style: text_style_of(n, Some(a)).ok_or_else(|| bad(cmd, "not a text layer"))?,
paragraph: Box::new(paragraph_style_of(n, Some((a, b))).ok_or_else(|| bad(cmd, "not a text layer"))?),
}
} else {
let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?;
kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))?
@@ -343,7 +383,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
};
doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone(), bound });
// The source layer (or the selected chars) becomes linked to the new style.
if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) {
if let (Some((tid, a, b)), StyleKind::Text { style, .. }) = (range, &kind) {
if let NodeKind::Text(td) = &mut doc.get_mut(tid)?.kind {
td.link_text_style(a, b, style, &sid);
}
@@ -387,11 +427,13 @@ pub fn apply_style_ref(doc: &mut Document, id: NodeId, field: &str, sid: Option<
n.grids = grids.clone();
n.styles.grid = Some(id.clone());
}
("textStyle", Some(Style { kind: StyleKind::Text { style }, id, .. })) => {
// Every char takes the style's typography (their fills and links stay).
("textStyle", Some(Style { kind: StyleKind::Text { style, paragraph }, id, .. })) => {
// Every char takes the style's typography (their fills and links stay), and the layer
// what the style sets at the paragraph level (#1829).
if let NodeKind::Text(t) = &mut n.kind {
let len = t.char_count();
t.link_text_style(0, len, style, id);
t.take_paragraph_style(paragraph);
}
n.styles.text = Some(id.clone());
}
@@ -416,7 +458,7 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
let st = req_string(cmd, p, "style")?;
let style = s.doc()?.style(&st).cloned().ok_or_else(|| bad(cmd, format!("no style `{st}`")))?;
// Text styles go on chars: a range, the selected chars of the edited text, or whole layers.
if let StyleKind::Text { style: def } = &style.kind {
if let StyleKind::Text { style: def, paragraph } = &style.kind {
// A text style is no property of other layers: they're left alone.
let doc = s.doc()?;
let targets: Vec<TextTarget> =
@@ -430,6 +472,8 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
Some((a, b)) => {
if let NodeKind::Text(t) = &mut doc.get_mut(i)?.kind {
t.link_text_style(a, b, def, &style.id);
// The list goes to the paragraphs touched; the rest is the layer's.
t.take_paragraph_style_in(a, b, paragraph);
}
}
None => apply_style_ref(doc, i, "textStyle", Some(&style.id))?,
@@ -506,7 +550,7 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
let cur = doc.styles.get(idx).map(|x| x.kind.type_name()).unwrap_or("paint");
Some(match cur {
"paint" => StyleKind::Paint { paints: paints_of(v).ok_or_else(|| bad(cmd, "value: paints"))? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)? },
"text" => StyleKind::Text { style: text_style_json(cmd, v)?, paragraph: Box::new(paragraph_style_json(cmd, v)?) },
"effect" => StyleKind::Effect { effects: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
_ => StyleKind::Grid { grids: serde_json::from_value(v.clone()).map_err(|e| bad(cmd, e.to_string()))? },
})
@@ -651,22 +695,23 @@ fn rename_folder(s: &mut Session, p: &Value) -> Result<Value> {
/// Re-apply style `sid` to every node (and text chars) linked to it.
pub fn propagate_style(doc: &mut Document, sid: &str) {
let text_def = match doc.style(sid).map(|s| &s.kind) {
Some(StyleKind::Text { style }) => Some(style.clone()),
Some(StyleKind::Text { style, paragraph }) => Some((style.clone(), paragraph.clone())),
_ => None,
};
let ids: Vec<NodeId> = doc.ids().collect();
for i in ids {
let Some(n) = doc.node(i) else { continue };
if let Some(def) = &text_def {
if let Some((def, paragraph)) = &text_def {
if n.kind.text().is_some_and(|t| t.links_text_style(sid)) {
let def = match doc.style(sid).map(|st| crate::style_vars::kind_for(doc, st, i).into_owned()) {
Some(StyleKind::Text { style }) => style,
Some(StyleKind::Text { style, .. }) => style,
_ => def.clone(),
};
if let Ok(n) = doc.get_mut(i)
&& let NodeKind::Text(t) = &mut n.kind
{
t.update_text_style(sid, &def);
t.update_paragraph_style(sid, paragraph);
}
}
continue;
@@ -695,7 +740,16 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
_ => None,
})
.collect();
// What each text style sets at the paragraph level (#1829); no variable binds those.
let paras: BTreeMap<&str, &ParagraphStyle> = doc
.styles
.iter()
.filter_map(|s| match &s.kind {
StyleKind::Text { paragraph, .. } => Some((s.id.as_str(), paragraph.as_ref())),
_ => None,
})
.collect();
@@ -707,7 +761,7 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
// Styles with bound fields read as this layer sees them.
let mut defs = std::borrow::Cow::Borrowed(&defs);
for st in &varying {
if let StyleKind::Text { style } = crate::style_vars::kind_for(doc, st, id).into_owned() {
if let StyleKind::Text { style, .. } = crate::style_vars::kind_for(doc, st, id).into_owned() {
defs.to_mut().insert(st.id.clone(), style);
}
}
@@ -717,11 +771,28 @@ pub fn sync_text_styles(doc: &mut Document, ids: &[NodeId]) {
&& t2.text_style_link() == Some(None)
&& let Some(def) = defs.get(sid)
&& t2.spans().iter().all(|(_, _, s)| s.shows_text_style(def))
&& paras.get(sid.as_str()).is_some_and(|ps| t2.shows_paragraph_style(ps))
{
let len = t2.char_count();
t2.restyle(0, len, |s| s.style_id = Some(sid.clone()));
}
t2.check_text_styles(|sid| defs.get(sid));
// Chars whose paragraphs left their style's list, and every char of a layer whose
// paragraph spacing, indent or features were edited, unlink too (#1829).
let gone: Vec<(usize, usize)> = t2
.spans()
.iter()
.filter(|(a, b, s)| {
// A style with no paragraph values (every style in older files) asks nothing of them.
s.style_id
.as_deref()
.is_some_and(|sid| paras.get(sid).is_none_or(|ps| **ps != ParagraphStyle::default() && !t2.shows_paragraph_style_in(*a, *b, ps)))
})
.map(|(a, b, _)| (*a, *b))
.collect();
for (a, b) in gone {
t2.unlink_text_style(a, b);
}
let link = t2.text_style_link().flatten().map(str::to_string);
if t2 != *t || link != n.styles.text {
updates.push((id, link, t2));
+29 -2
View File
@@ -1,8 +1,8 @@
//! Text menu and text editing commands.
use figcraft_doc::{
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle,
VariableId,
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextPath,
TextStyle, VariableId,
};
use serde_json::{Map, Value, json};
@@ -91,6 +91,13 @@ fn set_list(s: &mut Session, p: &Value, l: ListStyle, toggle: bool) -> Result<Va
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.list = l))
}
/// The base direction of the targeted paragraphs: the edited ones while editing, else every
/// paragraph of the targeted texts ("controls are available to override text direction per
/// paragraph", Add right-to-left text, #1830).
fn set_direction(s: &mut Session, p: &Value, d: TextDirection) -> Result<Value> {
para_range(s, p, |t, a, b| t.set_para_lists(a, b, |pl| pl.direction = d))
}
/// Whether every targeted char has `pred` (to decide toggle direction).
fn all_have(s: &Session, p: &Value, pred: impl Fn(&TextStyle) -> bool) -> bool {
let Ok(doc) = s.doc() else { return false };
@@ -319,6 +326,26 @@ pub fn specs() -> Vec<CommandSpec> {
para(s, p, |t| t.wrap_style = w)
}
),
spec!(
"text.direction",
"Text direction",
"{direction: auto|ltr|rtl, ids?} the base direction of the paragraphs (while editing: the selected ones): auto goes by the first letter with a direction, ltr runs them left to right and rtl right to left whatever their letters (Text ▸ Text direction)",
true,
|s, p| {
let d: TextDirection =
get(p, "direction").and_then(super::props::enum_of).ok_or_else(|| bad("text.direction", "`direction`: auto|ltr|rtl"))?;
set_direction(s, p, d)
}
),
spec!("text.directionAuto", "Text direction: auto", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Auto)
}),
spec!("text.directionLtr", "Text direction: left to right", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Ltr)
}),
spec!("text.directionRtl", "Text direction: right to left", "{ids?} while editing: the selected paragraphs", true, |s, p| {
set_direction(s, p, TextDirection::Rtl)
}),
spec!("text.paragraphIndent", "Paragraph indent", "{indent (px), ids?}", true, |s, p| {
let v = finite(req_num("text.paragraphIndent", p, "indent")?).clamp(0.0, 10_000.0);
para(s, p, |t| t.paragraph_indent = v)
+36
View File
@@ -529,6 +529,42 @@ pub fn specs() -> Vec<CommandSpec> {
tool(s, t)
}
),
spec!(
"tool.crosshair",
"Move the placement crosshair",
"{dx?, dy? (arrow-key steps, e.g. 1 or -1), big? (the large nudge, like Shift)} — the arrows with a shape or frame tool chosen and nothing selected: the first shows the crosshair in the middle of the view, the next move it by the nudge amount; Enter (`tool.place`) puts the object there",
false,
|s, p| {
if !s.tool.tool.draws_shape() {
return Err(EngineError::Disabled("tool.crosshair".into(), "choose a shape or frame tool first".into()));
}
let step = if boolean(p, "big").unwrap_or(false) { s.prefs.nudge_big } else { s.prefs.nudge_small };
let dir = |k| num(p, k).filter(|v| v.is_finite()).unwrap_or(0.0).clamp(-100.0, 100.0) * step;
let (dx, dy) = (dir("dx"), dir("dy"));
let (uid, page, centre, half) = {
let ds = s.ds()?;
let z = ds.view.zoom.max(1e-6);
(ds.uid, ds.page, figcraft_geom::Point::new(ds.view.cx, ds.view.cy), (ds.view.vw / z / 2.0, ds.view.vh / z / 2.0))
};
// "It will appear in the middle of the viewport": the first arrow shows it there (a
// crosshair left on another page or document starts over).
let at = match s.crosshair() {
Some(c) => figcraft_geom::Point::new(c.x + dx, c.y + dy),
None => centre,
};
s.tool.place_at = Some(at);
s.tool.place_on = Some((uid, page));
if let Ok(ds) = s.ds_mut() {
// "Move the crosshair around the canvas": nudged past an edge of the view, the
// view pans by the overshoot to keep it in sight.
let v = &mut ds.view;
v.cx += (at.x - (v.cx + half.0)).max(0.0) + (at.x - (v.cx - half.0)).min(0.0);
v.cy += (at.y - (v.cy + half.1)).max(0.0) + (at.y - (v.cy - half.1)).min(0.0);
ds.revision += 1;
}
Ok(json!({"x": at.x, "y": at.y}))
}
),
spec!(
"tool.place",
"Place with the tool",
+2 -2
View File
@@ -154,7 +154,7 @@ fn auto_layout(doc: &mut Document, id: NodeId, mode: LayoutMode, gap: f64, pad:
fn styled_text(doc: &mut Document, parent: NodeId, chars: &str, name: &str, style: &str) -> DocResult<NodeId> {
let id = doc.create(parent, text(chars), name, 0.0, 0.0, 60.0, 16.0)?;
let def = doc.styles.iter().find(|s| s.id == style).and_then(|s| match &s.kind {
StyleKind::Text { style } => Some(style.clone()),
StyleKind::Text { style, .. } => Some(style.clone()),
_ => None,
});
set_node(doc, id, |n| {
@@ -242,7 +242,7 @@ fn foundations(doc: &mut Document) -> (CollectionId, ModeId, ModeId) {
id: format!("ds:text:{name}"),
name: name.into(),
description: String::new(),
kind: StyleKind::Text { style },
kind: StyleKind::Text { style, paragraph: Default::default() },
bound: Default::default(),
});
}
+13
View File
@@ -307,6 +307,19 @@ fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tok
figcraft_doc::WrapStyle::Balance => put("text-wrap", "balance".into()),
figcraft_doc::WrapStyle::Pretty => put("text-wrap", "pretty".into()),
}
// A base direction forced on every paragraph (CSS has one per element).
let dir = tx.paragraphs.first().map(|p| p.direction).filter(|d| tx.paragraphs.iter().all(|p| p.direction == *d));
match dir {
Some(figcraft_doc::TextDirection::Ltr) => put("direction", "ltr".into()),
Some(figcraft_doc::TextDirection::Rtl) => {
put("direction", "rtl".into());
// Left alignment is absolute on the canvas; `start` would be the right edge here.
if tx.align_h == TextAlignH::Left {
put("text-align", "left".into());
}
}
_ => {}
}
match s.case {
TextCase::Original => {}
TextCase::Upper => put("text-transform", "uppercase".into()),
@@ -122,6 +122,27 @@ fn variables_styles_and_instance_sizes_come_back() {
assert!(after.iter().any(|l| l.contains("\"In the dark\"") && l.contains("FFFFFF")), "{after:#?}");
}
/// #1829: a text style's paragraph spacing and indent and its list style go through a `.fig`
/// file with it (the style's sample layer carries them, as a Figma file's does).
#[test]
fn a_text_styles_paragraph_values_come_back_from_a_fig_file() {
let mut s = Session::new();
let t = id(&run(&mut s, "text.create", json!({"text": "One\nTwo", "x": 0, "y": 0})));
run(&mut s, "node.set", json!({"ids": [t], "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted"}));
run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t}));
let path = std::env::temp_dir().join(format!("figcraft-fig-text-style-{}.fig", std::process::id()));
run(&mut s, "file.export", json!({"format": "fig", "path": path.to_string_lossy()}));
run(&mut s, "file.open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_file(&path);
let doc = s.doc().unwrap();
let st = doc.styles.iter().find(|x| x.name == "Audit").unwrap();
let figcraft_doc::StyleKind::Text { paragraph, .. } = &st.kind else { panic!("{:?}", st.kind) };
assert_eq!((paragraph.paragraph_spacing, paragraph.paragraph_indent, paragraph.list), (12.0, 8.0, figcraft_doc::ListStyle::Bulleted));
// The layer the style was made from is linked to it again, showing the same values.
let layer = doc.nodes().find(|n| n.kind.text().is_some()).unwrap();
assert_eq!(layer.styles.text.as_deref(), Some(st.id.as_str()));
}
#[test]
fn every_example_keeps_its_variables_and_styles() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples");
+1 -1
View File
@@ -54,7 +54,7 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
}
}
for st in &doc.styles {
if let StyleKind::Text { style } = &st.kind {
if let StyleKind::Text { style, .. } = &st.kind {
add(&style.font_family, false);
}
if let Some(var) = st.bound.get("fontFamily") {
+4 -1
View File
@@ -37,7 +37,10 @@ fn lists_families_of_layers_ranges_styles_and_variables_once_each() {
id: "S:1".into(),
name: "Heading".into(),
description: String::new(),
kind: StyleKind::Text { style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() } },
kind: StyleKind::Text {
style: TextStyle { font_family: "FigCraft Missing C".into(), ..TextStyle::default() },
paragraph: Default::default(),
},
bound: Default::default(),
});
s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Light", "Dark"]})).unwrap();
+172
View File
@@ -0,0 +1,172 @@
//! `file.import` of a file whose style, variable and collection ids the document already uses for
//! other things: ids are numbered per file, so two files made apart share them. The imported
//! layers keep their own variables, modes and styles, and a file imported again still shares what
//! it brought the first time.
use base64::Engine as _;
use figcraft_doc::proto::Action;
use figcraft_doc::{Document, Node, NodeId, VariableValue};
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::ex;
use crate::params::id_of;
fn idv(v: &Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn var_id(s: &Session, name: &str) -> String {
s.doc().unwrap().variables.get(name).unwrap().id.clone()
}
fn fill(s: &Session, id: NodeId) -> String {
s.doc().unwrap().node(id).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
/// The document's file: collection A (Light, Dark) with colours `a1`…`a4`, paint style `Host`
/// and a frame filled with `a1`.
fn host() -> (Session, NodeId) {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "A", "modes": ["Light", "Dark"]}));
for i in 1..=4 {
ex(&mut s, "variable.create", json!({"collection": "A", "name": format!("a{i}"), "type": "color", "value": format!("#A{i}0000")}));
}
ex(&mut s, "style.create", json!({"type": "paint", "name": "Host", "value": "#FF0000"}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": "Host frame"})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "a1"}));
(s, f)
}
/// Another file made apart: collection B (Light, Dark) with `b1` (green in each mode), `b2` (an
/// alias of `b1`) and `size`; paint style `Brand` bound to `b1`. Frame `Card` shows B in Dark, its
/// corners bound to `size`; in it `Swatch` is filled with `b2` and on click sets B to Dark and
/// `size` to `size + 1`, and `Styled` uses `Brand`.
fn other() -> Session {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "B", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b1", "type": "color", "values": {"Light": "#00B000", "Dark": "#004000"}}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "b2", "type": "color", "value": {"alias": "b1"}}));
ex(&mut s, "variable.create", json!({"collection": "B", "name": "size", "type": "number", "value": 8}));
let brand = ex(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": "#00B000"}))["id"].clone();
ex(&mut s, "style.bind", json!({"style": brand, "field": "fill", "variable": "b1"}));
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
ex(&mut s, "variable.setMode", json!({"ids": [card], "collection": "B", "mode": "Dark"}));
ex(&mut s, "variable.bind", json!({"ids": [card], "field": "cornerRadius", "variable": "size"}));
let swatch = idv(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Swatch"})));
ex(&mut s, "variable.bind", json!({"ids": [swatch], "field": "fill", "variable": "b2"}));
let actions = json!([
{"type": "setVariableMode", "collection": "B", "mode": "Dark"},
{"type": "setVariable", "variable": "size", "expression": "size + 1"}
]);
ex(&mut s, "proto.interaction.add", json!({"id": swatch, "actions": actions}));
let styled = idv(&ex(&mut s, "shape.rectangle", json!({"x": 60, "y": 10, "width": 40, "height": 40, "parent": card, "name": "Styled"})));
ex(&mut s, "style.apply", json!({"ids": [styled], "style": "Brand"}));
s
}
fn import(s: &mut Session, file: &Session) -> Value {
let bytes = figcraft_format::save_bytes(file.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
ex(s, "file.import", json!({"data": data, "name": "other.figcraft"}))
}
/// The layers on the pages after the first `kept` pages.
fn layers_after(doc: &Document, kept: usize) -> Vec<&Node> {
doc.pages[kept..].iter().flat_map(|p| doc.subtree(*p).into_iter().skip(1)).filter_map(|id| doc.node(id)).collect()
}
fn layer<'a>(doc: &'a Document, kept: usize, name: &str) -> &'a Node {
layers_after(doc, kept).into_iter().find(|n| n.name == name).unwrap()
}
#[test]
fn imported_layers_keep_their_own_variables_and_styles_when_the_ids_are_taken() {
let (mut s, frame) = host();
let file = other();
assert_eq!(var_id(&file, "b1"), var_id(&s, "a1"), "the ids must collide for this test");
assert_eq!(file.doc().unwrap().styles[0].id, s.doc().unwrap().styles[0].id);
let before = s.doc().unwrap().clone();
import(&mut s, &file);
let doc = s.doc().unwrap();
assert_eq!(doc.variables.variables.len(), 4 + 3);
assert_eq!(doc.variables.collections.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);
assert_eq!(doc.styles.iter().map(|st| st.name.as_str()).collect::<Vec<_>>(), ["Host", "Brand"]);
// The document's own are as they were.
assert_eq!(doc.variables.variables[..4], before.variables.variables[..]);
assert_eq!(doc.variables.collections[0], before.variables.collections[0]);
assert_eq!(doc.styles[0], before.styles[0]);
// Everything imported names B's variables, B and Brand.
let b = doc.variables.collection("B").unwrap().id.clone();
let imported = layers_after(doc, before.pages.len());
assert!(imported.iter().any(|n| !n.bound.is_empty()));
for n in imported {
for v in n.bound.values() {
assert_eq!(doc.variables.get_by_id(v).unwrap().collection, b, "{}: {v}", n.name);
}
assert!(n.var_modes.keys().all(|c| *c == b), "{}: {:?}", n.name, n.var_modes);
if let Some(st) = &n.styles.fill {
assert_eq!(doc.styles.iter().find(|x| x.id == *st).unwrap().name, "Brand");
}
}
let brand = doc.styles.iter().find(|st| st.name == "Brand").unwrap();
assert_eq!(brand.bound.values().map(|v| doc.variables.get_by_id(v).unwrap().name.as_str()).collect::<Vec<_>>(), ["b1"]);
// Bindings applied again leave the imported layers alone.
let swatch = layer(doc, before.pages.len(), "Swatch").id;
let a2 = var_id(&s, "a2");
ex(&mut s, "variable.set", json!({"variable": a2, "value": "#123456"}));
assert_eq!(fill(&s, swatch), "004000");
assert_eq!(fill(&s, frame), "A10000");
assert!(s.doc().unwrap().validate().is_empty());
}
#[test]
fn an_alias_a_frame_mode_and_prototype_actions_follow_the_renamed_collection() {
let (mut s, _) = host();
let file = other();
let kept = s.doc().unwrap().pages.len();
import(&mut s, &file);
let doc = s.doc().unwrap();
let vars = &doc.variables;
let b = vars.collection("B").unwrap();
let in_b = |name: &str| vars.variables.iter().find(|v| v.name == name && v.collection == b.id).unwrap();
let (b1, b2, size) = (in_b("b1"), in_b("b2"), in_b("size"));
assert!(b2.values.values().all(|v| *v == VariableValue::Alias(b1.id.clone())), "{:?}", b2.values);
// B's mode ids name B's new id, and its variables' values are by those.
let modes: Vec<&str> = b.modes.iter().map(|m| m.id.as_str()).collect();
assert_eq!(modes, [format!("{}/mode1", b.id), format!("{}/mode2", b.id)]);
assert!([b1, b2, size].iter().all(|v| v.values.keys().all(|m| modes.contains(&m.as_str()))));
// The frame shows B in Dark: its mode is one of B's.
let card = layer(doc, kept, "Card");
let dark = b.find_mode("Dark").unwrap().id.clone();
assert_eq!(card.var_modes.iter().collect::<Vec<_>>(), [(&b.id, &dark)]);
assert_eq!(card.bound.get("cornerRadius"), Some(&size.id));
let swatch = layer(doc, kept, "Swatch");
assert_eq!(swatch.bound.get("fill"), Some(&b2.id));
let shown = vars.resolve(&b2.id, &doc.modes_for(swatch.id));
assert!(matches!(shown, Some(VariableValue::Color(c)) if c.to_hex() == "004000"), "{shown:?}");
let actions = &swatch.interactions[0].actions;
assert_eq!(actions[0], Action::SetVariableMode { collection: b.id.clone(), mode: dark });
let Action::SetVariable { variable, expr: Some(e), .. } = &actions[1] else { panic!("{actions:?}") };
assert_eq!((variable, e.variables()), (&size.id, vec![size.id.as_str()]));
}
#[test]
fn importing_the_same_file_again_shares_what_it_brought() {
let file = other();
let mut s = other();
let before = s.doc().unwrap().clone();
import(&mut s, &file);
let doc = s.doc().unwrap();
assert_eq!(doc.variables, before.variables);
assert_eq!(doc.styles, before.styles);
let swatch = layer(doc, before.pages.len(), "Swatch");
assert_eq!(swatch.bound.get("fill"), Some(&var_id(&s, "b2")));
// A file imported into an empty one, then again: one copy of its variables.
let mut t = Session::new();
import(&mut t, &file);
import(&mut t, &file);
let doc = t.doc().unwrap();
assert_eq!((doc.variables.variables.len(), doc.variables.collections.len(), doc.styles.len()), (3, 1, 1));
assert!(layers_after(doc, 1).iter().flat_map(|n| n.bound.values()).all(|v| doc.variables.get_by_id(v).is_some()));
}
+1
View File
@@ -354,6 +354,7 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[
(Tools, "Pan with nothing selected (Shift: farther)", "← ↑ → ↓", "← ↑ → ↓"),
(Transform, "Rotate a degree (Shift: 15)", "⌥⌘ ← ↑ → ↓", "Ctrl+Alt+← ↑ → ↓"),
(Tools, "Place a shape or frame (its tool chosen)", "↵", "Enter"),
(Tools, "Show and move the placement crosshair (its tool chosen, nothing selected)", "← ↑ → ↓", "← ↑ → ↓"),
(Zoom, "Zoom tool: click to zoom in, drag an area", "Hold Z", "Hold Z"),
(Zoom, "Zoom tool: zoom out", "Hold ⌥Z", "Hold Alt+Z"),
(Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"),
+4
View File
@@ -367,6 +367,8 @@ mod images_left_in_file_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod import_ids_tests;
#[cfg(test)]
mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
@@ -467,6 +469,8 @@ mod penpot_export_tests;
#[cfg(test)]
mod pixel_preview_tests;
#[cfg(test)]
mod place_crosshair_tests;
#[cfg(test)]
mod placed_image_size_tests;
#[cfg(test)]
mod playground_tests;
@@ -385,6 +385,34 @@ fn library_components_keep_their_variable_bindings() {
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn a_file_bringing_older_copies_of_the_same_library_variables_shares_them() {
let d = dir("older-copies");
let lib_path = path_str(&d.join("kit.figcraft"));
let mut lib = Session::new();
theme(&mut lib);
let lid = publish_to(&mut lib, &lib_path);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": lib_path}));
let r = idv(&ex(&mut s, "shape.rectangle", json!({"width": 10, "height": 10, "name": "Swatch"})));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "primary", "field": "fill", "ids": [r]}));
let old_path = path_str(&d.join("old.figcraft"));
ex(&mut s, "file.saveAs", json!({"path": old_path}));
// The copies take an update; the saved file keeps the older ones under the same ids.
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#444444"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
let before = s.doc().unwrap().clone();
ex(&mut s, "file.import", json!({"path": old_path}));
let doc = s.doc().unwrap();
assert_eq!((&doc.variables, &doc.libraries.variables), (&before.variables, &before.libraries.variables), "shared by id, as before");
let swatches: Vec<&Node> = doc.nodes().filter(|n| n.name == "Swatch").collect();
assert_eq!(swatches.len(), 2);
assert!(swatches.iter().all(|n| n.bound.get("fill") == Some(&copy_of(&s, "primary").id)));
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_variable_commands_fail_cleanly() {
let mut s = Session::new();
+122
View File
@@ -0,0 +1,122 @@
//! The placement crosshair: the arrows with a shape or frame tool chosen show it in the middle of
//! the view and move it, and Enter places the object there ("Use the arrow keys to activate the
//! crosshair cursor. It will appear in the middle of the viewport indicating where the new object
//! will be placed." / "Use the arrow keys to move the crosshair around the canvas or board, then
//! press Return/Enter to place the object.", Accessibility at Figma, #1839).
use serde_json::json;
use crate::Session;
use crate::tools::{PointerEvent, PointerKind};
#[test]
fn the_arrows_show_and_move_the_crosshair_and_enter_places_there() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
assert!(s.execute("tool.crosshair", &json!({"dx": 1})).is_err(), "a shape or frame tool first");
s.execute("tool.rectangle", &json!({})).unwrap();
// The first arrow shows the crosshair in the middle of the view, unmoved; the next move it by
// the nudge amounts.
let r = s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(300.0), Some(200.0)));
s.execute("tool.crosshair", &json!({"dx": 1, "dy": 0})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 0, "dy": -1, "big": true})).unwrap();
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(301.0), Some(190.0)), "a nudge, then a big nudge");
assert_eq!(s.tool_info()["crosshair"], json!([301.0, 190.0]));
let v = &s.ds().unwrap().view;
assert_eq!((v.cx, v.cy), (300.0, 200.0), "the view hasn't panned");
// Enter: the rectangle is centred on the crosshair, which goes.
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y), (301.0, 190.0));
assert_eq!(s.tool.place_at, None);
assert!(s.tool_info()["crosshair"].is_null());
// Escape takes a shown crosshair away before anything else, the tool staying; a tool change
// takes it away too.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
assert!(s.tool.place_at.is_some());
s.escape().unwrap();
assert_eq!((s.tool.place_at, s.tool.tool), (None, crate::ToolId::Rectangle));
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.ellipse", &json!({})).unwrap();
assert_eq!(s.tool.place_at, None);
}
#[test]
fn the_crosshair_keeps_its_page_and_goes_with_a_pointer_placement_but_not_the_same_tool_again() {
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
// The tool already chosen named again keeps the crosshair: `tool.place {tool}` lands on it.
let placed = s.execute("tool.place", &json!({"tool": "rectangle"})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
assert_eq!(s.doc().unwrap().world_bounds(id).unwrap().center().x, 301.0);
// A click places too (the tool kept by the preference) and takes the crosshair with it.
s.prefs.keep_tool = true;
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 600.0, 600.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 600.0, 1.0)).unwrap();
assert_eq!((s.tool.tool, s.tool.place_at), (crate::ToolId::Rectangle, None), "placed by the pointer: the crosshair goes, the tool stays");
// Another page in front: the crosshair shown on the first isn't there, Escape has nothing to
// take, and the next arrow starts over in the middle of this page's view.
s.execute("select.set", &json!({"ids": []})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 5})).unwrap();
s.execute("page.add", &json!({})).unwrap();
s.execute("page.select", &json!({"index": 1})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
assert_eq!(s.crosshair(), None);
assert!(s.tool_info()["crosshair"].is_null());
s.escape().unwrap();
assert_eq!(s.tool.tool, crate::ToolId::Move, "Escape went on to the tool");
s.execute("tool.rectangle", &json!({})).unwrap();
let r = s.execute("tool.crosshair", &json!({"dx": 1})).unwrap();
let v = &s.ds().unwrap().view;
assert_eq!((r["x"].as_f64(), r["y"].as_f64()), (Some(v.cx), Some(v.cy)), "shown afresh in the middle of this page's view");
}
#[test]
fn the_view_pans_to_keep_the_crosshair_in_sight() {
// "Use the arrow keys to move the crosshair around the canvas or board": nudged past an edge
// of the view, the view follows it by the overshoot; inside it, the view stays put.
let mut s = Session::new();
s.execute("view.set", &json!({"zoom": 10.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.rectangle", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
for _ in 0..6 {
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((s.tool.place_at.map(|p| p.x), v.cx, v.cy), (Some(360.0), 300.0, 200.0), "at the edge of the 120 px wide view: no pan yet");
s.execute("tool.crosshair", &json!({"dx": 1, "big": true})).unwrap();
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 200.0), "ten past the edge: the view moved ten");
for _ in 0..5 {
s.execute("tool.crosshair", &json!({"dy": -1, "big": true})).unwrap();
}
let v = s.ds().unwrap().view.clone();
assert_eq!((v.cx, v.cy), (310.0, 190.0), "the view is 80 px tall: 40 up is its edge, 50 moves it ten");
}
#[test]
fn the_board_shapes_tool_places_on_the_crosshair_too() {
// "...move the crosshair around the canvas or board, then press Return/Enter to place the
// object": the board Shapes tool takes the crosshair like the shape tools, its shape centred
// on it (the tool centres its own 160 px shape on a press).
let mut s = crate::board_tests::board();
s.execute("view.set", &json!({"zoom": 1.0, "cx": 300, "cy": 200})).unwrap();
s.execute("tool.set", &json!({"tool": "shape"})).unwrap();
s.execute("tool.crosshair", &json!({})).unwrap();
s.execute("tool.crosshair", &json!({"dx": 2})).unwrap();
let placed = s.execute("tool.place", &json!({})).unwrap();
let id = crate::params::id_of(&placed["ids"][0]).unwrap();
let b = s.doc().unwrap().world_bounds(id).unwrap();
assert_eq!((b.center().x, b.center().y, b.width(), b.height()), (302.0, 200.0, 160.0, 160.0));
assert_eq!(s.tool.place_at, None);
}
+50 -1
View File
@@ -233,7 +233,14 @@ fn corner_smoothing_follows_the_documented_rules() {
(NodeKind::Frame, true, "frames"),
(NodeKind::Polygon { count: 5 }, true, "polygons"),
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"),
(NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"),
(
NodeKind::Vector {
network: figcraft_doc::VectorNetwork::from_points(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 10.0)], true),
},
true,
"vector networks that are closed shapes",
),
(NodeKind::Vector { network: Default::default() }, false, "an open vector has no closed shape to smooth"),
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
];
for (kind, smoothing, why) in doc_rows {
@@ -241,6 +248,48 @@ fn corner_smoothing_follows_the_documented_rules() {
}
}
#[test]
fn closed_vectors_take_corner_smoothing() {
// "Vector networks that are closed shapes" take corner smoothing, which applies to the entire
// shape; a single open stroke does not.
let mut s = Session::new();
let closed = create(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}));
let open = create(&mut s, "vector.create", json!({"points": [[200, 0], [300, 0], [300, 100]], "closed": false}));
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerRadius": 20}}));
let outline = |s: &Session, id: NodeId| figcraft_doc::geometry::shape_path(&node(s, id)).unwrap();
let svg = |s: &Session, id: NodeId| figcraft_svg::export_svg(s.doc().unwrap(), &[id], &figcraft_svg::SvgOptions::default()).unwrap();
let render = |s: &Session, id: NodeId| figcraft_render::node_outline(s.doc().unwrap(), &node(s, id)).unwrap().0;
let (round, open_round, svg_round, drawn_round) = (outline(&s, closed), outline(&s, open), svg(&s, closed), render(&s, closed));
ex(&mut s, "node.set", json!({"ids": [closed, open], "props": {"cornerSmoothing": 0.6}}));
let smooth = outline(&s, closed);
// The smoothed corner reaches 1.6 radii along each edge, as a smoothed rectangle's does.
let want = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs();
assert!((smooth.area().abs() - want).abs() < 0.1, "the closed vector smooths: {} vs {want}", smooth.area().abs());
assert!((smooth.area().abs() - round.area().abs()).abs() > 1.0, "smoothing changes the closed vector's outline");
assert_ne!(svg(&s, closed), svg_round, "SVG export draws the smoothed corners");
assert_ne!(render(&s, closed), drawn_round, "the canvas draws the smoothed corners");
assert_eq!(outline(&s, open), open_round, "an open vector keeps its plain rounded points");
let stroke = figcraft_doc::geometry::stroke_geometry(&node(&s, closed)).unwrap();
assert!((stroke.area().abs() - want).abs() < 0.1, "the stroke follows the smoothed outline");
// Smoothing needs a radius, and undo restores the plain corners in one step.
s.undo().unwrap();
assert_eq!(outline(&s, closed), round, "one undo step");
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 0}}));
let sharp = outline(&s, closed);
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0.6}}));
assert_eq!(outline(&s, closed), sharp, "smoothing needs a radius");
// A vector whose region has paints of its own draws the smoothed corners too, on the canvas
// and in SVG.
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerRadius": 20}}));
if let NodeKind::Vector { network } = &mut s.doc_mut().unwrap().get_mut(closed).unwrap().kind {
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
let (region_svg, region_path) = (svg(&s, closed), render(&s, closed));
assert!((region_path.area().abs() - want).abs() < 0.5, "region paints follow the smoothed outline: {}", region_path.area().abs());
ex(&mut s, "node.set", json!({"ids": [closed], "props": {"cornerSmoothing": 0}}));
assert_ne!(svg(&s, closed), region_svg, "SVG export draws the region's smoothed corners");
}
#[test]
fn rounded_vector_points_are_drawn() {
// A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn).
+1 -1
View File
@@ -520,7 +520,7 @@ fn sketch_shared_text_styles_match_their_layers() {
.styles
.iter()
.find_map(|st| match &st.kind {
figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()),
figcraft_doc::StyleKind::Text { style, .. } if st.name == "Body" => Some(style.clone()),
_ => None,
})
.unwrap();
+82 -1
View File
@@ -5,10 +5,11 @@
//! line; "docs silent" marks behaviour the documentation doesn't describe (pinned as it is).
use serde_json::{Value, json};
use std::collections::BTreeMap;
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{Node, NodeId};
use figcraft_doc::{ListStyle, Node, NodeId, ParagraphStyle, StyleKind, TextData};
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
@@ -355,6 +356,68 @@ fn text_styles_go_on_text_layers_and_chars() {
assert_eq!(tx.styles.text, None, "mixed: the layer isn't linked as a whole");
}
#[test]
fn text_styles_carry_paragraph_spacing_indent_lists_and_features() {
// "Supported in text styles": "Paragraph spacing and indentation", "Lists" and "OpenType
// features", beside the character properties (Create and apply text styles). A style made
// from a text takes them, applying it sets them, editing one on a styled text unlinks it.
let mut s = Session::new();
let td = |s: &Session, n: NodeId| -> TextData { node(s, n).kind.text().unwrap().clone() };
let t = text(&mut s, 0.0);
let props = json!({"characters": "One\nTwo", "paragraphSpacing": 12, "paragraphIndent": 8, "listStyle": "bulleted", "features": {"tnum": true, "liga": false}});
run(&mut s, "node.set", json!({"id": t, "props": props}));
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Audit", "from": t})));
let listed = run(&mut s, "style.list", json!({"type": "text"}));
let audit = listed.as_array().unwrap().iter().find(|x| x["id"] == json!(st)).unwrap();
assert_eq!(
audit["paragraph"],
json!({"paragraph_spacing": 12.0, "paragraph_indent": 8.0, "list": "bulleted", "features": {"liga": false, "tnum": true}})
);
// Applying it sets them on a plain text, every paragraph in the list; the layer links.
let t2 = text(&mut s, 300.0);
run(&mut s, "node.set", json!({"id": t2, "props": {"characters": "A\nB\nC"}}));
run(&mut s, "style.apply", json!({"ids": [t2], "style": st}));
let d = td(&s, t2);
assert_eq!((d.paragraph_spacing, d.paragraph_indent), (12.0, 8.0));
assert!((0..3).all(|p| d.para_list(p).list == ListStyle::Bulleted), "{:?}", d.paragraphs);
assert_eq!(d.features, BTreeMap::from([("liga".to_string(), false), ("tnum".to_string(), true)]));
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()));
// Redefining the style moves its layers' paragraph values with the rest.
let value = json!({"fontSize": 22, "paragraphSpacing": 20, "listStyle": "numbered", "openTypeFeatures": {"tnum": true}});
run(&mut s, "style.update", json!({"style": st, "value": value}));
let d = td(&s, t2);
assert_eq!((d.paragraph_spacing, d.paragraph_indent, d.para_list(1).list), (20.0, 0.0, ListStyle::Numbered));
assert_eq!(d.features, BTreeMap::from([("tnum".to_string(), true)]));
assert_eq!(font_size(&s, t2), 22.0);
assert_eq!(node(&s, t2).styles.text.as_deref(), Some(st.as_str()), "still linked after the update");
// Editing a paragraph value the style sets unlinks the text, as editing its font does.
run(&mut s, "node.set", json!({"id": t2, "props": {"paragraphSpacing": 5}}));
assert_eq!(node(&s, t2).styles.text, None, "paragraph spacing edited: unlinked");
assert!(td(&s, t2).spans().iter().all(|(_, _, x)| x.style_id.is_none()));
// On a range, the list goes to the paragraphs touched; the spacing is the layer's. Taking
// the paragraph out of the style's list unlinks its chars.
let t3 = text(&mut s, 600.0);
run(&mut s, "node.set", json!({"id": t3, "props": {"characters": "A\nB\nC"}}));
run(&mut s, "style.apply", json!({"ids": [t3], "style": st, "start": 0, "end": 1}));
let d = td(&s, t3);
assert_eq!((d.para_list(0).list, d.para_list(1).list, d.paragraph_spacing), (ListStyle::Numbered, ListStyle::None, 20.0));
assert_eq!(d.style_at(0).style_id.as_deref(), Some(st.as_str()));
run(&mut s, "node.set", json!({"id": t3, "props": {"start": 0, "end": 1, "listStyle": "none"}}));
assert_eq!(td(&s, t3).style_at(0).style_id, None, "its paragraph left the style's list");
// A definition given as a value takes the node.set spellings; junk is refused.
let value = json!({"fontSize": 11, "paragraphSpacing": 4, "listStyle": "bulleted", "features": {"liga": false}});
let st2 = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Note", "value": value})));
let want = ParagraphStyle {
paragraph_spacing: 4.0,
list: ListStyle::Bulleted,
features: BTreeMap::from([("liga".to_string(), false)]),
..Default::default()
};
assert!(matches!(&s.doc().unwrap().style(&st2).unwrap().kind, StyleKind::Text { paragraph, .. } if **paragraph == want));
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"listStyle": "dotted"}})).is_err());
assert!(s.execute("style.create", &json!({"type": "text", "name": "Bad", "value": {"paragraphSpacing": "wide"}})).is_err());
}
#[test]
fn styles_are_organised_by_slash_names_order_and_duplicates() {
// Documented: "/" in a name groups styles in folders; moving a style to another folder renames
@@ -410,3 +473,21 @@ fn descriptions_and_library_styles() {
run(&mut s, "style.update", json!({"style": st, "description": "Body text"}));
assert_eq!(s.doc().unwrap().style(&st).unwrap().description, "Body text");
}
#[test]
fn a_style_without_paragraph_values_keeps_a_bulleted_text_linked() {
// Styles from older files set no paragraph values, so a list or spacing on their text isn't
// an edit of the style: it stays linked (docs silent, pinned to how such files worked).
let mut s = Session::new();
let t = text(&mut s, 0.0);
run(
&mut s,
"node.set",
json!({"id": t, "props": {"characters": "A
B"}}),
);
let st = sid(run(&mut s, "style.create", json!({"type": "text", "name": "Plain", "value": {"fontSize": 11}})));
run(&mut s, "style.apply", json!({"ids": [t], "style": st}));
run(&mut s, "node.set", json!({"id": t, "props": {"listStyle": "bulleted", "paragraphSpacing": 6}}));
assert_eq!(node(&s, t).styles.text.as_deref(), Some(st.as_str()));
}
+58
View File
@@ -371,6 +371,64 @@ fn max_lines_on_auto_height_text_hugs_that_many_lines() {
assert_eq!(resize_mode(&s, fixed), TextAutoResize::Truncate);
}
#[test]
fn text_direction_is_forced_per_paragraph() {
// Add right-to-left text: "By default, Figma automatically handles text direction based on
// language detection. However, controls are available to override text direction per
// paragraph" ("Go to Text > Text direction. Select Left to right or Right to left.").
let font = test_mono_font();
let mut s = Session::new();
let t = text(&mut s, "abc def\nghi\njkl", json!({"fontFamily": font, "fontSize": 16, "lineHeight": 24}));
let rtl = |s: &Session| -> Vec<bool> {
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
l.lines.iter().map(|l| l.rtl).collect()
};
let inspect = |s: &Session| crate::inspect::node_json(s.doc().unwrap(), t, 0);
assert_eq!(rtl(&s), [false, false, false], "Latin letters read left to right");
assert!(inspect(&s).get("textDirection").is_none() && inspect(&s).get("lineDirections").is_none());
// The layer's paragraphs at once, by the command or `node.set`; inspect reports the one
// direction they share, and Dev Mode's CSS has it.
s.execute("text.direction", &json!({"ids": [t], "direction": "rtl"})).unwrap();
assert_eq!(rtl(&s), [true, true, true], "forced right to left whatever the letters");
assert_eq!(inspect(&s)["textDirection"], json!("rtl"));
let css = |s: &mut Session| s.execute("inspect.code", &json!({"id": t})).unwrap()["code"].as_str().unwrap().to_string();
assert!(css(&mut s).contains("direction: rtl;"), "{}", css(&mut s));
assert!(css(&mut s).contains("text-align: left;"), "Left stays the left edge, which `start` would not be: {}", css(&mut s));
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
assert_eq!(rtl(&s), [false, false, false]);
assert_eq!(inspect(&s)["textDirection"], json!("ltr"));
assert!(css(&mut s).contains("direction: ltr;"));
s.execute("text.directionAuto", &json!({"ids": [t]})).unwrap();
assert!(inspect(&s).get("textDirection").is_none(), "auto throughout says nothing");
assert!(!css(&mut s).contains("direction:"));
// While editing, the caret's paragraph alone (the menu's Right to left); inspect lists each
// paragraph's direction when they differ, and the CSS stays quiet.
s.execute("text.edit.begin", &json!({"id": t, "caret": 9})).unwrap();
s.execute("text.directionRtl", &json!({})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true, false], "the second paragraph only");
assert_eq!(inspect(&s)["lineDirections"], json!(["auto", "rtl", "auto"]));
assert!(!css(&mut s).contains("direction:"));
// Enter inside a forced paragraph keeps the direction for the new one, as list settings carry.
s.execute("text.edit.begin", &json!({"id": t, "caret": 10})).unwrap();
s.execute("text.edit.insert", &json!({"text": "\n"})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true, true, false]);
// An empty last paragraph (after a final newline) keeps the direction forced on it.
s.execute("node.set", &json!({"id": t, "props": {"characters": "abc\n", "textDirection": "auto"}})).unwrap();
s.execute("text.edit.begin", &json!({"id": t, "caret": 4})).unwrap();
s.execute("text.directionRtl", &json!({})).unwrap();
s.execute("text.edit.end", &json!({})).unwrap();
assert_eq!(rtl(&s), [false, true], "the empty last line runs right to left");
// A direction the document has words for; junk is refused.
s.execute("node.set", &json!({"id": t, "props": {"characters": "\u{05E9}\u{05DC}\u{05D5}\u{05DD} abc", "textDirection": "auto"}})).unwrap();
assert_eq!(rtl(&s), [true], "Hebrew first: right to left by detection");
s.execute("node.set", &json!({"id": t, "props": {"textDirection": "ltr"}})).unwrap();
assert_eq!(rtl(&s), [false], "forced left to right");
assert!(s.execute("node.set", &json!({"id": t, "props": {"textDirection": "sideways"}})).is_err());
assert!(s.execute("text.direction", &json!({"ids": [t], "direction": "up"})).is_err());
}
#[test]
fn balance_and_pretty_wrap_styles_break_lines_as_documented() {
// Explore text properties: "Auto: Figma fits as many words as possible on each line before
+2 -2
View File
@@ -86,7 +86,7 @@ fn editing_text_applies_to_the_selected_chars() {
s.execute("text.style", &json!({"id": t, "start": 6, "end": 11, "fontSize": 20})).unwrap();
let c = s.execute("style.create", &json!({"type": "text", "name": "Type/Caption"})).unwrap()["id"].as_str().unwrap().to_string();
let style = s.doc().unwrap().style(&c).cloned().unwrap();
let StyleKind::Text { style: def } = style.kind else { panic!("not a text style") };
let StyleKind::Text { style: def, .. } = style.kind else { panic!("not a text style") };
assert_eq!(def.font_size, 20.0);
assert_eq!(def.style_id, None, "definitions link to nothing");
assert_eq!(links(&s, t)[8].as_deref(), Some(c.as_str()));
@@ -117,7 +117,7 @@ fn edits_unlink_and_style_edits_follow() {
assert_eq!(layer_link(&s, a).as_deref(), Some(h.as_str()));
// Redefining the style moves linked chars only.
let mut def = match &s.doc().unwrap().style(&h).unwrap().kind {
StyleKind::Text { style } => style.clone(),
StyleKind::Text { style, .. } => style.clone(),
_ => panic!("not a text style"),
};
def.font_size = 40.0;
+9 -4
View File
@@ -64,7 +64,7 @@ fn a_list_with_a_start_number_toggles_off() {
let mut s = Session::new();
let t = text(&mut s, "two\nthree\nfour");
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
let n = |start_offset| ParaList { list: N, indent: 0, start_offset };
let n = |start_offset| ParaList { list: N, indent: 0, start_offset, ..ParaList::default() };
td.paragraphs = vec![n(1), n(0), n(0)];
}
let j = crate::inspect::node_json(s.doc().unwrap(), t, 0);
@@ -157,7 +157,8 @@ fn list_keys_at_list_items() {
for (chars, before, caret, k, shift, after, want, why) in cases {
let mut s = Session::new();
let t = text(&mut s, chars);
let ps: Vec<ParaList> = before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0 }).collect();
let ps: Vec<ParaList> =
before.iter().map(|(list, indent)| ParaList { list: *list, indent: *indent, start_offset: 0, ..ParaList::default() }).collect();
if let figcraft_doc::NodeKind::Text(td) = &mut s.doc_mut().unwrap().get_mut(t).unwrap().kind {
td.paragraphs = ps;
}
@@ -181,7 +182,11 @@ fn paragraph_lists_follow_edits() {
// A paragraph's list stays with it as text is typed, pasted and deleted: new paragraphs copy
// the one they split from; joined paragraphs keep the first one's.
let mut t = TextData::new("ab\ncd\nef");
t.paragraphs = vec![ParaList { list: B, indent: 1, start_offset: 0 }, ParaList::default(), ParaList { list: N, indent: 0, start_offset: 0 }];
t.paragraphs = vec![
ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() },
ParaList::default(),
ParaList { list: N, indent: 0, start_offset: 0, ..ParaList::default() },
];
let get = |t: &TextData| (0..t.paragraph_count()).map(|p| (t.para_list(p).list, t.para_list(p).indent)).collect::<Vec<_>>();
t.replace_range(1, 1, "x\ny");
assert_eq!(get(&t), [(B, 1), (B, 1), (P, 0), (N, 0)], "a pasted newline: a new item like it");
@@ -196,7 +201,7 @@ fn paragraph_lists_follow_edits() {
(seed >> 33) as usize % n.max(1)
};
let mut t = TextData::new("a\nb\nc\nd");
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0 }; 4];
t.paragraphs = vec![ParaList { list: B, indent: 2, start_offset: 0, ..ParaList::default() }; 4];
for _ in 0..500 {
let n = t.char_count();
let (a, b) = (rnd(n + 2), rnd(n + 2));
@@ -278,6 +278,45 @@ fn legacy_evenodd_holes_survive_bucket_offset_and_flatten() {
assert!(!net(&s, flattened).fill_contains(Point::new(100.0, 100.0)), "flatten retains the even-odd hole");
}
#[test]
fn simplify_and_offset_keep_a_closed_vectors_smoothed_corners() {
// A closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 draws a
// smoothed rectangle's outline; simplify and offset rebuild it from that outline.
let drawn = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6);
let plain = figcraft_geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs();
let (area, d) = (drawn.area().abs(), 2.0);
// Offsetting a convex outline by d adds its perimeter times d and a circle of radius d.
let grown = area + drawn.perimeter(1e-6) * d + std::f64::consts::PI * d * d;
assert!((area - plain).abs() > 10.0, "smoothing changes the outline: {area} vs {plain}");
// (command, params, region paints of its own, expected area, tolerance: the offset ring is
// flattened before the union)
let rows = [
("vector.simplify", json!({"tolerance": 0.01}), false, area, 1.0),
("vector.simplify", json!({"tolerance": 0.01}), true, area, 1.0),
("vector.offset", json!({"offset": d}), false, grown, 4.0),
("vector.offset", json!({"offset": d}), true, grown, 4.0),
];
let mut wrong = Vec::new();
for (cmd, mut params, painted, expected, tolerance) in rows {
let mut s = Session::new();
let id = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 20}}));
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}}));
if painted {
let doc = s.doc_mut().unwrap();
let NodeKind::Vector { network } = &mut doc.get_mut(id).unwrap().kind else { panic!("expected vector fixture") };
network.regions[0].fills = Some(vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]);
}
params["ids"] = json!([id]);
ex(&mut s, cmd, params);
let got = net(&s, id).fill_path().area().abs();
if (got - expected).abs() >= tolerance {
wrong.push(format!("{cmd} (region paints: {painted}): {got} vs {expected}"));
}
}
assert!(wrong.is_empty(), "simplify and offset keep the smoothed corners: {wrong:?}");
}
/// Give vector `id` a painted region, converting a plain shape to a vector first.
fn ex_to_vector(doc: &mut figcraft_doc::Document, id: NodeId) {
let n = doc.get(id).unwrap();
+2 -2
View File
@@ -225,7 +225,7 @@ fn same_after_round_trip(doc: &Document, what: &str) -> Vec<String> {
normalize(&mut n, &|i| i);
*paints = n.fills;
}
StyleKind::Text { style } => {
StyleKind::Text { style, .. } => {
if let NodeKind::Text(td) = &mut n.kind {
td.style = style.clone();
}
@@ -444,7 +444,7 @@ fn styled() -> Document {
bound: Default::default(),
};
doc.styles = vec![
style("s-text", "Heading", StyleKind::Text { style: heading.clone() }),
style("s-text", "Heading", StyleKind::Text { style: heading.clone(), paragraph: Default::default() }),
style("s-brand", "Brand/Primary", StyleKind::Paint { paints: vec![solid(0.25, 0.5, 1.0)] }),
style("s-unused", "Brand/Unused", StyleKind::Paint { paints: vec![solid(1.0, 0.0, 0.0), solid(0.0, 1.0, 0.0)] }),
style("s-shadow", "Elevation/1", StyleKind::Effect { effects: vec![shadow.clone()] }),
+2 -2
View File
@@ -27,8 +27,8 @@ use figcraft_doc::proto::{
use figcraft_doc::{
AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode,
LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap,
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType,
VariableValue, VectorNetwork, WindingRule,
StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextDirection, TextStyle, VConstraint,
VariableType, VariableValue, VectorNetwork, WindingRule,
};
use serde_json::Value;
use sha1::Digest;
+4 -3
View File
@@ -159,7 +159,7 @@ impl<'d> Exporter<'d> {
Bound::default()
} else {
let mut sample = Node::new(NodeId(0), NodeKind::Rectangle, "", 0.0, 0.0);
if let StyleKind::Text { style } = &st.kind {
if let StyleKind::Text { style, .. } = &st.kind {
sample.kind = NodeKind::Text(TextData { style: style.as_text_style(), ..TextData::new("Ag") });
}
sample.bound.clone_from(&st.bound);
@@ -179,8 +179,9 @@ impl<'d> Exporter<'d> {
let list = grids.iter().enumerate().map(|(i, g)| layout_grid(g, i, &none)).collect();
f.extend([("fillPaints", arr(Vec::new())), ("layoutGrids", arr(list))]);
}
StyleKind::Text { style } => {
let td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
StyleKind::Text { style, paragraph } => {
let mut td = TextData { style: style.as_text_style(), ..TextData::new("Ag") };
td.take_paragraph_style(paragraph);
let fills = [Paint::solid(Color::BLACK)];
f.push(("fillPaints", self.paints(style.fills.as_deref().unwrap_or(&fills), true, &none)));
self.text(&td, &fills, &mut f);
+2 -2
View File
@@ -34,10 +34,10 @@ pub(super) fn outlined(n: &Node) -> Option<(Node, String)> {
}
let path = figcraft_doc::geometry::stroke_geometry(n)?;
let net = match &n.kind {
NodeKind::Vector { network } => Some(network),
NodeKind::Vector { network } => Some(figcraft_doc::geometry::drawn_network(network, n.corners.smoothing)),
_ => None,
};
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net);
let area = figcraft_pathops::stroke_outline_of(&path, sp, figcraft_doc::geometry::is_closed(n), net.as_deref());
if area.elements().is_empty() || area.elements().len() > MAX_NETWORK / 4 {
return None;
}
+10 -5
View File
@@ -1,8 +1,8 @@
use super::common::{style_fields, unsupported_decoration_look};
use super::{
Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
join_name, network, o, path_blob, s, u, xy,
StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextDirection, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite,
fl, int, join_name, network, o, path_blob, s, u, xy,
};
impl<'d> Exporter<'d> {
@@ -136,11 +136,16 @@ impl<'d> Exporter<'d> {
let listed = *l != ListStyle::None;
let begins = first(i, l);
let start = starts.get(i).copied().unwrap_or(0);
let direction = match td.para_list(i).direction {
TextDirection::Auto => "AUTO",
TextDirection::Ltr => "LTR",
TextDirection::Rtl => "RTL",
};
o(vec![
("lineType", e(ty)),
("styleId", int(0)),
("indentationLevel", int(i64::from(listed))),
("sourceDirectionality", e("AUTO")),
("sourceDirectionality", e(direction)),
("listStartOffset", int(i64::from(start))),
("isFirstLineOfList", b(begins)),
])
@@ -265,7 +270,7 @@ impl<'d> Exporter<'d> {
if net.regions.iter().any(|r| r.winding != winding) {
self.warn("vectors whose filled areas use different winding rules use the first one's in their outline");
}
self.geometry("fillGeometry", &net.fill_path(), winding, f);
self.geometry("fillGeometry", &figcraft_doc::geometry::drawn_network(net, n.corners.smoothing).fill_path(), winding, f);
}
}
@@ -317,7 +322,7 @@ impl<'d> Exporter<'d> {
self.geometry("strokeGeometry", &ring, WindingRule::EvenOdd, f);
} else if sp.align == StrokeAlign::Center {
let path = match &n.kind {
NodeKind::Vector { network } => Some(network.stroke_path()),
NodeKind::Vector { .. } => figcraft_doc::geometry::stroke_geometry(n),
NodeKind::Text(_) => None,
_ => figcraft_doc::geometry::shape_path(n),
};
+43
View File
@@ -912,6 +912,49 @@ fn written_files_decode_and_list_parents_first() {
}
}
#[test]
fn closed_vector_geometry_is_written_with_its_corner_smoothing() {
// A closed vector with rounded points: its written fill and stroke outlines take the layer's
// corner smoothing, and the smoothing and each point's radius come back on import.
let mut doc = Document::new("smoothing");
let page = doc.first_page().unwrap();
let mut network = VectorNetwork::from_points(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)), true);
for (v, r) in network.vertices.iter_mut().zip([20.0, 0.0, 10.0, 0.0]) {
v.radius = r;
}
let id = doc.create(page, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
doc.node_mut(id).unwrap().strokes = vec![Paint::solid(Color::BLACK)];
// The vector's written fill and stroke outlines.
let outlines = |doc: &Document| -> Vec<Vec<u8>> {
let (bytes, _) = export(doc, None).unwrap();
let archive = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let blobs = msg.get("blobs").unwrap().list();
let changes = msg.get("nodeChanges").unwrap().list();
let nc = changes.iter().find(|nc| nc.get("type").and_then(Kv::str) == Some("VECTOR")).unwrap();
["fillGeometry", "strokeGeometry"]
.iter()
.map(|key| {
let paths = nc.get(key).map(Kv::list).unwrap_or_default();
let blob = paths.first().unwrap_or_else(|| panic!("{key} is written")).get("commandsBlob").unwrap().f64().unwrap() as usize;
blobs[blob].get("bytes").and_then(Kv::bytes).unwrap().to_vec()
})
.collect()
};
let plain = outlines(&doc);
doc.node_mut(id).unwrap().corners.smoothing = 0.6;
let smoothed = outlines(&doc);
assert_ne!(smoothed[0], plain[0], "the fill outline takes the smoothing");
assert_ne!(smoothed[1], plain[1], "the stroke outline takes the smoothing");
let (back, _) = round_trip(&doc, "smoothed vector");
let v = back.nodes().find(|n| matches!(n.kind, NodeKind::Vector { .. })).unwrap();
assert!((v.corners.smoothing - 0.6).abs() < 1e-6, "smoothing comes back: {}", v.corners.smoothing);
let NodeKind::Vector { network } = &v.kind else { panic!("vector") };
let radii: Vec<f64> = network.vertices.iter().map(|x| x.radius).collect();
assert_eq!(radii, vec![20.0, 0.0, 10.0, 0.0], "each point keeps its own radius");
}
#[test]
fn the_written_schema_parses_and_round_trips() {
let schema = Schema::parse(export::SCHEMA).unwrap();
+2 -2
View File
@@ -403,10 +403,10 @@ fn the_schema_numbers_what_the_format_numbers() {
#[test]
fn schema_text_takes_numbers_and_refuses_bad_ones() {
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=2").unwrap();
let s = Schema::parse("enum E A=3 B C=9\nmessage M uint a=7, E b, string c=5").unwrap();
let values = |i: usize| s.defs[i].fields.iter().map(|f| (f.name.as_str(), f.value)).collect::<Vec<_>>();
assert_eq!(values(0), [("A", 3), ("B", 1), ("C", 9)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 2)]);
assert_eq!(values(1), [("a", 7), ("b", 2), ("c", 5)]);
for bad in ["enum E A=", "enum E A=x", "enum E A=-1", "message M uint a=1.5", "message M uint a=99999999999", "enum E =", "message M uint ="] {
assert!(Schema::parse(bad).is_err() || bad.ends_with('='), "{bad}");
}
+6
View File
@@ -276,6 +276,9 @@ impl Schema {
let ty = r.var_int()?;
let array = r.byte()? & 1 != 0;
let value = r.var_uint()?;
if kind == Kind::Message && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema: duplicate field id {value} in {name}"));
}
raw_types.push(ty);
fields.push(Field { name: fname, ty: None, array, value });
}
@@ -645,6 +648,9 @@ impl Schema {
}
.ok_or_else(|| KiwiError(format!("schema text: unknown type {base}")))?;
let (name, value) = numbered(n.trim(), id)?;
if *kind == "message" && fields.iter().any(|f: &Field| f.value == value) {
return err(format!("schema text: duplicate field id {value} in {name}"));
}
fields.push(Field { name, ty: Some(ty), array: t.ends_with("[]"), value });
}
(if *kind == "struct" { Kind::Struct } else { Kind::Message }, fields)
+12
View File
@@ -130,6 +130,18 @@ impl<'a> Mapper<'a> {
if lines.iter().any(|l| *l != "NONE") {
o.insert("lineTypes".into(), json!(lines));
}
// A forced base direction per paragraph (`sourceDirectionality`; not a REST field).
let dirs: Vec<&str> = raw
.iter()
.map(|l| match l.get("sourceDirectionality").and_then(Kv::str) {
Some("LTR") => "LTR",
Some("RTL") => "RTL",
_ => "AUTO",
})
.collect();
if dirs.iter().any(|d| *d != "AUTO") {
o.insert("lineDirections".into(), json!(dirs));
}
// Where a numbered list starts counting, less one (not a REST field).
let starts: Vec<u32> =
raw.iter().map(|l| num(l, "listStartOffset").filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32)).collect();
+8
View File
@@ -562,6 +562,11 @@ fn oversized_grid_track_lists_warn_and_use_default_tracks() {
assert!(warnings.iter().any(|w| w.contains("grid track list exceeds 1000 entries")), "{warnings:?}");
}
#[test]
fn duplicate_explicit_field_ids_are_ambiguous() {
assert!(Schema::parse("message M uint a=1, uint b=1").is_err());
}
#[test]
fn kiwi_round_trip() {
let sc = schema();
@@ -1529,6 +1534,9 @@ mod image_filters;
#[path = "text_list_tests.rs"]
mod text_list;
#[path = "text_direction_tests.rs"]
mod text_direction;
/// `.fig` files in the folder `FIGCRAFT_FIG_SAMPLES` names (none when it's unset).
pub(super) fn samples() -> Vec<std::path::PathBuf> {
let Some(dir) = std::env::var_os("FIGCRAFT_FIG_SAMPLES") else { return Vec::new() };
@@ -0,0 +1,83 @@
//! A paragraph's forced base direction (#1830): each text line's `sourceDirectionality` (AUTO,
//! LTR or RTL) comes in as the paragraph's direction and goes back out. Fixtures built in code with
//! the parent module's helpers.
use figcraft_doc::{NodeKind, TextDirection};
use super::*;
const DIRECTION_SCHEMA: &str = "
enum NodeType NONE DOCUMENT CANVAS TEXT
enum LineType PLAIN ORDERED_LIST UNORDERED_LIST
enum SourceDirectionality AUTO LTR RTL
struct GUID uint sessionID, uint localID
struct Vector float x, float y
struct Matrix float m00, float m01, float m02, float m10, float m11, float m12
message ParentIndex GUID guid, string position
message TextLineData LineType lineType, int indentationLevel, SourceDirectionality sourceDirectionality, int listStartOffset, bool isFirstLineOfList
message TextData string characters, TextLineData[] lines
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, Vector size, Matrix transform, float fontSize, TextData textData
message Message NodeChange[] nodeChanges
";
/// A plain line with a base direction.
fn line(direction: &'static str) -> Kv<'static> {
o(vec![
("lineType", e("PLAIN")),
("indentationLevel", Kv::Int(0)),
("sourceDirectionality", e(direction)),
("listStartOffset", Kv::Int(0)),
("isFirstLineOfList", Kv::Bool(false)),
])
}
fn text_node(id: u64, name: &str, chars: &str, lines: Vec<Kv<'static>>) -> Kv<'static> {
let pos = ["!", "\"", "#", "$"][id as usize % 4];
node(
id,
Some((1, pos)),
"TEXT",
name,
vec![
("size", xy(200.0, 60.0)),
("transform", at(0.0, 0.0)),
("fontSize", f(16.0)),
("textData", o(vec![("characters", s(chars)), ("lines", arr(lines))])),
],
)
}
fn directions(doc: &figcraft_doc::Document, name: &str) -> Vec<TextDirection> {
let NodeKind::Text(t) = &named(doc, name).kind else { panic!("{name} is no text") };
(0..t.paragraph_count()).map(|p| t.para_list(p).direction).collect()
}
#[test]
fn a_lines_source_directionality_is_its_paragraphs_direction_both_ways() {
use TextDirection::{Auto as A, Ltr as L, Rtl as R};
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
text_node(2, "Detected", "abc\ndef", vec![line("AUTO"), line("AUTO")]),
text_node(3, "Forced", "abc\ndef\nghi", vec![line("RTL"), line("AUTO"), line("LTR")]),
];
let sc = Schema::parse(DIRECTION_SCHEMA).unwrap();
let (doc, warnings) = import(&canvas(&sc, &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(directions(&doc, "Detected"), [A, A]);
let NodeKind::Text(t) = &named(&doc, "Detected").kind else { panic!() };
assert!(t.paragraphs.is_empty(), "AUTO throughout keeps the paragraphs folded");
assert_eq!(directions(&doc, "Forced"), [R, A, L]);
// Out again: every line says its direction, and a reader gets the same paragraphs back.
let (bytes, _) = super::super::export(&doc, None).unwrap();
let a = Archive::open(&bytes).unwrap();
let (schema, message) = decode_canvas(&a.canvas).unwrap();
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
let text = msg.get("nodeChanges").unwrap().list().iter().find(|n| n.get("name").and_then(Kv::str) == Some("Forced")).unwrap();
let lines = text.get("textData").and_then(|d| d.get("lines")).map(Kv::list).unwrap();
let out: Vec<&str> = lines.iter().map(|l| l.get("sourceDirectionality").and_then(Kv::str).unwrap()).collect();
assert_eq!(out, ["RTL", "AUTO", "LTR"]);
let (back, _) = import(&bytes, "x").unwrap();
assert_eq!(directions(&back, "Forced"), [R, A, L]);
assert_eq!(directions(&back, "Detected"), [A, A]);
}
+1 -1
View File
@@ -75,7 +75,7 @@ fn the_export_writes_where_a_list_starts() {
let page = doc.first_page().unwrap();
use ListStyle::{Bulleted as B, None as P, Numbered as N};
let mut td = figcraft_doc::TextData::new("two\nthree\nplain\nsix\ndot");
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset };
let pl = |list, start_offset| ParaList { list, indent: 0, start_offset, ..ParaList::default() };
td.paragraphs = vec![pl(N, 1), pl(N, 0), pl(P, 0), pl(N, 5), pl(B, 4)];
doc.create(page, NodeKind::Text(td), "List", 0.0, 0.0, 200.0, 80.0).unwrap();
let (bytes, _) = super::super::export(&doc, None).unwrap();
+3 -3
View File
@@ -113,7 +113,7 @@ fn local_skip_ink_override_preserves_shared_style_and_omitted_value_inherits() {
let node = doc.nodes().find(|n| n.name == "Label").unwrap();
assert_eq!(node.kind.text().unwrap().style.look().skip_ink, expected);
let linked = doc.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("linked definition must remain text") };
let StyleKind::Text { style, .. } = &linked.kind else { panic!("linked definition must remain text") };
assert!(style.look().skip_ink, "local value must not alter definition");
}
}
@@ -223,7 +223,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only
id: "body".into(),
name: "Body".into(),
description: String::new(),
kind: StyleKind::Text { style: shared },
kind: StyleKind::Text { style: shared, paragraph: Default::default() },
bound: Default::default(),
});
let id = linked_doc
@@ -237,7 +237,7 @@ fn export_writes_one_actual_node_value_and_complete_run_values_without_skip_only
let node = back.nodes().find(|n| n.name == "Label").unwrap();
assert!(!node.kind.text().unwrap().style.look().skip_ink);
let linked = back.style(node.styles.text.as_deref().unwrap()).unwrap();
let StyleKind::Text { style } = &linked.kind else { panic!("shared style link must remain text") };
let StyleKind::Text { style, .. } = &linked.kind else { panic!("shared style link must remain text") };
assert!(style.look().skip_ink, "local false must not alter the shared definition");
}
+1 -1
View File
@@ -85,7 +85,7 @@ fn seen(doc: &Document) -> Seen {
assert_eq!((text.style.font_family.as_str(), text.style.font_size), ("Inter", 20.0));
let linked = doc.style(label.styles.text.as_deref().unwrap()).unwrap();
assert_eq!(linked.name, "Shared");
let StyleKind::Text { style } = &linked.kind else { panic!("the linked definition must stay a text style") };
let StyleKind::Text { style, .. } = &linked.kind else { panic!("the linked definition must stay a text style") };
Seen {
case: text.style.case,
decoration: text.style.decoration,
+5 -7
View File
@@ -1531,7 +1531,7 @@ fn import_typography_styles(
id: format!("penpot:text:{base}:{id}"),
name: name.chars().take(256).collect(),
description: "Imported from Penpot typography library".into(),
kind: StyleKind::Text { style },
kind: StyleKind::Text { style, paragraph: Default::default() },
bound: BTreeMap::new(),
});
}
@@ -1596,7 +1596,7 @@ fn composite_token_style(token: &Value) -> Option<StyleKind> {
}
}
let parsed = text_style(&Value::Object(fields), &style);
Some(StyleKind::Text { style: parsed })
Some(StyleKind::Text { style: parsed, paragraph: Default::default() })
}
"boxShadow" | "shadow" => {
let parts: Vec<&Value> = if let Some(items) = value.as_array() { items.iter().take(32).collect() } else { vec![value] };
@@ -5954,7 +5954,7 @@ mod tests {
let (doc, warnings) = import(&archive_with_design_system(tokens, None, None, None)).unwrap();
assert_eq!(doc.styles.len(), 2);
assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/title"
&& matches!(&s.kind, StyleKind::Text { style }
&& matches!(&s.kind, StyleKind::Text { style, .. }
if style.font_family == "Spicy Rice" && style.font_size == 24.0 && style.line_height == LineHeight::Pixels(32.0))));
assert!(doc.styles.iter().any(|s| s.name == "Penpot tokens/Core/elevation"
&& matches!(&s.kind, StyleKind::Effect { effects }
@@ -6010,10 +6010,8 @@ mod tests {
let (doc, warnings) = import(&archive_with_design_system(serde_json::json!({"sets":{}}), Some(typ), Some(color), None)).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
assert_eq!(doc.styles.len(), 2);
assert!(
doc.styles.iter().any(|s| s.name == "Brand/Heading"
&& matches!(&s.kind, StyleKind::Text { style } if style.font_family == "Inter" && style.font_size == 24.0))
);
assert!(doc.styles.iter().any(|s| s.name == "Brand/Heading"
&& matches!(&s.kind, StyleKind::Text { style, .. } if style.font_family == "Inter" && style.font_size == 24.0)));
assert!(doc.styles.iter().any(|s| s.name == "Brand/Primary" && matches!(s.kind, StyleKind::Paint { .. })));
assert_eq!(doc.document_colors.len(), 1);
}
+3 -1
View File
@@ -713,7 +713,9 @@ impl<'a> Exporter<'a> {
}
}
}
NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => self.doc.world_transform(id) * network.stroke_path(),
NodeKind::Vector { network } if node.fills.is_empty() || !node.strokes.is_empty() => {
self.doc.world_transform(id) * figcraft_doc::geometry::drawn_network(network, node.corners.smoothing).stroke_path()
}
_ => match figcraft_doc::geometry::shape_path(node) {
Some(p) => self.doc.world_transform(id) * p,
None => BezPath::new(),
+26
View File
@@ -174,6 +174,32 @@ fn rectangles_ellipses_and_paths_keep_their_geometry() {
assert!(matches!(back.node(named(&back, "Circle")).unwrap().kind, NodeKind::Ellipse { .. }));
}
#[test]
fn stroked_closed_vectors_are_written_with_their_corner_smoothing() {
// A stroked closed 100 × 100 vector with radius 20 on every point and corner smoothing 0.6 is
// written as the smoothed outline it draws.
let mut doc = Document::new("Smoothed vector");
let p = page(&doc);
let corners = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
let mut network = VectorNetwork::from_points(&corners, true);
for v in &mut network.vertices {
v.radius = 20.0;
}
let id = add(&mut doc, p, NodeKind::Vector { network }, "Smoothed", 0.0, 0.0, 100.0, 100.0);
{
let n = doc.node_mut(id).unwrap();
n.fills = vec![Paint::solid(Color::BLACK)];
n.strokes = vec![Paint::solid(Color::BLACK)];
n.corners.smoothing = 0.6;
}
let want = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.6).area().abs();
let plain = figcraft_doc::geom::rounded_rect(100.0, 100.0, [20.0; 4], 0.0).area().abs();
assert!((want - plain).abs() > 10.0, "smoothing changes the outline: {want} vs {plain}");
let (back, _, _) = roundtrip(&doc);
let written = figcraft_doc::geometry::shape_path(back.node(named(&back, "Smoothed")).unwrap()).unwrap().area().abs();
assert!((written - want).abs() < 0.5, "the written path keeps the smoothed corners: {written} vs {want}");
}
#[test]
fn fills_and_strokes_keep_their_order_and_styles() {
let mut doc = Document::new("Paints");
+8 -2
View File
@@ -929,7 +929,11 @@ impl<'a> Ctx<'a> {
Some("FILL") => StyleKind::Paint { paints: props::paints(self, node, "fills").unwrap_or_default() },
Some("EFFECT") => StyleKind::Effect { effects: props::effects(self, node) },
Some("GRID") => StyleKind::Grid { grids: props::layout_grids(node) },
Some("TEXT") => StyleKind::Text { style: text::text_data(self, node).style.as_text_style() },
Some("TEXT") => {
// The sample layer carries the paragraph-level values too (#1829).
let td = text::text_data(self, node);
StyleKind::Text { style: td.style.as_text_style(), paragraph: Box::new(td.paragraph_style()) }
}
_ => continue,
};
let name = text(d, "name").filter(|s| !s.is_empty()).map_or_else(|| format!("Style {id}"), |s| clip_str(s).to_string());
@@ -972,7 +976,9 @@ impl<'a> Ctx<'a> {
"effect" | "effects" => (StyleKind::Effect { effects: n.effects.clone() }, &mut n.styles.effect),
"grid" | "grids" => (StyleKind::Grid { grids: n.grids.clone() }, &mut n.styles.grid),
"text" => match &n.kind {
NodeKind::Text(t) => (StyleKind::Text { style: t.style.as_text_style() }, &mut n.styles.text),
NodeKind::Text(t) => {
(StyleKind::Text { style: t.style.as_text_style(), paragraph: Box::new(t.paragraph_style()) }, &mut n.styles.text)
}
_ => continue,
},
_ => continue,
+1 -1
View File
@@ -530,7 +530,7 @@ fn shared_styles_link_with_metadata_names() {
let fill = doc.style("S:1").unwrap();
assert_eq!((fill.name.as_str(), fill.description.as_str()), ("Brand/Primary", "Main"));
assert!(matches!(&fill.kind, StyleKind::Paint { paints } if paints.len() == 1));
assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style } if style.font_size == 32.0));
assert!(matches!(&doc.style("S:2").unwrap().kind, StyleKind::Text { style, .. } if style.font_size == 32.0));
assert!(matches!(&doc.style("Soft").unwrap().kind, StyleKind::Effect { effects } if effects.len() == 1));
let r = named(&doc, "R");
assert_eq!((r.styles.fill.as_deref(), r.styles.effect.as_deref()), (Some("S:1"), Some("S:4")));
+17 -12
View File
@@ -5,12 +5,12 @@ use std::collections::BTreeMap;
use figcraft_doc::{
DecorationLength, DecorationStyle, LetterSpacing, LineHeight, ListStyle, ParaList, StyleRun, TextAlignH, TextAlignV, TextAutoResize, TextCase,
TextData, TextDecoration, TextPath, TextStyle,
TextData, TextDecoration, TextDirection, TextPath, TextStyle,
};
use serde_json::Value;
use super::Ctx;
use super::props::{flag, len, list, num, paints, text};
use super::props::{flag, len, num, paints, text};
/// Most characters read from one text node.
const MAX_CHARS: usize = 1 << 20;
@@ -256,23 +256,28 @@ fn runs(cx: &mut Ctx, v: &Value, td: &TextData) -> Vec<StyleRun> {
out
}
/// Each paragraph's list type, nesting level and start number from `lineTypes`,
/// `lineIndentations` and (from `.fig` files) `listStartOffsets`.
/// Each paragraph's list type, nesting level, start number and base direction from `lineTypes`,
/// `lineIndentations` and (from `.fig` files) `listStartOffsets` and `lineDirections`.
fn lists(v: &Value, td: &mut TextData) {
let types = list(v, "lineTypes");
let indents = list(v, "lineIndentations");
let starts = list(v, "listStartOffsets");
td.paragraphs = types
.iter()
.enumerate()
.map(|(i, t)| ParaList {
list: match t.as_str() {
// One entry per paragraph: read up to the paragraph count (bounded by the text's size), not the
// general list limit, so a long text keeps every paragraph's settings.
let n = td.paragraph_count();
let column = |k: &str| v.get(k).and_then(Value::as_array).map(|a| a.get(..a.len().min(n)).unwrap_or_default()).unwrap_or_default();
let (types, indents, starts, dirs) = (column("lineTypes"), column("lineIndentations"), column("listStartOffsets"), column("lineDirections"));
td.paragraphs = (0..types.len().max(dirs.len()))
.map(|i| ParaList {
list: match types.get(i).and_then(Value::as_str) {
Some("UNORDERED") => ListStyle::Bulleted,
Some("ORDERED") => ListStyle::Numbered,
_ => ListStyle::None,
},
indent: indents.get(i).and_then(Value::as_f64).filter(|d| d.is_finite()).map_or(0, |d| d.clamp(0.0, 255.0) as u8),
start_offset: starts.get(i).and_then(Value::as_f64).filter(|n| n.is_finite()).map_or(0, |n| n.clamp(0.0, f64::from(u32::MAX)) as u32),
direction: match dirs.get(i).and_then(Value::as_str) {
Some("LTR") => TextDirection::Ltr,
Some("RTL") => TextDirection::Rtl,
_ => TextDirection::Auto,
},
})
.collect();
// Clamp the levels and fold one style for all back into the layer's list style.
+1 -1
View File
@@ -91,7 +91,7 @@ fn text_style_links() {
let sid = n.styles.text.clone().expect("text style linked");
let s = style(&doc, &sid);
assert_eq!(s.name, "Heading/H1");
let StyleKind::Text { style: ts } = &s.kind else { panic!("not a text style") };
let StyleKind::Text { style: ts, .. } = &s.kind else { panic!("not a text style") };
assert_eq!((ts.font_family.as_str(), ts.font_style.as_str(), ts.font_size), ("Helvetica", "Bold", 32.0));
assert!(n.fills.iter().any(|p| matches!(p.kind, PaintKind::Solid { color } if color.r == 1.0 && color.g == 0.0)));
}
+9
View File
@@ -14,6 +14,9 @@ use super::tree::{Cx, color};
const MAX_CHARS: usize = 1 << 20;
/// Most runs read from one text layer.
const MAX_RUNS: usize = 10_000;
/// Most characters that styled runs may write in all, overlaps counted again (a run's range is
/// rewritten in full, even where later runs overwrite it).
const MAX_RUN_WORK: usize = 16 * MAX_CHARS;
/// Fill `node` (a TEXT node) from text layer `l`.
pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
@@ -80,10 +83,16 @@ pub(crate) fn node(cx: &mut Cx, l: &Value, node: &mut JsonMap<String, Value>) {
// Runs that differ from the first: a style id per UTF-16 unit.
let mut ids = vec![0u64; units];
let mut table = JsonMap::new();
let mut work = 0usize;
for (start, end, s) in &styles {
if *s == base {
continue;
}
work = work.saturating_add(end.saturating_sub(*start));
if work > MAX_RUN_WORK {
cx.warn("text whose styled runs overlap a great deal keeps the first runs' styles");
break;
}
let id = table.len() as u64 + 1;
table.insert(id.to_string(), s.clone());
for slot in ids.get_mut(*start..*end).into_iter().flatten() {
+10
View File
@@ -271,3 +271,13 @@ fn later_runs_dont_inherit_the_first_runs_settings() {
assert_eq!(second.line_height, figcraft_doc::LineHeight::Auto, "{second:?}");
assert!(second.variations.is_empty(), "{second:?}");
}
#[test]
fn runs_that_overlap_a_great_deal_stop_being_applied() {
// 17 runs over the whole of a text of 2^20 characters: 17 × 2^20 writes without the limit.
let n = 1 << 20;
let mut runs = vec![(0, 1, "Helvetica", 10.0, BLACK)];
runs.extend((0..17).map(|i| (0, n, "Helvetica", 11.0 + f64::from(i), BLACK)));
let (_, warnings) = open(vec![text("Long", 0.0, 0.0, 200.0, 20.0, &"a".repeat(n), &runs)]);
assert!(warnings.iter().any(|w| w.contains("overlap a great deal")), "{warnings:?}");
}
+117 -51
View File
@@ -249,67 +249,133 @@ pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPat
};
let (v1, v2) = (*prev - *cur, *next - *cur);
let (l1, l2) = (v1.hypot(), v2.hypot());
let angle = if l1 < 1e-9 || l2 < 1e-9 { 0.0 } else { (v1 / l1).dot(v2 / l2).clamp(-1.0, 1.0).acos() };
if angle < 1e-6 || (PI - angle) < 1e-6 {
let corner = if l1 < 1e-9 || l2 < 1e-9 { None } else { smooth_corner(*cur, v1 / l1, v2 / l2, l1.min(l2) / 2.0, radius, s) };
let Some(k) = corner else {
line_or_move(&mut path, *cur);
continue;
};
line_or_move(&mut path, k.start);
match k.ease_in {
Some((h1, h2)) => path.curve_to(h1, h2, k.p3),
None => path.line_to(k.p3),
}
let (u1, u2) = (v1 / l1, v2 / l2);
// The plain corner's length along each edge, then the fit: smoothing first, then the radius.
let max_p = l1.min(l2) / 2.0;
let mut r = radius;
let mut t = r / (angle / 2.0).tan();
let mut sm = s;
if (1.0 + sm) * t > max_p {
sm = (max_p / t - 1.0).clamp(0.0, sm);
if k.sweep.abs() > 1e-9 {
kurbo::Arc::new(k.center, (k.r, k.r), k.a0, k.sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q));
}
if t > max_p {
r *= max_p / t;
t = max_p;
}
let p = ((1.0 + sm) * t).min(max_p);
// Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge
// and `side` across it; its last handle points along the arc there.
let beta = (PI - angle) * sm / 2.0;
let back = t - r * beta.sin();
let side = r * (1.0 - beta.cos());
let c = r * (beta / 2.0).tan() * beta.cos();
let b = ((p - back - c) / 3.0).max(0.0);
let a = 2.0 * b;
let bisector = (u1 + u2).normalize();
let center = *cur + bisector * (r / (angle / 2.0).sin());
// A point `along` an edge from the vertex and `across` it toward the arc's center.
let at = |u: Vec2, along: f64, across: f64| *cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across;
line_or_move(&mut path, at(u1, p, 0.0));
let p3 = at(u1, back, side);
let p4 = at(u2, back, side);
if a + b + c > 1e-9 {
path.curve_to(at(u1, p - a, 0.0), at(u1, p - a - b, 0.0), p3);
} else {
path.line_to(p3);
}
let a0 = (p3 - center).atan2();
let mut sweep = (p4 - center).atan2() - a0;
while sweep <= -PI {
sweep += TAU;
}
while sweep > PI {
sweep -= TAU;
}
if sweep.abs() > 1e-9 {
kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q));
}
let end = at(u2, p, 0.0);
if a + b + c > 1e-9 {
path.curve_to(at(u2, p - a - b, 0.0), at(u2, p - a, 0.0), end);
} else {
path.line_to(end);
match k.ease_out {
Some((h1, h2)) => path.curve_to(h1, h2, k.end),
None => path.line_to(k.end),
}
}
path.close_path();
path
}
/// One smoothed corner as [`smoothed_polygon`] draws it: it starts at `start` on the first edge,
/// eases into a circular arc at `p3` (through handles `ease_in`, or straight when `None`), follows
/// the arc of radius `r` about `center` from angle `a0` through `sweep` to `p4`, and eases out to
/// `end` on the second edge.
struct SmoothCorner {
start: Point,
ease_in: Option<(Point, Point)>,
p3: Point,
center: Point,
r: f64,
a0: f64,
sweep: f64,
p4: Point,
ease_out: Option<(Point, Point)>,
end: Point,
}
/// The smoothed corner at `cur` between edges that leave it along unit directions `u1` and `u2`,
/// with `max_p` of room along each: `None` where the edges run straight on or fold back.
fn smooth_corner(cur: Point, u1: Vec2, u2: Vec2, max_p: f64, radius: f64, s: f64) -> Option<SmoothCorner> {
let angle = u1.dot(u2).clamp(-1.0, 1.0).acos();
if angle < 1e-6 || (PI - angle) < 1e-6 {
return None;
}
// The plain corner's length along each edge, then the fit: smoothing first, then the radius.
let mut r = radius;
let mut t = r / (angle / 2.0).tan();
let mut sm = s;
if (1.0 + sm) * t > max_p {
sm = (max_p / t - 1.0).clamp(0.0, sm);
}
if t > max_p {
r *= max_p / t;
t = max_p;
}
let p = ((1.0 + sm) * t).min(max_p);
// Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge
// and `side` across it; its last handle points along the arc there.
let beta = (PI - angle) * sm / 2.0;
let back = t - r * beta.sin();
let side = r * (1.0 - beta.cos());
let c = r * (beta / 2.0).tan() * beta.cos();
let b = ((p - back - c) / 3.0).max(0.0);
let a = 2.0 * b;
let bisector = (u1 + u2).normalize();
let center = cur + bisector * (r / (angle / 2.0).sin());
// A point `along` an edge from the vertex and `across` it toward the arc's center.
let at = |u: Vec2, along: f64, across: f64| cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across;
let p3 = at(u1, back, side);
let p4 = at(u2, back, side);
let eased = a + b + c > 1e-9;
let a0 = (p3 - center).atan2();
let mut sweep = (p4 - center).atan2() - a0;
while sweep <= -PI {
sweep += TAU;
}
while sweep > PI {
sweep -= TAU;
}
Some(SmoothCorner {
start: at(u1, p, 0.0),
ease_in: eased.then(|| (at(u1, p - a, 0.0), at(u1, p - a - b, 0.0))),
p3,
center,
r,
a0,
sweep,
p4,
ease_out: eased.then(|| (at(u2, p - a - b, 0.0), at(u2, p - a, 0.0))),
end: at(u2, p, 0.0),
})
}
/// The curves of one corner rounded by `radius` with corner smoothing (0..=1) at `corner`, between
/// two straight edges that leave it along unit directions `u1` and `u2` with `max_p` of room along
/// each. The curves follow the profile of [`smoothed_polygon`], with the circular part as one
/// cubic, and run from the point on the `u1` edge to the point on the `u2` edge; a straight piece
/// has its handles on its ends. As in the polygon, smoothing is reduced first when the corner does
/// not fit, then the radius. `None` for a corner that is not rounded, or where the edges run
/// straight on or fold back.
pub fn smoothed_corner(corner: Point, u1: Vec2, u2: Vec2, max_p: f64, radius: f64, smoothing: f64) -> Option<Vec<kurbo::CubicBez>> {
let (s, radius, max_p) = (finite(smoothing).clamp(0.0, 1.0), finite(radius).max(0.0), finite(max_p).max(0.0));
let usable = |u: Vec2| u.x.is_finite() && u.y.is_finite() && u.hypot() > 1e-9;
if radius <= 0.0 || max_p <= 0.0 || !usable(u1) || !usable(u2) || !(corner.x.is_finite() && corner.y.is_finite()) {
return None;
}
let k = smooth_corner(corner, u1 / u1.hypot(), u2 / u2.hypot(), max_p, radius, s)?;
let piece = |p0: Point, handles: Option<(Point, Point)>, p3: Point| {
let (h1, h2) = handles.unwrap_or((p0, p3));
kurbo::CubicBez::new(p0, h1, h2, p3)
};
let mut out = vec![piece(k.start, k.ease_in, k.p3)];
if k.sweep.abs() > 1e-9 {
// The arc as one cubic: handles of 4/3·tan(θ/4)·r along the circle's tangents at its ends.
let h = 4.0 / 3.0 * (k.sweep.abs() / 4.0).tan() * k.r * k.sweep.signum();
let tangent = |a: f64| Vec2::new(-a.sin(), a.cos());
out.push(kurbo::CubicBez::new(k.p3, k.p3 + tangent(k.a0) * h, k.p4 - tangent(k.a0 + k.sweep) * h, k.p4));
}
out.push(piece(k.p4, k.ease_out, k.end));
out.retain(|q| (q.p3 - q.p0).hypot() > 1e-9 || (q.p1 - q.p0).hypot() > 1e-9 || (q.p2 - q.p3).hypot() > 1e-9);
let finite_point = |p: &Point| p.x.is_finite() && p.y.is_finite();
let ok = out.iter().all(|q| [q.p0, q.p1, q.p2, q.p3].iter().all(finite_point));
(ok && !out.is_empty()).then_some(out)
}
/// Starts `path` at `p`, or continues it there with a straight segment.
fn line_or_move(path: &mut BezPath, p: Point) {
if path.elements().is_empty() {
+47
View File
@@ -52,6 +52,53 @@ fn degenerate_inputs_dont_panic() {
let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), f64::INFINITY, f64::NAN);
let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), 3.0, f64::INFINITY);
let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0);
// (corner, first edge, second edge, room, radius, smoothing): none of these is a corner to round.
let x = Vec2::new(1.0, 0.0);
let rows = [
(Point::ZERO, x, Vec2::new(0.0, 1.0), 10.0, 0.0, 0.6),
(Point::ZERO, x, Vec2::new(0.0, 1.0), 0.0, 5.0, 0.6),
(Point::ZERO, x, x, 10.0, 5.0, 0.6),
(Point::ZERO, x, -x, 10.0, 5.0, 0.6),
(Point::ZERO, Vec2::ZERO, x, 10.0, 5.0, 0.6),
(Point::ZERO, Vec2::new(f64::NAN, 0.0), x, 10.0, 5.0, 0.6),
(Point::new(f64::INFINITY, 0.0), x, Vec2::new(0.0, 1.0), 10.0, 5.0, 0.6),
];
for (corner, u1, u2, room, r, s) in rows {
assert!(smoothed_corner(corner, u1, u2, room, r, s).is_none(), "{corner:?} {u1:?} {u2:?} {room} {r} {s}");
}
}
#[test]
fn smoothed_corner_draws_the_polygon_corner() {
use kurbo::ParamCurve;
// The corner of a square drawn by itself follows the smoothed polygon's corner and joins its
// edges and its own pieces without a kink.
for s in [0.0, 0.6, 1.0] {
let curves = smoothed_corner(Point::ZERO, Vec2::new(0.0, 1.0), Vec2::new(1.0, 0.0), 50.0, 20.0, s).unwrap();
let mut path = BezPath::new();
path.move_to(curves[0].p0);
for q in &curves {
path.curve_to(q.p1, q.p2, q.p3);
}
path.line_to((100.0, 0.0));
path.line_to((100.0, 100.0));
path.line_to((0.0, 100.0));
path.close_path();
let want = area(&rounded_rect(100.0, 100.0, [20.0, 0.0, 0.0, 0.0], s));
assert!((area(&path) - want).abs() < 0.05, "s={s}: {} vs {want}", area(&path));
assert!((curves[0].p0 - Point::new(0.0, 20.0 * (1.0 + s))).hypot() < 1e-9, "s={s}: it starts 1 + smoothing radii along the edge");
let last = curves[curves.len() - 1];
assert!((last.p3 - Point::new(20.0 * (1.0 + s), 0.0)).hypot() < 1e-9, "s={s}: and ends as far along the other");
let dir = |c: &kurbo::CubicBez, t: f64| {
let d = if t < 0.5 { c.eval(1e-6) - c.eval(0.0) } else { c.eval(1.0) - c.eval(1.0 - 1e-6) };
d.normalize()
};
assert!(dir(&curves[0], 0.0).dot(Vec2::new(0.0, -1.0)) > 0.999, "s={s}: it leaves along the first edge");
assert!(dir(&last, 1.0).dot(Vec2::new(1.0, 0.0)) > 0.999, "s={s}: it arrives along the second edge");
for pair in curves.windows(2) {
assert!(dir(&pair[0], 1.0).dot(dir(&pair[1], 0.0)) > 0.999, "s={s}: its pieces join smoothly");
}
}
}
#[test]
+2 -2
View File
@@ -338,8 +338,8 @@ impl<'a> Exporter<'a> {
if let NodeKind::Vector { network } = &n.kind
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
{
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if let Some(mut region_path) = net.region_path(i) {
+17
View File
@@ -284,3 +284,20 @@ fn depth_samples() {
std::fs::write(format!("{stem}-{name}.png"), figcraft_render::encode_png(&pix).unwrap()).unwrap();
}
}
#[test]
fn an_oversized_link_is_dropped_before_it_is_copied_per_line() {
// One link far past MAX_URL on text that wraps onto many lines: no annotation, and the
// destination is never copied once per line on the way to being refused.
let mut d = Document::new("long link");
let p = d.first_page().unwrap_or(NodeId(0));
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 300.0, 4000.0).unwrap();
let words = "word ".repeat(2_000);
let mut td = TextData::new(words.trim_end());
td.style.font_size = 20.0;
td.style.link = Some("https://example.com/".to_string() + &"a".repeat(1 << 20));
let t = d.create(f, NodeKind::Text(td), "T", 10.0, 10.0, 280.0, 3980.0).unwrap();
d.get_mut(t).unwrap().fills = vec![Paint::solid(Color::BLACK)];
let pdf = export_pdf(&d, &[f], &PdfOptions::default()).unwrap();
assert_eq!(text(&pdf).matches("/Subtype/Link").count(), 0);
}
+21
View File
@@ -82,6 +82,27 @@ fn pdf_vector_region_paints_emit_independent_colors() {
assert!(content.contains("1 0 0 rg") && content.contains("0 0 1 rg"), "{content}");
}
#[test]
fn pdf_vector_region_paints_draw_smoothed_corners() {
// A closed vector with rounded points and paints on its region: corner smoothing changes the
// exported outline, as on the canvas.
let corners = [(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| figcraft_geom::Point::new(x, y));
let mut network = VectorNetwork::from_points(&corners, true);
for v in &mut network.vertices {
v.radius = 20.0;
}
network.regions[0].fills = Some(vec![Paint::solid(Color::from_hex("FF0000").unwrap())]);
let mut d = Document::new("smoothing");
let p = d.first_page().unwrap();
let id = d.create(p, NodeKind::Vector { network }, "V", 0.0, 0.0, 100.0, 100.0).unwrap();
let content = |d: &Document| inflated(&export_pdf(d, &[id], &PdfOptions::default()).unwrap());
let plain = content(&d);
d.get_mut(id).unwrap().corners.smoothing = 0.6;
let smoothed = content(&d);
assert!(smoothed.contains("1 0 0 rg"), "{smoothed}");
assert_ne!(smoothed, plain, "the region's outline takes the smoothing");
}
/// A page with two frames: shapes, gradients, an image, strokes, a mask, a boolean, effects, text.
pub(crate) fn sample_doc() -> (Document, Vec<NodeId>) {
let mut d = Document::new("sample");
+3 -1
View File
@@ -121,7 +121,9 @@ fn record_links(ex: &mut Exporter, layout: &TextLayout, gxf: Affine) {
let glyphs = layout.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
for g in glyphs.iter().map(Some).chain([None]) {
let link = g.and_then(|g| {
let url = layout.styles.get(g.style)?.link.as_deref().filter(|u| !u.trim().is_empty())?;
// Checked here, not when the annotation is written: every linked line copies
// the destination, so one oversized link must not be copied at all.
let url = layout.styles.get(g.style)?.link.as_deref().map(str::trim).filter(|u| !u.is_empty() && u.len() <= crate::MAX_URL)?;
let (a, b) = (g.x, g.x + g.advance);
(a.is_finite() && b.is_finite()).then(|| (url, a.min(b), a.max(b)))
});
+8 -6
View File
@@ -274,7 +274,9 @@ fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath>
if let Some(p) = locked(stroke_cache()).get(key) {
return p;
}
let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, net));
// Points with styles of their own are stroked on the network as drawn (smoothed corners in).
let drawn = net.map(|network| figcraft_doc::geometry::drawn_network(network, n.corners.smoothing));
let out = Arc::new(figcraft_pathops::stroke_outline_of(path, props, closed, drawn.as_deref()));
locked(stroke_cache()).insert(key, out.clone());
out
}
@@ -334,9 +336,9 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
Some((p, FillRule::Winding))
}
NodeKind::Vector { network } => {
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let mut areas = BezPath::new();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
@@ -357,7 +359,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F
}
let rule =
if network.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd) { FillRule::EvenOdd } else { FillRule::Winding };
Some((network.fill_path(), rule))
Some((net.fill_path(), rule))
}
_ => figcraft_doc::geometry::shape_path(n).map(|p| (p, FillRule::Winding)),
}
@@ -849,8 +851,8 @@ fn region_painted(n: &Node) -> Option<&figcraft_doc::VectorNetwork> {
/// Each region of a vector with region paints, filled with its own paints (or the layer's) in its
/// own paint frame.
fn fill_vector_regions(ctx: &Ctx, pix: &mut Pixmap, n: &Node, network: &figcraft_doc::VectorNetwork, xf: Affine) {
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
if let Some(mut path) = net.region_path(i) {
+76 -13
View File
@@ -27,8 +27,50 @@ const GREY_CHROMA: f32 = 0.000_2;
/// The attributes that can hold a colour (with `style`, which can hold any property).
const COLOR_ATTRS: [&str; 8] = ["fill", "stroke", "stop-color", "flood-color", "lighting-color", "color", "solid-color", "style"];
/// A colour read from a call, with why it is only an approximation; or why it can't be read.
type Eval = Result<(Color, Option<&'static str>), &'static str>;
/// A colour read from a call, with why it is only an approximation (or why it can't be read) and
/// which of its components a mix should treat as missing.
type Eval = Result<(Color, Option<&'static str>, Missing), &'static str>;
/// Which components a colour gave as `none`: red, green, blue and alpha. A mix takes a missing
/// component from its other colour before interpolating (CSS Color 5 section 4.1): red, green and
/// blue only in an sRGB mix (in another space they are converted first, and a missing one is 0),
/// the alpha in every mix. Missing in both colours, a component stays missing (and 0).
#[derive(Clone, Copy)]
struct Missing {
r: bool,
g: bool,
b: bool,
a: bool,
}
impl Missing {
const NONE: Self = Self { r: false, g: false, b: false, a: false };
/// What a colour function's text (lower case) gives as `none`: the four values of `rgb()` or
/// `rgba()`, else only an alpha after a `/`.
fn of(color: &str) -> Self {
let rgb = color.strip_prefix("rgba(").or_else(|| color.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')'));
match rgb {
Some(inner) => {
let parts = split_top(inner, |c| c == ',' || c == '/' || c.is_whitespace());
let at = |i: usize| parts.get(i).is_some_and(|p| *p == "none");
Self { r: at(0), g: at(1), b: at(2), a: at(3) }
}
None => Self { a: alpha_is_none(color), ..Self::NONE },
}
}
}
/// Fill a component missing on one side from the other; true when it is missing on both.
fn carry(a: &mut f32, b: &mut f32, a_missing: bool, b_missing: bool) -> bool {
match (a_missing, b_missing) {
(true, false) => *a = *b,
(false, true) => *b = *a,
(true, true) => return true,
(false, false) => {}
}
false
}
/// `data` with its `color()` and `color-mix()` calls replaced by colours usvg reads, and notes on
/// those it read only approximately or not at all; `None` when there are none (or `data` isn't
@@ -90,8 +132,8 @@ fn rewrite_calls(text: &str, range: Range<usize>, profile: ColorProfile, edits:
};
let call = s.get(start..end).unwrap_or_default();
let color = match eval(call, profile, 0) {
Ok((c, None)) => c,
Ok((c, Some(why))) => {
Ok((c, None, _)) => c,
Ok((c, Some(why), _)) => {
notes.push(note(call, why));
c
}
@@ -176,12 +218,15 @@ fn color_fn(args: &str, profile: ColorProfile) -> Eval {
None => 1.0,
};
let c = Color::new(value(r)?, value(g)?, value(b)?, a);
// A `none` is 0 here and missing in a mix: reds, greens and blues are analogous across the RGB
// spaces (CSS Color 4 section 12.2), so a missing channel stays missing through the conversion.
let missing = Missing { r: *r == "none", g: *g == "none", b: *b == "none", a: alpha == Some("none") };
let (from, why) = match *space {
"srgb" => (ColorProfile::Srgb, None),
"display-p3" => (ColorProfile::DisplayP3, None),
_ => (ColorProfile::Srgb, Some("only the srgb and display-p3 spaces are read: its values were taken as sRGB")),
};
Ok((from.conversion_to(profile).apply(c), why))
Ok((from.conversion_to(profile).apply(c), why, missing))
}
/// A CSS number, a percentage (100% is 1) or `none` (0); finite only.
@@ -219,9 +264,18 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval {
["in", ..] => (Space::Srgb, Some("only srgb, oklab and oklch mixes are read: it was mixed in sRGB")),
_ => return Err("color-mix() starts with `in` and a colour space"),
};
let (a, pa, why_a) = operand(first, profile, depth)?;
let (b, pb, why_b) = operand(second, profile, depth)?;
let (mut a, pa, why_a, ma) = operand(first, profile, depth)?;
let (mut b, pb, why_b, mb) = operand(second, profile, depth)?;
why = why.or(why_a).or(why_b);
// Missing components are carried over from the other colour before the mix (see `Missing`);
// rendered on its own, the same colour has them at 0.
let srgb = matches!(space, Space::Srgb);
let missing = Missing {
r: srgb && carry(&mut a.r, &mut b.r, ma.r, mb.r),
g: srgb && carry(&mut a.g, &mut b.g, ma.g, mb.g),
b: srgb && carry(&mut a.b, &mut b.b, ma.b, mb.b),
a: carry(&mut a.a, &mut b.a, ma.a, mb.a),
};
if [pa, pb].into_iter().flatten().any(|p| !(0.0..=100.0).contains(&p)) {
return Err("mix percentages are between 0% and 100%");
}
@@ -236,11 +290,14 @@ fn mix(args: &str, profile: ColorProfile, depth: usize) -> Eval {
return Err("mix percentages add up to 0%");
}
let mixed = interpolate(space, a, b, pb / sum);
Ok((mixed.with_alpha(mixed.a * (sum / 100.0).min(1.0)), why))
Ok((mixed.with_alpha(mixed.a * (sum / 100.0).min(1.0)), why, missing))
}
/// One colour of a mix and its percentage (before or after it).
fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<(Color, Option<f32>, Option<&'static str>), &'static str> {
/// One colour of a mix: the colour, its percentage (before or after it), a note, and the
/// components it gave as `none` (see [`Missing`]).
type Operand = (Color, Option<f32>, Option<&'static str>, Missing);
fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<Operand, &'static str> {
let words = split_top(s, char::is_whitespace);
let pct = |w: &str| w.strip_suffix('%').and_then(|p| p.parse::<f32>().ok()).filter(|p| p.is_finite());
let (color, p) = match words.as_slice() {
@@ -249,12 +306,18 @@ fn operand(s: &str, profile: ColorProfile, depth: usize) -> Result<(Color, Optio
[p, c] if pct(p).is_some() => (*c, pct(p)),
_ => return Err("a mix colour is a colour and an optional percentage"),
};
let (c, why) = if color.starts_with("color(") || color.starts_with("color-mix(") {
let (c, why, missing) = if color.starts_with("color(") || color.starts_with("color-mix(") {
eval(color, profile, depth + 1)?
} else {
(Color::parse_css(color).ok_or("a mix colour isn't read")?, None)
(Color::parse_css(color).ok_or("a mix colour isn't read")?, None, Missing::of(color))
};
Ok((c, p, why))
Ok((c, p, why, missing))
}
/// Whether a colour function's text (already lower case) gives its alpha as `none`: what follows
/// its last `/`.
fn alpha_is_none(color: &str) -> bool {
color.rsplit_once('/').is_some_and(|(_, rest)| rest.trim().trim_end_matches(')').trim_end() == "none")
}
/// `a` and `b` mixed `t` of the way to `b`, premultiplied by their alpha.
+22
View File
@@ -73,6 +73,28 @@ fn color_mix_in_srgb_oklab_and_oklch() {
// Percentages under 100% lower the alpha; alphas mix premultiplied.
assert_eq!(rgba8(fill("color-mix(in srgb, red 20%, blue 20%)").0), Some([128, 0, 128, 102]));
assert_eq!(rgba8(fill("color-mix(in srgb, red, transparent)").0), Some([255, 0, 0, 128]));
// A missing alpha (`/ none`) takes the other colour's before the mix (CSS Color 5 section 4.1);
// on its own the same colour renders transparent.
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), blue)").0), Some([128, 0, 128, 255]));
assert_eq!(rgba8(fill("color-mix(in srgb, red, rgb(0 0 255 / none) 50%)").0), Some([128, 0, 128, 255]));
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / 50%))").0), Some([128, 0, 128, 128]));
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(255 0 0 / none), rgb(0 0 255 / none))").0), Some([128, 0, 128, 0]), "both missing: zero");
// A missing red, green or blue is carried over as well in an sRGB mix; in another space the
// converted channels are plain zeros (only analogous components carry, CSS Color 5 section 4.1).
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(none 0 0), red)").0), Some([255, 0, 0, 255]));
assert_eq!(rgba8(fill("color-mix(in srgb, rgb(none 0 0), rgb(255 none 0))").0), Some([255, 0, 0, 255]));
assert_eq!(rgba8(fill("color-mix(in oklab, rgb(none 0 0), red)").0), rgba8(fill("color-mix(in oklab, black, red)").0));
// Missing in both stays missing through a nested mix and is carried in the outer one.
assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, rgb(0 255 0 / none), rgb(0 255 0 / none)), red)").0), Some([128, 128, 0, 255]));
// `color()` gives its `none` values the same way, in either of its spaces (reds, greens and
// blues are analogous across RGB spaces, CSS Color 4 section 12.2).
assert_eq!(rgba8(fill("color-mix(in srgb, color(srgb 1 0 0 / none), blue)").0), Some([128, 0, 128, 255]));
assert_eq!(rgba8(fill("color-mix(in srgb, color(srgb none 0 0), red)").0), Some([255, 0, 0, 255]));
assert_eq!(rgba8(fill("color-mix(in srgb, color(display-p3 none 0 0), red)").0), Some([255, 0, 0, 255]));
assert_eq!(
rgba8(fill("color-mix(in srgb, color-mix(in srgb, color(srgb 0 1 0 / none), color(srgb 0 1 0 / none)), red)").0),
Some([128, 128, 0, 255])
);
// Nested, with color() inside.
assert_eq!(rgba8(fill("color-mix(in srgb, color-mix(in srgb, red, blue), color(srgb 0 1 0) 0%)").0), Some([128, 0, 128, 255]));
}
+2 -2
View File
@@ -537,8 +537,8 @@ impl W<'_> {
if let NodeKind::Vector { network } = &n.kind
&& network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some())
{
let rounded = network.rounded();
let net = rounded.as_ref().unwrap_or(network);
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
let net = drawn.as_ref();
for (i, region) in net.regions.iter().enumerate() {
let paints = region.fills.as_deref().unwrap_or(&n.fills);
let fr = if region.winding == figcraft_doc::WindingRule::EvenOdd { " fill-rule=\"evenodd\"" } else { "" };
+1 -1
View File
@@ -109,7 +109,7 @@ fn a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does() {
if face().is_none() {
return;
}
let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0 };
let level = |list: ListStyle, indent: u8| ParaList { list, indent, start_offset: 0, ..ParaList::default() };
let mut t = text("a\nb\nc", TextAutoResize::WidthAndHeight);
t.paragraphs = vec![level(ListStyle::Bulleted, 1), level(ListStyle::None, 1), level(ListStyle::None, 0)];
let l = layout(&t, None, None);
+9
View File
@@ -79,6 +79,15 @@ pub(crate) fn is_space(c: char) -> bool {
class(c) == Class::Space
}
/// Whether justified text may widen the gap between `a` and the `b` after it (a space widens on
/// its own): next to an ideograph, kana, Hangul or full-width form, which are written without
/// spaces, so each is a word of its own, as for line breaking, wherever a line may break there (no
/// gap before a closing mark or after an opening one).
pub(crate) fn justify_between(a: char, b: char) -> bool {
let (ca, cb) = (class(a), class(b));
(ca == Class::Ideo || cb == Class::Ideo) && ca != Class::Space && break_between(a, b)
}
/// Whether `c` is in a script written without spaces between words that the line segmenter has
/// word data for (UAX #14 class SA): Thai, Lao, Burmese (with the Myanmar extensions) and Khmer.
fn writes_no_spaces(c: char) -> bool {
+100 -26
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use figcraft_doc::{
DecorationLength, DecorationStyle, LeadingTrim, LetterSpacing, LineHeight, ListStyle, ParaList, TextAlignH, TextAutoResize, TextCase, TextData,
TextDecoration, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag,
TextDecoration, TextDirection, TextStyle, WrapStyle, color::PaintKind, valid_feature_tag,
};
use figcraft_geom::{Affine, BezPath, Rect};
use harfrust::{Direction, Feature, ShapeOptions, ShaperInstance, Tag, UnicodeBuffer, Variation};
@@ -265,29 +265,47 @@ impl Item {
}
}
/// Bidi embedding levels per char, and whether each char's paragraph runs right to left.
/// Bidi embedding levels per char, and whether each char's paragraph runs right to left;
/// `rtl_para` has one more entry, for the position after the last char (an empty last paragraph).
pub(crate) struct Bidi {
pub(crate) levels: Vec<u8>,
pub(crate) rtl_para: Vec<bool>,
}
impl Bidi {
/// None when the text has nothing right to left (the common case costs one scan).
pub(crate) fn of(text: &str) -> Option<Bidi> {
if !text.chars().any(is_rtl_char) {
/// None when the text has nothing right to left and no paragraph forces its direction (the
/// common case costs one scan). `forced`, by paragraph: `Some(true)` runs right to left and
/// `Some(false)` left to right whatever the letters, `None` goes by the first letter with a
/// direction (Text ▸ Text direction, #1830).
pub(crate) fn of(text: &str, forced: &[Option<bool>]) -> Option<Bidi> {
if forced.iter().all(Option::is_none) && !text.chars().any(is_rtl_char) {
return None;
}
let info = unicode_bidi::BidiInfo::new(text, None);
let mut paras = info.paragraphs.iter().peekable();
let n = text.chars().count();
let (mut levels, mut rtl_para) = (Vec::with_capacity(n), Vec::with_capacity(n));
for (b, _) in text.char_indices() {
while paras.peek().is_some_and(|p| p.range.end <= b) {
paras.next();
// Paragraph by paragraph (the algorithm starts over at each one), so each can be given its
// base level.
for (p, para) in text.split_inclusive('\n').enumerate() {
let base = forced.get(p).copied().flatten().map(|rtl| if rtl { unicode_bidi::Level::rtl() } else { unicode_bidi::Level::ltr() });
let info = unicode_bidi::BidiInfo::new(para, base);
let mut paras = info.paragraphs.iter().peekable();
for (b, _) in para.char_indices() {
while paras.peek().is_some_and(|q| q.range.end <= b) {
paras.next();
}
levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0));
rtl_para.push(paras.peek().is_some_and(|q| q.level.is_rtl()));
}
levels.push(info.levels.get(b).map(|l| l.number()).unwrap_or(0));
rtl_para.push(paras.peek().is_some_and(|p| p.level.is_rtl()));
}
// The position after the last char: in the last paragraph, or in the empty one after a
// final newline (its forced direction, else left to right), so an empty last line knows
// its direction too.
let after = if text.is_empty() || text.ends_with('\n') {
forced.get(text.matches('\n').count()).copied().flatten().unwrap_or(false)
} else {
rtl_para.last().copied().unwrap_or(false)
};
rtl_para.push(after);
Some(Bidi { levels, rtl_para })
}
}
@@ -297,6 +315,37 @@ pub(crate) fn is_rtl_char(c: char) -> bool {
matches!(c as u32, 0x0590..=0x08FF | 0xFB1D..=0xFDFF | 0xFE70..=0xFEFF | 0x10800..=0x10FFF | 0x1E800..=0x1EFFF | 0x200F | 0x202B | 0x202E | 0x2067)
}
/// Which visual positions of a line's items (in `visual_order(levels)`) a justify gap follows. The
/// gap after logical item k (`after[k]`) is reordered like a glyph of no width at the lower of its
/// two neighbours' levels, as rule N1 resolves a space between them: between logically neighbouring
/// glyphs it opens where they are, and where runs meet it opens beside the run it belongs to, so a
/// gap beside a Hebrew word in Chinese text opens beside the word, not inside it, and so does one
/// beside a Chinese word within that Hebrew. From item `trailing` on (a line's trailing spaces) a
/// gap opens after the item itself.
fn visual_gaps(levels: &[u8], after: &[bool], trailing: usize) -> Vec<bool> {
let gap_after = |k: usize| after.get(k).copied().unwrap_or(false);
let level = |k: usize| levels.get(k).copied().unwrap_or(0);
// Items at the even indices, the gap after item k at 2k + 1 (a gap's level never exceeds its
// neighbours', so every run reversed starts and ends on an item and the two keep alternating).
let mut both = Vec::with_capacity(after.len() * 2);
for k in 0..after.len() {
if k > 0 {
both.push(level(k).min(level(k - 1)));
}
both.push(level(k));
}
let order = visual_order(&both);
(0..after.len())
.map(|i| {
let k = order.get(2 * i).copied().unwrap_or(0) / 2;
if k >= trailing {
return gap_after(k);
}
order.get(2 * i + 1).is_some_and(|&g| g % 2 == 1 && g / 2 < trailing && gap_after(g / 2))
})
.collect()
}
/// Visual order of a line's items from their levels (rule L2: from the highest level down to the
/// lowest odd one, reverse every run at that level or above).
pub(crate) fn visual_order(levels: &[u8]) -> Vec<usize> {
@@ -479,8 +528,20 @@ fn shape_text(text: &TextData) -> ShapedText {
.filter_map(|(k, on)| <[u8; 4]>::try_from(k.as_bytes()).ok().map(|t| Feature::new(Tag::new(&t), u32::from(*on), ..)))
.collect();
// Shape each span, split by paragraph, bidi run and fallback face.
let bidi = Bidi::of(&text.characters);
// Shape each span, split by paragraph, bidi run and fallback face. A paragraph's direction is
// its letters' unless forced (Text ▸ Text direction).
let forced: Vec<Option<bool>> = if text.paragraphs.is_empty() {
Vec::new()
} else {
(0..text.paragraph_count())
.map(|p| match text.para_list(p).direction {
TextDirection::Auto => None,
TextDirection::Ltr => Some(false),
TextDirection::Rtl => Some(true),
})
.collect()
};
let bidi = Bidi::of(&text.characters, &forced);
let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0);
// A grapheme cluster (emoji sequence, letter with marks) is shaped with one face.
let cluster_start = crate::breaks::grapheme_starts(&text.characters);
@@ -1251,7 +1312,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
// Letter spacing separates glyphs: none after the line's last visible one.
let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing);
let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::<f64>() - end_spacing;
let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0);
// An empty last line has no item: the position after the last char is its paragraph's.
let first_char = items.get(*s).map_or(chars.len(), |it| it.char_start);
let rtl_line = rtl_para_at(first_char);
let line_indent = line_indent_of(*para, para_start_line, first_char);
// Hanging punctuation at the line's edges (the visible text's first and last chars).
@@ -1259,8 +1321,20 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let hang_r =
items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv);
let gstart = out.glyphs.len();
// Justify: extra space per gap.
let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count();
// Justify: extra space per gap (a space, or between ideographs, whose text has no spaces;
// never inside a grapheme cluster, so a mark stays on its base).
let widens = |k: usize| {
items.get(k).is_some_and(|it| {
it.stretches()
|| (k + 1 < vis_end
&& items.get(k + 1).is_some_and(|next| {
next.char_start > it.char_start
&& cluster_start.get(next.char_start).copied().unwrap_or(true)
&& crate::breaks::justify_between(it.ch, next.ch)
}))
})
};
let gaps = (*s..vis_end).filter(|k| widens(*k)).count();
let justify_extra = match (text.align_h, wrap) {
(TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => {
((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
@@ -1342,20 +1416,20 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
// Right-to-left runs are placed in visual order; trailing spaces take the paragraph's
// direction (rule L1).
let line_items = items.get(*s..*e).unwrap_or(&[]);
let order: Vec<usize> = if bidi.is_some() {
let para_level = u8::from(rtl_line);
let levels: Vec<u8> = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect();
visual_order(&levels)
} else {
(0..line_items.len()).collect()
};
for it in order.iter().filter_map(|k| line_items.get(*k)) {
let para_level = u8::from(rtl_line);
let levels: Vec<u8> = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect();
let order = visual_order(&levels);
// Each gap opens at its visual boundary; trailing spaces widen where they are.
let after: Vec<bool> = (0..line_items.len()).map(|k| widens(*s + k)).collect();
let gaps_at = visual_gaps(&levels, &after, vis_end - *s);
for (i, &k) in order.iter().enumerate() {
let Some(it) = line_items.get(k) else { continue };
if is_line_break(it.ch) {
continue;
}
out.glyphs.extend(glyph_at(&styles, it, pen, baseline));
pen += it.adv;
if it.stretches() {
if gaps_at.get(i).copied().unwrap_or(false) {
pen += justify_extra;
}
}
+2 -2
View File
@@ -43,11 +43,11 @@ fn starts(l: &TextLayout) -> Vec<(f64, Option<f64>)> {
}
fn pl(list: ListStyle, indent: u8) -> ParaList {
ParaList { list, indent, start_offset: 0 }
ParaList { list, indent, start_offset: 0, ..ParaList::default() }
}
fn from(list: ListStyle, indent: u8, start_offset: u32) -> ParaList {
ParaList { list, indent, start_offset }
ParaList { list, indent, start_offset, ..ParaList::default() }
}
#[test]
+85 -2
View File
@@ -406,14 +406,42 @@ fn bidi_visual_order_follows_levels() {
assert_eq!(crate::layout::visual_order(&[0, 0, 0]), vec![0, 1, 2]);
assert!(crate::layout::visual_order(&[]).is_empty());
// Plain left-to-right text skips the analysis.
assert!(crate::layout::Bidi::of("Hello, world 123").is_none());
let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x").unwrap();
assert!(crate::layout::Bidi::of("Hello, world 123", &[]).is_none());
assert!(crate::layout::Bidi::of("Hello, world 123", &[None, None]).is_none());
let b = crate::layout::Bidi::of("abc \u{5D0}\u{5D1}\u{5D2}\n\u{5D3} x", &[]).unwrap();
assert_eq!(b.levels.get(..4), Some(&[0, 0, 0, 0][..]));
assert!(b.levels.get(4..7).unwrap().iter().all(|l| l % 2 == 1));
// Paragraph direction comes from each paragraph's first strong char.
assert!(!b.rtl_para[0] && b.rtl_para[8]);
}
/// A paragraph's forced base direction (Text ▸ Text direction, #1830): the base level is given,
/// the letters' own levels still follow the rules, and the other paragraphs keep theirs.
#[test]
fn forced_direction_sets_a_paragraphs_base_level() {
use crate::layout::Bidi;
// Latin letters forced right to left: an embedded left-to-right run (level 2) in a
// right-to-left paragraph; the next paragraph, unforced, stays left to right.
let b = Bidi::of("ab\ncd", &[Some(true), None]).unwrap();
assert_eq!(b.rtl_para, [true, true, true, false, false, false], "and the position after the last char");
assert_eq!((b.levels.get(..2), b.levels.get(3..)), (Some(&[2, 2][..]), Some(&[0, 0][..])));
// Hebrew forced left to right: a right-to-left run (level 1) in a left-to-right paragraph.
let b = Bidi::of("\u{5D0}\u{5D1}", &[Some(false)]).unwrap();
assert_eq!(b.rtl_para, [false, false, false]);
assert_eq!(b.levels, [1, 1]);
// Forcing what the letters already say changes nothing; a paragraph past the forced list is
// detected.
let b = Bidi::of("\u{5D0}\n\u{5D1}", &[Some(true)]).unwrap();
assert_eq!(b.rtl_para, [true, true, true, true]);
assert_eq!((b.levels.first(), b.levels.last()), (Some(&1), Some(&1)));
// The empty paragraph after a final newline has its own direction: forced, else left to right.
let b = Bidi::of("ab\n", &[None, Some(true)]).unwrap();
assert_eq!(b.rtl_para, [false, false, false, true]);
let b = Bidi::of("\u{5D0}\n", &[]).unwrap();
assert_eq!(b.rtl_para, [true, true, false]);
assert_eq!(Bidi::of("", &[Some(true)]).map(|b| b.rtl_para), Some(vec![true]));
}
/// The x of the glyph for char `i`.
fn glyph_x(l: &TextLayout, i: usize) -> f64 {
l.glyphs.iter().find(|g| g.char_start == i && g.char_end > i).map(|g| g.x).unwrap()
@@ -590,6 +618,61 @@ fn lower_case_writes_final_sigma() {
assert_eq!(gids(&t).iter().map(|g| g.0).collect::<Vec<_>>(), want.iter().map(|g| g.0).collect::<Vec<_>>(), "split runs");
}
/// Chinese is written without spaces: justified, the room goes between its characters (each a word
/// of its own, as for line breaking), so the last one reaches the right edge too, and none opens
/// where a line couldn't break (before a closing mark, after an opening one). "Justify aligns the
/// first word in each line of text to the left-most edge, and the last to the right-most edge."
#[test]
fn justified_ideographs_spread_the_room_between_characters() {
if !have_fonts() {
return;
}
let lay_out = |s: &str, align, w| {
let mut t = TextData::new(s);
t.style.font_size = 20.0;
t.align_h = align;
t.auto_resize = figcraft_doc::TextAutoResize::Height;
layout(&t, w, None)
};
// Box width cut inside char `fit`, so the first line is chars 0..fit; how many gaps lie before
// each of its chars.
let rows: [(&str, usize, &[i32], &str); 6] = [
("一二三四五六七八九十", 6, &[0, 1, 2, 3, 4, 5], "one gap between each pair of ideographs"),
("一二三。四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none before the full stop, which never starts a line"),
("一二三「四五六七八九", 6, &[0, 1, 2, 3, 3, 4], "none after the opening bracket, which never ends a line"),
("一二三\u{20DD}四五六七八九", 6, &[0, 1, 2, 2, 3, 4], "none inside a grapheme cluster: a combining mark stays on its base"),
("漢אב字一二三", 4, &[0, 1, 1, 2], "beside a right-to-left word (laid out ב then א), not inside it"),
(
"漢\u{202B}אב字一גד\u{202C}文一二三",
10,
&[0, 1, 4, 4, 2, 3, 1, 1, 1, 5],
"beside a Chinese word embedded in a Hebrew phrase embedded in Chinese (laid out 漢 ד ג 字 一 ב א 文, the embedding marks of no width), and beside the phrase",
),
];
for (s, fit, gaps_before, why) in rows {
let natural = lay_out(s, TextAlignH::Left, None);
let (fits, next) = (natural.caret(fit).0, natural.caret(fit + 1).0);
assert!(next > fits, "{why}: chars need advances");
let w = fits + (next - fits) / 4.0;
let left = lay_out(s, TextAlignH::Left, Some(w));
let just = lay_out(s, TextAlignH::Justified, Some(w));
assert!(just.lines.len() >= 2 && just.lines[0].char_end == fit, "{why}: {:?}", just.lines);
let extra = (w - left.lines[0].width) / f64::from(gaps_before[fit - 1]);
assert_eq!(left.lines[0].char_end, fit, "{why}: {:?}", left.lines);
assert!(extra > 0.0, "{why}");
assert_eq!(left.glyphs.len(), just.glyphs.len());
for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= fit) {
let want = f64::from(gaps_before[l.char_start]) * extra;
assert!((j.x - l.x - want).abs() < 1e-6, "{why}: char {} moved {} (want {want})", l.char_start, j.x - l.x);
}
let last = just.glyphs.iter().rfind(|g| g.char_end <= fit).unwrap();
assert!((last.x + last.advance - w).abs() < 1e-6, "{why}: the last char ends at the right edge, not {}", last.x + last.advance);
// The last line is left-aligned, as always.
let (l2, j2) = (left.glyphs.iter().find(|g| g.char_start == fit).unwrap(), just.glyphs.iter().find(|g| g.char_start == fit).unwrap());
assert!((j2.x - l2.x).abs() < 1e-6, "{why}: the last line is not justified");
}
}
/// Thai is written without spaces between words; U+200B ZERO WIDTH SPACE marks where a line may
/// break. ภาษา|ไทย|เขียน|ติดกัน ("Thai is written joined up") ไม่มี|ช่องว่าง|ระหว่าง|คำ ("with no
/// spaces between words"), with one real space between the two phrases.
-3
View File
@@ -1520,7 +1520,6 @@ status = "draft — needs native review"
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "تحتفظ هذه الصفحة برمز المالك ما دامت مفتوحة فقط. انسخه لإدارة الملف لاحقًا؛ فهو كلمة مرور لهذا الملف ولن يُعرض مجددًا."
"This condition is edited as an expression" = "يُحرر هذا الشرط كتعبير"
"This file" = "هذا الملف"
"This file has unsaved changes" = "يحتوي هذا الملف على تغييرات غير محفوظة"
"This file is too large to join here (up to {mb} MB)" = "هذا الملف أكبر من أن يمكن الانضمام إليه هنا (الحد {mb} ميغابايت)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "يستخدم هذا الملف خطوطًا غير مثبتة على هذا الحاسوب. يُعرض نصها بخط بديل حتى تثبتها."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "هذا الملف على {server} ومتاح لك وحدك. شاركه ليتمكن الآخرون من الانضمام برمز جديد."
@@ -1580,8 +1579,6 @@ status = "draft — needs native review"
"Unresolve" = "إعادة فتح"
"Unsaved changes" = "تغييرات غير محفوظة"
"Unsaved changes in {names}. Save them before closing?" = "تغييرات غير محفوظة في {names}. هل تريد حفظها قبل الإغلاق؟"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "تغييرات غير محفوظة: احفظ أولًا ({save})، أو أغلق علامة التبويب بالنقر مع {alt} لتجاهلها"
"Unsaved changes: save first, or close again with {key} to discard" = "تغييرات غير محفوظة: احفظ أولًا، أو أغلق مجددًا مع {key} لتجاهلها"
"Up" = "لأعلى"
"Update check is not supported in the web browser." = "التحقق من التحديثات غير مدعوم في المتصفح."
"Update plugin" = "تحديث الإضافة"
-3
View File
@@ -1476,7 +1476,6 @@ status = "draft — needs native review"
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "এই পেজটি খোলা থাকা পর্যন্তই ওনার টোকেন রাখে। পরে ফাইলটি পরিচালনা করতে এটি কপি করুন: এটি এই ফাইলের জন্য একটি পাসওয়ার্ড এবং আর কখনো দেখানো হবে না।"
"This condition is edited as an expression" = "এই শর্তটি এক্সপ্রেশন হিসেবে এডিট করা হয়েছে"
"This file" = "এই ফাইল"
"This file has unsaved changes" = "এই ফাইলে সেভ করা হয়নি এমন পরিবর্তন রয়েছে"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "এই ফাইলে এমন ফন্ট ব্যবহার করা হয়েছে যা এই কম্পিউটারে ইনস্টল করা নেই। আপনি ইনস্টল না করা পর্যন্ত সেগুলোর টেক্সট একটি ফলব্যাক ফন্টে দেখানো হয়।"
"This file is on {server}, open only to you. Share it to let others in with a new code." = "এই ফাইলটি {server}-এ আছে, শুধুমাত্র আপনার জন্য খোলা। নতুন কোড দিয়ে অন্যদের প্রবেশ করাতে এটি শেয়ার করুন।"
"This frame is new on the branch." = "এই ফ্রেমটি ব্রাঞ্চে নতুন।"
@@ -1531,8 +1530,6 @@ status = "draft — needs native review"
"Unresolve" = "অমীমাংসিত করুন"
"Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন"
"Unsaved changes in {names}. Save them before closing?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। বন্ধ করার আগে সেগুলো সংরক্ষণ করবেন?"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন"
"Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন"
"Up" = "উপরে"
"Update plugin" = "প্লাগইন আপডেট করুন"
"Update" = "আপডেট"
-3
View File
@@ -1533,7 +1533,6 @@ status = "draft — needs native review"
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Ova stranica čuva token vlasnika samo dok je otvorena. Kopirajte ga da kasnije upravljate datotekom: to je lozinka za ovu datoteku i neće se ponovo prikazati."
"This condition is edited as an expression" = "Ovaj uslov se uređuje kao izraz"
"This file" = "Ova datoteka"
"This file has unsaved changes" = "Ova datoteka ima nesačuvane promjene"
"This file is too large to join here (up to {mb} MB)" = "Ova datoteka je prevelika za pridruživanje ovdje (do {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ova datoteka koristi fontove koji nisu instalirani na ovom računaru. Njihov tekst se iscrtava rezervnim fontom dok ih ne instalirate."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Ova datoteka je na serveru {server}, otvorena samo Vama. Podijelite je da drugi uđu s novim kodom."
@@ -1594,8 +1593,6 @@ status = "draft — needs native review"
"Unresolve" = "Poništi rješenje"
"Unsaved changes" = "Nesačuvane promjene"
"Unsaved changes in {names}. Save them before closing?" = "Nesačuvane promjene u: {names}. Sačuvati ih prije zatvaranja?"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nesačuvane promjene: prvo sačuvajte ({save}) ili zatvorite karticu uz {alt}-klik da ih odbacite"
"Unsaved changes: save first, or close again with {key} to discard" = "Nesačuvane promjene: prvo sačuvajte ili ponovo zatvorite s {key} da ih odbacite"
"Up" = "Gore"
"Update check is not supported in the web browser." = "Provjera ažuriranja nije podržana u web pregledniku."
"Update plugin" = "Ažuriraj dodatak"
-3
View File
@@ -1448,7 +1448,6 @@ status = "draft — needs native review"
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Tato stránka si token vlastníka pamatuje, jen dokud je otevřená. Zkopírujte ho pro pozdější správu souboru: je to heslo k tomuto souboru a znovu se nezobrazí."
"This condition is edited as an expression" = "Tato podmínka se upravuje jako výraz"
"This file" = "Tento soubor"
"This file has unsaved changes" = "Tento soubor obsahuje neuložené změny"
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Tento soubor je na serveru {server}, přístupný pouze vám. Sdílením umožníte ostatním připojení pomocí nového kódu."
"This frame is new on the branch." = "Tento rámec je ve větvi nový."
"This frame isn't on the branch." = "Tento rámec není ve větvi."
@@ -1501,8 +1500,6 @@ status = "draft — needs native review"
"Unresolve" = "Znovu otevřít"
"Unsaved changes" = "Neuložené změny"
"Unsaved changes in {names}. Save them before closing?" = "Neuložené změny v {names}. Uložit je před zavřením?"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je"
"Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je"
"Up" = "Nahoru"
"Update plugin" = "Aktualizovat plugin"
"Update" = "Aktualizovat"
+6 -3
View File
@@ -147,6 +147,7 @@ status = "draft — needs native review"
"Auto layout" = "Auto-Layout"
"Auto rows" = "Automatische Zeilen"
"Auto width" = "Automatische Breite"
"Auto: as many words as fit on each line" = "Auto: Jede Zeile wird so weit wie möglich gefüllt"
"Back to the FigCraft server" = "Zurück zum FigCraft-Server"
"Back to the font's value" = "Zurück zum Wert der Schrift"
"Back" = "Zurück"
@@ -160,6 +161,8 @@ status = "draft — needs native review"
"Back to the values the font style implies" = "Zurück zu den Werten des Schriftschnitts"
"Background" = "Hintergrund"
"Background blur" = "Hintergrundunschärfe"
"Balance" = "Ausgeglichen"
"Balance: every line close to the same length" = "Ausgeglichen: Alle Zeilen sind möglichst gleich lang"
"Basics" = "Grundlagen"
"Before" = "Vorher"
"Bend" = "Biegen"
@@ -1021,6 +1024,8 @@ status = "draft — needs native review"
"Post" = "Senden"
"Preferences" = "Einstellungen"
"Preferred" = "Bevorzugt"
"Pretty" = "Optimiert"
"Pretty: full lines, no one word alone on the last" = "Optimiert: Volle Zeilen ohne einzelnes Wort in der letzten Zeile"
"Present" = "Präsentieren"
"Present or preview" = "Präsentieren oder Vorschau"
"Present this flow" = "Diesen Ablauf präsentieren"
@@ -1416,7 +1421,6 @@ status = "draft — needs native review"
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Diese Seite behält das Eigentümer-Token nur, solange sie geöffnet ist. Kopieren Sie es, um die Datei später zu verwalten: Es ist ein Passwort für diese Datei und wird nicht noch einmal angezeigt."
"This condition is edited as an expression" = "Diese Bedingung wird als Ausdruck bearbeitet"
"This file" = "Diese Datei"
"This file has unsaved changes" = "Diese Datei hat nicht gespeicherte Änderungen"
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Diese Datei liegt auf {server} und ist nur für Sie zugänglich. Teilen Sie sie, um andere mit einem neuen Code hereinzulassen."
"This frame is new on the branch." = "Dieser Rahmen ist im Branch neu."
"This frame isn't on the branch." = "Dieser Rahmen ist nicht im Branch."
@@ -1469,8 +1473,6 @@ status = "draft — needs native review"
"Unresolve" = "Wieder öffnen"
"Unsaved changes" = "Nicht gespeicherte Änderungen"
"Unsaved changes in {names}. Save them before closing?" = "Nicht gespeicherte Änderungen in {names}. Vor dem Schließen speichern?"
"Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst ({save}) oder schließen Sie den Tab mit {alt}+Klick, um sie zu verwerfen"
"Unsaved changes: save first, or close again with {key} to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst oder schließen Sie erneut mit {key}, um sie zu verwerfen"
"Up" = "Nach oben"
"Update" = "Aktualisieren"
"Update from main" = "Aus Hauptdatei aktualisieren"
@@ -1555,6 +1557,7 @@ status = "draft — needs native review"
"With the layers' export settings" = "Mit den Exporteinstellungen der Ebenen"
"Wrap" = "Umbrechen"
"Wrap in new section" = "In neuen Abschnitt einschließen"
"Wrap style" = "Umbruchstil"
"Yellow" = "Gelb"
"Yesterday" = "Gestern"
"You" = "Sie"
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more