Merge main into feat/export-color-profile

This commit is contained in:
dotCooCoo
2026-11-01 17:05:42 -08:00
26 changed files with 563 additions and 99 deletions
+1
View File
@@ -12,6 +12,7 @@ on:
permissions:
contents: write
packages: write
concurrency:
group: release-${{ github.ref }}
Generated
+22 -22
View File
@@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"arboard",
"craft-menu",
@@ -1795,7 +1795,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"clap",
"clap_complete",
@@ -1813,7 +1813,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1831,7 +1831,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1854,7 +1854,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"kurbo",
"serde",
@@ -1863,7 +1863,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1876,7 +1876,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"craft-docpath",
@@ -1910,7 +1910,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-doc",
"figcraft-layout",
@@ -1927,7 +1927,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"kurbo",
"serde",
@@ -1935,7 +1935,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1943,7 +1943,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1959,7 +1959,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1968,7 +1968,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-color",
"serde",
@@ -1998,7 +1998,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -2017,7 +2017,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"chrono",
"crc32fast",
@@ -2042,7 +2042,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -2058,7 +2058,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"core-text",
"craft-font-catalog",
@@ -2076,7 +2076,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]]
name = "figcraft-vectordrawable"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -2118,7 +2118,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"base64 0.22.1",
"eframe",
@@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.13.1"
version = "0.14.0"
dependencies = [
"figcraft-engine",
"serde_json",
+1 -1
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.13.1"
version = "0.14.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+10 -2
View File
@@ -314,14 +314,22 @@ stand on its own.
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | **Interface design, components and prototypes · you are here** | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+11 -6
View File
@@ -942,9 +942,9 @@ impl Node {
matches!(&self.kind, NodeKind::Vector { network } if network.vertices.iter().any(|v| v.cap.is_some_and(StrokeCap::is_arrow)));
let caps =
if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w };
// Right-angled or smooth outlines (boxes, ellipses) end their mitres on the grown box.
let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like();
let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
// box; an arc's corners don't.
let miter = if self.grows_by_exactly_its_spread() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
} else {
0.0
@@ -960,14 +960,19 @@ impl Node {
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
}
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners).
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners). An arc
/// (an ellipse that doesn't go all the way round) has corners where its ends meet its sides.
fn grows_by_exactly_its_spread(&self) -> bool {
matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like()
match self.kind {
NodeKind::Rectangle | NodeKind::Section { .. } => true,
NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep),
_ => self.kind.is_frame_like(),
}
}
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
/// spread moves the edges out in parallel and they meet in a mitre (see
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread.
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread.
fn spread_miter_reach(&self) -> f64 {
if self.grows_by_exactly_its_spread() {
return 0.0;
+15 -1
View File
@@ -52,7 +52,12 @@ pub fn link(id: u64) -> String {
/// The thread id in a link (or a bare id).
pub fn parse_link(s: &str) -> Option<u64> {
let s = s.trim();
s.strip_prefix(LINK_PREFIX).unwrap_or(s).trim_end_matches('/').parse().ok()
let s = s.strip_prefix(LINK_PREFIX).unwrap_or(s).trim_end_matches('/');
// Digits only: `u64::parse` would also accept a leading '+'.
if !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// Which threads a list shows.
@@ -997,6 +1002,15 @@ mod tests {
use crate::Session;
#[test]
fn parse_link_rejects_sign() {
assert_eq!(super::parse_link("+5"), None);
assert_eq!(super::parse_link("figcraft://comment/+5"), None);
assert_eq!(super::parse_link(""), None);
assert_eq!(super::parse_link("5"), Some(5));
assert_eq!(super::parse_link("figcraft://comment/5/"), Some(5));
}
#[test]
fn comment_thread_lifecycle_and_anchoring() {
let mut s = Session::new();
+4 -2
View File
@@ -8,7 +8,7 @@ use figcraft_doc::{Collection, CollectionId, Document, Mode, ModeId, Variable, V
use serde_json::{Value, json};
use super::CommandSpec;
use crate::codegen::{css_var_name, css_var_of};
use crate::codegen::{CssNames, css_var_name};
use crate::library::{is_remote_collection, local_collections};
use crate::params::{get, string};
use crate::{EngineError, Result, Session, bad, spec};
@@ -73,7 +73,9 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
let flattener = vars.flattener();
// Tailwind theme names, unique per file and the same ones page code reads.
let theme = crate::tailwind::Theme::new(doc);
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
// CSS names, unique per file and the same ones generated code reads.
let names = CssNames::new(doc);
let name = |v: &Variable| if tailwind { theme.name(v) } else { names.name(v) };
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
// font weight don't.
let roles = crate::token_roles::Roles::new(doc);
+34 -3
View File
@@ -30,8 +30,8 @@ pub(crate) use compose::{compose, kotlin_color};
pub(crate) use css::Tokens;
#[cfg(test)]
pub(crate) use css::css_decls;
pub use css::{CssNames, css_var_name, css_var_of};
use css::{css, tok};
pub use css::{css_var_name, css_var_of};
pub(crate) use flutter::{FLUTTER_IMPORT, Place, flutter, flutter_indent};
use html::html;
pub(crate) use html::{Markup, class_name};
@@ -182,7 +182,9 @@ pub(crate) fn num(v: f64) -> String {
if !v.is_finite() {
return "0".into();
}
let r = (v * 100.0).round() / 100.0;
let scaled = v * 100.0;
// Past the x100 overflow every value is whole anyway, so keep it as is.
let r = if scaled.is_finite() { scaled.round() / 100.0 } else { v };
if (r - r.round()).abs() < 1e-9 {
// `as i64` saturates past 2^63, so larger whole values are printed from the float.
if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) }
@@ -193,7 +195,9 @@ pub(crate) fn num(v: f64) -> String {
/// `v` without trailing zeros (6 decimals), for lengths that must convert back to the same pixels.
fn num6(v: f64) -> String {
let r = (v * 1_000_000.0).round() / 1_000_000.0;
let scaled = v * 1_000_000.0;
// Past the x1e6 overflow every value is whole anyway, so keep it as is.
let r = if scaled.is_finite() { scaled.round() / 1_000_000.0 } else { v };
if r.is_finite() && r != 0.0 { format!("{r}") } else { "0".into() }
}
@@ -458,6 +462,21 @@ mod tests {
use crate::params::id_of;
use serde_json::json;
#[test]
fn num_keeps_finite_values_past_the_x100_overflow() {
for v in [1e307, -1e307, f64::MAX] {
let s = num(v);
assert_eq!(s, format!("{v:.0}"));
assert_eq!(s.parse::<f64>(), Ok(v));
}
}
#[test]
fn num6_keeps_finite_values_past_the_x1e6_overflow() {
assert_eq!(num6(1e305).parse::<f64>(), Ok(1e305));
assert_eq!(num6(0.1234567), "0.123457");
}
#[test]
fn num_keeps_huge_whole_numbers() {
assert_eq!(num(1e20), "100000000000000000000");
@@ -475,6 +494,18 @@ mod tests {
f
}
#[test]
fn css_gradient_folds_in_paint_opacity() {
let mut s = Session::new();
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
let mut p = figcraft_color::Paint::gradient(figcraft_color::Gradient::default_for(figcraft_color::GradientKind::Linear, Color::BLACK));
p.opacity = 0.5;
s.doc_mut().unwrap().get_mut(f).unwrap().fills = vec![p];
let css = code_for(s.doc().unwrap(), f, CodeLang::Css);
assert!(css.contains("rgba(0, 0, 0, 0.50) 0%"), "{css}");
assert!(!css.contains("#000000 0%"), "{css}");
}
#[test]
fn swiftui_and_compose_for_an_auto_layout_card() {
let mut s = Session::new();
+71 -6
View File
@@ -1,3 +1,7 @@
use std::collections::{HashMap, HashSet};
use figcraft_doc::VariableId;
use super::{
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PADDING, PaintKind,
TextAlignH, TextCase, TextDecoration, Variable, css_borders, num,
@@ -13,13 +17,18 @@ pub(crate) struct Tokens {
pub tailwind: bool,
/// The file's theme names (computed on first use).
theme: Option<crate::tailwind::Theme>,
/// The file's CSS names (computed on first use).
names: Option<CssNames>,
}
impl Tokens {
/// `var(--name, value)`, remembering the variable.
fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
let name =
if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
let name = if self.tailwind {
self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable)
} else {
self.names.get_or_insert_with(|| CssNames::new(doc)).name(variable)
};
let out = format!("var({name}, {value})");
if !self.vars.iter().any(|(n, _)| *n == name) {
self.vars.push((name, value));
@@ -60,6 +69,45 @@ pub fn css_var_of(v: &Variable) -> String {
if valid { inner.chars().take(80).collect() } else { css_var_name(inner) }
}
/// The file's variables' CSS custom properties, unique in the file: [`css_var_of`], and where an
/// earlier variable already has that name, the name with a numeric suffix (`--brand-primary-2`).
pub struct CssNames {
names: HashMap<VariableId, String>,
}
impl CssNames {
pub fn new(doc: &Document) -> Self {
let vars = &doc.variables.variables;
let mut names = HashMap::with_capacity(vars.len());
// Every plain name is reserved first, so a suffixed name never takes another's plain one;
// the next suffix per base keeps this linear.
let plain: Vec<String> = vars.iter().map(css_var_of).collect();
let mut taken: HashSet<String> = HashSet::with_capacity(vars.len());
let all: HashSet<&str> = plain.iter().map(String::as_str).collect();
let mut next: HashMap<&str, usize> = HashMap::new();
for (v, base) in vars.iter().zip(&plain) {
let mut name = base.clone();
if !taken.insert(name.clone()) {
let k = next.entry(base.as_str()).or_insert(1);
loop {
*k += 1;
name = format!("{base}-{k}");
if !all.contains(name.as_str()) && taken.insert(name.clone()) {
break;
}
}
}
names.insert(v.id.clone(), name);
}
CssNames { names }
}
/// The custom property for `v` (its [`css_var_of`] name for a variable not in the file).
pub fn name(&self, v: &Variable) -> String {
self.names.get(&v.id).cloned().unwrap_or_else(|| css_var_of(v))
}
}
/// `value`, or a reference to the variable bound to the first of `fields` that has one.
pub(super) fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
@@ -120,7 +168,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
// A variable binds the first paint's colour.
put(key, if i == 0 { tok(doc, n, &["fill"], c, t) } else { c });
}
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height)),
PaintKind::Gradient(g) => put("background", css_gradient(g, n.width, n.height, p.opacity)),
PaintKind::Image(_) => put("background", "url(image.png) center / cover".into()),
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
@@ -220,7 +268,19 @@ fn effect_decls(n: &Node, put: &mut impl FnMut(&str, String)) {
fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tokens, put: &mut impl FnMut(&str, String)) {
let px = |v: f64| format!("{}px", num(v));
let s = &tx.style;
put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t));
put(
"font-family",
tok(
doc,
n,
&["fontFamily"],
format!(
"\"{}\"",
s.font_family.replace('\\', "\\\\").replace('"', "\\\"").replace('<', "\\3C ").replace("\r\n", "\\A ").replace(['\n', '\r'], "\\A ")
),
t,
),
);
put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t));
put("font-weight", s.weight().to_string());
if s.italic() {
@@ -295,8 +355,13 @@ fn decoration_decls(s: &figcraft_doc::TextStyle, put: &mut impl FnMut(&str, Stri
/// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction
/// (CSS angles: 0deg up, 90deg right), radial with its handles' radii, angular as a conic
/// gradient from its start direction.
fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64) -> String {
let stops = g.sorted_stops().iter().map(|s| format!("{} {}%", s.color.to_css(), (s.position * 100.0).round())).collect::<Vec<_>>().join(", ");
fn css_gradient(g: &figcraft_color::Gradient, w: f64, h: f64, opacity: f32) -> String {
let stops = g
.sorted_stops()
.iter()
.map(|s| format!("{} {}%", s.color.with_alpha(s.color.a * opacity).to_css(), (s.position * 100.0).round()))
.collect::<Vec<_>>()
.join(", ");
let (dx, dy) = ((g.to.0 - g.from.0) * w, (g.to.1 - g.from.1) * h);
let angle = if dx == 0.0 && dy == 0.0 { 180.0 } else { dx.atan2(-dy).to_degrees().rem_euclid(360.0) };
let pct = |v: f64| format!("{}%", num(v * 100.0));
+15
View File
@@ -535,3 +535,18 @@ fn auto_layout_places_right_aligned_auto_width_text() {
let c = s.doc().unwrap().node(t).unwrap().transform.as_coeffs();
assert_eq!((c[4], c[5]), (10.0, 10.0), "the auto layout keeps it at its padding");
}
#[test]
fn css_font_family_escapes_backslash_and_newline() {
let mut s = Session::new();
let t = text(&mut s, "abc", json!({"fontFamily": "Foo\\"}));
let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true));
assert!(css.contains(r#"font-family: "Foo\\";"#), "{css}");
let t = text(&mut s, "abc", json!({"fontFamily": "Foo\nBar"}));
let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true));
assert!(css.contains(r#"font-family: "Foo\A Bar";"#), "{css}");
// A quote stays a quote, and a name can't close the page's <style> block.
let t = text(&mut s, "abc", json!({"fontFamily": "Foo\"</style>"}));
let css = crate::codegen::css_decls(s.doc().unwrap(), s.doc().unwrap().node(t).unwrap(), &mut Default::default(), (true, true));
assert!(css.contains(r#"font-family: "Foo\"\3C /style>";"#), "{css}");
}
+23
View File
@@ -639,3 +639,26 @@ fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() {
assert_eq!(result.overrides, std::collections::BTreeSet::from([light]));
}
}
#[test]
fn css_export_gives_clashing_variable_names_their_own_property() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Brand/Primary", "type": "color", "value": "#ff0000"}));
let blue = ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Brand Primary", "type": "color", "value": "#0000ff"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "link", "type": "color", "value": {"alias": blue["id"]}}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
let decls: Vec<(&str, &str)> =
css.lines().filter_map(|l| l.trim().strip_prefix("--").and_then(|d| d.strip_suffix(';')).and_then(|d| d.split_once(": "))).collect();
let mut names: Vec<&str> = decls.iter().map(|(n, _)| *n).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), decls.len(), "{css}");
let red = decls.iter().find(|(_, v)| *v == "#FF0000").map(|(n, _)| *n);
let blue = decls.iter().find(|(_, v)| *v == "#0000FF").map(|(n, _)| *n);
// The first keeps its plain name; the alias reads the one its target is declared under.
assert_eq!(red, Some("brand-primary"), "{css}");
let blue = blue.unwrap_or_default();
assert_ne!(blue, "brand-primary", "{css}");
assert!(css.contains(&format!("--link: var(--{blue});")), "{css}");
}
@@ -218,6 +218,64 @@ fn connector_mut<'a, 's>(message: &'a mut Kv<'s>, local_id: u64) -> Option<&'a m
})
}
fn set_connector_field(message: &mut Kv<'_>, key: &'static str, value: Kv<'static>) {
let connector = connector_mut(message, 123).expect("synthetic connector");
let Kv::Object(fields) = connector else { panic!("connector object") };
if let Some((_, current)) = fields.iter_mut().find(|(name, _)| *name == key) {
*current = value;
} else {
fields.push((key, value));
}
}
/// A connector without a saved snapshot (a curved or elbowed one is routed here) still draws its
/// line as the generated record says: the paint, weight, cap and dashes live there, not on the
/// connector (#1807).
#[test]
fn a_routed_connector_takes_its_line_style_from_the_generated_record() {
let mut message = synthetic_connector(1.0, false, 0.0);
set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED"));
set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))]));
set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))]));
set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0));
set_override_field(&mut message, "nodeGenerationData", 0, "strokeCap", Kv::Enum("ROUND"));
set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)]));
let (rest, warnings, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES);
let connector = &rest["document"]["children"][0]["children"][0];
assert_eq!(connector["type"], "VECTOR");
assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}");
assert_eq!(connector["strokes"].as_array().map(Vec::len), Some(1), "{connector}");
assert!((connector["strokes"][0]["color"]["b"].as_f64().unwrap() - 0.8).abs() < 1e-6);
assert_eq!(connector["strokeWeight"], 4.0);
assert_eq!(connector["strokeDashes"], serde_json::json!([16.0, 10.0]));
assert_eq!(connector["strokeCapEnd"], "ARROW");
assert!(warnings.iter().any(|warning| warning.contains("follow FigCraft's own routing")), "{warnings:?}");
let (native, _) = crate::rest::import_fig(&rest, &|_| None).unwrap();
let node = find_node(&native, "synthetic connector");
assert_eq!((node.strokes.len(), node.stroke.weight, node.stroke.dashes.clone()), (1, 4.0, vec![16.0, 10.0]));
}
/// A routed connector that writes its own empty paints and dash pattern is meant to have no stroke
/// and a solid line: the generated record's paint and dashes don't replace them.
#[test]
fn a_routed_connector_keeps_its_own_empty_paints_and_dashes() {
let mut message = synthetic_connector(1.0, false, 0.0);
set_connector_field(&mut message, "connectorLineStyle", Kv::Enum("CURVED"));
set_connector_field(&mut message, "connectorStart", obj(vec![("position", point(10.0, 10.0))]));
set_connector_field(&mut message, "connectorEnd", obj(vec![("position", point(110.0, 60.0))]));
set_connector_field(&mut message, "strokePaints", Kv::Array(Vec::new()));
set_connector_field(&mut message, "dashPattern", Kv::Array(Vec::new()));
set_override_field(&mut message, "nodeGenerationData", 0, "strokeWeight", scalar(4.0));
set_override_field(&mut message, "nodeGenerationData", 0, "dashPattern", Kv::Array(vec![scalar(16.0), scalar(10.0)]));
let (rest, _, _) = map::to_rest(&message, "synthetic", crate::rest::MAX_NODES);
let connector = &rest["document"]["children"][0]["children"][0];
assert!(connector.get("vectorNetwork").is_some(), "routed here: {connector}");
assert!(connector["strokes"].as_array().is_none_or(Vec::is_empty), "{connector}");
assert!(connector.get("strokeDashes").is_none(), "{connector}");
assert_eq!(connector["strokeWeight"], 4.0);
}
fn set_override_field(message: &mut Kv<'_>, field: &str, local_id: u64, key: &'static str, value: Kv<'static>) {
let connector = connector_mut(message, 123).expect("synthetic connector");
let section = field_mut(connector, field).expect("connector override section");
+26
View File
@@ -1,4 +1,5 @@
use super::layer::{cap_name, own_text, svg_path, valid_size};
use super::paint::{paint, stroke};
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
@@ -29,6 +30,7 @@ impl<'a> Mapper<'a> {
return Vec::new();
}
let mid = self.connector(o, v, space);
self.connector_line_style(o, v);
if shape.label {
return self.connector_label(o, v, rest_id, mid);
}
@@ -122,6 +124,30 @@ impl<'a> Mapper<'a> {
middle(&path).map(|m| m - b.origin().to_vec2())
}
/// The line's paint, weight, cap and dashes, which a connector keeps in its generated line
/// record rather than on itself: without a saved snapshot the routed line took none of them and
/// drew nothing (#1807). The connector's own values, when it has any, win.
fn connector_line_style(&mut self, o: &mut Obj, v: &View<'a>) {
let Some(line_style) = generated_record(v, "nodeGenerationData", 0) else { return };
let style = View::of(line_style);
// A connector that writes its own (even empty) paints or dash pattern keeps them: an empty
// list is no stroke, or a solid line, not a missing value.
if v.get("strokePaints").is_none() && o.get("strokes").and_then(Value::as_array).is_none_or(Vec::is_empty) {
let strokes: Vec<Value> = style.list("strokePaints").iter().filter_map(|p| paint(p, &mut |w| self.warn(w))).collect();
if !strokes.is_empty() {
o.insert("strokes".into(), Value::Array(strokes));
}
}
let mut line = Obj::new();
stroke(&mut line, &style);
if v.get("dashPattern").is_some() {
line.remove("strokeDashes");
}
for (key, value) in line {
o.entry(key).or_insert(value);
}
}
/// A connector with a label but no saved snapshot of it, as the frame a saved one imports as:
/// `o` keeps the connector's place and size and gives its line to a vector child, and the
/// label is a text child centred on `mid` (the line's middle; the frame's centre when the line
+7 -1
View File
@@ -319,6 +319,12 @@ fn line_or_move(path: &mut BezPath, p: Point) {
}
}
/// Whether an ellipse of this `sweep` (radians) goes all the way round: a whole ellipse or ring,
/// without the sharp corners where an arc's ends meet its sides.
pub fn full_sweep(sweep: f64) -> bool {
finite(sweep).abs() >= TAU - 1e-9
}
/// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in
/// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1.
pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath {
@@ -327,7 +333,7 @@ pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> B
let sweep = finite(sweep).clamp(-TAU, TAU);
let start = finite(start);
let inner = finite(inner).clamp(0.0, 1.0);
let full = sweep.abs() >= TAU - 1e-9;
let full = full_sweep(sweep);
let mut path = BezPath::new();
if full && inner <= 0.0 {
return kurbo::Ellipse::new(c, (rx, ry), 0.0).to_path(0.01);
+50
View File
@@ -142,3 +142,53 @@ fn spread_bands_are_cached_and_hostile_values_are_safe() {
let _ = render(&d, n);
}
}
/// An arc (a pie slice, or a ring cut short) has sharp corners where its ends meet its sides, and
/// a spread mitres them out past the spread: its layer makes room for that, as a star's does. A
/// clear shadow spread four times as far gives the same layer room to the full mitre: the shadow
/// it casts is the same. (Both layers end at the view here, so they line up pixel for pixel;
/// layers starting at different pixels can differ by a few levels at their edges.)
#[test]
fn arc_spread_mitres_are_not_cut_off() {
for kind in [
NodeKind::Ellipse { start: 1.0, sweep: 0.3, inner: 0.0 },
NodeKind::Ellipse { start: 0.0, sweep: 3.0, inner: 0.0 },
NodeKind::Ellipse { start: 1.0, sweep: 2.0, inner: 0.8 },
] {
for spread in [25.0, 40.0] {
let (d, n) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread))]);
let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(spread * 4.0) };
let (roomy, m) = shape(kind.clone(), vec![EffectKind::DropShadow(shadow(spread)), EffectKind::DropShadow(clear)]);
let (a, b) = (render(&d, n), render(&roomy, m));
let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0);
assert!(worst <= 2, "{kind:?}, a spread of {spread}: {worst} levels cut off");
}
}
}
/// A mitred stroke on an arc reaches out past its width at the arc's corners too, and a spread
/// grows from there: a thin slice with both keeps its whole shadow.
#[test]
fn arc_stroke_mitres_are_not_cut_off() {
use figcraft_doc::{StrokeAlign, StrokeJoin};
let kind = NodeKind::Ellipse { start: std::f64::consts::FRAC_PI_2 - 0.015, sweep: 0.03, inner: 0.0 };
for align in [StrokeAlign::Center, StrokeAlign::Outside] {
let make = |effects: Vec<EffectKind>| {
let mut d = Document::new("arc");
let p = d.first_page().unwrap();
let n = d.create(p, kind.clone(), "S", 0.0, 190.0, 400.0, 20.0).unwrap();
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::solid(Color::WHITE)];
node.strokes = vec![Paint::solid(Color::WHITE)];
(node.stroke.weight, node.stroke.align, node.stroke.join, node.stroke.miter_limit) = (20.0, align, StrokeJoin::Miter, 4.0);
node.effects = effects.into_iter().map(|kind| Effect { kind, visible: true }).collect();
(d, n)
};
let (d, n) = make(vec![EffectKind::DropShadow(shadow(10.0))]);
let clear = Shadow { color: Color::new(0.0, 0.0, 0.0, 0.0), ..shadow(80.0) };
let (roomy, m) = make(vec![EffectKind::DropShadow(shadow(10.0)), EffectKind::DropShadow(clear)]);
let (a, b) = (render(&d, n), render(&roomy, m));
let worst = a.data().chunks(4).zip(b.data().chunks(4)).map(|(p, q)| p[3].abs_diff(q[3])).max().unwrap_or(0);
assert!(worst <= 2, "{align:?}: {worst} levels cut off");
}
}
+42
View File
@@ -83,3 +83,45 @@ fn hostile_shadow_values_still_export() {
assert!(export_svg(&d, &[n], &SvgOptions::default()).is_some());
}
}
/// The filter a spread shadow is blurred in makes room for the spread as the node's transform
/// draws it: on a star scaled three times, the spread's mitres reach three times as far, and on a
/// skewed one further across than down.
#[test]
fn a_scaled_spread_shadow_keeps_its_filter_room() {
use figcraft_doc::SPREAD_MITER_LIMIT;
use figcraft_geom::{Affine, kurbo};
let star = NodeKind::Star { count: 5, inner_ratio: 0.4 };
let s =
Shadow { color: Color::from_hex("000000").unwrap(), offset: (6.0, -9.0), radius: 4.0, spread: 10.0, show_behind: true, ..Default::default() };
for (why, xf) in [
("scaled 3", Affine::scale(3.0)),
("scaled (3, 1)", Affine::scale_non_uniform(3.0, 1.0)),
("skewed", Affine::new([1.0, 0.0, 2.0, 1.0, 0.0, 0.0])),
("turned and stretched", Affine::rotate(0.5) * Affine::scale_non_uniform(3.0, 1.0)),
("as drawn", Affine::IDENTITY),
] {
let (mut d, n) = with_effects(star.clone(), vec![EffectKind::DropShadow(s.clone())]);
let t = d.get(n).unwrap().transform;
d.get_mut(n).unwrap().transform = t * xf;
let svg = export(&d, n);
let tag = &svg[svg.find("<filter ").unwrap()..];
let attr = |name: &str| -> f64 {
let at = tag.find(&format!(" {name}=\"")).unwrap() + name.len() + 3;
tag[at..at + tag[at..].find('"').unwrap()].parse().unwrap()
};
let region = kurbo::Rect::new(attr("x"), attr("y"), attr("x") + attr("width"), attr("y") + attr("height"));
// What the shadow paints, as the renderer draws it: the outline stroked twice the spread
// wide with mitred joins, through the node's transform, moved by the offset (in the
// export's frame, which starts at the node's export bounds) and blurred.
let bounds = figcraft_render::export_bounds(&d, n).unwrap();
let at = Affine::translate((s.offset.0 - bounds.x0, s.offset.1 - bounds.y0)) * d.world_transform(n);
let shape = figcraft_render::node_outline(&d, d.get(n).unwrap()).unwrap();
let stroke = kurbo::Stroke::new(s.spread * 2.0).with_join(kurbo::Join::Miter).with_miter_limit(SPREAD_MITER_LIMIT);
let mut grown = kurbo::stroke(shape.0.iter(), &stroke, &Default::default(), 0.01);
grown.apply_affine(at);
let blur = s.radius * 1.5;
let painted = figcraft_geom::Shape::bounding_box(&grown).inflate(blur, blur);
assert!(region.union(painted) == region, "{why}: the filter {region:?} cuts the shadow at {painted:?}");
}
}
+11 -1
View File
@@ -608,7 +608,17 @@ fn image(cx: &mut Ctx, img: &usvg::Image, id: &str, parent: NodeId, base: Affine
let hash = add_image(cx.doc, bytes, iw, ih);
let mut n = Node::new(cx.doc.alloc_id(), NodeKind::Rectangle, layer_name(id, "Image"), w, h);
n.transform = transform;
n.fills = vec![Paint::image(hash, ImageScaleMode::Fill)];
let mut fill = Paint::image(hash, ImageScaleMode::Fill);
// usvg sizes the box to the picture (`meet` and `slice` are in the transform), so a box of
// another shape is `preserveAspectRatio="none"`: the picture stretched edge to edge. A Fill
// would keep its shape and crop it; a crop of the whole picture stretches it.
if (w * ih - h * iw).abs() > 1e-6 * (w * ih).max(h * iw)
&& let figcraft_color::PaintKind::Image(ip) = &mut fill.kind
{
ip.scale_mode = ImageScaleMode::Crop;
ip.crop = Some(Affine::IDENTITY.as_coeffs());
}
n.fills = vec![fill];
cx.add(n, parent);
}
+22
View File
@@ -129,6 +129,28 @@ fn strokes_scale_with_their_layer() {
assert_eq!((l.width, l.stroke.weight, l.stroke.dashes.clone()), (30.0, 6.0, vec![12.0, 6.0]));
}
/// `preserveAspectRatio="none"` stretches a picture into its box edge to edge. usvg sizes the
/// box to the picture for `meet` and `slice` (the fit is in the transform), so a box of another
/// shape is a stretch: a Fill, which keeps the picture's shape and crops it, would show the
/// middle of the picture instead.
#[test]
fn an_image_stretched_into_its_box_stays_stretched() {
use figcraft_color::{ImageScaleMode, PaintKind};
let (d, f) =
import(&format!(r##"<image id="pic" x="10" y="20" width="40" height="40" preserveAspectRatio="none" href="data:image/png;base64,{PNG}"/>"##));
let n = node(&d, f, "pic");
assert_eq!((n.width, n.height), (40.0, 40.0));
let Some(PaintKind::Image(ip)) = n.fills.first().map(|p| &p.kind) else { panic!("{:?}", n.fills) };
// The 4 × 2 picture's pixels map onto the whole 40 × 40 box: 10 across, 20 down.
let m = figcraft_render::paint::image_transform(ip, 4.0, 2.0, n.width, n.height);
assert_eq!((m.sx, m.ky, m.kx, m.sy, m.tx, m.ty), (10.0, 0.0, 0.0, 20.0, 0.0, 0.0), "{ip:?}");
// With the aspect kept (the default) the box is the picture's own shape and a Fill is exact.
let (d, f) = import(&format!(r##"<image id="pic" x="10" y="20" width="40" height="40" href="data:image/png;base64,{PNG}"/>"##));
let n = node(&d, f, "pic");
assert_eq!((n.width, n.height), (40.0, 20.0));
assert!(matches!(n.fills.first().map(|p| &p.kind), Some(PaintKind::Image(i)) if i.scale_mode == ImageScaleMode::Fill), "{:?}", n.fills);
}
#[test]
fn linked_files_are_never_read() {
let dir = std::env::temp_dir().join("figcraft-svg-linked");
+7 -3
View File
@@ -1052,10 +1052,14 @@ impl W<'_> {
else {
continue;
};
let reach = s.spread.abs() * SPREAD_MITER_LIMIT + s.radius.abs() * 1.5 + 2.0;
// The spread grows the outline in its own units (mitres up to the limit), drawn through
// `xf`: as far across and down as a local step that long goes. The blur is in page units.
let spread = if s.spread.is_finite() { s.spread.abs() * SPREAD_MITER_LIMIT } else { 0.0 };
let [a, b_, c, d, _, _] = xf.as_coeffs();
let blur = s.radius.abs() * 1.5 + 2.0;
let region = b.inflate(spread * a.hypot(c) + blur, spread * b_.hypot(d) + blur);
let cover = if s.show_behind { None } else { self.cover_input(n, xf) };
let filter =
self.blur_filter(s.radius, b.inflate(reach, reach), cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
let filter = self.blur_filter(s.radius, region, cover.as_deref()).map(|f| format!(" filter=\"url(#{f})\"")).unwrap_or_default();
let op = f64::from(s.color.a * n.opacity.clamp(0.0, 1.0));
let blend = if matches!(s.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
String::new()
+6 -19
View File
@@ -1250,14 +1250,17 @@ fn system_font_dirs() -> Vec<std::path::PathBuf> {
}
#[cfg(all(unix, not(target_os = "macos")))]
{
v.push("/usr/share/fonts".into());
v.push("/usr/local/share/fonts".into());
v.extend(unix_user_font_dirs(std::env::var_os("HOME"), std::env::var_os("XDG_DATA_HOME")));
// The standard folders, the user's (`~/.fonts`, `$XDG_DATA_HOME/fonts`), `$XDG_DATA_DIRS`
// and those fontconfig names (NixOS and Guix keep fonts only there).
v.extend(craft_font_catalog::font_dirs(&craft_font_catalog::FontEnv::from_process()));
}
// Fonts any Craft app downloaded into the suite's font store (a download being installed is a
// `.part` file, which the walk skips).
#[cfg(not(target_arch = "wasm32"))]
v.extend(craft_font_catalog::font_store_dir(&craft_font_catalog::FontEnv::from_process()));
// Fontconfig usually lists folders already here (`/usr/share/fonts`): walk each once.
let mut seen = std::collections::HashSet::new();
v.retain(|d| seen.insert(d.clone()));
v
}
@@ -1270,22 +1273,6 @@ fn font_asset_dirs(store: &std::path::Path) -> Vec<std::path::PathBuf> {
rd.flatten().filter(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("com_apple_MobileAsset_Font"))).map(|e| e.path()).collect()
}
// Fontconfig's default configuration uses $XDG_DATA_HOME/fonts for user fonts.
// Ignore relative XDG paths, as required by the XDG Base Directory Specification.
#[cfg(all(unix, not(target_os = "macos")))]
fn unix_user_font_dirs(home: Option<std::ffi::OsString>, data_home: Option<std::ffi::OsString>) -> Vec<std::path::PathBuf> {
let home = home.map(std::path::PathBuf::from);
let data = data_home.map(std::path::PathBuf::from).filter(|p| p.is_absolute()).or_else(|| home.as_ref().map(|h| h.join(".local/share")));
let mut dirs = Vec::new();
if let Some(home) = home {
dirs.push(home.join(".fonts"));
}
if let Some(data) = data {
dirs.push(data.join("fonts"));
}
dirs
}
/// Adds the faces of every font file under `dir` (up to six folders deep) to `out`, keyed by
/// lowercased family: files named `.ttf`, `.otf`, `.ttc` or `.otc`, and files without an extension
/// that start with a font signature.
+7 -26
View File
@@ -264,32 +264,6 @@ fn every_platform_has_a_symbol_font_in_the_fallbacks() {
assert!(at("Noto Sans") < at("Noto Sans Symbols") && at("DejaVu Sans") < at("Noto Sans Math"));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn user_font_dirs_follow_xdg_data_home() {
use std::ffi::OsString;
use std::path::PathBuf;
let dirs = |home: Option<&str>, data: Option<&str>| unix_user_font_dirs(home.map(OsString::from), data.map(OsString::from));
let paths = |items: &[&str]| items.iter().map(PathBuf::from).collect::<Vec<_>>();
assert_eq!(dirs(Some("/home/user"), Some("/custom user data")), paths(&["/home/user/.fonts", "/custom user data/fonts"]));
for data in [None, Some(""), Some("relative/data")] {
assert_eq!(dirs(Some("/home/user"), data), paths(&["/home/user/.fonts", "/home/user/.local/share/fonts"]));
}
assert_eq!(dirs(None, Some("/custom-data")), paths(&["/custom-data/fonts"]));
assert_eq!(dirs(None, None), paths(&[]));
assert_eq!(dirs(None, Some("relative")), paths(&[]));
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn user_font_dirs_preserve_non_utf8_paths() {
use std::ffi::OsString;
use std::os::unix::ffi::{OsStrExt, OsStringExt};
let dirs = unix_user_font_dirs(Some(OsString::from_vec(b"/home/\xff".to_vec())), Some(OsString::from_vec(b"/data/\xfe".to_vec())));
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
}
#[test]
fn the_scan_reads_font_files_without_an_extension() {
// #298: Adobe Fonts keeps the fonts it activates in hidden files named by number. Any installed
@@ -614,3 +588,10 @@ fn core_text_lists_the_installed_font_files() {
assert!(files.iter().any(|f| f.starts_with("/System/Library/Fonts")), "{} files", files.len());
assert!(files.iter().all(|f| f.is_absolute()));
}
#[test]
fn system_font_dirs_are_walked_once_each() {
let dirs = super::system_font_dirs();
let unique: std::collections::HashSet<_> = dirs.iter().collect();
assert_eq!(unique.len(), dirs.len(), "{dirs:?}");
}
@@ -164,7 +164,11 @@ pub(crate) fn parse_line_height(s: &str, cur: Option<LineHeight>) -> Option<Valu
return widgets::eval(p.trim(), cur_pct).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v.max(0.0)))));
}
if let Some(px) = s.strip_suffix("px") {
return widgets::eval(px.trim(), None).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0)));
let cur_px = match cur {
Some(LineHeight::Pixels(p)) => Some(p),
_ => None,
};
return widgets::eval(px.trim(), cur_px).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0)));
}
match cur {
Some(LineHeight::Percent(p)) => widgets::eval(s, Some(p)).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v.max(0.0))))),
@@ -178,10 +182,18 @@ pub(crate) fn parse_line_height(s: &str, cur: Option<LineHeight>) -> Option<Valu
pub(crate) fn parse_letter_spacing(s: &str, cur: Option<LetterSpacing>) -> Option<Value> {
let s = s.trim();
if let Some(p) = s.strip_suffix('%') {
return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v))));
let cur_pct = match cur {
Some(LetterSpacing::Percent(p)) => Some(p),
_ => None,
};
return widgets::eval(p.trim(), cur_pct).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v))));
}
if let Some(p) = s.strip_suffix("px") {
return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(v));
let cur_px = match cur {
Some(LetterSpacing::Pixels(p)) => Some(p),
_ => None,
};
return widgets::eval(p.trim(), cur_px).filter(|v| v.is_finite()).map(|v| json!(v));
}
match cur {
Some(LetterSpacing::Pixels(px)) => widgets::eval(s, Some(px)).filter(|v| v.is_finite()).map(|v| json!(v)),
@@ -206,3 +218,21 @@ pub(crate) fn letter_spacing_of(v: &Value) -> Option<LetterSpacing> {
other => other.as_f64().filter(|v| v.is_finite()).map(LetterSpacing::Pixels),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relative_edit_with_unit_suffix_adds_to_current() {
let lh = Some(LineHeight::Pixels(10.0));
assert_eq!(parse_line_height("+2", lh), Some(json!(12.0)));
assert_eq!(parse_line_height("+2px", lh), Some(json!(12.0)));
let ls = Some(LetterSpacing::Pixels(10.0));
assert_eq!(parse_letter_spacing("+1", ls), Some(json!(11.0)));
assert_eq!(parse_letter_spacing("+1px", ls), Some(json!(11.0)));
let pct = Some(LetterSpacing::Percent(10.0));
assert_eq!(parse_letter_spacing("+1%", pct), parse_letter_spacing("+1", pct));
assert_eq!(parse_letter_spacing("+1%", pct), Some(json!("11%")));
}
}
+12 -1
View File
@@ -167,7 +167,12 @@ pub fn eval(text: &str, cur: Option<f64>) -> Option<f64> {
/// (0.1 + 0.2).
pub fn stepped(from: f64, steps: f64, step: f64) -> f64 {
let v = from + steps * step;
if v.is_finite() { (v * 1e6).round() / 1e6 } else { from }
if !v.is_finite() {
return from;
}
let rounded = (v * 1e6).round() / 1e6;
// Past ~1.8e302 the scaling overflows; such a value has no dust to remove.
if rounded.is_finite() { rounded } else { v }
}
#[cfg(test)]
@@ -182,4 +187,10 @@ mod tests {
assert_eq!(parse("50 %", true), Some(Typed::Value(50.0)));
assert_eq!(parse("3 px + 2 px", true), Some(Typed::Value(5.0)));
}
#[test]
fn stepped_keeps_a_huge_finite_value_finite() {
assert_eq!(stepped(1e305, 1.0, 1.0), 1e305);
assert!(stepped(f64::MAX / 2.0, 0.0, 1.0).is_finite());
}
}
File diff suppressed because one or more lines are too long
+8
View File
@@ -21,10 +21,12 @@ APP_ID=ai.storyteller.figcraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
OCI=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
--oci) OCI=1; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
@@ -62,6 +64,12 @@ OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [[ "$OCI" = 1 ]]; then
OUT_OCI="$DIST/$BASENAME-oci"
rm -rf "$OUT_OCI"
flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
echo "wrote OCI layout to $OUT_OCI"
fi
if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Build figcraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
# for consumption by Flatpak OCI clients (flatpak-oci-specs).
#
# Usage:
# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
#
# Environment variables:
# REGISTRY Target registry image (default: ghcr.io/storytold/figcraft)
# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH)
# GITHUB_TOKEN Token for GHCR authentication when --push is used
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
PUSH=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--push) PUSH=1; shift ;;
-h | --help) sed -n '2,12p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ;;
aarch64 | arm64) ARCH=aarch64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
BASENAME="figcraft-$VERSION-linux-$ARCH"
OUT_OCI="$DIST/$BASENAME-oci"
echo "==> Building Flatpak OCI layout for $BASENAME"
"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
REGISTRY="${REGISTRY:-ghcr.io/storytold/figcraft}"
TAG="${TAG:-$VERSION-$ARCH}"
echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
if [[ "$PUSH" = 1 ]]; then
command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
AUTH_ARGS=()
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
fi
echo "==> Pushing to $REGISTRY:$TAG"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
# Also update architecture-specific stable tag
echo "==> Pushing to $REGISTRY:stable-$ARCH"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
echo "==> Push complete"
fi