mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 20:29:33 -06:00
Merge main into feat/export-color-profile
This commit is contained in:
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
Generated
+22
-22
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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%")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Executable
+64
@@ -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
|
||||
Reference in New Issue
Block a user