mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 08:51:00 -06:00
Merge remote-tracking branch 'origin/main' into fix/ai-right-aligned-tabs
# Conflicts: # crates/text/src/layout/flow.rs
This commit is contained in:
@@ -1227,10 +1227,10 @@ impl<'a> Scene<'a> {
|
||||
let Some(&first) = run.first() else { continue };
|
||||
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
|
||||
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
|
||||
// A right-to-left paragraph's marker is drawn with its tab first and its number's
|
||||
// period at the number's left; the TEXT entity holds the characters in that order.
|
||||
// A right-to-left paragraph's marker is drawn with its number's period at the
|
||||
// number's left; the TEXT entity holds the characters in that order.
|
||||
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') {
|
||||
Some(number) if m.tab == m.glyphs.start => format!(".{number}"),
|
||||
Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"),
|
||||
_ => m.text.clone(),
|
||||
});
|
||||
let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
|
||||
|
||||
+21
-4
@@ -1076,15 +1076,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() {
|
||||
|
||||
/// A numbered list paragraph's number and period are a TEXT entity of their own where they are
|
||||
/// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the
|
||||
/// number's left.
|
||||
/// number's left, with the marker aligned left or right.
|
||||
#[test]
|
||||
fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let numbered = |text: &str, count: u32| {
|
||||
use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList};
|
||||
let numbered_aligned = |text: &str, count: u32, align: ListAlign| {
|
||||
let mut d = text_doc(text, 20.0);
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));
|
||||
t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align));
|
||||
t.para.left_indent = 30.0;
|
||||
t.para.first_line_indent = -30.0;
|
||||
t.list_count = count;
|
||||
@@ -1092,10 +1092,27 @@ fn a_number_is_text_of_its_own_before_its_paragraph() {
|
||||
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
|
||||
entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>()
|
||||
};
|
||||
let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left);
|
||||
assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]);
|
||||
let rtl = numbered("שלום", 11);
|
||||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||||
assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");
|
||||
// Aligned right, the number keeps its order and moves right, against the text.
|
||||
let right = numbered_aligned("Item", 11, ListAlign::Right);
|
||||
assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0)));
|
||||
assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}");
|
||||
let rtl = numbered_aligned("שלום", 11, ListAlign::Right);
|
||||
assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]);
|
||||
// A mixed paragraph: the period follows the paragraph's direction, not the script of the
|
||||
// words it holds, with either alignment.
|
||||
let marker = |text: &str, align: ListAlign| {
|
||||
let out = numbered_aligned(text, 11, align);
|
||||
out.into_iter().filter_map(|(s, _)| s).find(|s| s.contains("12")).unwrap_or_default()
|
||||
};
|
||||
for align in [ListAlign::Left, ListAlign::Right] {
|
||||
assert_eq!(marker("Item שלום", align), "12.", "{align:?}");
|
||||
assert_eq!(marker("שלום Item", align), ".12", "{align:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
|
||||
|
||||
+176
-102
@@ -1,19 +1,26 @@
|
||||
//! "VectorCraft Generic CMYK (SWOP-like)": our own parametric press model.
|
||||
//!
|
||||
//! We can't redistribute Adobe's or ECI's characterisation profiles, so the built-in CMYK space is
|
||||
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*: the numbers
|
||||
//! are rounded, typical values for coated web offset, not a measured dataset.
|
||||
//! a small physical model instead of an ICC LUT. It is *ours* and only SWOP-*like*. Two of its
|
||||
//! figures are published values for SWOP printing: the tone value increase of each ink at 40% and
|
||||
//! 50% from CGATS TR 001 (<https://registry.color.org/cmyk-registry/cgats_tr_001>), and the 300%
|
||||
//! total ink limit (<https://en.wikipedia.org/wiki/Specifications_for_Web_Offset_Publications>).
|
||||
//! The paper, solid and overprint colors and the black-generation curve are VectorCraft's own values.
|
||||
//!
|
||||
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area is widened by a parabolic dot-gain
|
||||
//! curve (`a + 4·g·a·(1−a)`, i.e. `g` gain at 50%); the effective areas mix the eight CMY
|
||||
//! Neugebauer primaries with Demichel weights; black is mixed in the same way against
|
||||
//! "primary + K" colours whose optical densities add but saturate towards a maximum density
|
||||
//! (normalised so K on paper is exact), so rich blacks don't multiply to an unrealistic depth.
|
||||
//! * **Forward (CMYK → XYZ):** each ink's nominal dot area `a` is widened by a tone curve
|
||||
//! `a + 4·g·a·(1−a)·(1 + s·(2a−1))` that passes through the TR 001 tone value increase at 50%
|
||||
//! (`g`) and 40% (which sets `s`). The effective areas mix the 16 Neugebauer primaries (paper and
|
||||
//! every solid and overprint of C, M, Y and K, media-relative) with Demichel weights, linearly in
|
||||
//! XYZ, so a single ink's colorimetric tone value increase (Murray–Davies) is the TR 001 value.
|
||||
//! * **Inverse (Lab → CMYK):** damped, bounded Gauss–Newton on the forward model solves CMY for a
|
||||
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the grey component
|
||||
//! `min(C, M, Y)`, map it through the black-generation curve to K, re-solve CMY. Under-colour
|
||||
//! fixed K. Black generation is GCR: solve CMY with K = 0, take the gray component
|
||||
//! `min(C, M, Y)`, add [`BlackGeneration::shortfall`] for each L* point by which that solve stays
|
||||
//! lighter than the target, map the sum through the black curve to K, re-solve CMY. Under-color
|
||||
//! removal enforces the total area coverage (TAC) limit by adding K and re-solving, then scaling
|
||||
//! CMY as a last resort.
|
||||
//! CMY as a last resort. The black point, which source black separates to and which displays as
|
||||
//! black with black-point compensation, is [`BlackGeneration::black_point_k`] black with neutral
|
||||
//! CMY filling the TAC. With compensation, a build darker than it in every XYZ channel also
|
||||
//! displays as black (see [`GenericCmyk::display_xyz`]).
|
||||
//! * **Rendering intents:** colorimetric intents map the source white to paper (relative) or not
|
||||
//! (absolute) and clip; perceptual applies black-point compensation plus soft chroma compression
|
||||
//! against a gamut boundary descriptor (segment maxima of the forward model); saturation keeps
|
||||
@@ -24,50 +31,82 @@ use std::sync::OnceLock;
|
||||
use super::Intent;
|
||||
use super::lab::{D50, Lab, delta_e2000, lab_to_xyz, srgb_to_lab, xyz_to_lab, xyz_to_srgb};
|
||||
|
||||
/// Black generation strength.
|
||||
/// Black generation.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct BlackGeneration {
|
||||
/// Grey component below which no black is generated.
|
||||
pub start: f32,
|
||||
/// Maximum black.
|
||||
pub max_k: f32,
|
||||
/// Curve exponent (>1 = lighter black in the midtones).
|
||||
pub gamma: f32,
|
||||
/// The black curve as `(x, K)` knots with rising `x`, read by linear interpolation. `x` is the
|
||||
/// gray component of the solve without black plus [`Self::shortfall`] per L* point by which
|
||||
/// that solve stays lighter than the target.
|
||||
pub curve: [(f32, f32); 13],
|
||||
/// Added to the gray component for each L* point the black-free solve stays too light.
|
||||
pub shortfall: f32,
|
||||
/// Black of the black point, whose CMY is neutral and fills the TAC.
|
||||
pub black_point_k: f32,
|
||||
}
|
||||
|
||||
/// Parameters of the parametric press model.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GenericCmykParams {
|
||||
pub name: &'static str,
|
||||
/// Paper color (absolute Lab, D50).
|
||||
pub paper: Lab,
|
||||
/// Solid overprints, Demichel order: C, M, CM, Y, CY, MY, CMY.
|
||||
pub solids: [Lab; 7],
|
||||
pub black: Lab,
|
||||
/// Dot gain at 50% per ink (C, M, Y, K).
|
||||
pub dot_gain: [f32; 4],
|
||||
/// Media-relative Lab of the solids and overprints, Demichel order (index `c | m<<1 | y<<2 |
|
||||
/// k<<3`, paper left out): C, M, CM, Y, CY, MY, CMY, then K, CK, MK, CMK, YK, CYK, MYK, CMYK.
|
||||
pub primaries: [Lab; 15],
|
||||
/// Tone value increase at 50% per ink (C, M, Y, K).
|
||||
pub tvi50: [f32; 4],
|
||||
/// Tone value increase at 40% per ink (C, M, Y, K).
|
||||
pub tvi40: [f32; 4],
|
||||
/// Total area coverage limit (sum of inks, 1.0 = 100%).
|
||||
pub tac: f32,
|
||||
pub bg: BlackGeneration,
|
||||
}
|
||||
|
||||
/// Rounded, typical SWOP-like values (not measured data).
|
||||
/// The built-in SWOP-like press: TR 001 tone value increase, a 300% ink limit, and VectorCraft's
|
||||
/// own paper, primaries and black curve.
|
||||
pub fn swop_like() -> GenericCmykParams {
|
||||
GenericCmykParams {
|
||||
name: super::GENERIC_CMYK,
|
||||
paper: Lab::new(93.0, -0.5, 2.5),
|
||||
solids: [
|
||||
Lab::new(55.0, -37.0, -50.0), // C
|
||||
Lab::new(48.0, 74.0, -3.0), // M
|
||||
Lab::new(24.0, 22.0, -46.0), // CM (blue)
|
||||
Lab::new(89.0, -5.0, 93.0), // Y
|
||||
Lab::new(50.0, -65.0, 27.0), // CY (green)
|
||||
Lab::new(47.0, 68.0, 48.0), // MY (red)
|
||||
Lab::new(24.0, 1.0, 0.0), // CMY
|
||||
primaries: [
|
||||
Lab::new(65.7, -42.6, -47.2), // C
|
||||
Lab::new(55.5, 76.2, -9.4), // M
|
||||
Lab::new(31.1, 20.3, -49.7), // CM (blue)
|
||||
Lab::new(96.2, -7.0, 90.1), // Y
|
||||
Lab::new(61.0, -68.4, 30.1), // CY (green)
|
||||
Lab::new(55.0, 69.8, 45.0), // MY (red)
|
||||
Lab::new(28.3, 0.7, -0.5), // CMY
|
||||
Lab::new(21.5, 0.8, -0.9), // K
|
||||
Lab::new(15.9, -8.4, -10.0), // CK
|
||||
Lab::new(14.7, 15.4, -1.0), // MK
|
||||
Lab::new(11.2, 8.9, -6.6), // CMK
|
||||
Lab::new(20.2, -2.4, 12.0), // YK
|
||||
Lab::new(15.7, -14.6, 4.6), // CYK
|
||||
Lab::new(14.5, 11.2, 5.7), // MYK
|
||||
Lab::new(11.0, -0.7, 0.1), // CMYK
|
||||
],
|
||||
black: Lab::new(19.0, 0.5, 0.5),
|
||||
dot_gain: [0.20, 0.20, 0.20, 0.22],
|
||||
tvi50: [0.200, 0.176, 0.168, 0.216],
|
||||
tvi40: [0.200, 0.173, 0.167, 0.219],
|
||||
tac: 3.0,
|
||||
bg: BlackGeneration { start: 0.1, max_k: 0.95, gamma: 1.25 },
|
||||
bg: BlackGeneration {
|
||||
curve: [
|
||||
(0.0, 0.0),
|
||||
(0.25, 0.0),
|
||||
(0.35, 0.006),
|
||||
(0.45, 0.059),
|
||||
(0.55, 0.139),
|
||||
(0.65, 0.23),
|
||||
(0.75, 0.322),
|
||||
(0.85, 0.41),
|
||||
(0.95, 0.511),
|
||||
(1.05, 0.6),
|
||||
(1.2, 0.733),
|
||||
(1.35, 0.858),
|
||||
(1.5, 1.0),
|
||||
],
|
||||
shortfall: 0.03,
|
||||
black_point_k: 0.9,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,11 +122,12 @@ const GBD_H: usize = 72; // 5° bins
|
||||
|
||||
pub struct GenericCmyk {
|
||||
pub params: GenericCmykParams,
|
||||
/// XYZ of the 8 CMY primaries (index = c | m<<1 | y<<2), without and with K.
|
||||
prim: [[f32; 3]; 8],
|
||||
prim_k: [[f32; 3]; 8],
|
||||
/// Media-relative XYZ of the 16 primaries (index = c | m<<1 | y<<2 | k<<3; 0 is paper).
|
||||
prim: [[f32; 3]; 16],
|
||||
/// Shape `s` of each ink's tone curve, from its 40% and 50% tone value increase.
|
||||
skew: [f32; 4],
|
||||
paper_xyz: [f32; 3],
|
||||
/// Media-relative XYZ of the darkest printable black (for black-point compensation).
|
||||
/// Media-relative XYZ of the black point (for black-point compensation).
|
||||
black_rel: [f32; 3],
|
||||
gbd: OnceLock<Gbd>,
|
||||
}
|
||||
@@ -95,37 +135,17 @@ pub struct GenericCmyk {
|
||||
impl GenericCmyk {
|
||||
pub fn new(params: GenericCmykParams) -> Self {
|
||||
let paper_xyz = lab_to_xyz(params.paper);
|
||||
let mut prim = [[0.0f32; 3]; 8];
|
||||
prim[0] = paper_xyz;
|
||||
for (p, lab) in prim[1..].iter_mut().zip(params.solids.iter()) {
|
||||
*p = lab_to_xyz(*lab);
|
||||
let mut prim = [D50; 16];
|
||||
for (p, lab) in prim[1..].iter_mut().zip(params.primaries.iter()) {
|
||||
*p = lab_to_xyz(*lab).map(|v| v.max(1e-6));
|
||||
}
|
||||
let k_xyz = lab_to_xyz(params.black);
|
||||
// Black over other inks: optical densities add, but saturate towards a maximum density
|
||||
// (ink trapping / surface reflection), normalised so that K on paper is exact.
|
||||
const D_MAX: f32 = 2.0;
|
||||
let sat = |d: f32| D_MAX * (1.0 - (-d / D_MAX).exp());
|
||||
let mut prim_k = [[0.0f32; 3]; 8];
|
||||
for i in 0..8 {
|
||||
for ch in 0..3 {
|
||||
let di = -(prim[i][ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let dk = -(k_xyz[ch] / paper_xyz[ch]).max(1e-6).log10();
|
||||
let d = sat(di + dk) * dk / sat(dk).max(1e-6);
|
||||
prim_k[i][ch] = paper_xyz[ch] * 10f32.powf(-d.max(di));
|
||||
}
|
||||
}
|
||||
let mut g = Self { params, prim, prim_k, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
|
||||
// Darkest black within TAC: search a few rich blacks.
|
||||
let mut best = [1.0f32; 3];
|
||||
for k in [0.8f32, 0.9, 1.0] {
|
||||
let rest = (g.params.tac - k).max(0.0) / 3.0;
|
||||
let cmy = rest.min(1.0);
|
||||
let x = g.to_xyz_rel([cmy, cmy, cmy, k]);
|
||||
if x[1] < best[1] {
|
||||
best = x;
|
||||
}
|
||||
}
|
||||
g.black_rel = best;
|
||||
// The curve's increase at 40% is 0.96·g·(1 − 0.2·s), so `s` follows from the 40% value.
|
||||
let skew = [0, 1, 2, 3].map(|i| {
|
||||
let g = params.tvi50[i];
|
||||
if g > 1e-6 { (1.0 - params.tvi40[i] / (0.96 * g)) / 0.2 } else { 0.0 }
|
||||
});
|
||||
let mut g = Self { params, prim, skew, paper_xyz, black_rel: [0.0; 3], gbd: OnceLock::new() };
|
||||
g.black_rel = g.to_xyz_rel(g.black_point_build());
|
||||
g
|
||||
}
|
||||
|
||||
@@ -133,35 +153,81 @@ impl GenericCmyk {
|
||||
self.params.name
|
||||
}
|
||||
|
||||
fn gain(a: f32, g: f32) -> f32 {
|
||||
let a = a.clamp(0.0, 1.0);
|
||||
(a + 4.0 * g * a * (1.0 - a)).clamp(0.0, 1.0)
|
||||
/// The black point: [`BlackGeneration::black_point_k`] black, with C, M and Y
|
||||
/// chosen so that they fill the TAC and the color is neutral (a* = b* = 0).
|
||||
pub fn black_point_build(&self) -> [f32; 4] {
|
||||
let k = self.params.bg.black_point_k.clamp(0.0, 1.0);
|
||||
let rest = (self.params.tac - k).clamp(0.0, 3.0);
|
||||
let build = |c: f32, m: f32| [c, m, (rest - c - m).clamp(0.0, 1.0), k];
|
||||
let ab = |c: f32, m: f32| {
|
||||
let l = self.to_lab_rel(build(c, m));
|
||||
[l.a, l.b]
|
||||
};
|
||||
let lo = (rest - 2.0).max(0.0);
|
||||
let hi = rest.min(1.0);
|
||||
let (mut c, mut m) = ((rest / 3.0).clamp(lo, hi), (rest / 3.0).clamp(lo, hi));
|
||||
for _ in 0..30 {
|
||||
let r = ab(c, m);
|
||||
if r[0].abs() + r[1].abs() < 1e-4 {
|
||||
break;
|
||||
}
|
||||
let h = 1e-3;
|
||||
let rc = ab(c - h, m);
|
||||
let rm = ab(c, m - h);
|
||||
let j = [[(r[0] - rc[0]) / h, (r[0] - rm[0]) / h], [(r[1] - rc[1]) / h, (r[1] - rm[1]) / h]];
|
||||
let det = j[0][0] * j[1][1] - j[0][1] * j[1][0];
|
||||
if det.abs() < 1e-9 {
|
||||
break;
|
||||
}
|
||||
let dc = (-r[0] * j[1][1] + r[1] * j[0][1]) / det;
|
||||
let dm = (-r[1] * j[0][0] + r[0] * j[1][0]) / det;
|
||||
c = (c + dc.clamp(-0.1, 0.1)).clamp(lo, hi);
|
||||
m = (m + dm.clamp(-0.1, 0.1)).clamp(lo, hi);
|
||||
// Yellow takes the rest, so keep it within 0..1 as well.
|
||||
let y = rest - c - m;
|
||||
if y > 1.0 {
|
||||
c += (y - 1.0) / 2.0;
|
||||
m += (y - 1.0) / 2.0;
|
||||
} else if y < 0.0 {
|
||||
c += y / 2.0;
|
||||
m += y / 2.0;
|
||||
}
|
||||
c = c.clamp(lo, hi);
|
||||
m = m.clamp(lo, hi);
|
||||
}
|
||||
build(c.clamp(lo, hi), m.clamp(lo, hi))
|
||||
}
|
||||
|
||||
/// Effective dot area of ink `i` at nominal area `a`: the TR 001-style tone curve.
|
||||
fn gain(&self, i: usize, a: f32) -> f32 {
|
||||
let a = if a.is_finite() { a.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let g = self.params.tvi50[i];
|
||||
(a + 4.0 * g * a * (1.0 - a) * (1.0 + self.skew[i] * (2.0 * a - 1.0))).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// CMYK (0..1) → absolute XYZ (D50).
|
||||
pub fn to_xyz(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let dg = self.params.dot_gain;
|
||||
let c = Self::gain(cmyk[0], dg[0]);
|
||||
let m = Self::gain(cmyk[1], dg[1]);
|
||||
let y = Self::gain(cmyk[2], dg[2]);
|
||||
let k = Self::gain(cmyk[3], dg[3]);
|
||||
let mut out = [0.0f32; 3];
|
||||
for i in 0..8 {
|
||||
let w = (if i & 1 != 0 { c } else { 1.0 - c }) * (if i & 2 != 0 { m } else { 1.0 - m }) * (if i & 4 != 0 { y } else { 1.0 - y });
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (ch, o) in out.iter_mut().enumerate() {
|
||||
*o += w * ((1.0 - k) * self.prim[i][ch] + k * self.prim_k[i][ch]);
|
||||
}
|
||||
}
|
||||
out
|
||||
let x = self.to_xyz_rel(cmyk);
|
||||
[x[0] * self.paper_xyz[0] / D50[0], x[1] * self.paper_xyz[1] / D50[1], x[2] * self.paper_xyz[2] / D50[2]]
|
||||
}
|
||||
|
||||
/// Media-relative XYZ (paper = D50 white).
|
||||
pub fn to_xyz_rel(&self, cmyk: [f32; 4]) -> [f32; 3] {
|
||||
let x = self.to_xyz(cmyk);
|
||||
[x[0] * D50[0] / self.paper_xyz[0], x[1] * D50[1] / self.paper_xyz[1], x[2] * D50[2] / self.paper_xyz[2]]
|
||||
let e = [0, 1, 2, 3].map(|i| self.gain(i, cmyk[i]));
|
||||
let mut out = [0.0f32; 3];
|
||||
for (i, p) in self.prim.iter().enumerate() {
|
||||
let mut w = 1.0;
|
||||
for (bit, a) in e.iter().enumerate() {
|
||||
w *= if i & (1 << bit) != 0 { *a } else { 1.0 - a };
|
||||
}
|
||||
if w == 0.0 {
|
||||
continue;
|
||||
}
|
||||
for (o, v) in out.iter_mut().zip(p) {
|
||||
*o += w * v;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn to_lab_rel(&self, cmyk: [f32; 4]) -> Lab {
|
||||
@@ -174,7 +240,9 @@ impl GenericCmyk {
|
||||
/// Display XYZ: media-relative (paper shows as white) or absolute (simulate paper colour).
|
||||
/// With `bpc` (media-relative only) the press black displays as black, undoing the
|
||||
/// compensation [`Self::gamut_map`] applies on the way in, so sRGB black separated to the
|
||||
/// darkest rich black shows as black again.
|
||||
/// black point shows as black again. Each XYZ channel is clamped at 0 on its own, so a build
|
||||
/// darker than the black point in every channel shows as black, and one darker in only some
|
||||
/// channels shows as a colored near-black.
|
||||
pub fn display_xyz(&self, cmyk: [f32; 4], absolute: bool, bpc: bool) -> [f32; 3] {
|
||||
if absolute {
|
||||
return self.to_xyz(cmyk);
|
||||
@@ -192,7 +260,7 @@ impl GenericCmyk {
|
||||
xyz_to_srgb(self.display_xyz(cmyk, absolute, bpc))
|
||||
}
|
||||
|
||||
/// Lab of the darkest printable black (media-relative).
|
||||
/// Lab of the black point (media-relative), see [`Self::black_point_build`].
|
||||
pub fn black_point(&self) -> Lab {
|
||||
xyz_to_lab(self.black_rel)
|
||||
}
|
||||
@@ -283,12 +351,21 @@ impl GenericCmyk {
|
||||
rgb.map(|v| ((1.0 - v - k) / d).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn black_for_grey(&self, grey: f32) -> f32 {
|
||||
let bg = self.params.bg;
|
||||
if grey <= bg.start {
|
||||
return 0.0;
|
||||
/// Black for `x` on the black curve (see [`BlackGeneration::curve`]).
|
||||
fn black_for(&self, x: f32) -> f32 {
|
||||
let curve = &self.params.bg.curve;
|
||||
let mut prev = curve[0];
|
||||
if x <= prev.0 {
|
||||
return prev.1.clamp(0.0, 1.0);
|
||||
}
|
||||
(((grey - bg.start) / (1.0 - bg.start)).clamp(0.0, 1.0).powf(bg.gamma) * bg.max_k).clamp(0.0, 1.0)
|
||||
for &(kx, kk) in &curve[1..] {
|
||||
if x <= kx {
|
||||
let t = if kx > prev.0 { (x - prev.0) / (kx - prev.0) } else { 1.0 };
|
||||
return (prev.1 + (kk - prev.1) * t).clamp(0.0, 1.0);
|
||||
}
|
||||
prev = (kx, kk);
|
||||
}
|
||||
prev.1.clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
/// Separate a media-relative Lab (already gamut-mapped) into CMYK with GCR/UCR and the TAC limit.
|
||||
@@ -297,13 +374,10 @@ impl GenericCmyk {
|
||||
return [0.0; 4];
|
||||
}
|
||||
let cmy0 = self.solve_cmy(target, 0.0, Self::initial_cmy(target, 0.0));
|
||||
let grey = cmy0[0].min(cmy0[1]).min(cmy0[2]);
|
||||
let mut k = self.black_for_grey(grey);
|
||||
// If CMY alone can't get dark enough, black must carry the rest.
|
||||
let gray = cmy0[0].min(cmy0[1]).min(cmy0[2]);
|
||||
// When CMY alone can't get dark enough, black carries the rest.
|
||||
let l0 = self.to_lab_rel([cmy0[0], cmy0[1], cmy0[2], 0.0]).l;
|
||||
if l0 > target.l + 1.0 {
|
||||
k = k.max(((l0 - target.l) / l0.max(1.0)).clamp(0.0, 1.0));
|
||||
}
|
||||
let mut k = self.black_for(gray + self.params.bg.shortfall * (l0 - target.l).max(0.0));
|
||||
let mut cmy = if k > 0.0 { self.solve_cmy(target, k, Self::initial_cmy(target, k)) } else { cmy0 };
|
||||
// UCR: trade CMY for K until within the TAC limit.
|
||||
for _ in 0..6 {
|
||||
|
||||
@@ -245,8 +245,8 @@ fn loads_system_cmyk_icc() {
|
||||
assert_eq!(c.cmyk_to_srgb([0.0; 4], false).map(|v| (v * 255.0).round() as u8), [255, 255, 255]);
|
||||
}
|
||||
|
||||
/// With black-point compensation, sRGB black separates to the press's darkest black and that black
|
||||
/// displays as black again (hex 000000 -> C67 M67 Y67 K100 -> 000000, not a dark grey).
|
||||
/// With black-point compensation, sRGB black separates to the press's black point and that black
|
||||
/// displays as black again (hex 000000 -> C75 M68 Y67 K90 -> 000000, not a dark gray).
|
||||
#[test]
|
||||
fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
let c = cms();
|
||||
@@ -259,25 +259,104 @@ fn generic_cmyk_black_round_trips_with_bpc() {
|
||||
assert!(raw.iter().all(|v| *v > 0.05), "{raw:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan at 0 separates to the same inks when its values change by a few
|
||||
/// float steps: hex ed1e79 with its red stepped one `f32` at a time over 4,001 values (about 0.00024
|
||||
/// in all) stays within 0.001 on every ink.
|
||||
/// Each ink's 40% and 50% tints gain like CGATS TR 001 (SWOP): the colorimetric tone value increase
|
||||
/// (Murray–Davies on X for cyan, Y for magenta and black, Z for yellow) is C 20.0, M 17.6, Y 16.8 and
|
||||
/// K 21.6 points at 50%, and C 20.0, M 17.3, Y 16.7 and K 21.9 points at 40%.
|
||||
#[test]
|
||||
fn generic_cmyk_tints_gain_like_tr001() {
|
||||
let g = generic();
|
||||
let tvi = |ink: usize, a: f32| {
|
||||
let ch = [0, 1, 2, 1][ink];
|
||||
let mut cmyk = [0.0f32; 4];
|
||||
cmyk[ink] = a;
|
||||
let tint = g.to_xyz_rel(cmyk)[ch];
|
||||
cmyk[ink] = 1.0;
|
||||
let solid = g.to_xyz_rel(cmyk)[ch];
|
||||
let paper = g.to_xyz_rel([0.0; 4])[ch];
|
||||
(paper - tint) / (paper - solid) - a
|
||||
};
|
||||
for (ink, (at50, at40)) in [(0.200, 0.200), (0.176, 0.173), (0.168, 0.167), (0.216, 0.219)].into_iter().enumerate() {
|
||||
assert!((tvi(ink, 0.5) - at50).abs() < 0.002, "ink {ink} at 50%: {}", tvi(ink, 0.5));
|
||||
assert!((tvi(ink, 0.4) - at40).abs() < 0.002, "ink {ink} at 40%: {}", tvi(ink, 0.4));
|
||||
}
|
||||
}
|
||||
|
||||
/// More of any ink never makes a Generic CMYK color lighter, in any XYZ channel: on a 10% grid of
|
||||
/// builds within the 300% ink limit, adding 10% of one ink keeps X, Y and Z or lowers them.
|
||||
#[test]
|
||||
fn generic_cmyk_more_ink_is_never_lighter() {
|
||||
let g = generic();
|
||||
let steps = (0..=10).map(|i| i as f32 / 10.0);
|
||||
for c in steps.clone() {
|
||||
for m in steps.clone() {
|
||||
for y in steps.clone() {
|
||||
for k in steps.clone() {
|
||||
let base = [c, m, y, k];
|
||||
if base.iter().sum::<f32>() > 3.0 - 0.1 + 1e-4 {
|
||||
continue;
|
||||
}
|
||||
let x0 = g.to_xyz_rel(base);
|
||||
for ink in 0..4 {
|
||||
if base[ink] >= 1.0 {
|
||||
continue;
|
||||
}
|
||||
let mut more = base;
|
||||
more[ink] += 0.1;
|
||||
let x1 = g.to_xyz_rel(more);
|
||||
assert!((0..3).all(|ch| x1[ch] <= x0[ch] + 1e-5), "{base:?} plus ink {ink}: {x0:?} to {x1:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// sRGB black separates to 90% black under neutral CMY that fills the 300% ink limit, light grays
|
||||
/// carry no black, and black rises steadily from the light end of the gray ramp to the dark end.
|
||||
#[test]
|
||||
fn generic_cmyk_black_generation_follows_the_black_curve() {
|
||||
let c = cms();
|
||||
let black = c.srgb_to_cmyk([0.0; 3], Intent::RelativeColorimetric);
|
||||
assert!((black[3] - 0.9).abs() < 0.005, "{black:?}");
|
||||
assert!((black.iter().sum::<f32>() - 3.0).abs() < 0.01, "{black:?}");
|
||||
let lab = c.cmyk_to_lab(black);
|
||||
assert!(lab.a.abs() < 0.05 && lab.b.abs() < 0.05, "neutral: {lab:?}");
|
||||
let light = c.srgb_to_cmyk([0.7; 3], Intent::RelativeColorimetric);
|
||||
assert_eq!(light[3], 0.0, "{light:?}");
|
||||
let mut prev = 0.0;
|
||||
for v in (0..=50).rev().map(|i| i as f32 / 50.0) {
|
||||
let k = c.srgb_to_cmyk([v; 3], Intent::RelativeColorimetric)[3];
|
||||
assert!(k >= prev - 1e-4, "gray {v}: K {k} after {prev}");
|
||||
prev = k;
|
||||
}
|
||||
}
|
||||
|
||||
/// A color whose solve holds an ink at a bound separates to the same inks when its values change by
|
||||
/// a few float steps: hex f01482 (cyan held at 0) and hex ed1e79 (magenta held at 100%), each with
|
||||
/// its red stepped one `f32` at a time over 4,001 values (about 0.00024 in all), stay within 0.001
|
||||
/// on every ink.
|
||||
#[test]
|
||||
fn generic_cmyk_separates_nearby_colors_at_the_gamut_edge_alike() {
|
||||
let c = cms();
|
||||
let pink = [237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0];
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -2000i32..=2000 {
|
||||
let mut rgb = pink;
|
||||
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
let spread = |pink: [f32; 3]| {
|
||||
let (mut lo, mut hi) = ([f32::MAX; 4], [f32::MIN; 4]);
|
||||
for step in -2000i32..=2000 {
|
||||
let mut rgb = pink;
|
||||
rgb[0] = f32::from_bits(pink[0].to_bits().wrapping_add_signed(step));
|
||||
let ink = c.srgb_to_cmyk(rgb, Intent::RelativeColorimetric);
|
||||
for i in 0..4 {
|
||||
lo[i] = lo[i].min(ink[i]);
|
||||
hi[i] = hi[i].max(ink[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(lo, hi)
|
||||
};
|
||||
let (lo, hi) = spread([240.0f32 / 255.0, 20.0 / 255.0, 130.0 / 255.0]);
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!(lo[0], 0.0, "{lo:?}");
|
||||
let (lo, hi) = spread([237.0f32 / 255.0, 30.0 / 255.0, 121.0 / 255.0]);
|
||||
assert!((0..4).all(|i| hi[i] - lo[i] < 1e-3), "{lo:?} to {hi:?}");
|
||||
assert_eq!(hi[1], 1.0, "{hi:?}");
|
||||
}
|
||||
|
||||
/// A color whose solve holds cyan and magenta at 1 separates to the same inks when its values change
|
||||
|
||||
@@ -376,6 +376,11 @@ impl Appearance {
|
||||
pub fn has_placed_gradient(&self) -> bool {
|
||||
self.has_gradient(true)
|
||||
}
|
||||
/// Does a fill or stroke carry a gradient that is drawn at a place in document space: a placed
|
||||
/// one, or a freeform gradient with points of its own whose vector is fitted to the bounds?
|
||||
pub fn has_positioned_gradient(&self) -> bool {
|
||||
self.gradients().any(|g| g.is_placed() || g.has_freeform_points())
|
||||
}
|
||||
/// Fix unplaced gradients to their fit on `bounds` (the object's geometric bounds; strokes fit
|
||||
/// the stroke-inflated box), so they can follow transforms that refitting wouldn't reproduce.
|
||||
pub fn pin_gradients(&mut self, bounds: vectorcraft_geom::Rect) {
|
||||
|
||||
@@ -34,6 +34,7 @@ impl Document {
|
||||
graphic_styles,
|
||||
char_styles,
|
||||
para_styles,
|
||||
composite_fonts,
|
||||
text_threads,
|
||||
symbols,
|
||||
graph_designs,
|
||||
@@ -90,6 +91,7 @@ impl Document {
|
||||
&& *graphic_styles == other.graphic_styles
|
||||
&& *char_styles == other.char_styles
|
||||
&& *para_styles == other.para_styles
|
||||
&& *composite_fonts == other.composite_fonts
|
||||
&& *text_threads == other.text_threads
|
||||
&& symbols.len() == other.symbols.len()
|
||||
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
|
||||
@@ -213,6 +215,7 @@ impl Document {
|
||||
graphic_styles: default_graphic_styles().into(),
|
||||
char_styles: Default::default(),
|
||||
para_styles: Default::default(),
|
||||
composite_fonts: Default::default(),
|
||||
text_threads: vec![],
|
||||
symbols: vec![],
|
||||
graph_designs: vec![],
|
||||
|
||||
@@ -121,6 +121,7 @@ pub use text::{
|
||||
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextFlow, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign,
|
||||
WrapShape,
|
||||
};
|
||||
pub use text::{CharClass, CompositeFont, CompositePart, CompositeParts};
|
||||
pub use trace::TraceView;
|
||||
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
|
||||
pub use vectorcraft_color as color;
|
||||
@@ -195,6 +196,9 @@ pub struct Document {
|
||||
/// Paragraph styles (besides the built-in [Normal Paragraph Style]).
|
||||
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
|
||||
pub para_styles: Shared<Vec<TextStyleDef>>,
|
||||
/// Type › Composite Fonts…: the composite fonts defined in the document, by name.
|
||||
#[serde(default, skip_serializing_if = "skip::is_empty_vec")]
|
||||
pub composite_fonts: Shared<Vec<CompositeFont>>,
|
||||
/// Threaded text: area-type frames one story flows through, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub text_threads: Vec<Vec<NodeId>>,
|
||||
|
||||
@@ -103,6 +103,11 @@ impl Document {
|
||||
let styles = |s: &Vec<TextStyleDef>| vec_bytes(s) + s.iter().map(|s| s.name.capacity() + map_bytes(&s.attrs)).sum::<usize>();
|
||||
n += shared(&self.char_styles, &newer.char_styles, styles);
|
||||
n += shared(&self.para_styles, &newer.para_styles, styles);
|
||||
n += shared(&self.composite_fonts, &newer.composite_fonts, |c| {
|
||||
let part = |p: &crate::CompositePart| p.font.capacity() + p.style.capacity();
|
||||
vec_bytes(c)
|
||||
+ c.iter().map(|f| f.name.capacity() + crate::CharClass::ALL.into_iter().map(|k| part(f.parts.get(k))).sum::<usize>()).sum::<usize>()
|
||||
});
|
||||
n += shared(&self.saved_selections, &newer.saved_selections, |s| {
|
||||
vec_bytes(s) + s.iter().map(|s: &SavedSelection| s.name.capacity() + vec_bytes(&s.objects)).sum::<usize>()
|
||||
});
|
||||
|
||||
+14
-1
@@ -8,6 +8,7 @@ use crate::appearance::{Appearance, AppearanceItem, Dash, FillLayer, LineCap, Li
|
||||
|
||||
mod area;
|
||||
mod char_style;
|
||||
mod composite;
|
||||
mod object;
|
||||
mod para_style;
|
||||
mod runs;
|
||||
@@ -17,12 +18,19 @@ mod wrap;
|
||||
pub use area::{AreaFit, AreaOptions, PathAlign, PathEffect, TextFlow, TextWrap, VerticalAlign, WrapShape};
|
||||
use char_style::yes;
|
||||
pub use char_style::{CharAlign, CharPosition, CharStyle, Digits, FontHeightReference, GlyphChoice, ScriptMetrics};
|
||||
pub use composite::{
|
||||
COMPOSITE_BASELINE, COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX, COMPOSITE_SCALE, COMPOSITE_SIZE, CharClass, CompositeFont, CompositePart,
|
||||
CompositeParts,
|
||||
};
|
||||
pub use para_style::{
|
||||
Burasagari, Composer, FirstBaseline, HyphenationOptions, JustificationOptions, Justify, Kashida, Kinsoku, LeadingModel, Mojikumi, ParaDirection,
|
||||
ParaStyle, SingleWordJustify,
|
||||
};
|
||||
pub use runs::{INLINE_CHAR, INLINE_MAX_SCALE, InlineArt, RubyAlign, RubyPosition, RubySpan, TextKind, TextRun, TextStyleDef};
|
||||
pub use tabs_lists::{Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, NumberStyle, ParaList, TabAlign, TabStop};
|
||||
pub use tabs_lists::{
|
||||
Bullet, DEFAULT_TAB_INTERVAL, LIST_HANGING_INDENT, LIST_LEVEL_INDENT, LIST_LEVELS, ListAlign, ListCounts, NumberStyle, ParaList, TabAlign,
|
||||
TabStop,
|
||||
};
|
||||
pub use wrap::{resolve_wrap, wrap_work};
|
||||
|
||||
/// A text object. `runs` split into paragraphs at `\n`.
|
||||
@@ -96,6 +104,11 @@ pub struct TextObject {
|
||||
/// file saves the count only when it is not 0.
|
||||
#[serde(default, rename = "listCount", skip_serializing_if = "crate::skip::is_default")]
|
||||
pub list_count: u32,
|
||||
/// Threaded text: as [`Self::list_count`], the counts the earlier frames carry into this frame
|
||||
/// at list levels 2 on, level 2 first ([`ListCounts::from_parts`]). Empty when they are all 0,
|
||||
/// and the file saves them only when not empty. A file's longer list opens with the first 8.
|
||||
#[serde(default, rename = "listLevelCounts", skip_serializing_if = "Vec::is_empty", deserialize_with = "tabs_lists::read_level_counts")]
|
||||
pub list_level_counts: Vec<u32>,
|
||||
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_bounds: Option<Rect>,
|
||||
|
||||
@@ -101,15 +101,24 @@ impl PathEffect {
|
||||
}
|
||||
}
|
||||
|
||||
/// Type on a Path Options › Align to Path: which height of the type runs along the path.
|
||||
/// Type on a Path Options › Align to Path: which height of the type runs along the path. Each
|
||||
/// character is measured by its own font, size and vertical scale, and in vertical type by its own
|
||||
/// size and horizontal scale. An inline graphic moves as a character of its run would, by its
|
||||
/// run's vertical scale in vertical type too. These distances are VectorCraft's own values.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PathAlign {
|
||||
/// The font's top edge: the type hangs below the path.
|
||||
/// The top of the font's em box: the type hangs below the path. The em box is the font's
|
||||
/// ideographic em box (from its `BASE` table, its typographic or `hhea` box, or its
|
||||
/// ideographs), else an em box centered on the cap height, else a box whose top is 0.88 em
|
||||
/// above the baseline. In vertical type the characters move half an em.
|
||||
Ascender,
|
||||
/// The font's bottom edge: the type stands above the path.
|
||||
/// The bottom of the font's bounding box, else the font's descent: the type stands above the
|
||||
/// path. In vertical type the characters move half an em the other way.
|
||||
Descender,
|
||||
/// Halfway between the ascender and the descender.
|
||||
/// A line at half the height of the top of the font's H above the baseline, else at half its
|
||||
/// cap height, else at the middle of its em box. In vertical type the characters stay on the
|
||||
/// path.
|
||||
Center,
|
||||
/// The baseline (the default).
|
||||
#[default]
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
//! Type › Composite Fonts…: a font made of other fonts, one for each class of character (the
|
||||
//! Kanji font is the base; kana, punctuation, symbols, Roman letters and numerals each take a font
|
||||
//! of their own, sized, raised and scaled against it). Type set in a composite font names it as its
|
||||
//! family; the layout sets each character in its class's font.
|
||||
//!
|
||||
//! Behavior reference (public documentation):
|
||||
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
|
||||
//! Which characters fall in which class, the ranges and the defaults are VectorCraft's own.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A class of characters a composite font gives a font of its own.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum CharClass {
|
||||
/// The base font: ideographs, full-width letters and digits, and every character no other
|
||||
/// class takes.
|
||||
Kanji,
|
||||
/// Hiragana and katakana, full-width and half-width.
|
||||
Kana,
|
||||
/// East Asian and full-width punctuation, and typographic quotes and dashes.
|
||||
Punctuation,
|
||||
/// Arrows, math, shapes, enclosed and other symbols.
|
||||
Symbols,
|
||||
/// Half-width Roman letters, their punctuation and spaces.
|
||||
Roman,
|
||||
/// The half-width digits 0 to 9.
|
||||
Numerals,
|
||||
}
|
||||
|
||||
impl CharClass {
|
||||
/// Every class, in the order the Composite Fonts dialog lists them.
|
||||
pub const ALL: [CharClass; 6] = [Self::Kanji, Self::Kana, Self::Punctuation, Self::Symbols, Self::Roman, Self::Numerals];
|
||||
|
||||
/// The name commands and files use (`kanji`, `kana`, …).
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Self::Kanji => "kanji",
|
||||
Self::Kana => "kana",
|
||||
Self::Punctuation => "punctuation",
|
||||
Self::Symbols => "symbols",
|
||||
Self::Roman => "roman",
|
||||
Self::Numerals => "numerals",
|
||||
}
|
||||
}
|
||||
|
||||
/// The class named `key` ([`Self::key`], any case).
|
||||
pub fn named(key: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.key().eq_ignore_ascii_case(key))
|
||||
}
|
||||
|
||||
/// Whether the class's characters can be scaled across and up (Kana, Roman and Numerals).
|
||||
pub fn scales(self) -> bool {
|
||||
matches!(self, Self::Kana | Self::Roman | Self::Numerals)
|
||||
}
|
||||
|
||||
/// The class of character `c`.
|
||||
pub fn of(c: char) -> Self {
|
||||
let u = c as u32;
|
||||
match u {
|
||||
0x30..=0x39 => Self::Numerals,
|
||||
// Basic Latin, Latin-1, Latin Extended, IPA, spacing and combining marks, and Latin
|
||||
// Extended Additional.
|
||||
0x00..=0x036F | 0x1E00..=0x1EFF => Self::Roman,
|
||||
// The other Latin blocks: combining marks extended, phonetic extensions and their
|
||||
// supplement, combining marks supplement, Latin Extended-C, -D, -E, -F and -G, and the
|
||||
// Latin ligatures of the alphabetic presentation forms.
|
||||
0x1AB0..=0x1AFF
|
||||
| 0x1D00..=0x1DFF
|
||||
| 0x2C60..=0x2C7F
|
||||
| 0xA720..=0xA7FF
|
||||
| 0xAB30..=0xAB6F
|
||||
| 0xFB00..=0xFB06
|
||||
| 0x10780..=0x107BF
|
||||
| 0x1DF00..=0x1DFFF => Self::Roman,
|
||||
// ・ and ゠ sit in the katakana block but are punctuation.
|
||||
0x30A0 | 0x30FB => Self::Punctuation,
|
||||
// Hiragana, katakana, katakana phonetic extensions, half-width katakana (with its
|
||||
// prolonged sound and voicing marks) and the kana supplements.
|
||||
0x3041..=0x309F | 0x30A1..=0x30FF | 0x31F0..=0x31FF | 0xFF66..=0xFF9F | 0x1B000..=0x1B16F => Self::Kana,
|
||||
// CJK symbols: 〄 〒 〓 〠 〶; ideographic space, 々 〆 〇 and the like stay with the base.
|
||||
0x3004 | 0x3012 | 0x3013 | 0x3020 | 0x3036 => Self::Symbols,
|
||||
0x3000 | 0x3005..=0x3007 | 0x3021..=0x302F | 0x3031..=0x3035 | 0x3038..=0x303C => Self::Kanji,
|
||||
0x3001..=0x303F => Self::Punctuation,
|
||||
// ※ is a symbol; the rest of General Punctuation (dashes, quotes, leaders) punctuation.
|
||||
0x203B => Self::Symbols,
|
||||
0x2010..=0x205E => Self::Punctuation,
|
||||
// Letterlike symbols, number forms, arrows, math, technical, enclosed alphanumerics, box
|
||||
// drawing, shapes, miscellaneous symbols and dingbats; enclosed CJK and CJK
|
||||
// compatibility; full-width signs.
|
||||
0x2100..=0x2BFF | 0x3200..=0x33FF | 0xFFE0..=0xFFEE => Self::Symbols,
|
||||
// Full-width forms of the ASCII signs.
|
||||
0xFF03..=0xFF06 | 0xFF0A | 0xFF0B | 0xFF1C..=0xFF1E | 0xFF20 | 0xFF3C | 0xFF3E | 0xFF5C | 0xFF5E => Self::Symbols,
|
||||
// Full-width forms of the other ASCII punctuation, and the half-width CJK punctuation.
|
||||
0xFF01..=0xFF0F | 0xFF1A..=0xFF1F | 0xFF3B..=0xFF40 | 0xFF5B..=0xFF65 => Self::Punctuation,
|
||||
_ => Self::Kanji,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The font a composite font sets one class of characters in, and how.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CompositePart {
|
||||
/// The font family and style.
|
||||
pub font: String,
|
||||
#[serde(default = "regular")]
|
||||
pub style: String,
|
||||
/// The size, in percent of the type's size.
|
||||
#[serde(default = "hundred")]
|
||||
pub size: f64,
|
||||
/// How far the characters are raised from the baseline, in percent of the type's size
|
||||
/// (negative lowers them).
|
||||
#[serde(default)]
|
||||
pub baseline: f64,
|
||||
/// Horizontal and vertical scale, in percent (Kana, Roman and Numerals only).
|
||||
#[serde(default = "hundred")]
|
||||
pub h_scale: f64,
|
||||
#[serde(default = "hundred")]
|
||||
pub v_scale: f64,
|
||||
/// Kana only: the characters are scaled from the center of the em box rather than from the
|
||||
/// baseline.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub scale_at_center: bool,
|
||||
}
|
||||
|
||||
fn hundred() -> f64 {
|
||||
100.0
|
||||
}
|
||||
|
||||
fn regular() -> String {
|
||||
"Regular".into()
|
||||
}
|
||||
|
||||
impl CompositePart {
|
||||
/// The part a new composite font gives every class: `font` in `style` at full size, on the
|
||||
/// baseline, unscaled.
|
||||
pub fn new(font: impl Into<String>, style: impl Into<String>) -> Self {
|
||||
Self { font: font.into(), style: style.into(), size: 100.0, baseline: 0.0, h_scale: 100.0, v_scale: 100.0, scale_at_center: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The six parts of a composite font, one per [`CharClass`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CompositeParts {
|
||||
pub kanji: CompositePart,
|
||||
pub kana: CompositePart,
|
||||
pub punctuation: CompositePart,
|
||||
pub symbols: CompositePart,
|
||||
pub roman: CompositePart,
|
||||
pub numerals: CompositePart,
|
||||
}
|
||||
|
||||
impl CompositeParts {
|
||||
/// Every class set in `part`.
|
||||
pub fn all(part: CompositePart) -> Self {
|
||||
Self { kanji: part.clone(), kana: part.clone(), punctuation: part.clone(), symbols: part.clone(), roman: part.clone(), numerals: part }
|
||||
}
|
||||
|
||||
pub fn get(&self, class: CharClass) -> &CompositePart {
|
||||
match class {
|
||||
CharClass::Kanji => &self.kanji,
|
||||
CharClass::Kana => &self.kana,
|
||||
CharClass::Punctuation => &self.punctuation,
|
||||
CharClass::Symbols => &self.symbols,
|
||||
CharClass::Roman => &self.roman,
|
||||
CharClass::Numerals => &self.numerals,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_mut(&mut self, class: CharClass) -> &mut CompositePart {
|
||||
match class {
|
||||
CharClass::Kanji => &mut self.kanji,
|
||||
CharClass::Kana => &mut self.kana,
|
||||
CharClass::Punctuation => &mut self.punctuation,
|
||||
CharClass::Symbols => &mut self.symbols,
|
||||
CharClass::Roman => &mut self.roman,
|
||||
CharClass::Numerals => &mut self.numerals,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A composite font (Type › Composite Fonts…), saved with the document. Its name is the family
|
||||
/// that type set in it names.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CompositeFont {
|
||||
pub name: String,
|
||||
pub parts: CompositeParts,
|
||||
}
|
||||
|
||||
/// The size a part can take, in percent of the type's size.
|
||||
pub const COMPOSITE_SIZE: std::ops::RangeInclusive<f64> = 10.0..=400.0;
|
||||
/// How far a part can raise or lower its characters, in percent of the type's size.
|
||||
pub const COMPOSITE_BASELINE: std::ops::RangeInclusive<f64> = -100.0..=100.0;
|
||||
/// The horizontal and vertical scale a part can take, in percent.
|
||||
pub const COMPOSITE_SCALE: std::ops::RangeInclusive<f64> = 10.0..=400.0;
|
||||
/// The longest composite font name, in characters.
|
||||
pub const COMPOSITE_NAME_MAX: usize = 255;
|
||||
/// The most composite fonts a document or a file keeps.
|
||||
pub const COMPOSITE_FONTS_MAX: usize = 256;
|
||||
|
||||
impl CompositeFont {
|
||||
/// A composite font named `name` setting every class in `font` and `style`.
|
||||
pub fn new(name: impl Into<String>, font: impl Into<String>, style: impl Into<String>) -> Self {
|
||||
Self { name: name.into(), parts: CompositeParts::all(CompositePart::new(font, style)) }
|
||||
}
|
||||
|
||||
/// Do `a` and `b` name the same composite font: equal in any case?
|
||||
pub fn same_name(a: &str, b: &str) -> bool {
|
||||
a == b || a.to_lowercase() == b.to_lowercase()
|
||||
}
|
||||
|
||||
/// The font of `fonts` (a document's composite fonts) named `name`, in any case.
|
||||
pub fn named<'a>(fonts: &'a [CompositeFont], name: &str) -> Option<&'a CompositeFont> {
|
||||
fonts.iter().find(|f| Self::same_name(&f.name, name))
|
||||
}
|
||||
|
||||
/// `name` trimmed, when it can name a composite font: not empty, at most
|
||||
/// [`COMPOSITE_NAME_MAX`] characters and without control characters.
|
||||
pub fn clean_name(name: &str) -> Option<String> {
|
||||
let name = name.trim();
|
||||
let ok = !name.is_empty() && name.chars().count() <= COMPOSITE_NAME_MAX && !name.chars().any(char::is_control);
|
||||
ok.then(|| name.to_string())
|
||||
}
|
||||
|
||||
/// Why this composite font can't be used, if it can't: a name [`Self::clean_name`] refuses, a
|
||||
/// part without a font, a value out of its range, or a scale or Scale at Center on a class
|
||||
/// that has none.
|
||||
pub fn check(&self) -> Result<(), String> {
|
||||
if Self::clean_name(&self.name).as_deref() != Some(self.name.as_str()) {
|
||||
return Err(format!("`{}` can't name a composite font", self.name));
|
||||
}
|
||||
for class in CharClass::ALL {
|
||||
let p = self.parts.get(class);
|
||||
let k = class.key();
|
||||
if p.font.trim().is_empty() {
|
||||
return Err(format!("{k}: give a font"));
|
||||
}
|
||||
let within = |v: f64, r: &std::ops::RangeInclusive<f64>| v.is_finite() && r.contains(&v);
|
||||
if !within(p.size, &COMPOSITE_SIZE) {
|
||||
return Err(format!("{k}: size must be {} to {}%", COMPOSITE_SIZE.start(), COMPOSITE_SIZE.end()));
|
||||
}
|
||||
if !within(p.baseline, &COMPOSITE_BASELINE) {
|
||||
return Err(format!("{k}: baseline must be {} to {}%", COMPOSITE_BASELINE.start(), COMPOSITE_BASELINE.end()));
|
||||
}
|
||||
for v in [p.h_scale, p.v_scale] {
|
||||
if !within(v, &COMPOSITE_SCALE) {
|
||||
return Err(format!("{k}: scale must be {} to {}%", COMPOSITE_SCALE.start(), COMPOSITE_SCALE.end()));
|
||||
}
|
||||
if v != 100.0 && !class.scales() {
|
||||
return Err(format!("{k}: only kana, roman and numerals can be scaled"));
|
||||
}
|
||||
}
|
||||
if p.scale_at_center && class != CharClass::Kana {
|
||||
return Err(format!("{k}: only kana can be scaled at the center"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `fonts` without the ones that can't be used: those [`Self::check`] refuses, a later font
|
||||
/// sharing an earlier one's name (in any case), a part set in one of the fonts, and those past
|
||||
/// [`COMPOSITE_FONTS_MAX`]. `None` when every font can be used.
|
||||
pub fn usable(fonts: &[CompositeFont]) -> Option<Vec<CompositeFont>> {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let mut kept: Vec<CompositeFont> =
|
||||
fonts.iter().filter(|f| f.check().is_ok() && names.insert(f.name.to_lowercase())).take(COMPOSITE_FONTS_MAX).cloned().collect();
|
||||
kept.retain(|f| CharClass::ALL.iter().all(|&c| !names.contains(&f.parts.get(c).font.to_lowercase())));
|
||||
(kept.len() != fonts.len()).then_some(kept)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each class takes its characters: digits and Latin letters are half-width numerals and
|
||||
/// Roman, kana both widths, and the punctuation, symbols and full-width forms their own.
|
||||
#[test]
|
||||
fn characters_fall_in_their_classes() {
|
||||
for (s, class) in [
|
||||
("漢字国一龍〇々", CharClass::Kanji),
|
||||
("AZa09 ", CharClass::Kanji),
|
||||
("あをアンーアンー", CharClass::Kana),
|
||||
("、。「」『』【】〜・!?():", CharClass::Punctuation),
|
||||
("“”‘’—…", CharClass::Punctuation),
|
||||
("※→★○〒+=$%①㈱", CharClass::Symbols),
|
||||
("AZaz é!? ()", CharClass::Roman),
|
||||
// Latin Extended-C, -D and -E, phonetic extensions and a ligature.
|
||||
("\u{2C60}\u{A7A0}\u{AB30}\u{1D00}\u{FB01}", CharClass::Roman),
|
||||
("0123456789", CharClass::Numerals),
|
||||
] {
|
||||
for c in s.chars() {
|
||||
assert_eq!(CharClass::of(c), class, "{c:?} (U+{:04X})", c as u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A composite font is read and written by its fields' names, and a part's omitted values
|
||||
/// take the defaults.
|
||||
#[test]
|
||||
fn composite_fonts_read_back_with_defaults() {
|
||||
let mut f = CompositeFont::new("Mixed", "Source Sans 3", "Regular");
|
||||
f.parts.kana.v_scale = 90.0;
|
||||
f.parts.kana.scale_at_center = true;
|
||||
f.parts.roman.baseline = -5.0;
|
||||
let json = serde_json::to_value(&f).unwrap();
|
||||
assert_eq!(json["parts"]["kana"]["vScale"], 90.0);
|
||||
assert_eq!(json["parts"]["kana"]["scaleAtCenter"], true);
|
||||
assert!(json["parts"]["roman"].get("scaleAtCenter").is_none());
|
||||
assert_eq!(serde_json::from_value::<CompositeFont>(json).unwrap(), f);
|
||||
let part: CompositePart = serde_json::from_value(serde_json::json!({"font": "Inter"})).unwrap();
|
||||
assert_eq!(part, CompositePart::new("Inter", "Regular"));
|
||||
}
|
||||
|
||||
/// Names are trimmed; values out of their ranges, and scales on classes that don't scale, are
|
||||
/// refused.
|
||||
#[test]
|
||||
fn composite_fonts_check_their_values() {
|
||||
assert_eq!(CompositeFont::clean_name(" Mixed ").as_deref(), Some("Mixed"));
|
||||
for bad in ["", " ", "a\tb", &"x".repeat(COMPOSITE_NAME_MAX + 1)] {
|
||||
assert_eq!(CompositeFont::clean_name(bad), None, "{bad:?}");
|
||||
}
|
||||
let ok = CompositeFont::new("Mixed", "Inter", "Regular");
|
||||
assert_eq!(ok.check(), Ok(()));
|
||||
let with = |f: fn(&mut CompositeParts)| {
|
||||
let mut c = ok.clone();
|
||||
f(&mut c.parts);
|
||||
c.check()
|
||||
};
|
||||
assert!(with(|p| p.roman.size = 9.0).is_err());
|
||||
assert!(with(|p| p.roman.size = f64::NAN).is_err());
|
||||
assert!(with(|p| p.kana.baseline = 101.0).is_err());
|
||||
assert!(with(|p| p.kana.h_scale = 401.0).is_err());
|
||||
assert!(with(|p| p.kanji.v_scale = 90.0).is_err());
|
||||
assert!(with(|p| p.symbols.h_scale = 90.0).is_err());
|
||||
assert!(with(|p| p.roman.scale_at_center = true).is_err());
|
||||
assert!(with(|p| p.numerals.font = " ".into()).is_err());
|
||||
assert_eq!(with(|p| p.kana.scale_at_center = true), Ok(()));
|
||||
assert_eq!(with(|p| p.numerals.h_scale = 50.0), Ok(()));
|
||||
let mut spaced = ok.clone();
|
||||
spaced.name = " Mixed".into();
|
||||
assert!(spaced.check().is_err());
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use super::area::{AreaOptions, PathAlign, PathEffect};
|
||||
use super::char_style::CharStyle;
|
||||
use super::para_style::ParaStyle;
|
||||
use super::runs::{RubySpan, TextKind, TextRun, move_anchors, reverse_from_end};
|
||||
use super::tabs_lists::ParaList;
|
||||
use super::tabs_lists::{ListCounts, ParaList};
|
||||
use super::{Affine, PathData, Point, Rect, TextObject, TextStyleScale, Vec2};
|
||||
|
||||
impl TextObject {
|
||||
@@ -93,10 +93,20 @@ impl TextObject {
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// The list counts the story's earlier frames carry into this frame at each level
|
||||
/// ([`Self::list_count`] and [`Self::list_level_counts`]).
|
||||
pub fn list_counts(&self) -> ListCounts {
|
||||
ListCounts::from_parts(self.list_count, &self.list_level_counts)
|
||||
}
|
||||
/// Set the list counts the story's earlier frames carry into this frame ([`Self::list_counts`]).
|
||||
pub fn set_list_counts(&mut self, counts: ListCounts) {
|
||||
(self.list_count, self.list_level_counts) = counts.parts();
|
||||
}
|
||||
pub fn plain_text(&self) -> String {
|
||||
self.runs.iter().map(|r| r.text.as_str()).collect()
|
||||
}
|
||||
|
||||
@@ -258,6 +258,12 @@ impl ParaStyle {
|
||||
}
|
||||
}
|
||||
|
||||
/// The left indent the layout sets the paragraph at: its left indent, and for a paragraph in a
|
||||
/// list at a level past the first, [`ParaList::level_indent`] more.
|
||||
pub fn laid_out_left_indent(&self) -> f64 {
|
||||
self.left_indent + self.list.map_or(0.0, |l| l.level_indent())
|
||||
}
|
||||
|
||||
/// The leading of characters styled `st` in this paragraph, as the layout sets it: their own
|
||||
/// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their
|
||||
/// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is
|
||||
|
||||
@@ -155,14 +155,29 @@ fn roman(mut n: u32) -> String {
|
||||
s
|
||||
}
|
||||
|
||||
/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets
|
||||
/// and Numbering dialog's Alignment.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListAlign {
|
||||
/// The marker starts where the first line starts: at the left indent plus the first-line
|
||||
/// indent.
|
||||
#[default]
|
||||
Left,
|
||||
/// The marker ends where the first line's text starts, against the text.
|
||||
Right,
|
||||
}
|
||||
|
||||
/// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the
|
||||
/// list's marker before the paragraph's first line, in the font, size and color of the paragraph's
|
||||
/// first character without its baseline shift, rotation, superscript or subscript, tracking, manual
|
||||
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
|
||||
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
|
||||
/// out as their defaults, and a build ignores the fields it doesn't know.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the
|
||||
/// first line's start, or against the text with [`ListAlign::Right`]. A paragraph at a level past
|
||||
/// the first ([`Self::level`]) is set [`LIST_LEVEL_INDENT`] further in for each level. The marker
|
||||
/// is not a character of the text, and an empty paragraph has none. A saved list reads the fields
|
||||
/// it leaves out as their defaults, and a build ignores the fields it doesn't know.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
/// The bullet each paragraph of a bulleted list starts with. A numbered list keeps it for
|
||||
@@ -172,17 +187,127 @@ pub struct ParaList {
|
||||
/// numbered list.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub number: Option<NumberStyle>,
|
||||
/// Where the marker stands before the first line's text. The file saves it only when it is
|
||||
/// [`ListAlign::Right`].
|
||||
#[serde(skip_serializing_if = "crate::skip::is_default")]
|
||||
pub align: ListAlign,
|
||||
/// The paragraph's level in the list, 1 to [`LIST_LEVELS`] (Tab and Shift+Tab at the start of
|
||||
/// the paragraph, Bullets and Numbering…'s Level): each level counts its own numbers
|
||||
/// ([`Self::next_marker`]). A value outside that range is read as the nearest level
|
||||
/// ([`Self::depth`]). The file saves it only when it is not 1.
|
||||
#[serde(skip_serializing_if = "is_first_level", deserialize_with = "read_level")]
|
||||
pub level: u8,
|
||||
}
|
||||
|
||||
impl Default for ParaList {
|
||||
/// A bulleted list with a dot, its marker aligned left, at level 1.
|
||||
fn default() -> Self {
|
||||
Self { bullet: Bullet::default(), number: None, align: ListAlign::default(), level: 1 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `level` the first list level, which the file does not save?
|
||||
fn is_first_level(level: &u8) -> bool {
|
||||
*level == 1
|
||||
}
|
||||
|
||||
/// Reads a saved list level, a whole number, as the nearest of 1 to [`LIST_LEVELS`], so a level
|
||||
/// such as -1 or 300 opens the file at level 1 or 9. Anything else is an error.
|
||||
fn read_level<'de, D: serde::Deserializer<'de>>(d: D) -> Result<u8, D::Error> {
|
||||
let level = match serde_json::Value::deserialize(d)? {
|
||||
serde_json::Value::Number(n) if n.is_i64() || n.is_u64() => n.as_i64().map_or(LIST_LEVELS as i64, |n| n.clamp(1, LIST_LEVELS as i64)),
|
||||
_ => return Err(serde::de::Error::custom("a list level must be a whole number")),
|
||||
};
|
||||
Ok(level as u8)
|
||||
}
|
||||
|
||||
/// Reads a type object's saved counts at list levels 2 on ([`ListCounts::from_parts`]), keeping
|
||||
/// the first `LIST_LEVELS - 1` and skipping the rest of a longer list without storing it.
|
||||
pub(super) fn read_level_counts<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
|
||||
struct Counts;
|
||||
impl<'de> serde::de::Visitor<'de> for Counts {
|
||||
type Value = Vec<u32>;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
f.write_str("a list of list counts")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<u32>, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while out.len() < LIST_LEVELS - 1 {
|
||||
match seq.next_element::<u32>()? {
|
||||
Some(c) => out.push(c),
|
||||
None => return Ok(out),
|
||||
}
|
||||
}
|
||||
while seq.next_element::<serde::de::IgnoredAny>()?.is_some() {}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
d.deserialize_seq(Counts)
|
||||
}
|
||||
|
||||
/// How many levels a list has (Bullets and Numbering…'s Level: 1 to 9).
|
||||
pub const LIST_LEVELS: usize = 9;
|
||||
|
||||
/// How much further in (pt) the layout sets a list paragraph for each level past the first: its
|
||||
/// left indent counts that much more, and the marker and the text move with it.
|
||||
pub const LIST_LEVEL_INDENT: f64 = 18.0;
|
||||
|
||||
/// The numbered paragraphs a story's list has counted at each level, level 1 first
|
||||
/// ([`ParaList::next_marker`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct ListCounts(pub [u32; LIST_LEVELS]);
|
||||
|
||||
impl ListCounts {
|
||||
/// The counts with `first` at level 1 and `deeper` at levels 2 on, a missing level at 0, as a
|
||||
/// type object saves them ([`TextObject::list_count`], [`TextObject::list_level_counts`]).
|
||||
pub fn from_parts(first: u32, deeper: &[u32]) -> Self {
|
||||
let mut counts = [0; LIST_LEVELS];
|
||||
counts[0] = first;
|
||||
for (c, &d) in counts[1..].iter_mut().zip(deeper) {
|
||||
*c = d;
|
||||
}
|
||||
Self(counts)
|
||||
}
|
||||
|
||||
/// The count at level 1, and the counts at levels 2 on without the zeros that end them.
|
||||
pub fn parts(&self) -> (u32, Vec<u32>) {
|
||||
let deeper = &self.0[1..];
|
||||
let used = deeper.iter().rposition(|&c| c != 0).map_or(0, |i| i + 1);
|
||||
(self.0[0], deeper[..used].to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
impl ParaList {
|
||||
/// A bulleted list with `bullet`.
|
||||
pub fn bulleted(bullet: Bullet) -> Self {
|
||||
Self { bullet, number: None }
|
||||
Self { bullet, ..Self::default() }
|
||||
}
|
||||
|
||||
/// A numbered list with `style`.
|
||||
pub fn numbered(style: NumberStyle) -> Self {
|
||||
Self { bullet: Bullet::default(), number: Some(style) }
|
||||
Self { number: Some(style), ..Self::default() }
|
||||
}
|
||||
|
||||
/// This list with its marker at `align`.
|
||||
pub fn aligned(self, align: ListAlign) -> Self {
|
||||
Self { align, ..self }
|
||||
}
|
||||
|
||||
/// This list at level `level` (1 to [`LIST_LEVELS`]).
|
||||
pub fn at_level(self, level: u8) -> Self {
|
||||
Self { level, ..self }
|
||||
}
|
||||
|
||||
/// The list's level, 1 to [`LIST_LEVELS`]: [`Self::level`], a value outside the range taken
|
||||
/// as the nearest level.
|
||||
pub fn depth(&self) -> usize {
|
||||
usize::from(self.level).clamp(1, LIST_LEVELS)
|
||||
}
|
||||
|
||||
/// How much further in (pt) than its left indent the layout sets a paragraph of this list:
|
||||
/// [`LIST_LEVEL_INDENT`] for each level past the first.
|
||||
pub fn level_indent(&self) -> f64 {
|
||||
(self.depth() - 1) as f64 * LIST_LEVEL_INDENT
|
||||
}
|
||||
|
||||
/// The characters of the marker the layout draws before the paragraph: the bullet, or for a
|
||||
@@ -195,18 +320,26 @@ impl ParaList {
|
||||
}
|
||||
|
||||
/// The marker of the next paragraph of a story, whose list is `list` (None: in no list) and
|
||||
/// which holds no text when `empty`, with `count` the numbered paragraphs counted before it.
|
||||
/// A paragraph in no list sets the count back to 0 and has no marker; an empty paragraph has
|
||||
/// none and leaves the count as it is; a bulleted paragraph has its bullet and leaves the
|
||||
/// count as it is; a numbered paragraph adds one to the count and is numbered with it.
|
||||
pub fn next_marker(list: Option<ParaList>, empty: bool, count: &mut u32) -> Option<String> {
|
||||
/// which holds no text when `empty`, with `counts` the numbered paragraphs counted before it at
|
||||
/// each level. A paragraph in no list sets every count back to 0 and has no marker; an empty
|
||||
/// paragraph has none and leaves the counts as they are. A paragraph with text sets the counts
|
||||
/// of the levels deeper than its own back to 0, so a deeper paragraph after it counts from 1
|
||||
/// again; a bulleted one has its bullet and leaves the count of its level as it is, and a
|
||||
/// numbered one adds one to the count of its level and is numbered with it. A paragraph back
|
||||
/// at a shallower level then goes on with that level's count.
|
||||
pub fn next_marker(list: Option<ParaList>, empty: bool, counts: &mut ListCounts) -> Option<String> {
|
||||
let Some(list) = list else {
|
||||
*count = 0;
|
||||
*counts = ListCounts::default();
|
||||
return None;
|
||||
};
|
||||
if empty {
|
||||
return None;
|
||||
}
|
||||
let level = list.depth() - 1;
|
||||
for deeper in &mut counts.0[level + 1..] {
|
||||
*deeper = 0;
|
||||
}
|
||||
let count = &mut counts.0[level];
|
||||
if list.number.is_some() {
|
||||
*count = count.saturating_add(1);
|
||||
}
|
||||
|
||||
@@ -194,13 +194,13 @@ fn number_styles_write_their_numbers_and_roll_over() {
|
||||
assert_eq!(ParaList::numbered(NumberStyle::UpperRoman).marker(4), "IV.");
|
||||
assert_eq!(ParaList::bulleted(Bullet::Square).marker(4), "\u{25AA}");
|
||||
let (num, dot) = (Some(ParaList::numbered(NumberStyle::Arabic)), Some(ParaList::bulleted(Bullet::Dot)));
|
||||
let mut count = 0;
|
||||
let mut count = ListCounts::default();
|
||||
let markers: Vec<Option<String>> = [(num, false), (num, true), (dot, false), (num, false), (None, false), (num, false)]
|
||||
.iter()
|
||||
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut count))
|
||||
.collect();
|
||||
assert_eq!(markers, [Some("1.".into()), None, Some("\u{2022}".into()), Some("2.".into()), None, Some("1.".into())]);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(count.parts(), (1, vec![]));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!(read(serde_json::json!({"bullet": "dash"})), ParaList::bulleted(Bullet::Dash));
|
||||
assert_eq!(read(serde_json::json!({"number": "threeDigits"})), ParaList::numbered(NumberStyle::ThreeDigits));
|
||||
@@ -211,6 +211,20 @@ fn number_styles_write_their_numbers_and_roll_over() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`;
|
||||
/// a list saved without it reads as Left.
|
||||
#[test]
|
||||
fn a_list_saves_its_marker_alignment_only_when_it_is_right() {
|
||||
let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right);
|
||||
assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left);
|
||||
assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"}));
|
||||
assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right);
|
||||
assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left);
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"align": "center"})).is_err());
|
||||
}
|
||||
|
||||
/// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph
|
||||
/// start a numbered list, and "*", "+", "#" and ">" and a space start a bulleted list with the
|
||||
/// filled dot, the hollow circle, the filled square and the dash. Other text, the same characters
|
||||
@@ -240,6 +254,70 @@ fn typed_list_starts_are_found_at_a_paragraphs_start() {
|
||||
assert_eq!(t.typed_list_at(19), Some((16, letters)));
|
||||
}
|
||||
|
||||
/// List levels: each level counts its own numbers, a deeper paragraph starts its count at 1,
|
||||
/// a paragraph back at a shallower level goes on with that level's count, and a paragraph in
|
||||
/// no list starts every level again. A level past the first sets the paragraph 18 pt further
|
||||
/// in; the file saves the level only when it is not 1, and a level outside 1 to 9 reads as the
|
||||
/// nearest one. A type object's counts save level 1 and the deeper levels apart.
|
||||
#[test]
|
||||
fn list_levels_count_their_own_numbers_and_save_only_past_the_first() {
|
||||
let num = |level| Some(ParaList::numbered(NumberStyle::Arabic).at_level(level));
|
||||
let dot = |level| Some(ParaList::bulleted(Bullet::Dot).at_level(level));
|
||||
let mut counts = ListCounts::default();
|
||||
let markers: Vec<Option<String>> = [
|
||||
(num(1), false),
|
||||
(num(2), false),
|
||||
(num(2), false),
|
||||
(num(3), false),
|
||||
(num(2), true),
|
||||
(num(2), false),
|
||||
(num(1), false),
|
||||
(num(2), false),
|
||||
(dot(1), false),
|
||||
(num(2), false),
|
||||
(num(1), false),
|
||||
(None, false),
|
||||
(num(2), false),
|
||||
]
|
||||
.iter()
|
||||
.map(|&(l, empty)| ParaList::next_marker(l, empty, &mut counts))
|
||||
.collect();
|
||||
let want = ["1.", "1.", "2.", "1.", "", "3.", "2.", "1.", "\u{2022}", "1.", "3.", "", "1."];
|
||||
assert_eq!(markers.iter().map(|m| m.as_deref().unwrap_or("")).collect::<Vec<_>>(), want);
|
||||
assert_eq!(counts, ListCounts([0, 1, 0, 0, 0, 0, 0, 0, 0]));
|
||||
assert_eq!((ParaList::default().level, ParaList::bulleted(Bullet::Dot).depth()), (1, 1));
|
||||
assert_eq!([0, 1, 4, 9, 12].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).depth()), [1, 1, 4, 9, 9]);
|
||||
assert_eq!([1, 2, 9].map(|l| ParaList::bulleted(Bullet::Dot).at_level(l).level_indent()), [0.0, 18.0, 144.0]);
|
||||
let para = ParaStyle { left_indent: 18.0, list: dot(3), ..Default::default() };
|
||||
assert_eq!((para.laid_out_left_indent(), ParaStyle { list: None, ..para.clone() }.laid_out_left_indent()), (54.0, 18.0));
|
||||
assert_eq!(serde_json::to_value(dot(1)).unwrap(), serde_json::json!({"bullet": "dot"}));
|
||||
assert_eq!(serde_json::to_value(num(4)).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "level": 4}));
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();
|
||||
assert_eq!((read(serde_json::json!({"bullet": "dash"})).level, read(serde_json::json!({"level": 4}))), (1, dot(4).unwrap()));
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": "two"})).is_err());
|
||||
assert!(serde_json::from_value::<ParaList>(serde_json::json!({"level": 2.5})).is_err());
|
||||
let levels = [serde_json::json!(0), serde_json::json!(-1), serde_json::json!(12), serde_json::json!(300), serde_json::json!(u64::MAX)];
|
||||
assert_eq!(
|
||||
levels.map(|l| read(serde_json::json!({ "level": l })).level),
|
||||
[1, 1, 9, 9, 9],
|
||||
"a saved level outside 1 to 9 reads as the nearest one"
|
||||
);
|
||||
let deep = ListCounts([5, 0, 2, 0, 0, 0, 0, 0, 0]);
|
||||
assert_eq!(deep.parts(), (5, vec![0, 2]));
|
||||
assert_eq!((ListCounts::from_parts(5, &[0, 2]), ListCounts::default().parts()), (deep, (0, vec![])));
|
||||
let mut t = TextObject::point(Point::ZERO, "Text", CharStyle::default());
|
||||
t.set_list_counts(deep);
|
||||
assert_eq!((t.list_count, t.list_level_counts.as_slice(), t.list_counts()), (5, &[0, 2][..], deep));
|
||||
let saved = serde_json::to_value(&t).unwrap();
|
||||
assert_eq!((&saved["listCount"], &saved["listLevelCounts"]), (&serde_json::json!(5), &serde_json::json!([0, 2])));
|
||||
t.set_list_counts(ListCounts::default());
|
||||
assert!(serde_json::to_value(&t).unwrap().get("listLevelCounts").is_none(), "no counts past level 1 are saved without them");
|
||||
let mut saved = serde_json::to_value(&t).unwrap();
|
||||
saved["listLevelCounts"] = serde_json::json!((1..=20).collect::<Vec<u32>>());
|
||||
let opened = serde_json::from_value::<TextObject>(saved).unwrap();
|
||||
assert_eq!(opened.list_level_counts, (1..=8).collect::<Vec<u32>>(), "a file keeps only the counts of levels 2 to 9");
|
||||
}
|
||||
|
||||
/// In a threaded frame whose first paragraph continues from the frame before, the text at the
|
||||
/// frame's start is not the start of a paragraph, so typing a list's start there starts nothing.
|
||||
/// The paragraphs after it still start lists.
|
||||
|
||||
@@ -236,10 +236,11 @@ impl Carry {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared
|
||||
/// with other objects is not copied for nothing.
|
||||
/// Does it move any of the gradients of `ap` (those drawn at a place in document space, a
|
||||
/// freeform gradient's own points included)? Callers check first, so an appearance shared with
|
||||
/// other objects is not copied for nothing.
|
||||
pub fn moves(&self, ap: &Appearance) -> bool {
|
||||
!self.0.is_empty() && ap.has_placed_gradient()
|
||||
!self.0.is_empty() && ap.has_positioned_gradient()
|
||||
}
|
||||
|
||||
/// Move the placed gradients of `ap` along, through each step in turn.
|
||||
|
||||
@@ -86,7 +86,7 @@ pub use reshape::{
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
|
||||
three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options,
|
||||
three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options,
|
||||
};
|
||||
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
|
||||
pub use svg_filter::SVG_FILTER;
|
||||
|
||||
@@ -670,8 +670,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
if follows {
|
||||
group_marks(art, &mut vec![], &HashSet::new(), &mut groups);
|
||||
}
|
||||
// Whether a path kept its shape (past the budget), and the ids of those that did.
|
||||
let mut kept = false;
|
||||
// The ids of the paths that kept their shape (past the budget).
|
||||
let mut kept_ids = HashSet::new();
|
||||
let transform = (e.id == "distort.transform").then(|| {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
@@ -697,7 +696,6 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
used = used.minus(before).plus(made);
|
||||
if !moved {
|
||||
kept = true;
|
||||
kept_ids.insert(leaf.id);
|
||||
}
|
||||
return;
|
||||
@@ -709,12 +707,11 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
}
|
||||
}
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else {
|
||||
kept = true;
|
||||
kept_ids.insert(leaf.id);
|
||||
return;
|
||||
};
|
||||
used = used.minus(before).plus(Budget::of(&out));
|
||||
if follows && !boxes && leaf.appearance.has_placed_gradient() {
|
||||
if follows && !boxes && leaf.appearance.has_positioned_gradient() {
|
||||
crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance);
|
||||
}
|
||||
set_geometry(leaf, out, rule);
|
||||
@@ -732,13 +729,13 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
}
|
||||
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
|
||||
// follow their bounds under Free Distort (they stay when no path changed them). Each stays
|
||||
// when a path under it kept its shape (past the budget).
|
||||
// when a path under it that its bounds measure kept its shape (past the budget).
|
||||
let mut after = vec![];
|
||||
let held = (!groups.is_empty() || !kept_ids.is_empty()) && group_marks(art, &mut vec![], &kept_ids, &mut after).1;
|
||||
if !groups.is_empty() {
|
||||
let mut after = vec![];
|
||||
group_marks(art, &mut vec![], &kept_ids, &mut after);
|
||||
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed));
|
||||
}
|
||||
if follows && !kept {
|
||||
if follows && !held {
|
||||
whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed));
|
||||
}
|
||||
}
|
||||
@@ -1020,7 +1017,7 @@ fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rec
|
||||
&& from != to
|
||||
{
|
||||
for_each_leaf(n, &mut |leaf| {
|
||||
if leaf.appearance.has_placed_gradient() {
|
||||
if leaf.appearance.has_positioned_gradient() {
|
||||
follow_boxes(&mut leaf.appearance, from, to);
|
||||
}
|
||||
});
|
||||
@@ -1049,11 +1046,11 @@ fn is_member_group(n: &Node) -> bool {
|
||||
|
||||
/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a
|
||||
/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and
|
||||
/// whether a path below it kept its shape.
|
||||
/// whether a path below it that its bounds measure kept its shape.
|
||||
type GroupMark = (Vec<usize>, Option<Rect>, bool);
|
||||
|
||||
/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or
|
||||
/// compound path below it is in `kept`, measured in one walk. Each group below `n` that
|
||||
/// compound path below it that those bounds measure is in `kept`, measured in one walk. Each group below `n` that
|
||||
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`.
|
||||
fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) {
|
||||
match &n.kind {
|
||||
@@ -1065,14 +1062,15 @@ fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
at.push(i);
|
||||
let (b, h) = group_marks(c, at, kept, out);
|
||||
if is_member_group(c) && c.appearance.has_placed_gradient() {
|
||||
if is_member_group(c) && c.appearance.has_positioned_gradient() {
|
||||
out.push((at.clone(), b, h));
|
||||
}
|
||||
at.pop();
|
||||
held |= h;
|
||||
// Only members measured in the bounds hold the group's gradients in place.
|
||||
let counts = if *clip { i == 0 } else { i >= skip && (c.visible || !layer) };
|
||||
if counts {
|
||||
bounds = vectorcraft_geom::union_opt(bounds, b);
|
||||
held |= h;
|
||||
}
|
||||
}
|
||||
(bounds, held)
|
||||
@@ -1126,7 +1124,7 @@ fn rebase_leaves(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
|
||||
if let Some(Some(from)) = before.next()
|
||||
&& let Some(to) = n.geometric_bounds()
|
||||
&& from != to
|
||||
&& n.appearance.has_placed_gradient()
|
||||
&& n.appearance.has_positioned_gradient()
|
||||
{
|
||||
follow_boxes(&mut n.appearance, from, to);
|
||||
}
|
||||
@@ -1224,7 +1222,7 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
|
||||
_ => {}
|
||||
}
|
||||
// The placed gradients of its own fills and strokes move with it, as a path's do.
|
||||
if xf != Affine::IDENTITY && one.appearance.has_placed_gradient() {
|
||||
if xf != Affine::IDENTITY && one.appearance.has_positioned_gradient() {
|
||||
one.appearance.transform_gradients(xf);
|
||||
}
|
||||
one
|
||||
|
||||
@@ -228,8 +228,9 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
|
||||
|
||||
/// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on
|
||||
/// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for
|
||||
/// each of them.
|
||||
pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, String> {
|
||||
/// each of them. Each mesh adds its triangles to `total` as it is built, and the first that takes
|
||||
/// `total` past `limit` stops with an error before the next is built.
|
||||
pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<crate::SolidMesh>, String> {
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path")?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> {
|
||||
@@ -250,7 +251,9 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, String>
|
||||
.find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i))
|
||||
.or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))
|
||||
.ok_or("the revolved object has no fill or stroke")?;
|
||||
return Ok(vec![mesh(&path, fx, item)?]);
|
||||
let mut out = vec![];
|
||||
crate::solids::keep_mesh(&mut out, total, mesh(&path, fx, item)?, limit)?;
|
||||
return Ok(out);
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
@@ -259,7 +262,7 @@ pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, String>
|
||||
&& let Some(fx) = item.effects().get(index)
|
||||
{
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
out.push(mesh(&p, fx, item)?);
|
||||
crate::solids::keep_mesh(&mut out, total, mesh(&p, fx, item)?, limit)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
|
||||
@@ -566,14 +566,15 @@ pub(crate) fn keep_mesh(out: &mut Vec<SolidMesh>, total: &mut usize, mesh: Solid
|
||||
|
||||
/// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns
|
||||
/// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one
|
||||
/// from the outline of each stroke. A stroke whose solid is past the limits is left out.
|
||||
fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result<Vec<SolidMesh>, String> {
|
||||
/// from the outline of each stroke. A stroke whose solid is past the limits is left out. Each mesh
|
||||
/// is counted against `limit` ([`keep_mesh`]) before the next is built.
|
||||
fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let view = view_options(&p);
|
||||
let mut out = vec![];
|
||||
let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES);
|
||||
let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, limit);
|
||||
if fx.id == ROTATE {
|
||||
for s in shapes {
|
||||
for item in &s.items {
|
||||
@@ -585,8 +586,8 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result<Vec<
|
||||
return Ok(out);
|
||||
}
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
// Inflate lays out every puff at once (they share one eye); each mesh, and each Extrude &
|
||||
// Bevel solid, is built as it is reached.
|
||||
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
|
||||
// solid and its mesh are built as the loop reaches them.
|
||||
let mut inflated = (fx.id == INFLATE).then(|| {
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
|
||||
@@ -614,16 +615,33 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result<Vec<
|
||||
/// colors they are painted ([`SolidMesh`]), faces turned away from the viewer included; meshes
|
||||
/// with no triangles are left out. On a group or layer, the effect's solids of its members
|
||||
/// ([`members`]). The object's opacity multiplies every color's. An error when `n` carries no
|
||||
/// such effect, its solids can't be built, or none has a triangle.
|
||||
/// such effect, its solids can't be built, none has a triangle, or together they have more than
|
||||
/// [`MAX_MESH_TRIANGLES`].
|
||||
pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, String> {
|
||||
// Triangles of every solid together, against [`MAX_MESH_TRIANGLES`].
|
||||
meshes_within(n, &mut 0, MAX_MESH_TRIANGLES)
|
||||
}
|
||||
|
||||
/// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports
|
||||
/// them: the objects share one budget of [`MAX_MESH_TRIANGLES`].
|
||||
pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Result<Vec<SolidMesh>, String> {
|
||||
meshes_of_within(nodes, MAX_MESH_TRIANGLES)
|
||||
}
|
||||
|
||||
/// [`three_d_meshes_of`] with a budget of `limit` triangles.
|
||||
fn meshes_of_within<'a>(nodes: impl IntoIterator<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
let mut total = 0usize;
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.extend(meshes_within(n, &mut total, limit)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an
|
||||
/// error as soon as `total` passes `limit`.
|
||||
fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
let meshes = if crate::has_revolve(n) {
|
||||
let mut out = vec![];
|
||||
for m in crate::revolve::revolve_meshes(n)? {
|
||||
keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?;
|
||||
}
|
||||
out
|
||||
crate::revolve::revolve_meshes(n, total, limit)?
|
||||
} else if !has_solid(n) {
|
||||
return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into());
|
||||
} else if is_container(n) {
|
||||
@@ -631,21 +649,21 @@ pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, String> {
|
||||
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let mut shapes = members(n, label)?;
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
meshes_for(fx, &shapes, &mut total)?
|
||||
meshes_for(fx, &shapes, total, limit)?
|
||||
} else {
|
||||
let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)?
|
||||
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)?
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?);
|
||||
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?);
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
@@ -54,9 +54,49 @@ fn the_meshes_of_one_object_share_one_triangle_budget() {
|
||||
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }];
|
||||
let Some(effect) = n.appearance.effects.first() else { panic!("no effect") };
|
||||
let mut total = 0;
|
||||
assert!(meshes_for(effect, &shapes, &mut total).is_ok());
|
||||
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok());
|
||||
let mut total = MAX_MESH_TRIANGLES - first + 1;
|
||||
assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex")));
|
||||
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_err_and(|e| e.contains("too complex")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_stops_at_the_first_mesh_past_the_budget() {
|
||||
// The fill revolves into a mesh; the stroke's profile, copied 1000 times, is too complex to
|
||||
// revolve at all.
|
||||
let mut n = square_node(1, 100.0);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.get_mut(0) {
|
||||
f.effects.push(fx(crate::REVOLVE, json!({})));
|
||||
}
|
||||
let fill_only = n.clone();
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) {
|
||||
s.effects.push(fx("distort.transform", json!({ "moveH": 1.0, "copies": 1000 })));
|
||||
s.effects.push(fx(crate::REVOLVE, json!({})));
|
||||
}
|
||||
let fill = three_d_meshes(&fill_only).unwrap();
|
||||
let triangles = fill[0].mesh.triangles.len();
|
||||
assert!(triangles > 1);
|
||||
let whole = three_d_meshes(&n);
|
||||
assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}");
|
||||
// With room for less than the fill's mesh, the stroke's is never built.
|
||||
let mut total = 0;
|
||||
let capped = meshes_within(&n, &mut total, triangles - 1);
|
||||
assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}");
|
||||
assert_eq!(total, triangles);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_objects_of_one_asset_share_one_triangle_budget() {
|
||||
let mut a = square_node(1, 0.0);
|
||||
a.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let mut b = square_node(2, 300.0);
|
||||
b.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::<usize>();
|
||||
let (ta, tb) = (triangles(&a), triangles(&b));
|
||||
assert!(meshes_of_within([&a], ta).is_ok());
|
||||
assert!(meshes_of_within([&b], tb).is_ok());
|
||||
assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export")));
|
||||
assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4));
|
||||
assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -455,6 +455,92 @@ fn art_past_the_budget_keeps_its_own_gradients_in_place() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A hidden path in a layer that keeps its shape past the budget does not hold the layer's
|
||||
/// gradients in place: the layer's bounds leave it out, so the layer's own gradient moves with
|
||||
/// the visible art under a Transform effect and Free Distort, as does the art's when the layer is
|
||||
/// the art.
|
||||
#[test]
|
||||
fn a_hidden_path_past_the_budget_leaves_a_layers_gradients_free_to_move() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer, LayerColor, NodeId, NodeKind, color::Paint};
|
||||
let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)];
|
||||
let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
|
||||
let end = |ap: &Appearance| match ap.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.map(|g| g.end.x),
|
||||
_ => None,
|
||||
};
|
||||
// A layer with a visible square and a hidden circle, both 0..100, and a gradient fill of its own.
|
||||
let layer = || {
|
||||
let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()));
|
||||
let mut circle = Node::path(NodeId(4), shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default());
|
||||
circle.visible = false;
|
||||
let mut layer = Node::layer(NodeId(2), "Layer 1", LayerColor::Preset(0));
|
||||
if let NodeKind::Layer { children, .. } = &mut layer.kind {
|
||||
*children = vec![square, Arc::new(circle)];
|
||||
}
|
||||
layer.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone())));
|
||||
layer
|
||||
};
|
||||
// No room beyond what the paths cost: the square takes each effect, the circle would be drawn
|
||||
// with more lines and keeps its shape.
|
||||
let budget = Budget { anchors: usize::MAX, lines: 0 };
|
||||
// Each scales the art ten times about its center: 0..100 becomes -450..550.
|
||||
let corners = json!({"corners": [[-4.5, -4.5], [5.5, -4.5], [5.5, 5.5], [-4.5, 5.5]]});
|
||||
for grow in [fx("distort.transform", json!({"scaleH": 1000, "scaleV": 1000})), fx("distort.freeDistort", corners)] {
|
||||
let mut art = layer();
|
||||
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
|
||||
let circle = art.children().unwrap()[1].geometric_bounds().unwrap();
|
||||
assert!(circle.width() < 101.0, "{}: the hidden circle keeps its shape: {circle:?}", grow.id);
|
||||
let square = art.children().unwrap()[0].geometric_bounds().unwrap();
|
||||
assert!((square.x1 - 550.0).abs() < 1e-6, "{}: the square takes the effect: {square:?}", grow.id);
|
||||
let mut own = Appearance::clone(&art.appearance);
|
||||
carry.apply(&mut own);
|
||||
assert_eq!(end(&own), Some(550.0), "{}: the layer's gradient, as the art", grow.id);
|
||||
let mut art = Node::group(NodeId(9), vec![Arc::new(layer())]);
|
||||
crate::reshape::reshape_leaves_within(&mut art, &[&grow], budget);
|
||||
assert_eq!(end(&art.children().unwrap()[0].appearance), Some(550.0), "{}: the layer's gradient, inside the art", grow.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// A freeform gradient whose points were edited (its vector still fitted to the bounds) moves its
|
||||
/// points with a Transform effect and Free Distort, on a path, on a group inside the art and on the
|
||||
/// art itself, as a placed gradient does.
|
||||
#[test]
|
||||
fn edited_freeform_points_follow_a_transform_effect_and_free_distort() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Freeform, FreeformPoint, Gradient, GradientKind, GradientPaint};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint};
|
||||
let mut ramp = GradientPaint::new(Gradient { kind: GradientKind::Freeform, ..Gradient::default() });
|
||||
let pt = |x: f64, c| FreeformPoint::new(Point::new(x, 50.0), c);
|
||||
ramp.set_freeform(Freeform { points: vec![pt(0.0, Color::BLACK), pt(100.0, Color::WHITE)], ..Default::default() });
|
||||
assert!(ramp.geom.is_none() && !ramp.is_placed());
|
||||
let freeform = Paint::Gradient(Box::new(ramp));
|
||||
let xs = |ap: &Appearance| match ap.fill_paint() {
|
||||
Paint::Gradient(g) => g.freeform.as_ref().map(|f| f.points.iter().map(|p| p.at.x).collect::<Vec<_>>()),
|
||||
_ => None,
|
||||
};
|
||||
let square = |ap: Appearance| Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap));
|
||||
// Each moves the art 30 pt right.
|
||||
let corners = json!({"corners": [[0.3, 0.0], [1.3, 0.0], [1.3, 1.0], [0.3, 1.0]]});
|
||||
for e in [fx("distort.transform", json!({"moveH": 30})), fx("distort.freeDistort", corners)] {
|
||||
let mut inner = Node::group(NodeId(2), vec![square(Appearance::default())]);
|
||||
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
|
||||
let mut art = Node::group(NodeId(9), vec![square(Appearance::basic(freeform.clone(), Paint::None, 0.0)), Arc::new(inner)]);
|
||||
art.appearance.items.push(AppearanceItem::Fill(FillLayer::new(freeform.clone())));
|
||||
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&e], Budget::EFFECTS);
|
||||
let members = art.children().unwrap();
|
||||
assert_eq!(xs(&members[0].appearance), Some(vec![30.0, 130.0]), "{}: the path's points", e.id);
|
||||
assert_eq!(xs(&members[1].appearance), Some(vec![30.0, 130.0]), "{}: the inner group's points", e.id);
|
||||
let mut own = Appearance::clone(&art.appearance);
|
||||
carry.apply(&mut own);
|
||||
assert_eq!(xs(&own), Some(vec![30.0, 130.0]), "{}: the art's own points", e.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3578: a large Roughen draws each of its points as many lines; they stay within the budget,
|
||||
/// the Roughen built again with fewer points where they wouldn't.
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod clipboard;
|
||||
mod colorcmds;
|
||||
pub mod colormgmt;
|
||||
pub mod colortheme;
|
||||
pub mod compositefonts;
|
||||
mod compoundshape;
|
||||
pub(crate) mod create;
|
||||
mod css;
|
||||
@@ -286,6 +287,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(inline::specs());
|
||||
v.extend(textstyles::specs());
|
||||
v.extend(fonts::specs());
|
||||
v.extend(compositefonts::specs());
|
||||
v.extend(fontfiles::specs());
|
||||
v.extend(help::specs());
|
||||
v.extend(threads::specs());
|
||||
@@ -420,8 +422,22 @@ pub(crate) fn text_param<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Optio
|
||||
pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
|
||||
p.get(key).and_then(Value::as_u64).map(NodeId)
|
||||
}
|
||||
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
|
||||
/// The object ids `p` gives for `key`, or `None` when `p` leaves it out or gives null. A value that
|
||||
/// is not an array, or an entry that is not a non-negative integer, is an error with the message
|
||||
/// [`checked_ids_param`] gives, so no entry is dropped. Whether each id names an object is left to
|
||||
/// the caller.
|
||||
pub(crate) fn ids_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
let values = match p.get(key) {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(Value::Array(a)) => a,
|
||||
Some(_) => return Err(bad(cmd, format!("{key} must be an array of non-negative integer object ids"))),
|
||||
};
|
||||
values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, cmd)).collect::<Result<Vec<_>>>().map(Some)
|
||||
}
|
||||
/// Entry `index` (from 0) of the `key` list as an object id: anything but a non-negative integer is
|
||||
/// an error naming the key, the entry and its value.
|
||||
fn id_entry(value: &Value, key: &str, index: usize, cmd: &str) -> Result<NodeId> {
|
||||
value.as_u64().map(NodeId).ok_or_else(|| bad(cmd, format!("`{key}` entry {index} is {value}, not a non-negative integer object id")))
|
||||
}
|
||||
/// The objects `p` gives `command`: its `ids`, else its `id` as `ids` of one (as [`targets`] reads
|
||||
/// them), each checked ([`checked_id`]): a malformed one, or one naming no object, is an error,
|
||||
@@ -447,7 +463,7 @@ pub(crate) fn index(v: u64) -> usize {
|
||||
pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str) -> Result<Vec<NodeId>> {
|
||||
let values =
|
||||
p.get(key).and_then(Value::as_array).ok_or_else(|| bad(command, format!("{key} must be an array of non-negative integer object ids")))?;
|
||||
values.iter().map(|value| checked_id(s, value, command)).collect()
|
||||
values.iter().enumerate().map(|(index, value)| id_entry(value, key, index, command).and_then(|_| checked_id(s, value, command))).collect()
|
||||
}
|
||||
|
||||
/// The objects a command acts on: `ids` when given (every one checked, and those inside another
|
||||
|
||||
@@ -962,11 +962,9 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let default = CharStyle::default().font_family;
|
||||
let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default);
|
||||
let db = FontDb::global();
|
||||
// Any name of the family is stored as its own name.
|
||||
let family = match db.resolve(wanted, "Regular") {
|
||||
Some((f, m)) if m != FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, "the replacement font is not available")),
|
||||
};
|
||||
// Any name of the family is stored as its own name, and a composite font as its own name.
|
||||
let composite = db.composite_font(wanted).is_some();
|
||||
let family = db.stored_family(wanted, "Regular").ok_or_else(|| bad(C, "the replacement font is not available"))?;
|
||||
let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing);
|
||||
let lacks = |t: &TextObject| t.runs.iter().any(|r| missing(&r.style));
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -988,8 +986,11 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut runs = 0;
|
||||
let mut replace = |t: &mut TextObject| {
|
||||
for r in t.runs.iter_mut().filter(|r| missing(&r.style)) {
|
||||
// The style the font has closest to the one the character had.
|
||||
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
|
||||
// The style the font has closest to the one the character had (a composite font's
|
||||
// parts name their own styles: the character keeps its style).
|
||||
if !composite {
|
||||
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
|
||||
}
|
||||
r.style.font_family = family.clone();
|
||||
r.style.font_version = None;
|
||||
r.style.font_axes.clear();
|
||||
|
||||
@@ -174,7 +174,7 @@ fn filter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let label = effects::effect_info(id).map_or("Apply as Filter", |e| e.label.trim_end_matches('…'));
|
||||
let kinds = effects::addons::duotone::swatch_kinds(&s.doc()?.doc);
|
||||
let swatches = |name: &str| kinds.get(name).copied();
|
||||
recolor_links(s, &given, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches))
|
||||
recolor_links(s, &given, C, label, &|c| map.apply(c), &|c, link, tint| map.apply_link(c, link, tint, &swatches))
|
||||
}
|
||||
|
||||
/// Apply Fill to Stroke (`to_stroke`) or Apply Stroke to Fill: each target copies one of its own
|
||||
|
||||
@@ -189,7 +189,7 @@ pub(super) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let given = json!({
|
||||
"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "fill": fill, "stroke": stroke, "includeImages": false, "includePatterns": false,
|
||||
});
|
||||
let r = recolor_links(s, &given, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?;
|
||||
let r = recolor_links(s, &given, APPLY, "Apply Nearest Swatch", &|c| to(c).and_then(Paint::color).unwrap_or(c), &links)?;
|
||||
squash(s, undo, "Apply Nearest Swatch");
|
||||
Ok(json!({ "changed": r["changed"], "swatches": names, "added": added }))
|
||||
}
|
||||
|
||||
@@ -1230,7 +1230,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let selected = if defaults_only {
|
||||
vec![]
|
||||
} else {
|
||||
match ids_param(p, "ids") {
|
||||
match ids_param(p, "ids", C)? {
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
}
|
||||
|
||||
@@ -277,12 +277,11 @@ fn export_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in ids {
|
||||
let Some(a) = d.asset(id) else { continue };
|
||||
let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D);
|
||||
let mut parts = vec![];
|
||||
for node in &a.nodes {
|
||||
let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?;
|
||||
let what = &a.name;
|
||||
parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?);
|
||||
}
|
||||
let nodes =
|
||||
a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::<Result<Vec<_>>>()?;
|
||||
// The asset's objects share one budget of triangles.
|
||||
let what = &a.name;
|
||||
let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?;
|
||||
let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into());
|
||||
let mut stem = base.clone();
|
||||
let mut k = 1;
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make",
|
||||
["Object", "Image Trace"],
|
||||
None,
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
"{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at)}",
|
||||
has_selection_or_ids,
|
||||
|s, p| trace_make(s, p, false)
|
||||
),
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
None,
|
||||
"{fill?: true, stroke?: true, includeImages?: true, includePatterns?: true, ids?} replace each colour by its RGB inverse (keeps the colour model) in fills, strokes, text, gradient meshes (with fills), colorized image fills (their pixels stay unchanged), other embedded images (a recoloured copy) and pattern tiles (a new pattern swatch; the original stays), as one undo step → {changed}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "Invert Colors", &invert)
|
||||
|s, p| recolor(s, p, "edit.colors.invert", "Invert Colors", &invert)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toCMYK",
|
||||
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
None,
|
||||
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "Convert to CMYK", &to_cmyk)
|
||||
|s, p| recolor(s, p, "edit.colors.toCMYK", "Convert to CMYK", &to_cmyk)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toGrayscale",
|
||||
@@ -50,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
None,
|
||||
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "Convert to Grayscale", &to_gray)
|
||||
|s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toRGB",
|
||||
@@ -60,7 +60,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
recolor(s, p, "Convert to RGB", &|c| {
|
||||
recolor(s, p, "edit.colors.toRGB", "Convert to RGB", &|c| {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::rgb(r, g, b)
|
||||
})
|
||||
@@ -331,26 +331,27 @@ impl ColorVisitor for Visit<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn recolor(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, label, Recolor::new(Scope::of(p), f))
|
||||
pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f))
|
||||
}
|
||||
|
||||
/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch
|
||||
/// link and tint.
|
||||
pub(crate) fn recolor_links(s: &mut Session, p: &Value, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
|
||||
run_pass(s, p, label, Recolor::with_links(Scope::of(p), f, links))
|
||||
pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
|
||||
run_pass(s, p, cmd, label, Recolor::with_links(Scope::of(p), f, links))
|
||||
}
|
||||
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step.
|
||||
fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids") {
|
||||
/// Run `pass` on the `ids` param's objects (default: the selection) as one undo step named `label`;
|
||||
/// errors name the command `cmd`.
|
||||
fn run_pass(s: &mut Session, p: &Value, cmd: &str, label: &str, pass: Recolor) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids", cmd)? {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let n = s.edit(label, |d, _| {
|
||||
let n = pass.run(d, &ids);
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(label, e))?;
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad(cmd, e))?;
|
||||
Ok(n)
|
||||
})?;
|
||||
Ok(json!({ "changed": n }))
|
||||
@@ -358,7 +359,7 @@ fn run_pass(s: &mut Session, p: &Value, label: &str, pass: Recolor) -> Result<Va
|
||||
|
||||
fn saturate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = (f64_req(p, "intensity", "edit.colors.saturate")?.clamp(-100.0, 100.0) / 100.0) as f32;
|
||||
recolor(s, p, "Saturate", &move |c| {
|
||||
recolor(s, p, "edit.colors.saturate", "Saturate", &move |c| {
|
||||
let [h, sat, v] = c.to_hsb();
|
||||
let to = (sat * (1.0 + i)).clamp(0.0, 1.0);
|
||||
// A color whose saturation stays the same is returned as it is, so it keeps its swatch link.
|
||||
@@ -416,12 +417,12 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(*tint, *c) = (t, base.tinted(t));
|
||||
true
|
||||
};
|
||||
return run_pass(s, p, "Adjust Colors", Recolor::links(Scope::of(p), &shift));
|
||||
return run_pass(s, p, C, "Adjust Colors", Recolor::links(Scope::of(p), &shift));
|
||||
}
|
||||
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
|
||||
};
|
||||
let convert = bool_or(p, "convert", false);
|
||||
recolor(s, p, "Adjust Colors", &move |c| {
|
||||
recolor(s, p, C, "Adjust Colors", &move |c| {
|
||||
let out = adjust(c);
|
||||
if convert { out } else { keep_model(c, out) }
|
||||
})
|
||||
|
||||
@@ -287,7 +287,8 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// How Document Color Mode converts neutral RGB greys (R = G = B) to CMYK (the `grays` param).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Grays {
|
||||
/// Through the CMYK profile like any colour: four-colour greys and a rich black.
|
||||
/// Through the CMYK profile like any colour: light grays in cyan, magenta and yellow only, darker
|
||||
/// grays in four inks, and a rich black.
|
||||
#[default]
|
||||
Profile,
|
||||
/// On the black plate only: K is the grey's ink percentage (as Convert to Grayscale and then
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Type › Composite Fonts…: the document's composite fonts ([`vectorcraft_doc::CompositeFont`]),
|
||||
//! which set each class of character (Kanji, Kana, Punctuation, Symbols, Roman, Numerals) in a
|
||||
//! font of its own. Type names a composite font as its family, and the font menus list them first.
|
||||
//! The font database holds the active document's composite fonts ([`sync`]); a file exported here
|
||||
//! carries them to another document.
|
||||
//!
|
||||
//! Behavior reference (public documentation):
|
||||
//! <https://help.adobe.com/archive/en_US/illustrator/cs3/illustrator_cs3_help.pdf#page=347>.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::text::{COMPOSITE_FONTS_MAX, COMPOSITE_NAME_MAX};
|
||||
use vectorcraft_doc::{CharClass, CompositeFont, CompositePart, CompositeParts};
|
||||
use vectorcraft_text::FontDb;
|
||||
|
||||
use super::fileio::{read_file_head, write_file};
|
||||
use super::textstyles::each_text_mut;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, switch_or, text_param};
|
||||
|
||||
/// The extension of the files Export writes and Import reads.
|
||||
pub const EXTS: &[&str] = &["vccomposite"];
|
||||
|
||||
/// The largest file Import reads, in bytes.
|
||||
const MAX_FILE_BYTES: usize = 4 << 20;
|
||||
|
||||
/// The keys a part may give.
|
||||
const PART_KEYS: [&str; 7] = ["font", "style", "size", "baseline", "hScale", "vScale", "scaleAtCenter"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "type.compositeFonts.list",
|
||||
"Composite Fonts",
|
||||
[],
|
||||
None,
|
||||
"{} the active document's composite fonts → {fonts: [{name, parts: {kanji, kana, punctuation, symbols, roman, numerals: {font, style, size: % of the type size, baseline: % of the type size (positive raises), hScale: %, vScale: %, scaleAtCenter?: bool}}}]}",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
cmd!(
|
||||
"type.compositeFonts.set",
|
||||
"Composite Fonts…",
|
||||
["Type"],
|
||||
None,
|
||||
"{fonts: [{name, parts: {kanji: {font, style?: \"Regular\", size?: % 10..400 = 100, baseline?: % -100..100 = 0}, kana?, punctuation?, symbols?, roman?, numerals?: {font, style?, size?, baseline?, hScale?, vScale?: % 10..400 = 100, scaleAtCenter?: bool = false}}}]} replace the active document's composite fonts (Type › Composite Fonts…'s OK), as one undo step, none when nothing changes. A composite font sets each class of character in its part's font: kanji (the base: ideographs, full-width letters and digits and anything no other class takes), kana (hiragana and katakana, both widths), punctuation (East Asian and full-width punctuation, quotes and dashes), symbols (arrows, math, shapes and other symbols), roman (half-width letters, their punctuation and spaces) and numerals (the digits 0 to 9); a part left out is the kanji part. size and baseline are percent of the type's size, hScale and vScale scale kana, roman and numerals only, and scaleAtCenter (kana only) scales them from the em box's center instead of the baseline. Names are trimmed, unique in any case, at most 255 characters, and may not name an installed font; a part's font may not be a composite font; at most 256. Type set in a composite font names it as its family (text.setStyle {font: name}), and the font menus list composite fonts first. A font taken out stays named by its type, which then shows as missing → {fonts}",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
query "type.compositeFonts.export",
|
||||
"Export Composite Fonts",
|
||||
[],
|
||||
None,
|
||||
"{path?: .vccomposite file, names?: [name] (default: all), fonts?: [{name, parts}] (as type.compositeFonts.set takes them)} write the active document's composite fonts (all, or those named, any case), or the fonts given, to a file type.compositeFonts.import reads; without path the file's text comes back as data → {path, count} | {data, count}",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
"type.compositeFonts.import",
|
||||
"Import Composite Fonts",
|
||||
[],
|
||||
None,
|
||||
"{path: .vccomposite file | data: its text | dataBase64: its bytes (at most 4 MB), apply?: bool = true} read the composite fonts a file holds (type.compositeFonts.export writes them); with apply they join the active document's, a font of the same name (any case) replaced, as one undo step, none when nothing changes; apply false only reads them → {fonts, imported: [name]}",
|
||||
has_doc,
|
||||
import
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Register the active document's composite fonts in the font database, and remove the ones the
|
||||
/// other open documents (or an earlier active document) defined. Run after each command and when
|
||||
/// another document takes the front, so an undo, a revert, an opened or closed file or a document
|
||||
/// switch draws type in the active document's own composite fonts, and type naming one it does
|
||||
/// not define shows as missing. Whether a composite font was added, changed or removed.
|
||||
pub(crate) fn sync(s: &mut Session) -> bool {
|
||||
let db = FontDb::global();
|
||||
let mut names = Vec::new();
|
||||
let mut changed = false;
|
||||
for f in s.active().map(|d| d.doc.composite_fonts.to_vec()).unwrap_or_default() {
|
||||
changed |= db.set_composite_font(&f);
|
||||
names.push(f.name.to_lowercase());
|
||||
}
|
||||
let others = s.docs.iter().flat_map(|d| d.doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
|
||||
let stale: Vec<String> = s.composite_names.iter().cloned().chain(others).filter(|n| !names.contains(n)).collect();
|
||||
for gone in stale {
|
||||
changed |= db.remove_composite_font(&gone);
|
||||
}
|
||||
s.composite_names = names;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Leave out a file's unusable composite fonts before registering and measuring its type.
|
||||
pub(crate) fn filter_loaded(doc: &mut vectorcraft_doc::Document) {
|
||||
let db = FontDb::global();
|
||||
if let Some(kept) = CompositeFont::usable(&doc.composite_fonts) {
|
||||
*doc.composite_fonts = kept;
|
||||
}
|
||||
doc.composite_fonts.retain(|f| !db.has_installed_family(&f.name));
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "fonts": *s.doc()?.doc.composite_fonts }))
|
||||
}
|
||||
|
||||
/// Composite font `v` from command parameters or a file: its name trimmed, each part left out a
|
||||
/// copy of the Kanji part, and keys or values a part can't take refused.
|
||||
fn parse_font(v: &Value, c: &str) -> Result<CompositeFont> {
|
||||
let o = v.as_object().ok_or_else(|| bad(c, "each font must be {name, parts}"))?;
|
||||
if let Some(k) = o.keys().find(|k| !["name", "parts"].contains(&k.as_str())) {
|
||||
return Err(bad(c, format!("unknown key `{k}`; a font takes name and parts")));
|
||||
}
|
||||
let raw = o.get("name").and_then(Value::as_str).ok_or_else(|| bad(c, "each font needs a name"))?;
|
||||
let name = CompositeFont::clean_name(raw)
|
||||
.ok_or_else(|| bad(c, format!("`{raw}` can't name a composite font (1 to {COMPOSITE_NAME_MAX} characters, no control characters)")))?;
|
||||
let parts = o.get("parts").and_then(Value::as_object).ok_or_else(|| bad(c, format!("`{name}`: parts must be an object")))?;
|
||||
if let Some(k) = parts.keys().find(|k| !CharClass::ALL.iter().any(|c| c.key() == k.as_str())) {
|
||||
return Err(bad(c, format!("`{name}`: unknown class `{k}`; the classes are kanji, kana, punctuation, symbols, roman and numerals")));
|
||||
}
|
||||
let part = |class: CharClass| -> Result<Option<CompositePart>> {
|
||||
let Some(p) = parts.get(class.key()) else { return Ok(None) };
|
||||
let po = p.as_object().ok_or_else(|| bad(c, format!("`{name}`: {} must be an object", class.key())))?;
|
||||
if let Some(k) = po.keys().find(|k| !PART_KEYS.contains(&k.as_str())) {
|
||||
return Err(bad(c, format!("`{name}`: unknown key `{k}` in {}", class.key())));
|
||||
}
|
||||
serde_json::from_value(p.clone()).map(Some).map_err(|e| bad(c, format!("`{name}`: {}: {e}", class.key())))
|
||||
};
|
||||
let kanji = part(CharClass::Kanji)?.ok_or_else(|| bad(c, format!("`{name}`: give the kanji part")))?;
|
||||
let mut f = CompositeFont { name: name.clone(), parts: CompositeParts::all(kanji) };
|
||||
for class in CharClass::ALL.into_iter().skip(1) {
|
||||
if let Some(p) = part(class)? {
|
||||
*f.parts.get_mut(class) = p;
|
||||
}
|
||||
}
|
||||
f.check().map_err(|e| bad(c, format!("`{}`: {e}", f.name)))?;
|
||||
Ok(f)
|
||||
}
|
||||
|
||||
/// Error unless `fonts` can stand together in a document: names unique in any case, none naming
|
||||
/// an installed font, no part set in a composite font, and at most [`COMPOSITE_FONTS_MAX`].
|
||||
fn check_set(fonts: &[CompositeFont], c: &str) -> Result<()> {
|
||||
if fonts.len() > COMPOSITE_FONTS_MAX {
|
||||
return Err(bad(c, format!("a document keeps at most {COMPOSITE_FONTS_MAX} composite fonts")));
|
||||
}
|
||||
let db = FontDb::global();
|
||||
let mut names = std::collections::HashSet::new();
|
||||
for f in fonts {
|
||||
if !names.insert(f.name.to_lowercase()) {
|
||||
return Err(bad(c, format!("two composite fonts are named `{}`", f.name)));
|
||||
}
|
||||
if db.has_installed_family(&f.name) {
|
||||
return Err(bad(c, format!("`{}` is the name of an installed font", f.name)));
|
||||
}
|
||||
}
|
||||
for f in fonts {
|
||||
for class in CharClass::ALL {
|
||||
let font = &f.parts.get(class).font;
|
||||
if names.contains(&font.to_lowercase()) || db.composite_font(font).is_some() {
|
||||
return Err(bad(c, format!("`{}`: {} is set in `{font}`, a composite font", f.name, class.key())));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Make `after` the active document's composite fonts, as one undo step named `label` (none when
|
||||
/// they are what it has). The font database forgets the ones taken out, and type set in a font
|
||||
/// that changed is measured again.
|
||||
fn replace(s: &mut Session, label: &str, after: Vec<CompositeFont>) -> Result<()> {
|
||||
let before = s.doc()?.doc.composite_fonts.to_vec();
|
||||
if before == after {
|
||||
return Ok(());
|
||||
}
|
||||
let changed: Vec<String> =
|
||||
before.iter().filter(|b| !after.contains(b)).chain(after.iter().filter(|a| !before.contains(a))).map(|f| f.name.to_lowercase()).collect();
|
||||
let db = FontDb::global();
|
||||
for f in &before {
|
||||
if !after.iter().any(|a| CompositeFont::same_name(&a.name, &f.name)) {
|
||||
db.remove_composite_font(&f.name);
|
||||
}
|
||||
}
|
||||
for f in &after {
|
||||
db.set_composite_font(f);
|
||||
}
|
||||
let r = s.edit(label, |d, _| {
|
||||
*d.composite_fonts = after.clone();
|
||||
each_text_mut(d, &mut |t| {
|
||||
if t.runs.iter().any(|r| changed.contains(&r.style.font_family.to_lowercase())) {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
});
|
||||
sync(s);
|
||||
r
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.compositeFonts.set";
|
||||
let list = p.get("fonts").and_then(Value::as_array).ok_or_else(|| bad(C, "give fonts: [{name, parts}…]"))?;
|
||||
let fonts = list.iter().map(|v| parse_font(v, C)).collect::<Result<Vec<_>>>()?;
|
||||
check_set(&fonts, C)?;
|
||||
replace(s, "Composite Fonts", fonts.clone())?;
|
||||
Ok(json!({ "fonts": fonts }))
|
||||
}
|
||||
|
||||
fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.compositeFonts.export";
|
||||
let path = text_param(p, "path", C)?;
|
||||
if path.is_some_and(|p| p.trim().is_empty()) {
|
||||
return Err(bad(C, "path is empty"));
|
||||
}
|
||||
let all = match p.get("fonts") {
|
||||
None | Some(Value::Null) => s.doc()?.doc.composite_fonts.to_vec(),
|
||||
Some(Value::Array(list)) => {
|
||||
let fonts = list.iter().map(|v| parse_font(v, C)).collect::<Result<Vec<_>>>()?;
|
||||
check_set(&fonts, C)?;
|
||||
fonts
|
||||
}
|
||||
Some(_) => return Err(bad(C, "fonts must be a list of composite fonts")),
|
||||
};
|
||||
let fonts = match p.get("names") {
|
||||
None | Some(Value::Null) => all,
|
||||
Some(Value::Array(names)) => {
|
||||
let mut out = Vec::with_capacity(names.len());
|
||||
for n in names {
|
||||
let n = n.as_str().ok_or_else(|| bad(C, "names must be strings"))?;
|
||||
let f = all
|
||||
.iter()
|
||||
.find(|f| CompositeFont::same_name(&f.name, n.trim()))
|
||||
.ok_or_else(|| bad(C, format!("no composite font named `{n}`")))?;
|
||||
if !out.contains(f) {
|
||||
out.push(f.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
Some(_) => return Err(bad(C, "names must be a list of names")),
|
||||
};
|
||||
if fonts.is_empty() {
|
||||
return Err(bad(C, "no composite fonts to export"));
|
||||
}
|
||||
let mut file = Map::new();
|
||||
file.insert("compositeFonts".into(), json!(fonts));
|
||||
let text = serde_json::to_string_pretty(&file).map_err(|e| bad(C, e.to_string()))?;
|
||||
match path {
|
||||
Some(path) => {
|
||||
write_file(path, text.as_bytes())?;
|
||||
Ok(json!({ "path": path, "count": fonts.len() }))
|
||||
}
|
||||
None => Ok(json!({ "data": text, "count": fonts.len() })),
|
||||
}
|
||||
}
|
||||
|
||||
/// The composite fonts in the bytes of an exported file.
|
||||
fn read_fonts(bytes: &[u8], c: &str) -> Result<Vec<CompositeFont>> {
|
||||
let v: Value = serde_json::from_slice(bytes).map_err(|e| bad(c, format!("not a composite fonts file: {e}")))?;
|
||||
let list = v.get("compositeFonts").and_then(Value::as_array).ok_or_else(|| bad(c, "not a composite fonts file: no compositeFonts list"))?;
|
||||
if list.len() > COMPOSITE_FONTS_MAX {
|
||||
return Err(bad(c, format!("the file holds more than {COMPOSITE_FONTS_MAX} composite fonts")));
|
||||
}
|
||||
list.iter().map(|f| parse_font(f, c)).collect()
|
||||
}
|
||||
|
||||
fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.compositeFonts.import";
|
||||
let apply = switch_or(p, "apply", true, C)?;
|
||||
let given = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?);
|
||||
let too_large = || bad(C, "the file is larger than 4 MB");
|
||||
let bytes = match given {
|
||||
(Some(path), None, None) if !path.trim().is_empty() => read_file_head(path, MAX_FILE_BYTES + 1)?,
|
||||
(None, Some(text), None) => text.as_bytes().to_vec(),
|
||||
(None, None, Some(b64)) => {
|
||||
if b64.len() > MAX_FILE_BYTES / 3 * 4 + 4 {
|
||||
return Err(too_large());
|
||||
}
|
||||
vectorcraft_format::base64_decode(b64).ok_or_else(|| bad(C, "dataBase64 is not base64"))?
|
||||
}
|
||||
_ => return Err(bad(C, "give one of path, data or dataBase64")),
|
||||
};
|
||||
if bytes.len() > MAX_FILE_BYTES {
|
||||
return Err(too_large());
|
||||
}
|
||||
let fonts = read_fonts(&bytes, C)?;
|
||||
if fonts.is_empty() {
|
||||
return Err(bad(C, "the file holds no composite fonts"));
|
||||
}
|
||||
let mut names = std::collections::HashSet::new();
|
||||
if let Some(f) = fonts.iter().find(|f| !names.insert(f.name.to_lowercase())) {
|
||||
return Err(bad(C, format!("the file holds two composite fonts named `{}`", f.name)));
|
||||
}
|
||||
if apply {
|
||||
let mut after = s.doc()?.doc.composite_fonts.to_vec();
|
||||
for f in &fonts {
|
||||
match after.iter_mut().find(|a| CompositeFont::same_name(&a.name, &f.name)) {
|
||||
Some(a) => *a = f.clone(),
|
||||
None => after.push(f.clone()),
|
||||
}
|
||||
}
|
||||
check_set(&after, C)?;
|
||||
replace(s, "Import Composite Fonts", after)?;
|
||||
} else {
|
||||
check_set(&fonts, C)?;
|
||||
}
|
||||
let imported: Vec<&str> = fonts.iter().map(|f| f.name.as_str()).collect();
|
||||
Ok(json!({ "fonts": fonts, "imported": imported }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,502 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A composite font named `name`: Source Sans 3 for the base, JetBrains Mono at half size for
|
||||
/// Roman, Inter for numerals.
|
||||
fn font(name: &str) -> Value {
|
||||
json!({"name": name, "parts": {
|
||||
"kanji": {"font": "Source Sans 3"},
|
||||
"roman": {"font": "JetBrains Mono", "size": 50, "baseline": 10},
|
||||
"numerals": {"font": "Inter", "hScale": 80},
|
||||
}})
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn names(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.composite_fonts.iter().map(|f| f.name.clone()).collect()
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn text(s: &Session, id: u64) -> vectorcraft_doc::TextObject {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A scratch folder of the test's own.
|
||||
fn folder(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("vectorcraft-composite-fonts-{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Composite Fonts' OK replaces the document's composite fonts as one undo step (none when nothing
|
||||
/// changes); parts left out copy the Kanji part; they are saved with the document and undone with
|
||||
/// it.
|
||||
#[test]
|
||||
fn composite_fonts_are_set_as_one_undo_step_and_saved() {
|
||||
let mut s = session();
|
||||
let before = steps(&s);
|
||||
let r = s.execute("type.compositeFonts.set", &json!({"fonts": [font(" CF Saved A "), font("CF Saved B")]})).unwrap();
|
||||
assert_eq!(r["fonts"][0]["name"], "CF Saved A");
|
||||
assert_eq!(names(&s), ["CF Saved A", "CF Saved B"]);
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
let f = &s.doc().unwrap().doc.composite_fonts[0];
|
||||
assert_eq!((f.parts.kana.font.as_str(), f.parts.roman.size, f.parts.numerals.h_scale), ("Source Sans 3", 50.0, 80.0));
|
||||
// The same fonts again change nothing.
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Saved A"), font("CF Saved B")]})).unwrap();
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
let listed = s.execute("type.compositeFonts.list", &json!({})).unwrap();
|
||||
assert_eq!(listed["fonts"].as_array().map(Vec::len), Some(2));
|
||||
// They travel with the file.
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save_file(&s.doc().unwrap().doc)).unwrap();
|
||||
assert_eq!(*back.composite_fonts, *s.doc().unwrap().doc.composite_fonts);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(names(&s).is_empty());
|
||||
}
|
||||
|
||||
/// Bad fonts are refused with an error and no undo step: a name that is empty, shared (in any
|
||||
/// case) or an installed font's; a part set in a composite font; an unknown class or key; a value
|
||||
/// out of range, of the wrong type, or on a class that can't take it; no Kanji part.
|
||||
#[test]
|
||||
fn bad_composite_fonts_are_refused() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Refused Base")]})).unwrap();
|
||||
let before = steps(&s);
|
||||
let with = |f: &dyn Fn(&mut Value)| {
|
||||
let mut v = font("CF Refused");
|
||||
f(&mut v);
|
||||
json!({"fonts": [v]})
|
||||
};
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"fonts": "CF"}),
|
||||
json!({"fonts": [font(" ")]}),
|
||||
json!({"fonts": [font("CF Twice"), font("cf twice")]}),
|
||||
json!({"fonts": [font("Source Sans 3")]}),
|
||||
json!({"fonts": [font("Tab\tName")]}),
|
||||
with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused Base")),
|
||||
with(&|v| v["parts"]["roman"]["font"] = json!("CF Refused")),
|
||||
with(&|v| v["parts"]["hangul"] = json!({"font": "Inter"})),
|
||||
with(&|v| v["parts"]["Roman"] = json!({"font": "Inter"})),
|
||||
with(&|v| v["parts"]["roman"]["width"] = json!(3)),
|
||||
with(&|v| v["parts"]["roman"]["size"] = json!(5)),
|
||||
with(&|v| v["parts"]["roman"]["size"] = json!("50")),
|
||||
with(&|v| v["parts"]["roman"]["baseline"] = json!(-120)),
|
||||
with(&|v| v["parts"]["kanji"]["hScale"] = json!(90)),
|
||||
with(&|v| v["parts"]["punctuation"] = json!({"font": "Inter", "vScale": 90})),
|
||||
with(&|v| v["parts"]["roman"]["scaleAtCenter"] = json!(true)),
|
||||
with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "scaleAtCenter": "yes"})),
|
||||
with(&|v| v["parts"]["roman"]["font"] = json!("")),
|
||||
with(&|v| v["extra"] = json!(1)),
|
||||
with(&|v| {
|
||||
v["parts"].as_object_mut().unwrap().remove("kanji");
|
||||
}),
|
||||
] {
|
||||
assert!(s.execute("type.compositeFonts.set", &bad).is_err(), "accepted {bad}");
|
||||
assert_eq!(names(&s), ["CF Refused Base"]);
|
||||
assert_eq!(steps(&s), before);
|
||||
}
|
||||
// Kana scaled at the center is allowed.
|
||||
let ok = with(&|v| v["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true}));
|
||||
s.execute("type.compositeFonts.set", &ok).unwrap();
|
||||
assert_eq!(names(&s), ["CF Refused"]);
|
||||
}
|
||||
|
||||
/// Type set in a composite font names it and is not missing; each class is drawn in its part's
|
||||
/// font; changing the font measures the type again, and taking it out leaves the type naming it,
|
||||
/// shown as missing.
|
||||
#[test]
|
||||
fn type_set_in_a_composite_font_follows_it() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Type")]})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.setStyle", &json!({"ids": [id], "font": "CF Type"})).unwrap();
|
||||
assert!(r.get("warning").is_none(), "{r}");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.runs[0].style.font_family, "CF Type");
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let face = |family: &str| db.face(family, "Regular").unwrap().id();
|
||||
let lay = vectorcraft_text::layout(db, &t);
|
||||
let fonts: Vec<u32> = lay.glyphs.iter().map(|g| g.font_id).collect();
|
||||
assert_eq!(fonts, [face("JetBrains Mono"), face("JetBrains Mono"), face("Inter"), face("Inter")]);
|
||||
let missing = |s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().filter(|f| f["missing"] == true).count();
|
||||
assert_eq!(missing(&mut s), 0);
|
||||
// Roman at full size makes the type wider, measured again in the same step.
|
||||
let wide = t.cached_bounds.unwrap().width();
|
||||
let mut full = font("CF Type");
|
||||
full["parts"]["roman"]["size"] = json!(100);
|
||||
let before = steps(&s);
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
|
||||
assert_eq!(steps(&s), before + 1);
|
||||
assert!(text(&s, id).cached_bounds.unwrap().width() > wide + 1.0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!((text(&s, id).cached_bounds.unwrap().width() - wide).abs() < 1e-6);
|
||||
assert_eq!(db.composite_font("CF Type").unwrap().parts.roman.size, 50.0);
|
||||
// Taken out, the type keeps the name, now missing; undone, the font is back.
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap();
|
||||
assert!(db.composite_font("CF Type").is_none());
|
||||
assert_eq!(text(&s, id).runs[0].style.font_family, "CF Type");
|
||||
assert_eq!(missing(&mut s), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(db.composite_font("CF Type").is_some());
|
||||
assert_eq!(missing(&mut s), 0);
|
||||
}
|
||||
|
||||
/// Type in a symbol's art is measured again too when the composite font it names changes.
|
||||
#[test]
|
||||
fn type_in_symbol_art_follows_its_composite_font() {
|
||||
fn width(s: &Session) -> f64 {
|
||||
fn find(n: &vectorcraft_doc::Node) -> Option<f64> {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
return t.cached_bounds.map(|b| b.width());
|
||||
}
|
||||
n.children().into_iter().flatten().find_map(|c| find(c))
|
||||
}
|
||||
s.doc().unwrap().doc.symbols.iter().find_map(|sym| find(&sym.art)).unwrap()
|
||||
}
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Symbol")]})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Symbol"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "CF Mark", "ids": [id]})).unwrap();
|
||||
let narrow = width(&s);
|
||||
let mut full = font("CF Symbol");
|
||||
full["parts"]["roman"]["size"] = json!(100);
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [full]})).unwrap();
|
||||
assert!(width(&s) > narrow + 1.0);
|
||||
}
|
||||
|
||||
/// The font database forgets a composite font once no open document defines it: after the step
|
||||
/// that added it is undone, and after its document closes.
|
||||
#[test]
|
||||
fn composite_fonts_no_document_defines_are_forgotten() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Undo")]})).unwrap();
|
||||
assert!(db.composite_font("CF Forgotten Undo").is_some());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(db.composite_font("CF Forgotten Undo").is_none());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(db.composite_font("CF Forgotten Undo").is_some());
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Forgotten Close")]})).unwrap();
|
||||
s.execute("file.close", &json!({})).unwrap();
|
||||
assert!(db.composite_font("CF Forgotten Close").is_none());
|
||||
assert!(db.composite_font("CF Forgotten Undo").is_some());
|
||||
}
|
||||
|
||||
/// SVG and legacy `.ai` write type in a composite font as its outlines, since a reader can't find
|
||||
/// the font by its name.
|
||||
#[test]
|
||||
fn exports_outline_type_in_a_composite_font() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Export")]})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Export"})).unwrap();
|
||||
let svg = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let svg = String::from_utf8(vectorcraft_format::base64_decode(svg["dataBase64"].as_str().unwrap()).unwrap()).unwrap();
|
||||
assert!(!svg.contains("CF Export") && !svg.contains("<text") && svg.contains("<path"), "{svg}");
|
||||
let ai = vectorcraft_eps::export_legacy_ai(&s.doc().unwrap().doc, &vectorcraft_eps::LegacyAiOptions::default()).unwrap();
|
||||
assert!(!String::from_utf8_lossy(&ai.bytes).contains("CF Export"));
|
||||
}
|
||||
|
||||
/// A document exported without a session (whose composite fonts the font database doesn't hold),
|
||||
/// or while another document defines the name differently, still has its type in a composite font
|
||||
/// written as outlines, drawn in its own definition's parts: SVG
|
||||
/// writes the outlines the same type set in the part's font writes, PDF embeds the part's font, and
|
||||
/// legacy `.ai` doesn't name the composite font.
|
||||
#[test]
|
||||
fn exports_draw_composite_fonts_of_a_document_without_a_session() {
|
||||
let mut s = session();
|
||||
let mono = json!({"name": "CF Standalone", "parts": {"kanji": {"font": "JetBrains Mono"}}});
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [mono]})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab1", "size": 20, "font": "CF Standalone"})).unwrap()["id"].as_u64().unwrap();
|
||||
let doc = (*s.doc().unwrap().doc).clone();
|
||||
// The font database forgets the font; the copy of the document still defines it.
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": []})).unwrap();
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
assert!(db.composite_font("CF Standalone").is_none());
|
||||
let paths = |svg: &str| svg.split(" d=\"").skip(1).filter_map(|p| p.split('"').next().map(str::to_string)).collect::<Vec<_>>();
|
||||
let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default());
|
||||
assert!(!svg.contains("CF Standalone") && !svg.contains("<text"), "{svg}");
|
||||
s.execute("text.setStyle", &json!({"ids": [id], "font": "JetBrains Mono"})).unwrap();
|
||||
let plain = vectorcraft_svg::export(&s.doc().unwrap().doc, &vectorcraft_svg::ExportOptions { outline_text: true, ..Default::default() });
|
||||
assert!(!paths(&svg).is_empty());
|
||||
assert_eq!(paths(&svg), paths(&plain));
|
||||
let pdf = vectorcraft_pdf::export(&doc, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&pdf).contains("JetBrainsMono"));
|
||||
let ai = vectorcraft_eps::export_legacy_ai(&doc, &vectorcraft_eps::LegacyAiOptions::default()).unwrap();
|
||||
assert!(!String::from_utf8_lossy(&ai.bytes).contains("CF Standalone"));
|
||||
// The fonts lent for the exports are forgotten again.
|
||||
assert!(db.composite_font("CF Standalone").is_none());
|
||||
// With the front document defining the name differently, the exported document's own
|
||||
// definition draws its type, and the front document's is put back.
|
||||
let inter = json!({"name": "CF Standalone", "parts": {"kanji": {"font": "Inter"}}});
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [inter]})).unwrap();
|
||||
let svg = vectorcraft_svg::export(&doc, &vectorcraft_svg::ExportOptions::default());
|
||||
assert_eq!(paths(&svg), paths(&plain));
|
||||
assert_eq!(db.composite_font("CF Standalone").unwrap().parts.kanji.font, "Inter");
|
||||
}
|
||||
|
||||
/// Replace Font and Replace Missing Fonts set type in a composite font by the font's own name,
|
||||
/// not its Kanji part's, and keep the type's style.
|
||||
#[test]
|
||||
fn replacing_a_font_with_a_composite_font_names_it() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Replace")]})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "cf replace"}})).unwrap();
|
||||
assert_eq!(r["runs"], 1, "{r}");
|
||||
assert!(r.get("warning").is_none(), "{r}");
|
||||
let t = text(&s, id);
|
||||
assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("CF Replace", "Regular"));
|
||||
let gone = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 90, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "CF Replace"})).unwrap();
|
||||
assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("CF Replace")), "{r}");
|
||||
let t = text(&s, gone);
|
||||
assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("CF Replace", "Bold"));
|
||||
}
|
||||
|
||||
/// When a part's font is missing, though the Kanji part's is installed, text.missingFonts names
|
||||
/// that part's font for type with characters in that part, text.fonts shows the composite font as
|
||||
/// missing (resolved in the fallback font that draws that part), and setting type in it warns;
|
||||
/// missingGlyphs counts the characters that the font of their part lacks. Each run is checked on
|
||||
/// its own, as layout splits it.
|
||||
#[test]
|
||||
fn font_inventory_checks_the_part_of_each_character() {
|
||||
let mut s = session();
|
||||
let mut f = font("CF Inventory");
|
||||
f["parts"]["kanji"]["font"] = json!("Source Serif 4");
|
||||
f["parts"]["roman"]["font"] = json!("No Such Part Font");
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [f]})).unwrap();
|
||||
// Numerals are drawn in Inter: nothing is missing.
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "12", "size": 20, "font": "CF Inventory"})).unwrap();
|
||||
assert!(r.get("warning").is_none(), "{r}");
|
||||
assert_eq!(s.execute("text.missingFonts", &json!({})).unwrap()["count"], 0);
|
||||
// Roman letters are drawn in the missing font.
|
||||
let r = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab", "size": 20, "font": "CF Inventory"})).unwrap();
|
||||
assert!(r["warning"].as_str().is_some_and(|w| w.contains("No Such Part Font")), "{r}");
|
||||
let missing = s.execute("text.missingFonts", &json!({})).unwrap();
|
||||
let names: Vec<&str> = missing["fonts"].as_array().unwrap().iter().filter_map(|f| f["family"].as_str()).collect();
|
||||
assert_eq!(names, ["No Such Part Font"], "{missing}");
|
||||
let fonts = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Inventory").cloned().unwrap();
|
||||
assert_eq!((&row["missing"], &row["status"]), (&json!(true), &json!("missing")), "{row}");
|
||||
assert_eq!(row["resolved"]["family"], vectorcraft_text::FALLBACK_FAMILY, "{row}");
|
||||
// A run that starts with a combining mark after a run ending in kana: the mark is Roman.
|
||||
let boundary = json!({"name": "CF Boundary", "parts": {"kanji": {"font": "Source Sans 3"}, "roman": {"font": "No Such Part Font"}}});
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [boundary]})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 170, "text": "\u{304B}\u{301}", "size": 20, "font": "CF Boundary"})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let row =
|
||||
|s: &mut Session| s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|r| r["family"] == "CF Boundary").cloned();
|
||||
assert_eq!(row(&mut s).unwrap()["status"], "exact");
|
||||
s.execute("text.setRangeStyle", &json!({"id": id, "start": 3, "end": 5, "size": 30})).unwrap();
|
||||
assert_eq!(text(&s, id).runs.len(), 2);
|
||||
assert_eq!(row(&mut s).unwrap()["status"], "missing");
|
||||
// A symbol the Kanji part's font draws but the Symbols part's font lacks.
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let (mono, sans) = (db.face("JetBrains Mono", "Regular").unwrap(), db.face("Source Sans 3", "Regular").unwrap());
|
||||
let c = ('\u{2190}'..='\u{2BFF}').find(|c| mono.covers(*c) && !sans.covers(*c)).expect("a symbol only JetBrains Mono draws");
|
||||
let glyphs = json!({"name": "CF Glyphs", "parts": {"kanji": {"font": "JetBrains Mono"}, "symbols": {"font": "Source Sans 3"}}});
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [glyphs]})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 10, "y": 130, "text": c.to_string(), "size": 20, "font": "CF Glyphs"})).unwrap();
|
||||
let fonts = s.execute("text.fonts", &json!({})).unwrap();
|
||||
let row = fonts.as_array().unwrap().iter().find(|r| r["family"] == "CF Glyphs").cloned().unwrap();
|
||||
assert_eq!(row["missingGlyphs"], 1, "{row}");
|
||||
}
|
||||
|
||||
/// The Glyphs panel's alternates of a character in a composite font come from the font of the
|
||||
/// part its grapheme cluster falls in.
|
||||
#[test]
|
||||
fn glyph_alternates_of_composite_type_come_from_the_characters_part() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Glyph Part")]})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 2})).unwrap();
|
||||
assert_eq!(r["font"], "Inter", "{r}");
|
||||
let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 0})).unwrap();
|
||||
assert_eq!(r["font"], "JetBrains Mono", "{r}");
|
||||
// A combining mark is drawn in the part of the character it follows.
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "1\u{301}", "size": 20, "font": "CF Glyph Part"})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let r = s.execute("text.glyphAlternates", &json!({"id": id, "start": 1})).unwrap();
|
||||
assert_eq!(r["font"], "Inter", "{r}");
|
||||
}
|
||||
|
||||
/// With two documents defining one name differently, the type is drawn in the front document's.
|
||||
#[test]
|
||||
fn the_front_document_defines_a_shared_name() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Front")]})).unwrap();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let mut other = font("CF Front");
|
||||
other["parts"]["roman"]["size"] = json!(75);
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [other]})).unwrap();
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 75.0);
|
||||
s.execute("document.activate", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(db.composite_font("CF Front").unwrap().parts.roman.size, 50.0);
|
||||
}
|
||||
|
||||
/// A composite font only a document in the back defines is not available in the front one, where
|
||||
/// type naming it shows as missing; a click on the back document's tab brings it back.
|
||||
#[test]
|
||||
fn a_font_only_a_back_document_defines_is_missing_in_front() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Back Only")]})).unwrap();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
assert!(db.composite_font("CF Back Only").is_none());
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Back Only"})).unwrap();
|
||||
let fonts = s.execute("text.fonts", &json!({})).unwrap();
|
||||
assert!(fonts.as_array().unwrap().iter().any(|f| f["missing"] == true), "{fonts}");
|
||||
assert!(s.set_active(0));
|
||||
assert!(db.composite_font("CF Back Only").is_some());
|
||||
}
|
||||
|
||||
/// A newly opened document measures a missing font without using the previous tab's composite
|
||||
/// font of the same name.
|
||||
#[test]
|
||||
fn opened_document_measures_missing_composite_font_as_missing() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Previous Tab")]})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "CF Previous Tab"})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.composite_fonts.clear();
|
||||
if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
|
||||
t.cached_bounds = None;
|
||||
}
|
||||
s.add_document(doc, None);
|
||||
assert!(s.set_active(0));
|
||||
assert!(s.set_active(1));
|
||||
let actual = text(&s, id);
|
||||
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// A loaded composite font cannot replace an installed family used by existing type.
|
||||
#[test]
|
||||
fn loaded_composite_font_cannot_take_an_installed_family_name() {
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab", "size": 20, "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
doc.composite_fonts.push(vectorcraft_doc::CompositeFont::new("Inter", "Source Sans 3", "Regular"));
|
||||
if let NodeKind::Text(t) = &mut doc.node_mut(NodeId(id)).unwrap().kind {
|
||||
t.cached_bounds = None;
|
||||
}
|
||||
s.add_document(doc, None);
|
||||
assert!(names(&s).is_empty());
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font("Inter").is_none());
|
||||
let actual = text(&s, id);
|
||||
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &actual).bounds;
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// A file's composite fonts that Composite Fonts… would refuse (a value out of range, a name used
|
||||
/// twice) are left out when the file opens.
|
||||
#[test]
|
||||
fn unusable_composite_fonts_in_a_file_are_left_out() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut s = session();
|
||||
let mut doc = (*s.doc().unwrap().doc).clone();
|
||||
let mut wild = vectorcraft_doc::CompositeFont::new("CF Wild", "Source Sans 3", "Regular");
|
||||
wild.parts.roman.size = 5000.0;
|
||||
let good = vectorcraft_doc::CompositeFont::new("CF Kept", "Source Sans 3", "Regular");
|
||||
let twice = vectorcraft_doc::CompositeFont::new("cf kept", "Inter", "Regular");
|
||||
*doc.composite_fonts = vec![wild, good, twice];
|
||||
s.add_document(doc, None);
|
||||
assert_eq!(names(&s), ["CF Kept"]);
|
||||
s.execute("document.activate", &json!({"index": 1})).unwrap();
|
||||
assert!(db.composite_font("CF Wild").is_none());
|
||||
assert_eq!(db.composite_font("CF Kept").unwrap().parts.roman.font, "Source Sans 3");
|
||||
}
|
||||
|
||||
/// Export writes the document's composite fonts (all, or those named) to a file; Import reads it:
|
||||
/// with apply false it only returns them, otherwise they join the document's, replacing a font of
|
||||
/// the same name, as one undo step.
|
||||
#[test]
|
||||
fn composite_fonts_export_and_import() {
|
||||
let dir = folder("io");
|
||||
let path = dir.join("Mixed fonts (日本).vccomposite");
|
||||
let path = path.to_str().unwrap();
|
||||
let mut s = session();
|
||||
assert!(s.execute("type.compositeFonts.export", &json!({"path": path})).is_err(), "nothing to export");
|
||||
let mut slash = font("CF IO a/b: \"quoted\" 日本");
|
||||
slash["parts"]["kana"] = json!({"font": "Inter", "vScale": 80, "scaleAtCenter": true});
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [slash, font("CF IO Other")]})).unwrap();
|
||||
assert!(s.execute("type.compositeFonts.export", &json!({"path": path, "names": ["CF IO Nope"]})).is_err());
|
||||
let r = s
|
||||
.execute("type.compositeFonts.export", &json!({"path": path, "names": ["cf io a/b: \"quoted\" 日本", "CF IO a/b: \"quoted\" 日本"]}))
|
||||
.unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
let saved = s.doc().unwrap().doc.composite_fonts[0].clone();
|
||||
|
||||
let mut t = session();
|
||||
let mut mine = font("CF IO a/b: \"quoted\" 日本");
|
||||
mine["parts"]["roman"]["size"] = json!(90);
|
||||
t.execute("type.compositeFonts.set", &json!({"fonts": [mine, font("CF IO Kept")]})).unwrap();
|
||||
let before = steps(&t);
|
||||
let read = t.execute("type.compositeFonts.import", &json!({"path": path, "apply": false})).unwrap();
|
||||
assert_eq!(read["fonts"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(steps(&t), before);
|
||||
t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap();
|
||||
assert_eq!(steps(&t), before + 1);
|
||||
assert_eq!(names(&t), ["CF IO a/b: \"quoted\" 日本", "CF IO Kept"]);
|
||||
assert_eq!(t.doc().unwrap().doc.composite_fonts[0], saved);
|
||||
// The same file again changes nothing.
|
||||
t.execute("type.compositeFonts.import", &json!({"path": path})).unwrap();
|
||||
assert_eq!(steps(&t), before + 1);
|
||||
|
||||
// Files that aren't composite fonts, or are too large, are refused.
|
||||
for (file, bytes) in [
|
||||
("bad.vccomposite", b"not json".to_vec()),
|
||||
("empty.vccomposite", br#"{"compositeFonts": []}"#.to_vec()),
|
||||
("twice.vccomposite", format!(r#"{{"compositeFonts": [{}, {}]}}"#, font("CF Dup"), font("cf dup")).into_bytes()),
|
||||
("large.vccomposite", vec![b' '; (4 << 20) + 1]),
|
||||
] {
|
||||
let p = dir.join(file);
|
||||
std::fs::write(&p, bytes).unwrap();
|
||||
assert!(t.execute("type.compositeFonts.import", &json!({"path": p.to_str().unwrap()})).is_err(), "{file}");
|
||||
assert_eq!(steps(&t), before + 1);
|
||||
}
|
||||
assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "apply": "no"})).is_err());
|
||||
assert!(t.execute("type.compositeFonts.import", &json!({"path": ""})).is_err());
|
||||
assert!(t.execute("type.compositeFonts.import", &json!({"path": path, "data": "{}"})).is_err());
|
||||
assert!(t.execute("type.compositeFonts.import", &json!({"dataBase64": "%%%"})).is_err());
|
||||
|
||||
// Without a path, Export gives the file's text, which Import takes as data.
|
||||
let data = s.execute("type.compositeFonts.export", &json!({"names": ["CF IO Other"]})).unwrap();
|
||||
assert_eq!(data["count"], 1);
|
||||
let mut u = session();
|
||||
let r = u.execute("type.compositeFonts.import", &json!({"data": data["data"]})).unwrap();
|
||||
assert_eq!(r["imported"], json!(["CF IO Other"]));
|
||||
assert_eq!(names(&u), ["CF IO Other"]);
|
||||
// Fonts given are written in place of the document's; bad ones are refused.
|
||||
let given = s.execute("type.compositeFonts.export", &json!({"fonts": [font("CF IO Given")]})).unwrap();
|
||||
assert!(given["data"].as_str().unwrap().contains("CF IO Given"));
|
||||
assert!(s.execute("type.compositeFonts.export", &json!({"fonts": [font("")]})).is_err());
|
||||
assert!(s.execute("type.compositeFonts.export", &json!({"fonts": []})).is_err());
|
||||
assert!(s.execute("type.compositeFonts.export", &json!({"path": " "})).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -743,7 +743,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut made = super::typecmd::with_overflow(s, id.as_slice(), made);
|
||||
if p.get("font").is_some() || p.get("style").is_some() {
|
||||
let runs = match id.and_then(|i| s.doc().ok()?.doc.node(i)).map(|n| &n.kind) {
|
||||
Some(NodeKind::Text(t)) => t.runs.iter().map(|r| super::fonts::font_of(&r.style)).collect(),
|
||||
Some(NodeKind::Text(t)) => t.runs.iter().flat_map(super::fonts::run_fonts).collect(),
|
||||
_ => Default::default(),
|
||||
};
|
||||
if let Some(w) = super::fonts::set_font_warning(runs) {
|
||||
|
||||
@@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}",
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks, effect colors and graphs' series and label paints included), symbols, graph designs, pattern tiles, graphic styles (their effect colors included), brushes, swatches and the paints new art takes through the color settings, together in one undo step (Undo restores their original values; Redo reapplies the conversion); swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into light grays in cyan, magenta and yellow only, darker four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}",
|
||||
has_doc,
|
||||
super::colormgmt::convert_mode
|
||||
),
|
||||
|
||||
@@ -794,11 +794,14 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mut doc = Document::clone(chosen.as_ref().unwrap_or(&st.doc));
|
||||
let (imported_from, import_losses, opened_by) = (st.imported_from.clone(), st.import_losses.clone(), st.opened_by.clone());
|
||||
if let Some(path) = st.path.as_deref() {
|
||||
let base = super::links::LinkBase::of(st);
|
||||
if base.doc.is_some() {
|
||||
// Files are looked for within the asking caller's roots, else within those of the caller that opened the source.
|
||||
let roots = crate::file_access::current().or_else(|| opened_by.clone());
|
||||
crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, path));
|
||||
crate::file_access::confine(roots.as_ref(), || super::links::with_found_paths(&mut doc, base));
|
||||
}
|
||||
// The copy's links keep their relative paths, which name places from the same file.
|
||||
let links_from = base.relative.map(str::to_string);
|
||||
doc.drop_edit_modes();
|
||||
if doc.pattern_edit.is_some() {
|
||||
super::patterncmds::finish_edit(&mut doc, &mut vectorcraft_doc::Selection::default())?;
|
||||
@@ -815,6 +818,7 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc_mut()?;
|
||||
st.imported_from = imported_from;
|
||||
st.import_losses = import_losses;
|
||||
st.links_from = links_from;
|
||||
st.opened_by = st.opened_by.take().or(opened_by);
|
||||
st.mark_unsaved();
|
||||
Ok(json!({ "index": i, "title": st.title() }))
|
||||
|
||||
@@ -564,13 +564,14 @@ impl ReadFile {
|
||||
|
||||
/// The first half of [`open_bytes_with`], which needs no session (an app may do it on another
|
||||
/// thread): read and decode the file, read its linked files and lay out its type → what
|
||||
/// [`open_read`] opens. The linked files are looked for from `path`, where the file was read from,
|
||||
/// [`open_read`] opens. Type in the file's composite fonts is measured again once the document
|
||||
/// opens and its composite fonts are registered. The linked files are looked for from `path`, where the file was read from,
|
||||
/// and modified ones are read again when `update_links` (Preferences › File Handling › Update
|
||||
/// Links set to Automatically; see [`crate::cmd::links::resolve`]).
|
||||
pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value, update_links: bool, low_res_proxy_eps: bool) -> Result<ReadFile> {
|
||||
let opts = LoadOptions::from_params("document.open", p)?;
|
||||
let mut loaded = load_with(name, bytes, &opts)?;
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path.as_deref(), update_links, low_res_proxy_eps);
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, crate::cmd::links::LinkBase::file(path.as_deref()), update_links, low_res_proxy_eps);
|
||||
Session::refresh_text_bounds(&mut loaded.doc);
|
||||
Ok(ReadFile { loaded, opts, path, links })
|
||||
}
|
||||
@@ -590,7 +591,8 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
||||
"only this Affinity file's embedded preview could be read: use File › Open to see it with its warning, or export SVG or PDF from Affinity first",
|
||||
));
|
||||
}
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, path, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
|
||||
let base = crate::cmd::links::LinkBase::file(path);
|
||||
let links = crate::cmd::links::resolve(&mut loaded.doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
|
||||
open_loaded(s, loaded, links, path.map(str::to_string), &LoadOptions::default(), true)
|
||||
}
|
||||
|
||||
@@ -629,6 +631,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
// writes .ai that way).
|
||||
let partial = format.id == "pdf" && opts.is_partial();
|
||||
let lossy_ai = format.id == "ai" && !restored;
|
||||
let read_from = path.clone();
|
||||
let path = path.filter(|_| !template && !partial && !lossy_ai && SAVE_FORMATS.contains(&format.id));
|
||||
let saves_back = path.is_some();
|
||||
let converted = saves_back && converted && s.prefs.append_converted;
|
||||
@@ -648,6 +651,7 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
st.converted = converted;
|
||||
st.imported_from = source_path;
|
||||
st.import_losses = losses;
|
||||
st.links_from = read_from;
|
||||
let opened = &s.documents()[index];
|
||||
let (title, file_info_title) = (opened.title(), opened.doc.title.clone());
|
||||
Ok(super::merge(
|
||||
|
||||
@@ -777,7 +777,8 @@ fn job_for_stamped(s: &Session, st: &DocState, plan: SavePlan, stamped: Option<i
|
||||
stamp_doc_dates(Arc::make_mut(&mut snapshot), at);
|
||||
}
|
||||
// A native file records its links' paths relative to where it is written.
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
|
||||
let base = crate::cmd::links::LinkBase::of(st);
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, base, p));
|
||||
let doc = relative.map_or(own, Arc::new);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
|
||||
@@ -720,7 +720,7 @@ fn a_file_whose_pdf_part_cant_be_read_outlines_its_type_when_asked() {
|
||||
assert!(paths > 0, "the outlines are there: {r}");
|
||||
}
|
||||
|
||||
/// An image too large to read from the editing data of a file whose PDF part can't be read is
|
||||
/// An image whose data is cut short in the editing data of a file whose PDF part can't be read is
|
||||
/// left out, and both notes say so.
|
||||
#[test]
|
||||
fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() {
|
||||
@@ -729,7 +729,7 @@ fn a_file_whose_pdf_part_cant_be_read_notes_an_image_left_out() {
|
||||
let mut s = Session::new();
|
||||
let e = open(&mut s, name, &file, json!({"editingData": false})).unwrap_err().to_string();
|
||||
let r = open(&mut s, name, &file, json!({})).unwrap();
|
||||
assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image too large to read from the editing data was left out"]), "{r}");
|
||||
assert_eq!(r["warnings"], json!([unread_pdf_part(&e), "an image whose data is cut short was left out"]), "{r}");
|
||||
}
|
||||
|
||||
/// A copied file whose PDF part can't be read pastes the objects of its editing data when asked
|
||||
|
||||
@@ -932,25 +932,31 @@ fn gradients_and_patterns_come_back_from_the_editing_copy() {
|
||||
}
|
||||
let r = s.execute("document.export", &json!({"format": "ai", "aiVersion": 7})).unwrap();
|
||||
let legacy = load::load_with("art.ai", &b64(&r), &LoadOptions::default()).unwrap();
|
||||
// Each gradient the art paints with, by its kind and its stops' places and colors to 5% (version
|
||||
// 7 writes a gradient stroke as its filled outline, so the objects differ, and a spot color's
|
||||
// tint through its CMYK equivalent, 2% off here).
|
||||
// Each gradient the art paints with, by its kind and its stops' places, and its stops' colors
|
||||
// within 5% (version 7 writes a gradient stroke as its filled outline, so the objects differ),
|
||||
// except a spot color's tint, which version 7 writes through its CMYK equivalent: 70% of that
|
||||
// equivalent displays about 7% off the tint of the spot color.
|
||||
let gradients = |doc: &vectorcraft_doc::Document| {
|
||||
let mut all = std::collections::BTreeSet::new();
|
||||
let mut all = std::collections::BTreeMap::new();
|
||||
doc.walk(|node| {
|
||||
for it in node.appearance.items.iter() {
|
||||
if let vectorcraft_color::Paint::Gradient(g) = it.paint() {
|
||||
let stops: Vec<String> = g
|
||||
.gradient
|
||||
.stops
|
||||
.iter()
|
||||
.map(|s| format!("{:.3} {:?}", s.offset, s.color.to_rgb().map(|v| (v * 20.0).round() * 5.0)))
|
||||
.collect();
|
||||
all.insert(format!("{:?} {stops:?}", g.gradient.kind));
|
||||
let places: Vec<String> = g.gradient.stops.iter().map(|s| format!("{:.3}", s.offset)).collect();
|
||||
let colors: Vec<([f32; 3], bool)> =
|
||||
g.gradient.stops.iter().map(|s| (s.color.to_rgb(), s.swatch.is_some() && s.tint < 1.0)).collect();
|
||||
all.insert(format!("{:?} {places:?}", g.gradient.kind), colors);
|
||||
}
|
||||
}
|
||||
});
|
||||
all
|
||||
};
|
||||
assert_eq!(gradients(&legacy.doc), gradients(&before));
|
||||
let (got, want) = (gradients(&legacy.doc), gradients(&before));
|
||||
assert_eq!(got.keys().collect::<Vec<_>>(), want.keys().collect::<Vec<_>>());
|
||||
for (key, stops) in &want {
|
||||
for (i, (rgb, spot)) in stops.iter().enumerate() {
|
||||
let back = got[key][i].0;
|
||||
let most = if *spot { 0.1 } else { 0.05 };
|
||||
assert!((0..3).all(|c| (back[c] - rgb[c]).abs() < most), "{key} stop {i}: {back:?} for {rgb:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Missing Fonts",
|
||||
[],
|
||||
None,
|
||||
"{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols, that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)",
|
||||
"{} → {fonts: [{family, style, status: missing|substitute, resolved: {family, style}}] (sorted by name; at most 1000), count, fontsNextToDocument?} the fonts the active document's type uses, in its layers and its symbols (for type in a composite font, the fonts of the parts its characters are drawn in), that aren't available as named: missing (the family is unknown and the fallback family stands in) or substitute (the family is there but not the style; resolved names the stand-in), as text.fonts says. CSS generic families such as sans-serif and names longer than 256 bytes are left out. fontsNextToDocument: the Fonts folder next to the saved document (File › Package writes it), when there is one and a search may start there (not in another app's folders, such as a mail app's attachments)",
|
||||
has_doc,
|
||||
missing
|
||||
),
|
||||
@@ -103,8 +103,8 @@ pub struct MissingFont {
|
||||
pub resolved: Option<(String, String)>,
|
||||
}
|
||||
|
||||
/// The fonts the type of `d` uses, in its layers and its symbols, that aren't available as named
|
||||
/// (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif`
|
||||
/// The fonts the type of `d` uses, in its layers and its symbols (a composite font's parts, those
|
||||
/// its characters are drawn in, in place of its name), that aren't available as named (sorted by family and style, each once whatever version its type names; at most 1000). CSS generic families such as `sans-serif`
|
||||
/// ([`GENERIC_FAMILIES`]) and names longer than 256 bytes are left out.
|
||||
pub fn missing_fonts(d: &Document) -> Vec<MissingFont> {
|
||||
let db = FontDb::global();
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fonts in Document",
|
||||
[],
|
||||
None,
|
||||
"{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font)",
|
||||
"{selectionOnly?} (the type in the art of the symbols in scope included: all of the document's, or those the selected instances show) → [{family, style, runs, objects, missing, status: exact|substitute|missing, resolved: {family, style}, missingGlyphs, version?: the installed version the type names (when it names one)}] sorted by name. A font is found by any of its names (ヒラギノ角ゴシック = Hiragino Sans) among the loaded and installed fonts; substitute: the family is there but not the style (resolved names the stand-in); missing: the family is unknown (the fallback family stands in); missingGlyphs: characters of its text the resolved font lacks (drawn by a fallback font). For a composite font, status is the worst among the parts its characters are drawn in, and missingGlyphs counts the characters their part's font lacks",
|
||||
has_doc,
|
||||
fonts
|
||||
),
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Replace Font",
|
||||
[],
|
||||
None,
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}",
|
||||
"{from: {family, style?}, to: {family (any of its names; stored as the font's own family name, a composite font's as its own name), style?}, selectionOnly?} (style omitted: every style of the family / keep the closest style); the type in the art of the symbols in scope changes too (all of the document's, or those the selected instances show, so every instance of them follows); text that is hidden or locked, in a hidden or locked group or layer (or group of a symbol's art), or on a locked artboard keeps its font; when no run changes (text already set in the new font, or none to change), the document and its undo history stay as they are → {runs: the runs changed, skipped?: the text objects left in the font, warning?: text (some text was left in its font, or the style set isn't available: its type is drawn in the family's closest style)}",
|
||||
has_doc,
|
||||
replace
|
||||
),
|
||||
@@ -208,7 +208,18 @@ pub(super) fn font_label((family, style, version): &UsedFont) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
|
||||
/// The fonts (family, style, version) run `r` is drawn in: its own, or, set in a composite font,
|
||||
/// the fonts of the parts its characters fall in ([`vectorcraft_text::FontDb::composite_fonts_for`]).
|
||||
pub(super) fn run_fonts(r: &TextRun) -> Vec<UsedFont> {
|
||||
let parts = if r.inline.is_some() { None } else { vectorcraft_text::FontDb::global().composite_fonts_for(&r.style.font_family, &r.text) };
|
||||
match parts {
|
||||
Some(parts) => parts.into_iter().map(|(family, style)| (family, style, None)).collect(),
|
||||
None => vec![font_of(&r.style)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The fonts (family, style, version) the type in the layers and symbols of `d` is drawn in, a
|
||||
/// composite font's parts in place of its name ([`run_fonts`]).
|
||||
pub(super) fn used_fonts(d: &Document) -> BTreeSet<UsedFont> {
|
||||
art_fonts(d, &[])
|
||||
}
|
||||
@@ -219,7 +230,7 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet<UsedFont> {
|
||||
let mut fonts = BTreeSet::new();
|
||||
let mut add = |n: &Node| {
|
||||
if let NodeKind::Text(t) = &n.kind {
|
||||
fonts.extend(t.runs.iter().map(|r| (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())));
|
||||
fonts.extend(t.runs.iter().flat_map(run_fonts));
|
||||
}
|
||||
};
|
||||
d.walk(&mut add);
|
||||
@@ -344,7 +355,7 @@ impl Printed<'_> {
|
||||
self.overflow.insert(n.id);
|
||||
}
|
||||
for r in runs {
|
||||
self.fonts.insert((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone()));
|
||||
self.fonts.extend(run_fonts(r));
|
||||
// Inline graphics draw their symbols' art.
|
||||
if let Some(art) = &r.inline {
|
||||
self.symbol(&art.symbol);
|
||||
@@ -546,14 +557,15 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result<Value> {
|
||||
let symbols = if selected.is_some() { Some(selected_symbols(s, selected)?.0) } else { None };
|
||||
let mut texts: Vec<&TextObject> = ids.iter().filter_map(|id| text(d, *id)).collect();
|
||||
texts.extend(symbol_texts(d, symbols.as_ref()).into_iter().map(|(t, _)| t));
|
||||
let mut found: BTreeMap<UsedFont, (usize, usize, String)> = BTreeMap::new();
|
||||
// Each font's runs, objects and the text of its runs.
|
||||
let mut found: BTreeMap<UsedFont, (usize, usize, Vec<&str>)> = BTreeMap::new();
|
||||
for t in texts {
|
||||
let mut seen = BTreeSet::new();
|
||||
for r in &t.runs {
|
||||
let font = (r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone());
|
||||
let e = found.entry(font.clone()).or_default();
|
||||
e.0 += 1;
|
||||
e.2.push_str(&r.text);
|
||||
e.2.push(&r.text);
|
||||
if seen.insert(font) {
|
||||
e.1 += 1;
|
||||
}
|
||||
@@ -561,14 +573,27 @@ fn font_inventory(s: &Session, selected: Option<&[NodeId]>) -> Result<Value> {
|
||||
}
|
||||
let list: Vec<Value> = found
|
||||
.into_iter()
|
||||
.map(|((family, style, version), (runs, objects, chars))| {
|
||||
.map(|((family, style, version), (runs, objects, texts))| {
|
||||
// The face the type is set in: the version it names when that one is installed.
|
||||
let resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = chars.chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
let mut resolved = db.resolve(&family, &style).map(|(f, m)| (db.face_version(&family, &style, version.as_deref()).unwrap_or(f), m));
|
||||
let (status, lacking) = match db.composite_coverage(&family, &texts) {
|
||||
// A composite font: each character is drawn by the font of the part it falls in, and
|
||||
// the row names the face of the first part that isn't there as named.
|
||||
Some(c) => {
|
||||
if c.status != vectorcraft_text::FontMatch::Exact {
|
||||
resolved = c.face.map(|f| (f, c.status));
|
||||
}
|
||||
(c.status, c.lacking)
|
||||
}
|
||||
None => {
|
||||
let status = resolved.as_ref().map(|(_, m)| *m).unwrap_or(vectorcraft_text::FontMatch::Missing);
|
||||
let mut lacking: Vec<char> = texts.concat().chars().filter(|c| !c.is_whitespace() && !c.is_control()).collect();
|
||||
lacking.sort_unstable();
|
||||
lacking.dedup();
|
||||
lacking.retain(|c| !resolved.as_ref().is_some_and(|(f, _)| f.covers(*c)));
|
||||
(status, lacking)
|
||||
}
|
||||
};
|
||||
let mut row = json!({
|
||||
"family": family, "style": style, "runs": runs, "objects": objects,
|
||||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||||
@@ -612,10 +637,11 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (ff, fs) = font_param(p, "from", C)?;
|
||||
let (tf, ts) = font_param(p, "to", C)?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans).
|
||||
let tf = match db.resolve(&tf, ts.as_deref().unwrap_or("Regular")) {
|
||||
Some((f, m)) if m != vectorcraft_text::FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, format!("font family `{tf}` is not available"))),
|
||||
// Any name of the family (ヒラギノ角ゴシック) is stored as its own name (Hiragino Sans), and a
|
||||
// composite font as its own name.
|
||||
let composite = db.composite_font(&tf).is_some();
|
||||
let Some(tf) = db.stored_family(&tf, ts.as_deref().unwrap_or("Regular")) else {
|
||||
return Err(bad(C, format!("font family `{tf}` is not available")));
|
||||
};
|
||||
let matches = |st: &vectorcraft_doc::CharStyle| {
|
||||
st.font_family.eq_ignore_ascii_case(&ff) && fs.as_ref().is_none_or(|x| st.font_style.eq_ignore_ascii_case(x))
|
||||
@@ -626,8 +652,10 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !matches(st) {
|
||||
return None;
|
||||
}
|
||||
// An explicit style, else the closest the new family has to the old one.
|
||||
let style = ts.clone().or_else(|| db.face(&tf, &st.font_style).map(|f| f.style.clone())).unwrap_or_else(|| st.font_style.clone());
|
||||
// An explicit style, else the closest the new family has to the old one (a composite font's
|
||||
// parts name their own styles: the run keeps its style).
|
||||
let closest = || if composite { None } else { db.face(&tf, &st.font_style).map(|f| f.style.clone()) };
|
||||
let style = ts.clone().or_else(closest).unwrap_or_else(|| st.font_style.clone());
|
||||
(st.font_family != tf || st.font_style != style || st.font_version.is_some() || !st.font_axes.is_empty()).then_some(style)
|
||||
};
|
||||
// Only text the Selection tool can reach changes: not hidden or locked, in a hidden or locked
|
||||
@@ -674,7 +702,7 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Another font: none of the old one's versions or Variable Font settings.
|
||||
r.style.font_version = None;
|
||||
r.style.font_axes.clear();
|
||||
set.insert(font_of(&r.style));
|
||||
set.extend(run_fonts(r));
|
||||
changed = true;
|
||||
n += 1;
|
||||
}
|
||||
|
||||
@@ -601,7 +601,7 @@ fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.highlight";
|
||||
let mut ids = ids_param(p, "ids").ok_or_else(|| bad(C, "missing ids"))?;
|
||||
let mut ids = ids_param(p, "ids", C)?.ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let mode = str_param(p, "mode").unwrap_or("set");
|
||||
let st = s.doc_mut()?;
|
||||
@@ -740,7 +740,7 @@ enum Place {
|
||||
/// Drag rows in the Layers panel.
|
||||
fn move_rows(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "layer.move";
|
||||
let mut ids = ids_param(p, "ids").or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
|
||||
let mut ids = ids_param(p, "ids", C)?.or_else(|| id_param(p, "id").map(|i| vec![i])).ok_or_else(|| bad(C, "missing ids"))?;
|
||||
ids.truncate(MAX_ROWS);
|
||||
let target = id_param(p, "target").ok_or_else(|| bad(C, "missing target"))?;
|
||||
let place = match str_param(p, "place").unwrap_or("inside") {
|
||||
|
||||
@@ -966,7 +966,7 @@ enum Item {
|
||||
/// The selected objects (or `ids`) as a library graphic (its id and name set by the caller).
|
||||
fn graphic_of(s: &Session, p: &Value, cmd: &str) -> Result<LibraryGraphic> {
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
let ids = match ids_param(p, "ids", cmd)? {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.in_paint_order(&st.doc),
|
||||
};
|
||||
@@ -1006,7 +1006,7 @@ fn color_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<NewColor>
|
||||
let given = NewColor { name: color.to_hex(), color, spot: None, tint: 1.0 };
|
||||
return Ok(spot_color(s, given, swatch, tint));
|
||||
}
|
||||
match selected_paint(s, p, stroke)? {
|
||||
match selected_paint(s, p, stroke, cmd)? {
|
||||
Paint::Solid { color, swatch, tint } => {
|
||||
let swatch = swatch.filter(|n| !n.is_empty());
|
||||
let name = swatch.clone().unwrap_or_else(|| color.to_hex());
|
||||
@@ -1029,9 +1029,9 @@ fn spot_color(s: &Session, c: NewColor, swatch: Option<String>, tint: f32) -> Ne
|
||||
|
||||
/// The first selected object's (or `ids`') fill (`stroke`: stroke) paint (type's: its first
|
||||
/// character's, as the proxy shows it), else the default one.
|
||||
fn selected_paint(s: &Session, p: &Value, stroke: bool) -> Result<Paint> {
|
||||
fn selected_paint(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<Paint> {
|
||||
let st = s.doc()?;
|
||||
let ids = match ids_param(p, "ids") {
|
||||
let ids = match ids_param(p, "ids", cmd)? {
|
||||
Some(ids) => ids,
|
||||
None => st.selection.objects.clone(),
|
||||
};
|
||||
@@ -1071,7 +1071,7 @@ fn gradient_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<Librar
|
||||
let sw = s.doc()?.doc.swatch(name).filter(|w| matches!(w.paint, Paint::Gradient(_)));
|
||||
(Some(name.to_string()), sw.ok_or_else(|| bad(cmd, format!("no gradient swatch `{name}`")))?.paint.clone())
|
||||
}
|
||||
None => (None, selected_paint(s, p, stroke)?),
|
||||
None => (None, selected_paint(s, p, stroke, cmd)?),
|
||||
};
|
||||
let Paint::Gradient(mut g) = paint else {
|
||||
return Err(bad(cmd, if stroke { "the selection has no gradient stroke" } else { "the selection has no gradient fill" }));
|
||||
@@ -1094,7 +1094,7 @@ fn pattern_of(s: &Session, p: &Value, stroke: bool, cmd: &str) -> Result<Library
|
||||
_ if d.pattern(name).is_some() => name.to_string(),
|
||||
_ => return Err(bad(cmd, format!("no pattern swatch `{name}`"))),
|
||||
},
|
||||
None => match selected_paint(s, p, stroke)? {
|
||||
None => match selected_paint(s, p, stroke, cmd)? {
|
||||
Paint::Pattern { pattern, .. } => pattern,
|
||||
_ => return Err(bad(cmd, if stroke { "the selection has no pattern stroke" } else { "the selection has no pattern fill" })),
|
||||
},
|
||||
|
||||
+153
-65
@@ -23,7 +23,7 @@
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Component, Path};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -219,6 +219,30 @@ struct Found {
|
||||
modified: Option<u64>,
|
||||
/// Read when telling whether it changed needed them.
|
||||
bytes: Option<Vec<u8>>,
|
||||
/// `path` relative to the folder of [`LinkBase::relative`], when [`locate`] had that file.
|
||||
relative: Option<Option<String>>,
|
||||
}
|
||||
|
||||
/// The files a document's links are looked for from: `doc`, whose folder a link's path is followed
|
||||
/// from and its file is looked for by name in, and `relative`, the file its links' stored relative
|
||||
/// paths name places from (the file the document was read from).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct LinkBase<'a> {
|
||||
pub doc: Option<&'a str>,
|
||||
pub relative: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> LinkBase<'a> {
|
||||
/// Both the file at `path`.
|
||||
pub fn file(path: Option<&'a str>) -> Self {
|
||||
Self { doc: path, relative: path }
|
||||
}
|
||||
|
||||
/// The open document `st`'s. One with no file of its own looks beside the file it was read from.
|
||||
pub(crate) fn of(st: &'a crate::DocState) -> Self {
|
||||
let (path, from) = (st.path.as_deref(), st.links_from.as_deref());
|
||||
Self { doc: path.or(from), relative: from.or(path) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Found {
|
||||
@@ -237,7 +261,17 @@ impl Found {
|
||||
|
||||
/// [`Self::link`] with the bytes' hash already taken.
|
||||
fn link_hashed(&self, hash: String, base: &LinkInfo) -> LinkInfo {
|
||||
LinkInfo { path: self.path.clone(), size: Some(self.size), modified: self.modified, hash: Some(hash), ..base.clone() }
|
||||
LinkInfo { size: Some(self.size), modified: self.modified, hash: Some(hash), ..self.moved(base) }
|
||||
}
|
||||
|
||||
/// `base` pointing at this file. A file found away from `base`'s path takes its relative path
|
||||
/// from where it was found.
|
||||
fn moved(&self, base: &LinkInfo) -> LinkInfo {
|
||||
let relative = match &self.relative {
|
||||
Some(found) if self.path != base.path => found.clone(),
|
||||
_ => base.relative.clone(),
|
||||
};
|
||||
LinkInfo { path: self.path.clone(), relative, ..base.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,15 +289,41 @@ pub(crate) fn link_info(path: &str, bytes: &[u8]) -> LinkInfo {
|
||||
/// The file at `path` when there is one.
|
||||
fn found_at(path: String) -> Option<Found> {
|
||||
let (size, modified) = file_stamp(&path)?;
|
||||
Some(Found { path, size, modified, bytes: None })
|
||||
Some(Found { path, size, modified, bytes: None, relative: None })
|
||||
}
|
||||
|
||||
/// Where the file of `link` (stored in the document saved at `doc_path`) is: at its path, else at
|
||||
/// its relative path or by name in the document's folder. Only paths
|
||||
/// [`fileio::document_path`] follows are looked at.
|
||||
fn locate(link: &LinkInfo, doc_path: Option<&str>) -> Option<Found> {
|
||||
let stored = [Some(link.path.as_str()), link.relative.as_deref(), Some(link.name())];
|
||||
stored.into_iter().flatten().filter_map(|p| fileio::document_path(p, doc_path).ok()).find_map(found_at)
|
||||
/// Where the file of `link` (of a document looking from `base`) is: at its path, else at its
|
||||
/// relative path from the folder of [`LinkBase::relative`], then from the document's folder, else
|
||||
/// by name in the document's folder, then in that of [`LinkBase::relative`]. Only paths
|
||||
/// [`fileio::document_path`] follows from the document's own file are looked at.
|
||||
fn locate(link: &LinkInfo, base: LinkBase) -> Option<Found> {
|
||||
let (relative, other) = (link.relative.as_deref(), base.doc != base.relative);
|
||||
let stored = [
|
||||
(Some(link.path.as_str()), base.doc),
|
||||
(relative, base.relative),
|
||||
(relative.filter(|_| other), base.doc),
|
||||
(Some(link.name()), base.doc),
|
||||
(other.then_some(link.name()), base.relative),
|
||||
];
|
||||
let mut f = stored.into_iter().filter_map(|(p, from)| follow_from(p?, from, base.doc, fileio::document_path)).find_map(found_at)?;
|
||||
f.relative = base.relative.map(|from| {
|
||||
let from = absolute_path(from);
|
||||
Path::new(&from).parent().and_then(|dir| relative_path(Path::new(&f.path), dir))
|
||||
});
|
||||
Some(f)
|
||||
}
|
||||
|
||||
/// The place `stored` names from the file `from`, as `resolve` ([`fileio::document_path`]) follows
|
||||
/// it, when it also follows that place from the document's own file `doc`: a network share the
|
||||
/// document is no longer on is refused.
|
||||
pub(crate) fn follow_from(
|
||||
stored: &str,
|
||||
from: Option<&str>,
|
||||
doc: Option<&str>,
|
||||
resolve: impl Fn(&str, Option<&str>) -> std::result::Result<String, craft_docpath::Refused>,
|
||||
) -> Option<String> {
|
||||
let path = resolve(stored, from).ok()?;
|
||||
if from == doc { Some(path) } else { resolve(&path, doc).ok() }
|
||||
}
|
||||
|
||||
/// Why the path of `link` (stored in the document saved at `doc_path`) isn't followed, when it
|
||||
@@ -272,10 +332,10 @@ fn refused(link: &LinkInfo, doc_path: Option<&str>) -> Option<craft_docpath::Ref
|
||||
fileio::document_path(&link.path, doc_path).err().filter(|r| *r != craft_docpath::Refused::Invalid)
|
||||
}
|
||||
|
||||
/// Where the file of group `g` (of the document saved at `doc_path`) is and whether it still has
|
||||
/// Where the file of group `g` (of a document looking from `base`) is and whether it still has
|
||||
/// the content shown. Its bytes are read only when its size or modification time changed.
|
||||
fn probe(g: &Group, doc_path: Option<&str>) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, doc_path) else { return (Status::Missing, None) };
|
||||
fn probe(g: &Group, base: LinkBase) -> (Status, Option<Found>) {
|
||||
let Some(mut f) = locate(&g.link, base) else { return (Status::Missing, None) };
|
||||
let l = &g.link;
|
||||
if l.size == Some(f.size) && l.modified.is_some() && l.modified == f.modified {
|
||||
return (Status::Ok, Some(f));
|
||||
@@ -328,9 +388,9 @@ pub(crate) struct LinkedFile {
|
||||
pub bytes: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Every file the linked images of `d` (saved at `doc_path`) show, in the layers, symbols and
|
||||
/// Every file the linked images of `d` (looking from `base`) show, in the layers, symbols and
|
||||
/// patterns, once each.
|
||||
pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFile> {
|
||||
pub(crate) fn linked_files(d: &Document, base: LinkBase) -> Vec<LinkedFile> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
groups(d, None)
|
||||
.into_iter()
|
||||
@@ -338,7 +398,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
.map(|g| {
|
||||
let named = fileio::format_for_name(g.link.name()).or_else(|| fileio::readable_format_for_name(g.link.name()));
|
||||
let own = |b: &&ImageBlob| !b.is_proxy() && named.is_some_and(|f| f.mime == b.mime);
|
||||
let found = locate(&g.link, doc_path);
|
||||
let found = locate(&g.link, base);
|
||||
let found_path = found.as_ref().map(|f| f.path.clone());
|
||||
let bytes = found.and_then(|mut f| f.read().ok()).or_else(|| d.images.get(&g.key).filter(own).map(|b| b.bytes.to_vec()));
|
||||
LinkedFile { path: g.link.path.clone(), name: g.link.name().to_string(), found_path, bytes }
|
||||
@@ -348,7 +408,7 @@ pub(crate) fn linked_files(d: &Document, doc_path: Option<&str>) -> Vec<LinkedFi
|
||||
|
||||
/// `d` for output: linked raster images that still show only their saved preview read their files
|
||||
/// again (Preferences › Use Low Resolution Proxy for Linked EPS keeps the preview on screen only).
|
||||
pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> (Cow<'a, Document>, Vec<String>) {
|
||||
pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a, Document>, Vec<String>) {
|
||||
let mut keys = BTreeSet::new();
|
||||
d.visit_images(|_, im| {
|
||||
if im.link.is_some() && d.images.get(&im.key).is_some_and(ImageBlob::is_proxy) {
|
||||
@@ -363,7 +423,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) ->
|
||||
let all = groups(d, None);
|
||||
for key in keys {
|
||||
let Some(g) = all.iter().find(|g| g.document.is_none() && g.key == key) else { continue };
|
||||
let Some(mut f) = locate(&g.link, doc_path) else {
|
||||
let Some(mut f) = locate(&g.link, base) else {
|
||||
previews.insert(key);
|
||||
continue;
|
||||
};
|
||||
@@ -398,7 +458,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) ->
|
||||
pub(crate) fn files_to_find(st: &crate::DocState, ids: Option<&[NodeId]>) -> Vec<(LinkInfo, Vec<NodeId>)> {
|
||||
groups(&st.doc, ids)
|
||||
.into_iter()
|
||||
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, st.path.as_deref()).0 == Status::Missing))
|
||||
.filter(|g| !g.ids.is_empty() && (ids.is_some() || probe(g, LinkBase::of(st)).0 == Status::Missing))
|
||||
.map(|g| (g.link, g.ids))
|
||||
.collect()
|
||||
}
|
||||
@@ -847,13 +907,13 @@ impl Resolved {
|
||||
}
|
||||
}
|
||||
|
||||
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
|
||||
/// again (their images had the preview), modified ones are read again when `update`, links found
|
||||
/// away from their path take the new path. A link nothing is known of whose image, or placed
|
||||
/// The linked files of document `d`, just read and looking from `base`: those found unchanged are
|
||||
/// read again (their images had the preview), modified ones are read again when `update`, links
|
||||
/// found away from their path take the new path. A link nothing is known of whose image, or placed
|
||||
/// document, shows a preview (a file another app wrote kept only that) was never read: its file is
|
||||
/// read now, into the object's bounds, which isn't a change to report. A placed document whose
|
||||
/// file's bytes are gone isn't showing a preview. Nothing here is an undo step.
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
|
||||
pub fn resolve(d: &mut Document, base: LinkBase, update: bool, low_res_proxy_eps: bool) -> Resolved {
|
||||
let mut out = Resolved::default();
|
||||
// The image (with its preview) and hash of each file read for a preview here, by path: images
|
||||
// of other keys linking to the same file share them, rather than each reading, decoding and
|
||||
@@ -863,14 +923,14 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
// away from its path or read again.
|
||||
let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![];
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g), doc_path);
|
||||
let row = rows(std::iter::once(&g), base.doc);
|
||||
let shows_preview = match g.document {
|
||||
None => previewing(d, &g.key),
|
||||
Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy),
|
||||
};
|
||||
let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview;
|
||||
let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d);
|
||||
match probe(&g, doc_path) {
|
||||
match probe(&g, base) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
(Status::Modified, Some(mut f)) if update || unread => {
|
||||
let force = unread.then_some(Preserve::Bounds);
|
||||
@@ -894,7 +954,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
None => {
|
||||
let link = match f.bytes.take() {
|
||||
Some(b) => f.link(&b, &g.link),
|
||||
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
|
||||
None => f.moved(&g.link),
|
||||
};
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
@@ -932,7 +992,7 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
// Found away from its path, or read again: the link follows.
|
||||
let link = match &bytes {
|
||||
Some(b) => f.link(b, &g.link),
|
||||
None => LinkInfo { path: f.path.clone(), ..g.link.clone() },
|
||||
None => f.moved(&g.link),
|
||||
};
|
||||
if link != g.link {
|
||||
set_links(d, &g.ids, &link);
|
||||
@@ -1035,42 +1095,68 @@ fn relative_path(target: &Path, base: &Path) -> Option<String> {
|
||||
Some(ups.chain(rest).collect::<Vec<std::borrow::Cow<str>>>().join("/"))
|
||||
}
|
||||
|
||||
/// `d` as saved to `dest`: the links' paths relative to its folder written in; `None` when they
|
||||
/// are already. Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
|
||||
pub fn with_relative_paths(d: &Document, dest: &str) -> Option<Document> {
|
||||
let dest = absolute_path(dest);
|
||||
let dir = Path::new(&dest).parent()?;
|
||||
let relative = |l: &LinkInfo| relative_path(Path::new(&l.path), dir);
|
||||
let outdated = |l: &LinkInfo| relative(l) != l.relative;
|
||||
let mut changed = false;
|
||||
d.visit_images(|_, im| changed |= im.link.as_ref().is_some_and(outdated));
|
||||
d.visit_placed(|_, p| changed |= outdated(&p.link));
|
||||
if !changed {
|
||||
return None;
|
||||
/// The file `link` (of a document looking from `base`) is written relative to: where [`locate`]
|
||||
/// finds it, else the place its relative path names from the folder of [`LinkBase::relative`],
|
||||
/// else its path.
|
||||
fn link_target(link: &LinkInfo, base: LinkBase) -> PathBuf {
|
||||
if let Some(f) = locate(link, base) {
|
||||
return PathBuf::from(f.path);
|
||||
}
|
||||
match (base.relative, link.relative.as_deref()) {
|
||||
(Some(_), Some(rel)) => fileio::document_path(rel, base.relative).map_or_else(|_| PathBuf::from(&link.path), PathBuf::from),
|
||||
_ => PathBuf::from(&link.path),
|
||||
}
|
||||
let mut d = d.clone();
|
||||
d.update_links_where(outdated, |l| l.relative = relative(l));
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Sets each link of `d`, a copy of the document saved at `doc_path` that has no file of its own,
|
||||
/// to the path where that document finds its file, so the copy finds the same files.
|
||||
/// Links in the layers, symbols, graph designs, patterns and opacity masks are all set.
|
||||
pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) {
|
||||
// The new path of each link by what `locate` reads of it: its path, relative path and name.
|
||||
let mut found: BTreeMap<(String, Option<String>), Option<String>> = BTreeMap::new();
|
||||
/// `d` (looking from `base`) as saved to `dest`: the links' paths relative to its folder written
|
||||
/// in; `None` when they are already. A link whose file is not at its path is written relative to
|
||||
/// where its file is found, else to the place its relative path names. Links in the layers,
|
||||
/// symbols, graph designs, patterns and opacity masks are all set.
|
||||
pub fn with_relative_paths(d: &Document, base: LinkBase, dest: &str) -> Option<Document> {
|
||||
let dest = absolute_path(dest);
|
||||
let dir = Path::new(&dest).parent()?;
|
||||
// The relative path of each link by its path and stored relative path, each file looked at once.
|
||||
let mut wanted: BTreeMap<(String, Option<String>), Option<String>> = BTreeMap::new();
|
||||
let mut look = |l: &LinkInfo| {
|
||||
found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| locate(l, Some(doc_path)).map(|f| f.path));
|
||||
wanted.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| relative_path(&link_target(l, base), dir));
|
||||
};
|
||||
d.visit_images(|_, im| im.link.iter().for_each(&mut look));
|
||||
d.visit_placed(|_, p| look(&p.link));
|
||||
found.retain(|(path, _), f| f.as_ref().is_some_and(|f| f != path));
|
||||
wanted.retain(|(_, stored), rel| rel != stored);
|
||||
if wanted.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let key = |l: &LinkInfo| (l.path.clone(), l.relative.clone());
|
||||
let mut d = d.clone();
|
||||
d.update_links_where(|l| wanted.contains_key(&key(l)), |l| l.relative = wanted.get(&key(l)).cloned().flatten());
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Sets each link of `d`, a copy of a document looking from `base`, to the path where that
|
||||
/// document finds its file, and its relative path to it as that document's links have them, so
|
||||
/// the copy finds the same files. Links in the layers, symbols, graph designs, patterns and opacity
|
||||
/// masks are all set.
|
||||
pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
|
||||
// The new path and relative path of each link by what `locate` reads of it: its path, relative
|
||||
// path and name.
|
||||
type PathAndRelative = (String, Option<String>);
|
||||
let mut found: BTreeMap<PathAndRelative, Option<PathAndRelative>> = BTreeMap::new();
|
||||
let mut look = |l: &LinkInfo| {
|
||||
found.entry((l.path.clone(), l.relative.clone())).or_insert_with(|| {
|
||||
let moved = locate(l, base)?.moved(l);
|
||||
Some((moved.path, moved.relative))
|
||||
});
|
||||
};
|
||||
d.visit_images(|_, im| im.link.iter().for_each(&mut look));
|
||||
d.visit_placed(|_, p| look(&p.link));
|
||||
found.retain(|(path, _), f| f.as_ref().is_some_and(|(f, _)| f != path));
|
||||
if found.is_empty() {
|
||||
return;
|
||||
}
|
||||
d.update_links(|l| {
|
||||
if let Some(Some(f)) = found.get(&(l.path.clone(), l.relative.clone())) {
|
||||
l.path = f.clone();
|
||||
if let Some(Some((path, relative))) = found.get(&(l.path.clone(), l.relative.clone())) {
|
||||
l.path = path.clone();
|
||||
l.relative = relative.clone();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1079,15 +1165,15 @@ pub(crate) fn with_found_paths(d: &mut Document, doc_path: &str) {
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let ids = ids_param(p, "ids");
|
||||
let ids = ids_param(p, "ids", "links.check")?;
|
||||
let (mut missing, mut modified) = (0, 0);
|
||||
let mut links = vec![];
|
||||
for g in groups(&st.doc, ids.as_deref()).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, st.path.as_deref());
|
||||
let (status, found) = probe(g, LinkBase::of(st));
|
||||
missing += usize::from(status == Status::Missing);
|
||||
modified += usize::from(status == Status::Modified);
|
||||
let mut row = json!({ "name": g.link.name(), "path": g.link.path, "status": status.as_str(), "ids": ids_json(&g.ids) });
|
||||
note_refused(&mut row, &g.link, st.path.as_deref());
|
||||
note_refused(&mut row, &g.link, LinkBase::of(st).doc);
|
||||
if let Some(f) = found.filter(|f| f.path != g.link.path) {
|
||||
row["found"] = json!(f.path);
|
||||
}
|
||||
@@ -1100,7 +1186,7 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
update_where(s, ids_param(p, "ids"), None)
|
||||
update_where(s, ids_param(p, "ids", "links.update")?, None)
|
||||
}
|
||||
|
||||
/// Update Links in the active document of `s` for objects `ids` (default: the linked files that
|
||||
@@ -1112,7 +1198,7 @@ fn update_where(s: &mut Session, ids: Option<Vec<NodeId>>, path: Option<&str>) -
|
||||
let (mut reads, mut missing) = (vec![], vec![]);
|
||||
let asked = ids.is_some() || path.is_some();
|
||||
for g in groups(&st.doc, ids.as_deref()).into_iter().filter(|g| path.is_none_or(|p| g.document.is_some() && g.link.path == p)) {
|
||||
match probe(&g, st.path.as_deref()) {
|
||||
match probe(&g, LinkBase::of(st)) {
|
||||
(Status::Missing, _) | (_, None) => missing.extend(ids_json(&g.ids)),
|
||||
// Unchanged and showing the file: nothing to read unless asked for.
|
||||
(Status::Ok, Some(_)) if !asked && !stale(&st.doc, &g) => {}
|
||||
@@ -1225,14 +1311,14 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let mut ids: Vec<NodeId> = match (&each, ids_param(p, "ids")) {
|
||||
let mut ids: Vec<NodeId> = match (&each, ids_param(p, "ids", C)?) {
|
||||
(Some(each), _) => each.iter().flat_map(|(ids, _)| ids.iter().copied()).collect(),
|
||||
(None, Some(ids)) => ids,
|
||||
(None, None) => {
|
||||
let selected: Vec<NodeId> = st.selection.objects.iter().copied().filter(|id| image(id).is_some()).collect();
|
||||
match folder_param {
|
||||
Some(_) if selected.is_empty() => {
|
||||
groups(&st.doc, None).into_iter().filter(|g| probe(g, st.path.as_deref()).0 == Status::Missing).flat_map(|g| g.ids).collect()
|
||||
groups(&st.doc, None).into_iter().filter(|g| probe(g, LinkBase::of(st)).0 == Status::Missing).flat_map(|g| g.ids).collect()
|
||||
}
|
||||
_ => selected,
|
||||
}
|
||||
@@ -1368,7 +1454,7 @@ fn missing_in_folder(
|
||||
let Some(dir) = files.first().and_then(|(_, p)| Path::new(p).parent()).map(Path::to_path_buf) else { return };
|
||||
for g in groups(&st.doc, None).into_iter().filter(|g| !(images_only && g.document.is_some())) {
|
||||
let rest: Vec<NodeId> = g.ids.iter().copied().filter(|id| !given.contains(id)).collect();
|
||||
if rest.is_empty() || probe(&g, st.path.as_deref()).0 != Status::Missing {
|
||||
if rest.is_empty() || probe(&g, LinkBase::of(st)).0 != Status::Missing {
|
||||
continue;
|
||||
}
|
||||
let found = absolute_path(&dir.join(g.link.name()).to_string_lossy());
|
||||
@@ -1482,7 +1568,7 @@ fn linked_param(st: &crate::DocState, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
pub(crate) fn statuses(st: &crate::DocState) -> BTreeMap<NodeId, (Status, Option<String>, bool)> {
|
||||
let mut out = BTreeMap::new();
|
||||
for g in groups(&st.doc, None).iter().filter(|g| !g.ids.is_empty()) {
|
||||
let (status, found) = probe(g, st.path.as_deref());
|
||||
let (status, found) = probe(g, LinkBase::of(st));
|
||||
let found = found.map(|f| f.path).filter(|p| *p != g.link.path);
|
||||
let preview = stale(&st.doc, g);
|
||||
out.extend(g.ids.iter().map(|id| (*id, (status, found.clone(), preview))));
|
||||
@@ -1603,7 +1689,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(NodeKind::PlacedDocument(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect());
|
||||
let ids = ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(linked).collect());
|
||||
if let Some(id) = ids.iter().find(|id| !matches!(kind(id), Some(NodeKind::Image(_) | NodeKind::PlacedDocument(_)))) {
|
||||
return Err(bad(C, format!("object {} is not an image or a placed document", id.0)));
|
||||
}
|
||||
@@ -1625,7 +1711,7 @@ fn embed(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
done.extend(g.ids);
|
||||
continue;
|
||||
}
|
||||
match probe(&g, st.path.as_deref()) {
|
||||
match probe(&g, LinkBase::of(st)) {
|
||||
(Status::Ok | Status::Modified, Some(mut f)) => match read_image(&mut f, &g.link) {
|
||||
Ok(read) => {
|
||||
done.extend(&g.ids);
|
||||
@@ -1714,8 +1800,9 @@ fn file_fields(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value)
|
||||
}
|
||||
|
||||
fn file_fields_here(st: &crate::DocState, id: NodeId, l: &LinkInfo, out: &mut Value) {
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, st.path.as_deref()).0);
|
||||
let doc_path = st.path.as_deref();
|
||||
let base = LinkBase::of(st);
|
||||
let status = groups(&st.doc, Some(&[id])).first().map_or(Status::Missing, |g| probe(g, base).0);
|
||||
let doc_path = base.doc;
|
||||
let path = fileio::document_path(&l.path, doc_path).ok();
|
||||
let stamp = path.as_deref().and_then(file_stamp);
|
||||
out["status"] = json!(status.as_str());
|
||||
@@ -1792,7 +1879,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn placement_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "links.placementOptions";
|
||||
let st = s.doc()?;
|
||||
let ids = ids_param(p, "ids").unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect());
|
||||
let ids =
|
||||
ids_param(p, "ids", C)?.unwrap_or_else(|| st.selection.objects.iter().copied().filter(|id| placement_of(&st.doc, *id).is_some()).collect());
|
||||
if let Some(id) = ids.iter().find(|id| placement_of(&st.doc, **id).is_none()) {
|
||||
return Err(bad(C, format!("object {} is not an image or a linked file", id.0)));
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ fn apply_transform_moving_guides(
|
||||
let c = xf.as_coeffs();
|
||||
let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9;
|
||||
let delta = guides.unwrap_or(Vec2::new(c[4], c[5]));
|
||||
let with_guides = objects && ids_param(p, "ids").is_none() && is_translation;
|
||||
let with_guides = objects && p.get("ids").is_none_or(Value::is_null) && is_translation;
|
||||
let has_guides = with_guides && !s.doc()?.selection.guides.is_empty();
|
||||
// A transform of ids that name no object, or an identity transform without a copy, moves
|
||||
// nothing: the document and its undo history stay as they are, and Transform Again repeats it.
|
||||
@@ -254,7 +254,7 @@ pub(super) fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if p.get("ids").is_none() && crate::cmd::brushsym::selected_part(s)?.is_some() {
|
||||
return Ok(json!({ "warning": crate::cmd::brushsym::part_only().to_string() }));
|
||||
}
|
||||
let (ids, anchors) = match ids_param(p, "ids") {
|
||||
let (ids, anchors) = match ids_param(p, "ids", "object.transform")? {
|
||||
Some(v) => (outermost(&s.doc()?.doc, v), false),
|
||||
None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)),
|
||||
};
|
||||
|
||||
@@ -210,7 +210,7 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(id) => Err(bad(C, format!("object {} is hidden or locked, or on a locked artboard", id.0))),
|
||||
None => Ok(()),
|
||||
};
|
||||
let mut ids = match ids_param(p, "ids") {
|
||||
let mut ids = match ids_param(p, "ids", C)? {
|
||||
Some(ids) => {
|
||||
if let Some(id) = ids.iter().find(|id| d.node(**id).is_none()) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
|
||||
@@ -229,7 +229,7 @@ pub(crate) fn overprint_black(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
rich: bool_or(p, "includeCmyBlacks", false),
|
||||
spot: bool_or(p, "includeSpotBlacks", false),
|
||||
};
|
||||
let ids = match ids_param(p, "ids") {
|
||||
let ids = match ids_param(p, "ids", "edit.colors.overprintBlack")? {
|
||||
Some(v) => v,
|
||||
None => selected_roots(s)?,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ use vectorcraft_doc::{Document, LinkInfo};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::fileio::{create_dir, write_file};
|
||||
use super::links::LinkBase;
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, cmd, has_doc, str_param, unique_name};
|
||||
|
||||
const C: &str = "file.package";
|
||||
@@ -187,8 +188,18 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
|
||||
/// Collects the files `doc` links. `by_name`: `doc` is a placed `.ai` file's editing data,
|
||||
/// whose links are not rewritten, so its copy finds them by name beside it.
|
||||
fn collect(&mut self, doc: &mut Document, source: &str, destination: &str, depth: usize, by_name: bool) -> Result<()> {
|
||||
/// whose links are not rewritten, so its copy finds them by name beside it. `lookup`: the
|
||||
/// document whose links (with the same paths as `doc`'s) are looked for, and where from, when
|
||||
/// not `doc` from `source`.
|
||||
fn collect(
|
||||
&mut self,
|
||||
doc: &mut Document,
|
||||
source: &str,
|
||||
destination: &str,
|
||||
depth: usize,
|
||||
by_name: bool,
|
||||
lookup: Option<(&Document, LinkBase)>,
|
||||
) -> Result<()> {
|
||||
// The caller checks cycles and depth before descending; active documents have
|
||||
// a destination reserved so back-links can point at their single packaged copy.
|
||||
self.visiting.insert(source.to_string(), destination.to_string());
|
||||
@@ -210,6 +221,10 @@ impl<'a> Collector<'a> {
|
||||
// The fonts of the type on the PDF and `.ai` pages placed linked.
|
||||
let mut placed_fonts = BTreeSet::new();
|
||||
let mut placed = BTreeMap::<String, PlacedPages>::new();
|
||||
let linked_files = |doc: &Document| match lookup {
|
||||
Some((d, base)) => super::links::linked_files(d, base),
|
||||
None => super::links::linked_files(doc, LinkBase::file(Some(source))),
|
||||
};
|
||||
doc.visit_placed(|_, p| {
|
||||
let page = (p.link.page.unwrap_or(1), p.bounding, p.page_box);
|
||||
let pages = placed.entry(p.link.path.clone()).or_default();
|
||||
@@ -218,7 +233,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
});
|
||||
if self.opts.copy_fonts && !self.opts.copy_links && !placed.is_empty() {
|
||||
for file in super::links::linked_files(doc, Some(source)) {
|
||||
for file in linked_files(doc) {
|
||||
if let (Some(pages), Some(bytes)) = (placed.get(&file.path), &file.bytes) {
|
||||
placed_fonts.extend(page_fonts(&file.name, bytes, file.found_path.as_deref().unwrap_or(&file.path), pages));
|
||||
}
|
||||
@@ -226,7 +241,7 @@ impl<'a> Collector<'a> {
|
||||
}
|
||||
if self.opts.copy_links {
|
||||
heading(&mut lines, "LINKED FILES");
|
||||
for file in super::links::linked_files(doc, Some(source)) {
|
||||
for file in linked_files(doc) {
|
||||
let found_path = file.found_path.as_deref().unwrap_or(&file.path);
|
||||
if let Some(ancestor) = self.visiting.get(found_path).cloned() {
|
||||
// The ancestor is already included (possibly the root document). A
|
||||
@@ -272,7 +287,7 @@ impl<'a> Collector<'a> {
|
||||
if depth >= vectorcraft_doc::placed_document::MAX_DEPTH {
|
||||
self.warn(&mut lines, format!("{}: nesting limit reached; copied unchanged without collecting its own links", file.path));
|
||||
} else {
|
||||
self.collect(&mut art, found_path, &destination_file, depth + 1, true)?;
|
||||
self.collect(&mut art, found_path, &destination_file, depth + 1, true, None)?;
|
||||
}
|
||||
} else if self.opts.copy_fonts
|
||||
&& let Some(pages) = pages
|
||||
@@ -289,7 +304,7 @@ impl<'a> Collector<'a> {
|
||||
let include_linked =
|
||||
saved.doc.linked_only_images().iter().any(|key| saved.doc.images.get(key).is_some_and(|blob| !blob.is_proxy()));
|
||||
let mut nested = saved.doc;
|
||||
self.collect(&mut nested, found_path, &destination_file, depth + 1, false)?;
|
||||
self.collect(&mut nested, found_path, &destination_file, depth + 1, false, None)?;
|
||||
if self.opts.relink {
|
||||
#[cfg(test)]
|
||||
PARTS_READ.with(|c| c.set(c.get() + 1));
|
||||
@@ -405,6 +420,8 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub struct Package {
|
||||
o: Options,
|
||||
doc_path: String,
|
||||
/// The file the document's links' stored relative paths are taken from.
|
||||
links_from: String,
|
||||
stem: String,
|
||||
name: String,
|
||||
root: Option<PathBuf>,
|
||||
@@ -450,7 +467,8 @@ fn prepare(s: &mut Session, p: &Value) -> Result<Package> {
|
||||
let root = folder.map(|f| Path::new(f).join(&name));
|
||||
let info = if o.report { Some(super::docinfo::report(s, false)?) } else { None };
|
||||
let doc = s.doc()?.doc.clone();
|
||||
Ok(Package { o, doc_path, stem, name, root, info, doc })
|
||||
let links_from = s.doc()?.links_from.clone().unwrap_or_else(|| doc_path.clone());
|
||||
Ok(Package { o, doc_path, links_from, stem, name, root, info, doc })
|
||||
}
|
||||
|
||||
impl Package {
|
||||
@@ -464,18 +482,27 @@ impl Package {
|
||||
|
||||
/// [`Package::write`].
|
||||
fn collect_and_write(package: Package, write: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let Package { o, doc_path, stem, name, root, info, doc } = package;
|
||||
let mut doc = (*doc).clone();
|
||||
let Package { o, doc_path, links_from, stem, name, root, info, doc: original } = package;
|
||||
let mut doc = (*original).clone();
|
||||
let doc_name = format!("{stem}.{}", vectorcraft_format::EXTENSION);
|
||||
let base = LinkBase { doc: Some(&doc_path), relative: Some(&links_from) };
|
||||
// After a save in another folder, links found away from their paths take the paths where they
|
||||
// were found, as a copy of the document has them.
|
||||
let found = (links_from != doc_path).then(|| {
|
||||
super::links::with_found_paths(&mut doc, base);
|
||||
doc.clone()
|
||||
});
|
||||
let looked = found.as_ref().unwrap_or(&original);
|
||||
// Prepare original links for the destination *before* rebasing copied links.
|
||||
// Recalculating afterward would overwrite the portable relative paths.
|
||||
if let Some(root) = &root
|
||||
&& let Some(d) = super::links::with_relative_paths(&doc, &root.join(&doc_name).to_string_lossy())
|
||||
&& let Some(d) = super::links::with_relative_paths(&doc, base, &root.join(&doc_name).to_string_lossy())
|
||||
{
|
||||
doc = d;
|
||||
}
|
||||
let mut collector = Collector::new(&o, root.clone(), &doc_name);
|
||||
collector.collect(&mut doc, &doc_path, &doc_name, 0, false)?;
|
||||
// The files are looked for where the links name them before the rebasing above.
|
||||
collector.collect(&mut doc, &doc_path, &doc_name, 0, false, Some((looked, base)))?;
|
||||
let mut entries = std::mem::take(&mut collector.entries);
|
||||
entries.insert(0, (doc_name.clone(), vectorcraft_format::save_file(&doc)));
|
||||
let mut taken = std::mem::take(&mut collector.taken);
|
||||
|
||||
@@ -950,7 +950,7 @@ fn proxy_recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(
|
||||
// The proxy recolors its paints, including a colorized image's fill, without changing image pixels or pattern tiles.
|
||||
ItemTarget::Top => {
|
||||
let q = json!({"fill": !stroke, "stroke": stroke, "includeImages": false, "includePatterns": false, "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()});
|
||||
(super::colorcmds::recolor(s, &q, label, f)?, None)
|
||||
(super::colorcmds::recolor(s, &q, cmd, label, f)?, None)
|
||||
}
|
||||
};
|
||||
let shown_ids = if shown_item.is_some() { ids } else { leaf_targets(s, &ids)? };
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -597,7 +597,7 @@ pub(super) fn join(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(ids) = ids_param(p, "ids") {
|
||||
if let Some(ids) = ids_param(p, "ids", C)? {
|
||||
if ends.is_some() && ids.len() > 2 {
|
||||
return Err(bad(C, "`ends` applies to joining exactly two paths"));
|
||||
}
|
||||
@@ -1101,7 +1101,7 @@ fn average_join_ends(s: &Session, p: &Value, c: &str) -> Result<Option<[End; 2]>
|
||||
let doc = &st.doc;
|
||||
let subpath = |id: NodeId, si: usize| doc.node(id)?.path_data()?.subpaths.get(si).filter(|sp| !sp.closed && !sp.anchors.is_empty());
|
||||
let last = |sp: &SubPath| sp.anchors.len().saturating_sub(1);
|
||||
let ids = ids_param(p, "ids");
|
||||
let ids = ids_param(p, "ids", c)?;
|
||||
let ends = if ids.is_none() && !st.selection.anchors.is_empty() {
|
||||
let picked: Vec<End> = st.selection.anchors.iter().flat_map(|(&id, set)| set.iter().map(move |&(si, ai)| (id, si, ai))).collect();
|
||||
match picked.as_slice() {
|
||||
|
||||
@@ -219,8 +219,8 @@ fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
fn roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match ids_param(p, "ids") {
|
||||
fn roots(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
match ids_param(p, "ids", cmd)? {
|
||||
Some(ids) => {
|
||||
let st = s.doc()?;
|
||||
let mut sel = Selection::default();
|
||||
@@ -306,7 +306,7 @@ fn pattern_make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if editing(s).is_some() {
|
||||
return Err(bad(C, "already in pattern editing mode"));
|
||||
}
|
||||
let roots = roots(s, p)?;
|
||||
let roots = roots(s, p, C)?;
|
||||
if roots.is_empty() {
|
||||
return Err(bad(C, "select the art for the pattern tile"));
|
||||
}
|
||||
@@ -665,9 +665,9 @@ fn pattern_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// ---------- Repeat ----------
|
||||
|
||||
/// Make a repeat from the selection, or switch the selected repeat's kind.
|
||||
fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<Node>>) -> RepeatSpec) -> Result<Value> {
|
||||
fn make_repeat(s: &mut Session, p: &Value, cmd: &str, label: &str, make: impl Fn(Vec<Arc<Node>>) -> RepeatSpec) -> Result<Value> {
|
||||
let existing = selected_repeats(s);
|
||||
if ids_param(p, "ids").is_none()
|
||||
if ids_param(p, "ids", cmd)?.is_none()
|
||||
&& let [r] = existing.as_slice()
|
||||
{
|
||||
let r = *r;
|
||||
@@ -680,7 +680,7 @@ fn make_repeat(s: &mut Session, p: &Value, label: &str, make: impl Fn(Vec<Arc<No
|
||||
})?;
|
||||
return Ok(json!({ "id": r.0 }));
|
||||
}
|
||||
let roots = roots(s, p)?;
|
||||
let roots = roots(s, p, cmd)?;
|
||||
if roots.is_empty() {
|
||||
return Err(EngineError::Other("select the art to repeat".into()));
|
||||
}
|
||||
@@ -699,14 +699,14 @@ fn repeat_radial(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("object.repeat.radial", "instances must be 1..1000"));
|
||||
}
|
||||
let radius = p.get("radius").and_then(Value::as_f64).filter(|r| r.is_finite() && *r >= 0.0);
|
||||
make_repeat(s, p, "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
|
||||
make_repeat(s, p, "object.repeat.radial", "Radial Repeat", |src| RepeatSpec::radial(src, n as u32, radius))
|
||||
}
|
||||
|
||||
fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (hs, vs) = (p.get("hSpacing").and_then(Value::as_f64), p.get("vSpacing").and_then(Value::as_f64));
|
||||
let rows = p.get("rows").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
|
||||
let cols = p.get("cols").and_then(Value::as_u64).map(|v| v.clamp(1, 500) as u32);
|
||||
make_repeat(s, p, "Grid Repeat", |src| {
|
||||
make_repeat(s, p, "object.repeat.grid", "Grid Repeat", |src| {
|
||||
let b = vectorcraft_doc::live::nodes_bounds(&src).unwrap_or(Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut r = RepeatSpec::grid(src, hs.unwrap_or(b.width() / 4.0), vs.unwrap_or(b.height() / 4.0));
|
||||
if let RepeatKind::Grid { rows: rr, cols: cc, .. } = &mut r.kind {
|
||||
@@ -720,7 +720,7 @@ fn repeat_grid(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn repeat_mirror(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let angle = f64_or(p, "angle", 90.0);
|
||||
let offset = f64_or(p, "offset", 10.0);
|
||||
make_repeat(s, p, "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
|
||||
make_repeat(s, p, "object.repeat.mirror", "Mirror Repeat", |src| RepeatSpec::mirror(src, angle, offset))
|
||||
}
|
||||
|
||||
fn repeat_release(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -254,7 +254,7 @@ fn load(p: &Value, cmd: &str, board: Option<Rect>, link_pdf: bool) -> Result<Loa
|
||||
));
|
||||
}
|
||||
// Linked images show their files, found from its folder.
|
||||
super::links::resolve(&mut l.doc, src.path, false, false);
|
||||
super::links::resolve(&mut l.doc, super::links::LinkBase::file(src.path), false, false);
|
||||
(l.doc, l.warnings)
|
||||
};
|
||||
doc.drop_edit_modes();
|
||||
@@ -312,7 +312,7 @@ pub(crate) fn pdf_art(
|
||||
// file again: one already being read further up, or past the most nested reads, keeps its links
|
||||
// as stored, as a VectorCraft document's do.
|
||||
if alone && let Some(_reading) = document::Reading::enter(bytes) {
|
||||
super::links::resolve(&mut doc, path, false, false);
|
||||
super::links::resolve(&mut doc, super::links::LinkBase::file(path), false, false);
|
||||
}
|
||||
doc.drop_edit_modes();
|
||||
let layers = std::mem::take(&mut doc.layers);
|
||||
|
||||
@@ -133,7 +133,7 @@ pub(crate) fn document_art(
|
||||
// A document already being read further up, or past [`MAX_NESTED_READS`] of them, has its links
|
||||
// left as stored.
|
||||
if let Some(_reading) = Reading::enter(bytes) {
|
||||
super::super::links::resolve(&mut l.doc, path, false, false);
|
||||
super::super::links::resolve(&mut l.doc, super::super::links::LinkBase::file(path), false, false);
|
||||
}
|
||||
l.doc.drop_edit_modes();
|
||||
let n = l.doc.artboards.len();
|
||||
@@ -332,7 +332,7 @@ pub(crate) fn node(d: &mut Document, s: &Source, link: LinkInfo, bounding: bool)
|
||||
/// `d` for output: linked images and placed documents that keep only their previews get their
|
||||
/// files back, else output as their previews, with a warning.
|
||||
pub(crate) fn full_documents(d: &Document) -> (Cow<'_, Document>, Vec<String>) {
|
||||
let (d, mut warnings) = super::super::links::full_linked_images(d, None);
|
||||
let (d, mut warnings) = super::super::links::full_linked_images(d, Default::default());
|
||||
let doc = match &d {
|
||||
Cow::Borrowed(doc) => doc,
|
||||
Cow::Owned(doc) => doc,
|
||||
|
||||
+113
-20
@@ -24,7 +24,10 @@
|
||||
//! `vc_cancelled` and `vc_log` ([`vectorcraft_plugins::host`]): run inside a
|
||||
//! [`vectorcraft_doc::cancel::scope`] (the app's heavy filters and background exports), its
|
||||
//! progress goes to the scope's sink and it sees the scope's Cancel; its log lines go to the log
|
||||
//! under its id.
|
||||
//! under its id. One that lists `settings` or `cache` may keep settings and a cache of its own
|
||||
//! (`vc_settings_*`, `vc_cache_*`), in the plug-in store under its id ([`crate::store`]):
|
||||
//! `plugin.clearCache` empties the cache, `plugin.list` and `plugin.info` give its size
|
||||
//! (`cacheBytes`), replacing the plug-in keeps both and `plugin.remove` deletes them.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
@@ -141,7 +144,7 @@ impl FilterRun {
|
||||
let st = s.doc()?;
|
||||
// While an object of a dynamic symbol instance's art is selected, the selection is refused,
|
||||
// as `selected_roots` refuses it.
|
||||
let selected = match ids_param(p, "ids") {
|
||||
let selected = match ids_param(p, "ids", C)? {
|
||||
Some(ids) => ids,
|
||||
None => {
|
||||
super::edit::refuse_symbol_part(s)?;
|
||||
@@ -340,13 +343,27 @@ fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// Would installing `plugin` take another module's place: one installed with its id and other
|
||||
/// bytes, or, with none installed, the settings and cache the plug-in store keeps for its id?
|
||||
fn takes_over(s: &Session, plugin: &Plugin) -> bool {
|
||||
match registry::get(plugin.id()) {
|
||||
Some(installed) => installed.hash() != plugin.hash(),
|
||||
None => s.has_plugin_storage(plugin.id()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
|
||||
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id
|
||||
/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant
|
||||
/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
|
||||
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted its id
|
||||
/// unless `grant` lists them all, one that would take another module's place ([`takes_over`])
|
||||
/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with
|
||||
/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?);
|
||||
let replace = match p.get("replace") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(switch_or(p, "replace", false, C)?),
|
||||
};
|
||||
let allow_heavy = switch_or(p, "allowHeavy", false, C)?;
|
||||
let grant = grant_param(C, p)?;
|
||||
let (plugin, bytes) = load_given(C, p)?;
|
||||
// `hash` pins the module the user was shown: one with other bytes is refused.
|
||||
@@ -355,9 +372,21 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(C, "the module doesn't have the SHA-256 `hash` gives: nothing was installed"));
|
||||
}
|
||||
if !replace && registry::get(plugin.id()).is_some() {
|
||||
if replace == Some(false) && registry::get(plugin.id()).is_some() {
|
||||
return Err(bad(C, format!("a plug-in with id {:?} is already installed", plugin.id())));
|
||||
}
|
||||
// Another module under the id gets the settings and cache kept for it: whoever installs it asks
|
||||
// the user first.
|
||||
if replace != Some(true) && takes_over(s, &plugin) {
|
||||
let name = permissions::display_text(&plugin.manifest().name, permissions::MAX_SHOWN_NAME);
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"“{name}” is another module than the plug-in with id {:?} and would get the settings and cache kept for it: install it with `replace: true` once the user agrees",
|
||||
plugin.id()
|
||||
),
|
||||
));
|
||||
}
|
||||
// A heavy plug-in is installed once the user agrees: whoever installs it asks them first.
|
||||
if plugin.manifest().budget == Budget::Heavy && !allow_heavy {
|
||||
let (minutes, times) = vectorcraft_plugins::heavy_terms();
|
||||
@@ -370,7 +399,8 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
}
|
||||
// A plug-in that lists permissions installs once the user grants them: whoever installs it
|
||||
// asks them first, unless they already granted its id all of them.
|
||||
// asks them first, unless they already granted all of them (with a plug-in store, to this
|
||||
// module).
|
||||
let m = plugin.manifest();
|
||||
let wanted = m.permissions();
|
||||
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
|
||||
@@ -383,8 +413,9 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
registry::grant(g.clone());
|
||||
s.store_grant(&m.id, Some(g));
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541).
|
||||
// Installed plug-ins stay installed after a restart (#2541), with their settings and cache.
|
||||
let stored = s.store_plugin(plugin.id(), &bytes);
|
||||
s.attach_plugin_storage(plugin.id());
|
||||
// Installing a disabled plug-in again (an updated version) turns it on for the next launch too,
|
||||
// also when it is disabled by its stored file alone (it stopped a launch while loading). When
|
||||
// the new version could not replace the stored file, the old one stays disabled.
|
||||
@@ -399,15 +430,17 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if changed {
|
||||
s.store_disabled_plugins();
|
||||
}
|
||||
Ok(describe(®istry::install(plugin)))
|
||||
Ok(describe_installed(®istry::install(plugin)))
|
||||
}
|
||||
|
||||
/// `plugin.inspect`: what installing the module would install, without installing it, and whether
|
||||
/// that needs the user to grant its permissions first (`needsApproval`).
|
||||
/// `plugin.inspect`: what installing the module would install, without installing it, whether that
|
||||
/// needs the user to grant its permissions first (`needsApproval`) and whether it would take
|
||||
/// another module's place (`needsReplace`).
|
||||
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
load_given("plugin.inspect", p).map(|(plugin, _)| {
|
||||
let mut v = describe(&plugin);
|
||||
v["needsApproval"] = json!(!s.plugin_approved(&plugin));
|
||||
v["needsReplace"] = json!(takes_over(s, &plugin));
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -478,6 +511,9 @@ fn load_folder(s: &Session, folder: &str) -> Value {
|
||||
};
|
||||
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) {
|
||||
Ok(r) => {
|
||||
for id in &r.loaded {
|
||||
s.attach_plugin_storage(id);
|
||||
}
|
||||
if r.disabled_changed {
|
||||
s.store_disabled_plugins();
|
||||
}
|
||||
@@ -509,11 +545,18 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// [`describe`] for an installed plug-in, with the bytes its cache holds (`cacheBytes`).
|
||||
fn describe_installed(p: &Plugin) -> Value {
|
||||
let mut v = describe(p);
|
||||
v["cacheBytes"] = json!(registry::storage(p.id()).map_or(0, |st| st.cache_bytes()));
|
||||
v
|
||||
}
|
||||
|
||||
fn list(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
let folder = FOLDER.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|(_, _, r)| r.clone());
|
||||
let disabled: Vec<Value> = safe_mode::disabled().iter().map(safe_mode::Item::to_json).collect();
|
||||
Ok(
|
||||
json!({"plugins": registry::list().iter().map(|p| describe(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
|
||||
json!({"plugins": registry::list().iter().map(|p| describe_installed(p)).collect::<Vec<_>>(), "folder": folder, "safeMode": safe_mode::active(), "disabled": disabled}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -568,7 +611,7 @@ fn set_enabled(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plugin = plugin_by_id("plugin.info", p)?;
|
||||
let mut v = describe(&plugin);
|
||||
let mut v = describe_installed(&plugin);
|
||||
v["manifest"] = serde_json::to_value(plugin.manifest()).unwrap_or(Value::Null);
|
||||
v["defaults"] = Value::Object(plugin.manifest().defaults());
|
||||
v["lastError"] = json!(effect::last_error(plugin.id()));
|
||||
@@ -577,15 +620,48 @@ fn info(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("plugin.remove", "missing `id`"))?;
|
||||
let installed = registry::get(id).is_some();
|
||||
// Settings and cache the store keeps for an id that isn't installed are deleted too.
|
||||
let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id);
|
||||
// Its settings and cache go first: when they can't be moved aside, the plug-in stays installed,
|
||||
// as it was, and a second `plugin.remove` tries again.
|
||||
if (installed || stored)
|
||||
&& let Err(e) = s.remove_plugin_storage(id)
|
||||
{
|
||||
if installed {
|
||||
s.attach_plugin_storage(id);
|
||||
}
|
||||
return Err(EngineError::Other(e.to_string()));
|
||||
}
|
||||
// Installed again, it asks again.
|
||||
registry::revoke(id);
|
||||
s.store_grant(id, None);
|
||||
s.unstore_plugin(id);
|
||||
if registry::remove(id) { Ok(json!({"removed": id})) } else { Err(err(vectorcraft_plugins::Error::NotFound(id.into()))) }
|
||||
if !stored && (!installed || !registry::remove(id)) {
|
||||
return Err(err(vectorcraft_plugins::Error::NotFound(id.into())));
|
||||
}
|
||||
Ok(json!({"removed": id}))
|
||||
}
|
||||
|
||||
/// `plugin.clearCache`: empties the cache of plug-in `id`, else of every installed plug-in →
|
||||
/// `{cleared: [id], bytesFreed}`. Settings stay.
|
||||
fn clear_cache(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let plugins = match p.get("id").filter(|v| !v.is_null()) {
|
||||
Some(_) => vec![plugin_by_id("plugin.clearCache", p)?],
|
||||
None => registry::list(),
|
||||
};
|
||||
let (mut cleared, mut freed) = (Vec::new(), 0_u64);
|
||||
for plugin in plugins {
|
||||
if let Some(st) = registry::storage(plugin.id()) {
|
||||
freed += st.clear_cache().map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
}
|
||||
cleared.push(plugin.id().to_string());
|
||||
}
|
||||
Ok(json!({"cleared": cleared, "bytesFreed": freed}))
|
||||
}
|
||||
|
||||
/// This module's commands: `plugin.list`, `.info`, `.run`, `.install`, `.inspect`, `.remove`,
|
||||
/// `.reload`, `.safeMode` and `.setEnabled`.
|
||||
/// `.reload`, `.clearCache`, `.safeMode` and `.setEnabled`.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -593,7 +669,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"List Plug-ins",
|
||||
[],
|
||||
None,
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
"{} → {plugins: [{id, name, version, kind: \"filter\"|\"effect\"|\"exporter\", author, description, params (doc), paramsSchema, effect? (the effect id of a live effect: plugin.<id>), format? and extensions? (an exporter's format id, plugin.<id>, for document.export, and its file extensions), budget?: \"heavy\" (a filter that may compute for longer, see plugin.install's allowHeavy), size, source, permissions: [name] (what it may do: the ones its manifest lists, else the ones its kind implies; progress: it may report its progress, see Cancel and write log lines; settings: it may keep settings of its own; cache: it may keep a cache of its own), hash (the module's SHA-256), api (the plug-in API level it was built for, 1 or 2), cacheBytes (the bytes its cache holds; see plugin.clearCache)}], folder: the Additional Plug-ins Folder load report {folder, loaded, failed: [{file, error}]} | null, safeMode: bool (no plug-in loads or runs, see plugin.safeMode), disabled: [{id, file, name, label}] (turned off for the next launch, see plugin.setEnabled)}",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -620,7 +696,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count, each for the module with its hash alone), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
@@ -629,11 +705,28 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inspect Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
|
||||
always,
|
||||
inspect
|
||||
),
|
||||
cmd!(query "plugin.remove", "Remove Plug-in", [], None, "{id} uninstall a plug-in (its live effects then leave the geometry as it is) → {removed}", always, remove),
|
||||
cmd!(
|
||||
query "plugin.remove",
|
||||
"Remove Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
|
||||
always,
|
||||
remove
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.clearCache",
|
||||
"Clear Plug-in Cache",
|
||||
[],
|
||||
None,
|
||||
"{id?} delete every entry in the cache of plug-in id (default: of every installed plug-in); its settings stay → {cleared: [id], bytesFreed}",
|
||||
always,
|
||||
clear_cache
|
||||
),
|
||||
cmd!(
|
||||
query "plugin.reload",
|
||||
"Reload Plug-ins",
|
||||
|
||||
@@ -575,6 +575,10 @@ struct Meta {
|
||||
/// (see [`crate::DocState::import_losses`]): a restored copy still refuses to replace it.
|
||||
imported_from: Option<String>,
|
||||
import_losses: Vec<String>,
|
||||
/// The file the links' relative paths name places from, for a copy without `path` (see
|
||||
/// [`crate::DocState::links_from`]).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
links_from: Option<String>,
|
||||
/// The document's images by key, in entries of the area (none in copies written before
|
||||
/// images were kept apart: the document entry holds them).
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
@@ -844,6 +848,7 @@ pub fn jobs(s: &mut Session, store: &Arc<dyn RecoveryStore>) -> Result<(Vec<Reco
|
||||
saved,
|
||||
imported_from: st.imported_from.clone(),
|
||||
import_losses: st.import_losses.clone(),
|
||||
links_from: st.links_from.clone().filter(|_| st.path.is_none()),
|
||||
// Filled in as the copy is written.
|
||||
images: BTreeMap::new(),
|
||||
};
|
||||
@@ -1061,11 +1066,14 @@ fn restore_one(s: &mut Session, store: &Arc<dyn RecoveryStore>, file: &str) -> R
|
||||
if doc.title.is_empty() {
|
||||
doc.title = if meta.title.is_empty() { stem.to_string() } else { meta.title.clone() };
|
||||
}
|
||||
// Linked files are looked for from the document's own folder.
|
||||
let links = super::links::resolve(&mut doc, meta.path.as_deref(), s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
|
||||
// Linked files are looked for from the document's own folder, else from the file it was read from.
|
||||
let links_from = meta.path.clone().or_else(|| meta.links_from.clone());
|
||||
let base = super::links::LinkBase::file(links_from.as_deref());
|
||||
let links = super::links::resolve(&mut doc, base, s.prefs.update_links == "automatically", s.prefs.low_res_proxy_eps);
|
||||
let index = s.add_document(doc, meta.path.clone());
|
||||
let st = s.doc_mut()?;
|
||||
st.recovered = true;
|
||||
st.links_from = links_from;
|
||||
st.imported_from = meta.imported_from.clone();
|
||||
st.import_losses = meta.import_losses.clone();
|
||||
st.format = SAVE_FORMATS.iter().copied().find(|f| *f == meta.format).unwrap_or("vectorcraft");
|
||||
|
||||
@@ -204,7 +204,7 @@ impl Session {
|
||||
/// The slices a command acts on: `slices` (ids that must all be slices), else the selected ones.
|
||||
fn slice_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
match ids_param(p, "slices") {
|
||||
match ids_param(p, "slices", cmd)? {
|
||||
Some(ids) => match ids.iter().find(|id| !st.doc.is_slice(**id)) {
|
||||
Some(id) => Err(bad(cmd, format!("{id} is not a slice"))),
|
||||
None => Ok(dedup(ids)),
|
||||
@@ -290,7 +290,7 @@ pub(crate) fn options_json(o: &SliceOptions) -> Value {
|
||||
// ---------- Object → Slice ----------
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = match ids_param(p, "ids") {
|
||||
let ids = match ids_param(p, "ids", "object.slice.make")? {
|
||||
Some(ids) => super::edit::outermost(&s.doc()?.doc, ids),
|
||||
None => super::edit::selected_roots(s)?,
|
||||
};
|
||||
@@ -734,7 +734,8 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = slice_targets(s, &json!({ "slices": p.get("slices").cloned().unwrap_or(json!([])) }), "object.slice.select")?;
|
||||
let slices = p.get("slices").filter(|v| !v.is_null()).cloned().unwrap_or(json!([]));
|
||||
let ids = slice_targets(s, &json!({ "slices": slices }), "object.slice.select")?;
|
||||
let toggle = bool_or(p, "toggle", false);
|
||||
s.select(|_, sel| {
|
||||
let mut now = if toggle { sel.slices.clone() } else { vec![] };
|
||||
|
||||
@@ -65,7 +65,8 @@ pub(crate) fn enter(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if st.doc.pattern_edit.is_some() {
|
||||
return Err(bad(C, "finish editing the pattern first"));
|
||||
}
|
||||
let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| super::ids_param(p, "ids").filter(|v| !v.is_empty()));
|
||||
let ids = super::ids_param(p, "ids", C)?;
|
||||
let given = object_param(p, "id", C)?.map(|i| vec![i]).or_else(|| ids.filter(|v| !v.is_empty()));
|
||||
let (name, xf, instance) = match given {
|
||||
Some(ids) => {
|
||||
let symbols = ids.iter().map(|id| instance_of(&st.doc, *id)).collect::<Option<Vec<_>>>();
|
||||
|
||||
@@ -1068,7 +1068,7 @@ fn set_range_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sets_font {
|
||||
// Empty text has one run, which the empty range styles. Characters that kept their font aren't the command's.
|
||||
let styled = if a == b { t.runs.clone() } else { unprotected_runs(&t.runs, a, b, &kept) };
|
||||
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
|
||||
set.extend(styled.iter().flat_map(super::fonts::run_fonts));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
n = if *f == id { t.runs.len() } else { n };
|
||||
@@ -1140,7 +1140,12 @@ fn glyph_target(t: &TextObject, p: &Value, c: &str) -> Result<(char, std::ops::R
|
||||
return Err(bad(c, "an inline graphic has no glyphs"));
|
||||
}
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let face = db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family));
|
||||
let face = match db.composite_resolve_at(&run.style.font_family, &run.text, start - at) {
|
||||
// Type in a composite font: the character is drawn in the font of the part its
|
||||
// grapheme cluster falls in.
|
||||
Some((face, m)) => (m != vectorcraft_text::FontMatch::Missing).then_some(face),
|
||||
None => db.style_face(&run.style).filter(|_| db.has_family(&run.style.font_family)),
|
||||
};
|
||||
let face = face.ok_or_else(|| bad(c, format!("the font {} is not available", run.style.font_family)))?;
|
||||
return Ok((ch, start..start + ch.len_utf8(), face));
|
||||
}
|
||||
@@ -1602,6 +1607,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
system_layout: false,
|
||||
fractional_widths: true,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
@@ -1890,6 +1896,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
|
||||
system_layout: t.system_layout,
|
||||
fractional_widths: t.fractional_widths,
|
||||
list_count: 0,
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -327,7 +327,7 @@ fn font_change(before: &CharStyle, mut st: CharStyle, attrs: &Map<String, Value>
|
||||
|
||||
/// Change text in layers, symbols, graph designs, pattern tiles, saved pattern tiles restored by
|
||||
/// Cancel, and brush art, including opacity masks in those trees.
|
||||
fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> {
|
||||
pub(crate) fn each_text_mut(d: &mut Document, f: &mut impl FnMut(&mut vectorcraft_doc::TextObject) -> Result<()>) -> Result<()> {
|
||||
fn has_text(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Text(_))
|
||||
|| n.extras().mask.as_ref().is_some_and(|mask| has_text(&mask.art))
|
||||
|
||||
@@ -195,6 +195,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
|| optical != t.optical_margin
|
||||
|| vertical != t.vertical
|
||||
|| part.list_count != t.list_count
|
||||
|| part.list_level_counts != t.list_level_counts
|
||||
|| continues != t.first_para_continues
|
||||
{
|
||||
t.runs = next.runs;
|
||||
@@ -203,6 +204,7 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
t.optical_margin = optical;
|
||||
t.vertical = vertical;
|
||||
t.list_count = part.list_count;
|
||||
t.list_level_counts = part.list_level_counts;
|
||||
t.first_para_continues = continues;
|
||||
refresh_bounds(t);
|
||||
}
|
||||
@@ -216,14 +218,15 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
|
||||
}
|
||||
|
||||
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
|
||||
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
|
||||
/// its thread) gets 0 and is laid out again.
|
||||
/// from 1. Each such type object with another [`TextObject::list_count`] or
|
||||
/// [`TextObject::list_level_counts`] (a copy of a frame, a frame taken out of its thread, a frame
|
||||
/// converted to point type, which [`reflow`] takes out of its thread) gets 0 at every level and is
|
||||
/// laid out again.
|
||||
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
let mut stale = vec![];
|
||||
doc.walk(|n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.list_count != 0
|
||||
&& (t.list_count != 0 || !t.list_level_counts.is_empty())
|
||||
{
|
||||
stale.push(n.id);
|
||||
}
|
||||
@@ -240,6 +243,7 @@ pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
|
||||
for id in stale {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||||
t.list_count = 0;
|
||||
t.list_level_counts.clear();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
@@ -444,13 +448,13 @@ fn cut_to_shown(n: &mut Node, ids: &[NodeId]) {
|
||||
{
|
||||
// Split as `take_overflow` splits it: the first part is what the frame shows.
|
||||
let from: &TextObject = t;
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
let parts = vectorcraft_text::thread::distribute_continuing_levels(
|
||||
vectorcraft_text::FontDb::global(),
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
from.list_counts(),
|
||||
);
|
||||
if let (Some(head), Some(_)) = (parts.first(), parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty()))) {
|
||||
t.runs = head.runs.clone();
|
||||
@@ -485,13 +489,13 @@ fn take_overflow(d: &mut Document, to: NodeId, from: &TextObject) {
|
||||
// `from`'s text split as if it flowed on into a second frame: the second part is what `from`
|
||||
// has no room for. Its share is measured as it is laid out, continuing a paragraph or not and
|
||||
// with its numbered list paragraphs counted on from the frames before it.
|
||||
let parts = vectorcraft_text::thread::distribute_continuing(
|
||||
let parts = vectorcraft_text::thread::distribute_continuing_levels(
|
||||
db,
|
||||
&[from, from],
|
||||
&from.runs,
|
||||
&from.paragraph_styles(),
|
||||
from.first_para_continues,
|
||||
from.list_count,
|
||||
from.list_counts(),
|
||||
);
|
||||
let Some(rest) = parts.get(1).filter(|p| p.runs.iter().any(|r| !r.text.is_empty())) else { return };
|
||||
let mut tail = from.clone();
|
||||
|
||||
@@ -981,6 +981,74 @@ fn outlining_a_numbered_last_frame_keeps_only_the_text_it_has_no_room_for() {
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
|
||||
}
|
||||
|
||||
/// As above for a list at level 2: the last frame's numbered paragraphs are counted on from the
|
||||
/// count the frames before it reached at that level.
|
||||
#[test]
|
||||
fn outlining_a_last_frame_numbered_at_a_deeper_level_keeps_only_the_text_it_has_no_room_for() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
|
||||
let story = format!("{items}{}", TEXT.repeat(4));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14})).unwrap();
|
||||
let shown = [a, c].map(|id| text(&s, id));
|
||||
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{shown:?}");
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
|
||||
assert_eq!((t.list_count, t.list_level_counts.as_slice()), (0, &[7][..]));
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert_eq!(laid.markers.first().map(|m| m.text.as_str()), Some("VIII."));
|
||||
assert!(laid.overflow, "the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
|
||||
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "counted from 1, the frame would show other text");
|
||||
let hidden = shown[1][end..].trim_start_matches(' ');
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute("type.createOutlines", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]));
|
||||
}
|
||||
|
||||
/// New Symbol and Redefine Symbol on a thread's last frame numbered at level 2: the symbol's
|
||||
/// art holds the text the frame shows, measured with the count the frames before it reached at
|
||||
/// that level, and the frame before it takes the rest.
|
||||
#[test]
|
||||
fn a_symbol_of_a_last_frame_numbered_at_a_deeper_level_holds_the_text_it_shows() {
|
||||
for command in ["symbol.new", "symbol.update"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items: String = (1..=7).map(|i| format!("Item {i}\n")).collect();
|
||||
let story = format!("{items}{}", TEXT.repeat(4));
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 106}})).unwrap());
|
||||
let c = new_id(s.execute("text.create", &json!({"x": 200, "y": 10, "text": "", "area": {"width": 150, "height": 20}})).unwrap());
|
||||
s.execute("text.thread.add", &json!({"from": a, "to": c})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, c], "number": "upperRoman", "listLevel": 2, "leftIndent": 14, "firstLineIndent": -14}))
|
||||
.unwrap();
|
||||
let shown = [a, c].map(|id| text(&s, id));
|
||||
assert!(shown[0].ends_with("Item 7\n") && shown[1].starts_with("The quick"), "{command}: {shown:?}");
|
||||
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(c)).cloned() else { panic!("not area type") };
|
||||
let laid = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(laid.overflow, "{command}: the last frame has more text than it shows");
|
||||
let end = laid.lines.last().map_or(0, |l| l.end);
|
||||
let from_one = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &TextObject { list_level_counts: vec![], ..t });
|
||||
assert_ne!(from_one.lines.last().map_or(0, |l| l.end), end, "{command}: counted from 1, the frame would show other text");
|
||||
let hidden = shown[1][end..].trim_start_matches(' ');
|
||||
let name = if command == "symbol.update" {
|
||||
let mark = new_id(s.execute("shape.rectangle", &json!({"x": 400, "y": 200, "width": 20, "height": 20})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [mark]})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
||||
"Mark"
|
||||
} else {
|
||||
"Story"
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [c]})).unwrap();
|
||||
s.execute(command, &json!({"name": name})).unwrap();
|
||||
let held = symbol_texts(&s, name);
|
||||
assert_eq!(held.iter().map(|t| t.trim_end()).collect::<Vec<_>>(), [shown[1][..end].trim_end()], "{command}: the symbol's art");
|
||||
assert_eq!(text(&s, a), format!("{}{hidden}", shown[0]), "{command}: the frame before it takes the rest");
|
||||
}
|
||||
}
|
||||
|
||||
/// A story of one-line paragraphs that each start with a quotation mark, threaded from area
|
||||
/// type `a` into the rectangle `b`, which shows the paragraphs that `a` has no room for.
|
||||
fn quoted_thread() -> (Session, u64, u64) {
|
||||
@@ -1325,6 +1393,41 @@ fn numbered_list_paragraphs_count_on_through_a_thread() {
|
||||
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
|
||||
}
|
||||
|
||||
/// List levels in a thread: a deeper level's count runs on into the next frame, which saves
|
||||
/// it (`listLevelCounts`), and a pasted copy of that frame counts every level from 1.
|
||||
#[test]
|
||||
fn list_levels_count_on_through_a_thread() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
|
||||
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
let frame = |s: &Session, id: u64| text_of(&s.doc().unwrap().doc, NodeId(id)).cloned().unwrap();
|
||||
let numbers =
|
||||
|t: &TextObject| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>();
|
||||
let first = numbers(&frame(&s, a));
|
||||
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
|
||||
let expected: Vec<String> = std::iter::once("1.".to_string()).chain((1..=9).map(|n| format!("{n}."))).collect();
|
||||
assert_eq!([first.clone(), numbers(&frame(&s, b))].concat(), expected);
|
||||
let b_counts = frame(&s, b).list_counts();
|
||||
assert_eq!(b_counts.parts(), (1, vec![first.len() as u32 - 1]));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.list_counts(), numbers(back)), (b_counts, numbers(&frame(&s, b))), "reopened");
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
|
||||
assert_ne!(copy, NodeId(b));
|
||||
let pasted = frame(&s, copy.0);
|
||||
assert_eq!((pasted.list_count, pasted.list_level_counts.as_slice()), (0, &[][..]));
|
||||
assert_eq!(numbers(&pasted).first().map(String::as_str), Some("1."), "a pasted copy counts from 1");
|
||||
}
|
||||
|
||||
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
|
||||
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
|
||||
#[test]
|
||||
|
||||
@@ -919,7 +919,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
super::textedit::unprotected_runs(&t.runs, a, b, &kept)
|
||||
}
|
||||
};
|
||||
set.extend(styled.iter().map(|r| super::fonts::font_of(&r.style)));
|
||||
set.extend(styled.iter().flat_map(super::fonts::run_fonts));
|
||||
}
|
||||
if let Some(j) = justify {
|
||||
let span = para_span(range, t);
|
||||
@@ -1279,7 +1279,7 @@ fn headline_fit(t: &TextObject, tracking: f64) -> Option<(f64, f64, usize)> {
|
||||
let cell = lay.frames.first()?;
|
||||
let line = lay.lines.first()?;
|
||||
let para = t.para_at(0);
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.left_indent - para.right_indent;
|
||||
let avail = cell.width() - 2.0 * t.area.inset - para.laid_out_left_indent() - para.right_indent;
|
||||
Some((line.x1 - line.x0, avail, line.end))
|
||||
}
|
||||
|
||||
|
||||
@@ -437,7 +437,7 @@ fn bulleted_lists_are_set_per_paragraph_saved_and_continued() {
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square"}));
|
||||
// A saved list reads the fields it leaves out as their defaults and ignores those it doesn't know.
|
||||
let read = |v: serde_json::Value| serde_json::from_value::<vectorcraft_doc::ParaStyle>(json!({ "list": v })).unwrap().list;
|
||||
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "level": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
|
||||
assert_eq!((read(json!({})), read(json!({"bullet": "dash", "restart": 2}))), (dot, Some(ParaList::bulleted(Bullet::Dash))));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (square, dot, None));
|
||||
@@ -500,7 +500,7 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square)));
|
||||
s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap();
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) });
|
||||
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() });
|
||||
assert_eq!(list(&s, 0), letters);
|
||||
assert_eq!(numbers(&s), ["a.", "2.", "3."]);
|
||||
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
|
||||
@@ -536,6 +536,70 @@ fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
|
||||
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
|
||||
}
|
||||
|
||||
/// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker
|
||||
/// stands in the list paragraphs it touches, in one undo step, and refuses unknown values;
|
||||
/// a paragraph in no list gets no list from it. A list keeps its alignment when it changes
|
||||
/// between bullets and numbers, and a call that puts a paragraph in a list gives it the
|
||||
/// alignment too. The alignment is saved only when it is right, opens with the file, and
|
||||
/// paragraph styles carry it both ways.
|
||||
#[test]
|
||||
fn list_markers_are_aligned_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let list = |s: &Session, i: usize| text(s).para_at(i).list;
|
||||
let align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::<Vec<_>>();
|
||||
let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right));
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, None]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(align(&s), [right, left, None]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, None]);
|
||||
s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap();
|
||||
assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one");
|
||||
for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] {
|
||||
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing");
|
||||
// Bullets and numbers keep it; a call that makes a list sets it there too.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)));
|
||||
s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right)));
|
||||
s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"}));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((back.para_at(0).list, back.para_at(1).list), (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right))));
|
||||
// A list turned off and on again starts aligned left.
|
||||
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(align(&s)[1], left);
|
||||
// A style captured from a list aligned right gives other lists that alignment, and one
|
||||
// captured from a list aligned left takes it back.
|
||||
let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap();
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!(t.para_at(0).list.map(|l| l.align), right);
|
||||
s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap();
|
||||
assert_eq!(align(&s), [left, left, left]);
|
||||
}
|
||||
|
||||
/// The text object `id`.
|
||||
fn text_at(s: &Session, id: u64) -> vectorcraft_doc::text::TextObject {
|
||||
match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
@@ -724,6 +788,56 @@ fn point_and_vertical_type_convert(s: &mut Session) {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// Convert to Text of a list whose markers are aligned right writes the same characters as for
|
||||
/// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops:
|
||||
/// the text after the tab stays where it was, and the marker moves to the first line's start,
|
||||
/// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went
|
||||
/// to the left indent) and wider (their tab went to the next default stop), and bullets.
|
||||
#[test]
|
||||
fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let text_glyphs = |s: &Session, id: u64| {
|
||||
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
|
||||
let milli = |v: f64| (v * 1000.0).round() as i64;
|
||||
lay.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.len > 0 && !g.outline.elements().is_empty())
|
||||
.map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y)))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.plain_text(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
|
||||
for (list, converted) in [
|
||||
("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n")),
|
||||
("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::<Vec<_>>().join("\n")),
|
||||
] {
|
||||
let make = |s: &mut Session, align: &str| {
|
||||
let id = s.execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}})).unwrap()
|
||||
["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap();
|
||||
id
|
||||
};
|
||||
let (left, right) = (make(&mut s, "left"), make(&mut s, "right"));
|
||||
let drawn_left = drawn_glyphs(&s, left);
|
||||
let text_before = text_glyphs(&s, right);
|
||||
assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment");
|
||||
assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere");
|
||||
s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap();
|
||||
assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab");
|
||||
let after = text_glyphs(&s, right);
|
||||
assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was");
|
||||
assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them");
|
||||
assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays");
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left list paragraph converted to text starts with the number, its period and a
|
||||
/// tab, which are read first: the line draws its glyphs in the order it drew them with the
|
||||
/// marker, the number at the right with its period at its left. Letters and roman numerals
|
||||
@@ -907,6 +1021,130 @@ fn convert_to_text_moves_the_type_tools_selection() {
|
||||
assert_eq!(s.active().unwrap().selection.objects, vec![NodeId(a)], "the first frame is selected");
|
||||
}
|
||||
|
||||
/// Convert to Text of list paragraphs at deeper levels writes each level's own numbers, those
|
||||
/// a thread's earlier frames counted at each level included, and every glyph is drawn where it
|
||||
/// was: each converted paragraph's left indent takes its level's indent, with the plain left
|
||||
/// stop there.
|
||||
#[test]
|
||||
fn convert_to_text_keeps_deeper_list_levels_in_place() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let text = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let numbers = |s: &Session, id: u64| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s, id)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
let text_in = "One\nTwo\nThree\nFour\nFive";
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": text_in, "area": {"width": 400, "height": 400}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 2, "start": 4, "end": 9})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "listLevel": 3, "start": 14, "end": 14})).unwrap();
|
||||
let before = drawn_glyphs(&s, id);
|
||||
s.execute("text.convertListToText", &json!({"ids": [id]})).unwrap();
|
||||
assert_eq!(drawn_glyphs(&s, id), before, "every glyph is drawn where it was");
|
||||
let t = text(&s, id);
|
||||
assert_eq!(t.plain_text(), "1.\tOne\n1.\tTwo\n2.\tThree\n1.\tFour\n2.\tFive");
|
||||
let paras = (0..5)
|
||||
.map(|k| {
|
||||
let p = t.para_at(k);
|
||||
(p.list.is_none(), p.left_indent, p.tabs.iter().map(|tab| tab.position).collect::<Vec<_>>())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(paras, [18.0, 36.0, 36.0, 54.0, 18.0].map(|x| (true, x, vec![x])), "out of the list, at the level's indent");
|
||||
// A thread whose second frame goes on with the numbers of level 2.
|
||||
let items = std::iter::once("Head".to_string()).chain((1..=9).map(|i| format!("Item {i}"))).collect::<Vec<_>>().join("\n");
|
||||
let a =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 500, "text": items, "area": {"width": 150, "height": 60}})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 500, "width": 150, "height": 200})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number", "listLevel": 2})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [a], "listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
let first = numbers(&s, a).len();
|
||||
assert!((3..10).contains(&first) && text(&s, a).plain_text().ends_with('\n'), "both frames hold items: {first}");
|
||||
assert_eq!(numbers(&s, b).first(), Some(&format!("{first}.")), "the second frame counts level 2 on");
|
||||
let before = (drawn_glyphs(&s, a), drawn_glyphs(&s, b));
|
||||
s.execute("text.convertListToText", &json!({"ids": [a, b]})).unwrap();
|
||||
assert_eq!((drawn_glyphs(&s, a), drawn_glyphs(&s, b)), before, "both frames");
|
||||
let story = std::iter::once("1.\tHead".to_string()).chain((1..=9).map(|i| format!("{i}.\tItem {i}"))).collect::<Vec<_>>().join("\n");
|
||||
assert_eq!(text(&s, a).plain_text() + &text(&s, b).plain_text(), story);
|
||||
}
|
||||
|
||||
/// List levels: `text.setFormat {listLevel}` sets the level of the list paragraphs it touches
|
||||
/// as one undo step, from 1 to 9, and refuses other values; bullets and numbers keep it, a
|
||||
/// list turned off and on starts at level 1 again, each level counts its own numbers, and the
|
||||
/// level is saved past level 1, reopened and carried by paragraph styles.
|
||||
#[test]
|
||||
fn list_levels_are_set_per_paragraph_saved_and_styled() {
|
||||
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id =
|
||||
s.execute("text.create", &json!({"x": 10, "y": 10, "text": "One\nTwo\nThree\nFour", "area": {"width": 300, "height": 200}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
let levels = |s: &Session| (0..4).map(|i| text(s).para_at(i).list.map(|l| l.level)).collect::<Vec<_>>();
|
||||
let numbers = |s: &Session| {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
|
||||
};
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 2, "start": 4, "end": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(2), Some(2), None]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1), Some(1), None]);
|
||||
s.execute("text.setFormat", &json!({"listLevel": 2.0, "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(levels(&s)[1], Some(2));
|
||||
s.execute("text.setFormat", &json!({"listLevel": 9})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(9), Some(9), Some(9), None], "the paragraph in no list stays out of one");
|
||||
let bad = [json!({"listLevel": 0}), json!({"listLevel": 10}), json!({"listLevel": 1.5}), json!({"listLevel": "2"}), json!({"listLevel": true})];
|
||||
for p in bad {
|
||||
assert!(s.execute("text.setFormat", &p).is_err(), "{p}");
|
||||
}
|
||||
let err = s.execute("text.setFormat", &json!({"listLevel": 10})).unwrap_err().to_string();
|
||||
assert!(err.contains("`listLevel` must be a whole number between 1 and 9 (got 10)"), "{err}");
|
||||
assert_eq!(levels(&s)[0], Some(9), "an unknown value changes nothing");
|
||||
// A deeper paragraph counts from 1, and one back at a shallower level goes on.
|
||||
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 0, "end": 0})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 3, "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"listLevel": 1, "start": 8, "end": 8})).unwrap();
|
||||
assert_eq!(numbers(&s), ["1.", "1.", "2."]);
|
||||
// Bullets and numbers keep the level; a call that makes a list sets it there too.
|
||||
s.execute("text.setFormat", &json!({"bullet": "square", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).list, Some(ParaList::bulleted(Bullet::Square).at_level(3)));
|
||||
s.execute("text.setFormat", &json!({"list": "bullet", "listLevel": 4, "start": 14, "end": 14})).unwrap();
|
||||
assert_eq!(text(&s).para_at(3).list, Some(ParaList::bulleted(Bullet::Dot).at_level(4)));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(1)).unwrap()["list"], json!({"bullet": "square", "level": 3}));
|
||||
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "dot", "number": "arabic"}));
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert_eq!((0..4).map(|i| back.para_at(i).list).collect::<Vec<_>>(), (0..4).map(|i| text(&s).para_at(i).list).collect::<Vec<_>>());
|
||||
// A list turned off and on again starts at level 1.
|
||||
s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();
|
||||
assert_eq!(text(&s).para_at(1).list, Some(ParaList::numbered(NumberStyle::Arabic)));
|
||||
// A style captured from a paragraph at level 4 sets that level, and one captured at level 1
|
||||
// takes it back.
|
||||
s.execute("paraStyle.new", &json!({"name": "Level 4", "id": id, "start": 14, "end": 14})).unwrap();
|
||||
s.execute("paraStyle.new", &json!({"name": "Level 1", "id": id, "start": 0, "end": 0})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Level 4", "id": id, "start": 0, "end": 0})).unwrap();
|
||||
assert_eq!(levels(&s)[0], Some(4));
|
||||
s.execute("paraStyle.apply", &json!({"name": "Level 1", "id": id})).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1); 4]);
|
||||
}
|
||||
|
||||
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
|
||||
#[test]
|
||||
fn create_outlines_makes_a_path_of_the_bullet() {
|
||||
@@ -1627,9 +1865,10 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
|
||||
s.execute("text.setStyle", &json!({"style": "Bold"})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wdth": 75}})).unwrap();
|
||||
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Inter"}})).unwrap();
|
||||
s.execute("text.replaceFont", &json!({"from": {"family": FAMILY}, "to": {"family": "Source Sans 3"}})).unwrap();
|
||||
assert!(runs(&s).iter().all(|r| r.style.font_axes.is_empty()));
|
||||
// A static font has no axes to set.
|
||||
// A static font has no axes to set (Source Sans 3, not Inter: an installed Inter
|
||||
// Variable would shadow the bundled static and give it axes).
|
||||
s.execute("text.setFormat", &json!({"fontAxes": {"wght": 650}})).unwrap();
|
||||
assert_eq!(saved(&s), None);
|
||||
// A missing font keeps them as given, for when it is installed.
|
||||
@@ -1651,7 +1890,7 @@ fn variable_font_settings_are_a_character_attribute_saved_only_when_set() {
|
||||
{"tag": "wdth", "name": "Width", "min": 75.0, "default": 100.0, "max": 100.0, "value": 100.0, "hidden": false},
|
||||
])
|
||||
);
|
||||
assert_eq!(s.execute("text.fontList", &json!({"family": "Inter"})).unwrap()["axes"], json!([]));
|
||||
assert_eq!(s.execute("text.fontList", &json!({"family": "Source Sans 3"})).unwrap()["axes"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -56,7 +56,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Type on a Path Options…",
|
||||
["Type", "Type on a Path"],
|
||||
None,
|
||||
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
"{ids?|id?, start?: 0..1, end?: 0..1|null, flip?: bool, effect?: rainbow|skew|3dRibbon|stairStep|gravity, alignToPath?: ascender|descender|center|baseline, spacing?: pt} set the selected type on a path's options: start and end place its start and end brackets as fractions of the path's length (end null: the end of the path, or once round a closed path), and flip then turns the type to the other side of its path (the path runs the other way, the brackets swap ends); alignToPath runs each character's em box top (ascender), font bounding box bottom (descender), half the height of its H's top above the baseline (center) or baseline along the path, by the character's own font, size and vertical scale (vertical type: half an em either way, or none for center); none given: query → the first object's {start, end, flip: false, effect, alignToPath, spacing}",
|
||||
has_selection,
|
||||
path_options
|
||||
),
|
||||
@@ -373,8 +373,10 @@ fn to_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// The point origin sits where the alignment anchors the first line.
|
||||
let p0 = t.para_at(0);
|
||||
let (x0, x1) =
|
||||
lay.lines.first().map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.left_indent - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let (x0, x1) = lay
|
||||
.lines
|
||||
.first()
|
||||
.map_or((fb.x0, fb.x1), |l| (l.avail.0 - p0.laid_out_left_indent() - p0.first_line_indent, l.avail.1 + p0.right_indent));
|
||||
let ox = match p0.justify {
|
||||
Justify::Center | Justify::JustifyCenter => (x0 + x1) * 0.5,
|
||||
Justify::Right | Justify::JustifyRight => x1,
|
||||
|
||||
@@ -121,13 +121,14 @@ pub(crate) const SELECTS_GIVEN_IDS: &[&str] = &[
|
||||
/// The non-empty `ids` given to a command whose params description names `ids` as a key (the
|
||||
/// key callers outside the app may pass it, [`cmd::params::takes_key`]), or its `id` as `ids` of
|
||||
/// one when no `ids` are given and the description takes `id` so
|
||||
/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them.
|
||||
/// ([`cmd::params::id_stands_for_ids`]), as [`cmd::targets`] reads them. Entries of `ids` that are
|
||||
/// not ids are left out of this list; `Session::execute` refuses them before it reads it.
|
||||
pub(crate) fn given_ids(description: &'static str, params: &Value) -> Option<Vec<NodeId>> {
|
||||
if !cmd::params::takes_key(description, "ids") {
|
||||
return None;
|
||||
}
|
||||
match params.get("ids").filter(|v| !v.is_null()) {
|
||||
Some(_) => cmd::ids_param(params, "ids"),
|
||||
Some(v) => v.as_array().map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect()),
|
||||
None => cmd::id_param(params, "id").filter(|_| cmd::params::id_stands_for_ids(description)).map(|id| vec![id]),
|
||||
}
|
||||
.filter(|ids| !ids.is_empty())
|
||||
|
||||
@@ -95,6 +95,11 @@ pub struct DocState {
|
||||
/// so an export or save to it asks first ([`cmd::fileio::check_not_lossy_overwrite`]). Not saved.
|
||||
pub imported_from: Option<String>,
|
||||
pub import_losses: Vec<String>,
|
||||
/// The file its links' stored relative paths name places from: the file the document was read
|
||||
/// from, also when that file is not its own (such as a `.ai` file another app wrote). A save
|
||||
/// writes the saved file's relative paths and leaves the open document's links as they are.
|
||||
/// `None`: the document's own file. Not saved ([`crate::cmd::links::LinkBase`]).
|
||||
pub links_from: Option<String>,
|
||||
/// Transform Again after a perspective move or scale (Perspective Selection tool): the
|
||||
/// `perspective.transform` params it repeats. `None` once an ordinary transform follows.
|
||||
pub last_perspective: Option<Value>,
|
||||
@@ -296,6 +301,7 @@ impl DocState {
|
||||
layers_open,
|
||||
imported_from: None,
|
||||
import_losses: vec![],
|
||||
links_from: None,
|
||||
last_perspective: None,
|
||||
opened_by: file_access::current(),
|
||||
empty_artboards: Default::default(),
|
||||
|
||||
@@ -172,6 +172,9 @@ pub struct Session {
|
||||
/// has inherited the selection's appearance: the documents ([`DocState::uid`]) whose newest
|
||||
/// history step takes the colors the batch finally left.
|
||||
pub(crate) batch_paint_docs: Vec<u64>,
|
||||
/// The composite fonts (lowercase names) that the open documents defined when the font
|
||||
/// database was last brought in step with them ([`cmd::compositefonts::sync`]).
|
||||
pub(crate) composite_names: Vec<String>,
|
||||
/// While a command that changes nothing in the document runs (a query, or one of
|
||||
/// [`command_sets::JOURNALED_READS`]): it reads the instance of an object selected in a symbol
|
||||
/// instance's art.
|
||||
|
||||
@@ -83,6 +83,7 @@ impl Session {
|
||||
batch_stash: None,
|
||||
batch_steps: None,
|
||||
batch_paint_docs: Vec::new(),
|
||||
composite_names: Vec::new(),
|
||||
reading: false,
|
||||
place_hand_off: None,
|
||||
queue_hand_off: None,
|
||||
@@ -176,10 +177,16 @@ impl Session {
|
||||
|
||||
pub fn set_active(&mut self, index: usize) -> bool {
|
||||
if index < self.docs.len() {
|
||||
if self.active != Some(index) {
|
||||
let switched = self.active != Some(index);
|
||||
if switched {
|
||||
self.reset_tool_for_doc_switch();
|
||||
}
|
||||
self.active = Some(index);
|
||||
// Type is drawn in the composite fonts of the document now in front, and measured again
|
||||
// when they differ from those of the document before.
|
||||
if switched && cmd::compositefonts::sync(self) {
|
||||
self.refresh_document_text_bounds(index);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
@@ -196,7 +203,7 @@ impl Session {
|
||||
format!("Untitled-{}", self.untitled_counter)
|
||||
}
|
||||
|
||||
/// Text layout bounds are a cache (not saved): compute them for a document just read, or
|
||||
/// Text layout bounds are a cache (not saved): compute them after its fonts are registered, or
|
||||
/// selection boxes and hit testing would use the rough estimate until each text is edited, and
|
||||
/// what is fitted to type's bounds (an envelope warping it) would come out differently. That
|
||||
/// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too,
|
||||
@@ -211,6 +218,14 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Session::refresh_text_bounds`] for open document `index`, as a change that is not an
|
||||
/// edit: a saved document stays saved, and its undo history keeps its documents as they are.
|
||||
fn refresh_document_text_bounds(&mut self, index: usize) {
|
||||
if let Some(st) = self.docs.get_mut(index) {
|
||||
st.change_unsaved(|doc| Self::refresh_text_bounds(Arc::make_mut(doc)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
|
||||
/// an undo group with the begun group's name opens in it: here outside a command, and after the
|
||||
/// command that adds it otherwise ([`Session::execute`]).
|
||||
@@ -218,7 +233,7 @@ impl Session {
|
||||
// Every edit is checked against the document before it, so the first one is within the cap
|
||||
// (importers and the native reader have seen to theirs already, with a warning).
|
||||
doc.limit_depth();
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
cmd::compositefonts::filter_loaded(&mut doc);
|
||||
// Guides from files saved before guides had layers go on a layer.
|
||||
doc.adopt_guides();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
@@ -229,6 +244,8 @@ impl Session {
|
||||
self.docs.push(st);
|
||||
let i = self.docs.len() - 1;
|
||||
self.active = Some(i);
|
||||
cmd::compositefonts::sync(self);
|
||||
self.refresh_document_text_bounds(i);
|
||||
if self.depth == 0
|
||||
&& let Some(label) = self.explicit_undo_group.clone()
|
||||
{
|
||||
@@ -244,7 +261,7 @@ impl Session {
|
||||
return false;
|
||||
}
|
||||
doc.limit_depth();
|
||||
Self::refresh_text_bounds(&mut doc);
|
||||
cmd::compositefonts::filter_loaded(&mut doc);
|
||||
doc.adopt_guides();
|
||||
cmd::dimension::conform(&mut doc);
|
||||
if self.active == Some(index) {
|
||||
@@ -280,6 +297,12 @@ impl Session {
|
||||
if let Some(stash) = &mut self.batch_stash {
|
||||
stash.push(old);
|
||||
}
|
||||
// Type in the composite fonts of a document in the back is measured again when it comes to
|
||||
// the front.
|
||||
if self.active == Some(index) {
|
||||
cmd::compositefonts::sync(self);
|
||||
}
|
||||
self.refresh_document_text_bounds(index);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -296,6 +319,7 @@ impl Session {
|
||||
stash.push(old);
|
||||
}
|
||||
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
|
||||
cmd::compositefonts::sync(self);
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -15,11 +15,14 @@ impl Session {
|
||||
r
|
||||
}
|
||||
|
||||
/// Error, under the command's own id, at the first of `ids` that is malformed, names no node, or
|
||||
/// names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on objects.
|
||||
/// Error, under the command's own id, at the first of `ids` that is malformed, else at the first
|
||||
/// that names no node or names a layer or sublayer: a command in [`SELECTS_GIVEN_IDS`] acts on
|
||||
/// objects.
|
||||
fn check_given_ids(&self, id: &str, params: &Value) -> Result<()> {
|
||||
let (Some(st), Some(ids)) = (self.active(), params.get("ids")) else { return Ok(()) };
|
||||
let Some(given) = ids.as_array() else { return cmd::checked_ids_param(self, params, "ids", id).map(drop) };
|
||||
// A malformed entry anywhere in the list is refused first, under the key and the entry.
|
||||
cmd::ids_param(params, "ids", id)?;
|
||||
for value in given {
|
||||
let node = cmd::checked_id(self, value, id)?;
|
||||
if st.doc.node(node).is_some_and(|n| n.is_layer()) {
|
||||
@@ -65,6 +68,13 @@ impl Session {
|
||||
if SELECTS_GIVEN_IDS.contains(&id) {
|
||||
self.check_given_ids(id, params)?;
|
||||
}
|
||||
// An `ids` list with an entry that is not an object id, or an `ids` that is not a list, is
|
||||
// refused under the command's id before the menu check, which would otherwise run on the
|
||||
// other entries or on the selection. This holds with no document open too, where commands
|
||||
// such as `effect.list` still run.
|
||||
if cmd::params::takes_key(spec.params, "ids") {
|
||||
cmd::ids_param(params, "ids", id)?;
|
||||
}
|
||||
// Clear's menu needs a selection; an agent supplying explicit targets only needs a
|
||||
// document. The command validates those targets before making any edit.
|
||||
let given = given_ids(spec.params, params);
|
||||
@@ -147,6 +157,9 @@ impl Session {
|
||||
}
|
||||
if let Some(open) = open {
|
||||
self.open_undo_groups_in_new_documents(&open);
|
||||
// Type is drawn in the composite fonts of the documents open now, an undone or opened
|
||||
// document's included.
|
||||
cmd::compositefonts::sync(self);
|
||||
}
|
||||
let journaled = spec.journal || (self.depth == 0 && std::mem::take(&mut self.journal_query));
|
||||
if journaled && self.depth == 0 && self.active().is_none_or(|d| d.interaction.is_none()) {
|
||||
|
||||
+412
-6
@@ -19,6 +19,11 @@
|
||||
//! copied: it is loaded from where it is. `grants.json` holds what the user granted the plug-ins
|
||||
//! stored there that list permissions (id, module hash, permissions); a stored plug-in whose
|
||||
//! permissions it doesn't grant is skipped with a log record, and stays in the folder.
|
||||
//! * **Plug-in settings and caches**: `storage/id-<id>/` in the plug-in store, the id written as
|
||||
//! [`case_safe`] writes it, holds what a plug-in
|
||||
//! keeps with the `settings` and `cache` permissions ([`PluginStorage`]): `settings.json` and a
|
||||
//! `cache` folder with one file per entry. The host names every file; a plug-in only names keys.
|
||||
//! Replacing a plug-in keeps them, and `plugin.remove` deletes them.
|
||||
//!
|
||||
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
|
||||
//! parse is skipped with a log record, and every write is atomic.
|
||||
@@ -26,9 +31,12 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use vectorcraft_pdf::{PdfPreset, builtin_preset};
|
||||
use vectorcraft_plugins::safe_mode;
|
||||
use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError};
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::pdfcmds::{PRESET_FORMAT, PresetFile};
|
||||
@@ -55,6 +63,25 @@ pub const DISABLED_LOCK: &str = "disabled.lock";
|
||||
const GRANTS_FILE: &str = "grants.json";
|
||||
/// The largest grants file read.
|
||||
const MAX_GRANTS_FILE: u64 = 1 << 20;
|
||||
/// The folder in the plug-in store that holds each plug-in's settings and cache.
|
||||
const PLUGIN_DATA_DIR: &str = "storage";
|
||||
/// A plug-in's settings file, in its folder.
|
||||
const SETTINGS_FILE: &str = "settings.json";
|
||||
/// Where a settings file that doesn't read is moved, replacing the one moved there before.
|
||||
const BAD_SETTINGS_FILE: &str = "settings.unreadable.json";
|
||||
/// The folder of a plug-in's cache entries, in its folder.
|
||||
const CACHE_DIR: &str = "cache";
|
||||
/// The extension of a cache entry's file.
|
||||
const CACHE_EXT: &str = "bin";
|
||||
/// Items in a cache folder a write looks at, at most: the entries, and temporary files a write left.
|
||||
/// A write to a folder that holds more is refused as full.
|
||||
pub(crate) const MAX_CACHE_SCAN: usize = storage::MAX_CACHE_ENTRIES * 2;
|
||||
/// Held while a plug-in's settings or cache change, so two runs can't pass a cap together or lose
|
||||
/// each other's settings.
|
||||
static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(());
|
||||
/// The closed flag of each plug-in folder's storage, shared by every storage made for the folder
|
||||
/// until the plug-in is removed, which sets it and drops it.
|
||||
static PLUGIN_DATA_OPEN: Mutex<BTreeMap<PathBuf, Arc<AtomicBool>>> = Mutex::new(BTreeMap::new());
|
||||
|
||||
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
|
||||
/// file system, then a hash of the name, so that names that differ only in characters a file name
|
||||
@@ -262,7 +289,8 @@ impl Session {
|
||||
/// Install the plug-ins kept in the plug-in folder: none in Safe Mode, and none the user
|
||||
/// disabled (matched by file name, which is made from the id). The launch marker names each
|
||||
/// file while it loads. A module that doesn't load, and one that lists permissions the grants
|
||||
/// there don't grant it, is skipped with a log record.
|
||||
/// there don't grant it, is skipped with a log record. A grant covers only the module it was
|
||||
/// given to ([`grant_covers`]).
|
||||
pub fn load_stored_plugins(&mut self) {
|
||||
let Some(dir) = self.plugin_dir.clone() else { return };
|
||||
if safe_mode::active() {
|
||||
@@ -294,15 +322,19 @@ impl Session {
|
||||
// module stored here load.
|
||||
Ok(plugin) if plugin.manifest().asks_permissions() => {
|
||||
let m = plugin.manifest();
|
||||
match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) {
|
||||
match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) {
|
||||
Some(g) => {
|
||||
vectorcraft_plugins::registry::grant(g.clone());
|
||||
vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id));
|
||||
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
|
||||
}
|
||||
None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()),
|
||||
None => {
|
||||
log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(plugin) => {
|
||||
vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id()));
|
||||
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
|
||||
}
|
||||
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
|
||||
@@ -345,8 +377,9 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Whether `plugin` installs without asking the user: it lists no permissions, or its id was
|
||||
/// granted every one it lists, in the grants of the attached plug-in store when there is one,
|
||||
/// else in this process's ([`vectorcraft_plugins::registry::approved`]).
|
||||
/// granted every one it lists, in the grants of the attached plug-in store when there is one
|
||||
/// (for this module alone, [`grant_covers`]), else in this process's
|
||||
/// ([`vectorcraft_plugins::registry::approved`]).
|
||||
pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool {
|
||||
self.plugin_approver()(plugin)
|
||||
}
|
||||
@@ -358,7 +391,7 @@ impl Session {
|
||||
move |plugin| {
|
||||
let m = plugin.manifest();
|
||||
match &grants {
|
||||
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())),
|
||||
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| grant_covers(g, plugin)),
|
||||
_ => vectorcraft_plugins::registry::approved(plugin),
|
||||
}
|
||||
}
|
||||
@@ -422,6 +455,379 @@ impl Session {
|
||||
take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without
|
||||
/// one, nowhere (its storage calls then fail as unavailable).
|
||||
pub(crate) fn attach_plugin_storage(&self, id: &str) {
|
||||
vectorcraft_plugins::registry::set_storage(id, plugin_storage(self.plugin_dir.as_deref(), id));
|
||||
}
|
||||
|
||||
/// Does the attached plug-in store have a folder of settings and cache for plug-in `id`? A
|
||||
/// folder behind a `storage` folder that is a link is not counted: the plug-in's storage calls
|
||||
/// can't reach it and removing the plug-in leaves it.
|
||||
pub(crate) fn has_plugin_storage(&self, id: &str) -> bool {
|
||||
self.plugin_dir
|
||||
.as_deref()
|
||||
.is_some_and(|dir| !is_link(&dir.join(PLUGIN_DATA_DIR)) && std::fs::symlink_metadata(plugin_data_dir(dir, id)).is_ok())
|
||||
}
|
||||
|
||||
/// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it.
|
||||
/// The folder is first renamed out of the way (`<folder>.removed`), and that fails, leaving it
|
||||
/// as it was, when the rename does; what the deletion then leaves stays under that name, with a
|
||||
/// log record, and is deleted at the plug-in's next removal.
|
||||
pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> {
|
||||
vectorcraft_plugins::registry::set_storage(id, None);
|
||||
let Some(dir) = &self.plugin_dir else { return Ok(()) };
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let path = plugin_data_dir(dir, id);
|
||||
// Every storage handed out for the folder, including those runs still hold, is closed once
|
||||
// the folder is out of the way; until then they keep working.
|
||||
let close = || {
|
||||
if let Some(closed) = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).remove(&path) {
|
||||
closed.store(true, Ordering::Release);
|
||||
}
|
||||
};
|
||||
// A `storage` folder that is a link leads out of the store: nothing there is deleted. A link
|
||||
// in place of the plug-in's own folder is removed itself (`remove_dir_all` doesn't follow it).
|
||||
let data = dir.join(PLUGIN_DATA_DIR);
|
||||
if is_link(&data) {
|
||||
log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display());
|
||||
close();
|
||||
return Ok(());
|
||||
}
|
||||
let mut aside = path.clone().into_os_string();
|
||||
aside.push(".removed");
|
||||
let aside = PathBuf::from(aside);
|
||||
let _ = std::fs::remove_dir_all(&aside);
|
||||
match std::fs::rename(&path, &aside) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))),
|
||||
Ok(()) => {}
|
||||
}
|
||||
close();
|
||||
if std::fs::symlink_metadata(&aside).is_ok()
|
||||
&& let Err(e) = std::fs::remove_dir_all(&aside)
|
||||
{
|
||||
log::warn!("can't delete {}: {e}", aside.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// `name` (a plug-in id or a cache entry name, ASCII) written for a file name that no other name
|
||||
/// gets on a file system that ignores case: `_` is written `__`, each capital letter `_` and the
|
||||
/// letter in lower case, and `.` `_0`.
|
||||
fn case_safe(name: &str) -> String {
|
||||
let mut file = String::with_capacity(name.len() * 2);
|
||||
for c in name.chars() {
|
||||
match c {
|
||||
'_' => file.push_str("__"),
|
||||
'.' => file.push_str("_0"),
|
||||
c if c.is_ascii_uppercase() => {
|
||||
file.push('_');
|
||||
file.push(c.to_ascii_lowercase());
|
||||
}
|
||||
c => file.push(c),
|
||||
}
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
/// The folder of plug-in `id`'s settings and cache in the plug-in store `dir`. The `id-` in front
|
||||
/// keeps an id such as `con` from naming a device on Windows.
|
||||
pub(crate) fn plugin_data_dir(dir: &Path, id: &str) -> PathBuf {
|
||||
dir.join(PLUGIN_DATA_DIR).join(format!("id-{}", case_safe(id)))
|
||||
}
|
||||
|
||||
/// Where plug-in `id` keeps its settings and cache in the plug-in store `dir`.
|
||||
fn plugin_storage(dir: Option<&Path>, id: &str) -> Option<Arc<dyn Storage>> {
|
||||
let dir = dir?;
|
||||
let data = plugin_data_dir(dir, id);
|
||||
let closed = PLUGIN_DATA_OPEN.lock().unwrap_or_else(|e| e.into_inner()).entry(data.clone()).or_default().clone();
|
||||
Some(Arc::new(PluginStorage { root: dir.to_path_buf(), dir: data, closed }))
|
||||
}
|
||||
|
||||
/// The settings and cache of one plug-in, in its folder of the plug-in store (see the module docs).
|
||||
/// Keys and entry names are [`storage::valid_name`]s; an entry's file name is its name as
|
||||
/// [`case_safe`] writes it, so that names differing only in case get different files. A link
|
||||
/// anywhere in the folder's path below the store is refused, and so is a cache file that is not a
|
||||
/// plain file.
|
||||
pub(crate) struct PluginStorage {
|
||||
/// The plug-in store, and the plug-in's folder in it.
|
||||
root: PathBuf,
|
||||
dir: PathBuf,
|
||||
/// Set, under [`PLUGIN_DATA_LOCK`], once the plug-in is removed; shared by every storage of the
|
||||
/// folder ([`PLUGIN_DATA_OPEN`]).
|
||||
closed: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// The sizes of `files` added up, at most `u64::MAX`: the sizes come from the file system.
|
||||
fn sum_sizes(files: &[(PathBuf, u64)]) -> u64 {
|
||||
files.iter().fold(0, |sum, (_, n)| sum.saturating_add(*n))
|
||||
}
|
||||
|
||||
/// Is `name` the file name of a cache entry? Entry files end in `.bin` and never start with a `.`
|
||||
/// ([`case_safe`] writes no `.`), while the temporary files of `write_atomic` do.
|
||||
fn is_entry_file(name: &std::ffi::OsStr) -> bool {
|
||||
name.to_str().is_some_and(|n| !n.starts_with('.') && n.ends_with(&format!(".{CACHE_EXT}")))
|
||||
}
|
||||
|
||||
/// What a storage call is told when the file system fails.
|
||||
fn io(e: &std::io::Error) -> StorageError {
|
||||
StorageError::Io(e.to_string())
|
||||
}
|
||||
|
||||
/// Does grant `g` cover `plugin`: was it given to this module (the same SHA-256) for every
|
||||
/// permission the module lists? A module with other bytes under the id needs the user's approval
|
||||
/// again, and so doesn't get the settings and cache kept for the id without it, and so does every
|
||||
/// module when the grant records no hash.
|
||||
fn grant_covers(g: &vectorcraft_plugins::permissions::Grant, plugin: &vectorcraft_plugins::Plugin) -> bool {
|
||||
!g.hash.is_empty() && g.hash == plugin.hash() && g.covers(&plugin.manifest().permissions())
|
||||
}
|
||||
|
||||
/// Is `path` a link (a symbolic link or, on Windows, a junction)?
|
||||
fn is_link(path: &Path) -> bool {
|
||||
std::fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink() || reparse_point(&m))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn reparse_point(m: &std::fs::Metadata) -> bool {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
|
||||
m.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn reparse_point(_: &std::fs::Metadata) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
impl PluginStorage {
|
||||
fn settings_path(&self) -> PathBuf {
|
||||
self.dir.join(SETTINGS_FILE)
|
||||
}
|
||||
|
||||
fn cache_dir(&self) -> PathBuf {
|
||||
self.dir.join(CACHE_DIR)
|
||||
}
|
||||
|
||||
/// The file of the cache entry `name`.
|
||||
fn entry_path(&self, name: &str) -> PathBuf {
|
||||
self.cache_dir().join(format!("{}.{CACHE_EXT}", case_safe(name)))
|
||||
}
|
||||
|
||||
/// Checks that no folder between the store and `path`, nor `path` itself, is a link.
|
||||
fn no_links(&self, path: &Path) -> Result<(), StorageError> {
|
||||
let mut at = path;
|
||||
while at != self.root {
|
||||
if is_link(at) {
|
||||
return Err(StorageError::Unavailable);
|
||||
}
|
||||
match at.parent() {
|
||||
Some(up) => at = up,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The settings, by key. A file that is too large or doesn't parse is moved aside
|
||||
/// ([`BAD_SETTINGS_FILE`]) with a log record, and the settings are empty. `meter` is charged the
|
||||
/// file's size before it is read.
|
||||
fn read_settings(&self, meter: &mut Meter) -> Result<BTreeMap<String, Vec<u8>>, StorageError> {
|
||||
let path = self.settings_path();
|
||||
self.no_links(&path)?;
|
||||
let size = match std::fs::metadata(&path) {
|
||||
Ok(m) => m.len(),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
|
||||
Err(e) => return Err(io(&e)),
|
||||
};
|
||||
let readable = size <= storage::MAX_SETTINGS_BYTES as u64;
|
||||
if readable {
|
||||
meter.take(size)?;
|
||||
}
|
||||
let parsed = readable
|
||||
.then(|| read_capped(&path, storage::MAX_SETTINGS_BYTES as u64).ok())
|
||||
.flatten()
|
||||
.and_then(|bytes| serde_json::from_slice::<BTreeMap<String, String>>(&bytes).ok())
|
||||
.and_then(|stored| {
|
||||
let decode =
|
||||
|(k, v): (String, String)| if storage::valid_name(&k) { vectorcraft_format::base64_decode(&v).map(|v| (k, v)) } else { None };
|
||||
let settings: Option<BTreeMap<String, Vec<u8>>> = stored.into_iter().map(decode).collect();
|
||||
settings.filter(|s| s.len() <= storage::MAX_SETTINGS && s.values().all(|v| v.len() <= storage::MAX_SETTING_BYTES))
|
||||
});
|
||||
match parsed {
|
||||
Some(settings) => Ok(settings),
|
||||
None => {
|
||||
let aside = self.dir.join(BAD_SETTINGS_FILE);
|
||||
log::warn!("the plug-in settings {} don't read: moved to {} and started empty", path.display(), aside.display());
|
||||
std::fs::rename(&path, &aside).map_err(|e| io(&e))?;
|
||||
Ok(BTreeMap::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes `settings` as the settings file, or removes it when there are none. `meter` is charged
|
||||
/// the new file's size before it is written.
|
||||
fn write_settings(&self, settings: &BTreeMap<String, Vec<u8>>, meter: &mut Meter) -> Result<(), StorageError> {
|
||||
let path = self.settings_path();
|
||||
if settings.is_empty() {
|
||||
return match std::fs::remove_file(&path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io(&e)),
|
||||
_ => Ok(()),
|
||||
};
|
||||
}
|
||||
let stored: BTreeMap<&str, String> = settings.iter().map(|(k, v)| (k.as_str(), vectorcraft_format::base64_encode(v))).collect();
|
||||
let bytes = serde_json::to_vec(&stored).map_err(|e| StorageError::Io(e.to_string()))?;
|
||||
if bytes.len() > storage::MAX_SETTINGS_BYTES {
|
||||
return Err(StorageError::Full);
|
||||
}
|
||||
meter.take(bytes.len() as u64)?;
|
||||
std::fs::create_dir_all(&self.dir).map_err(|e| io(&e))?;
|
||||
self.no_links(&path)?;
|
||||
write_atomic(&path, &bytes).map_err(|e| io(&e))
|
||||
}
|
||||
|
||||
/// Fails with [`StorageError::Unavailable`] once the storage is closed.
|
||||
fn open(&self) -> Result<(), StorageError> {
|
||||
if self.closed.load(Ordering::Acquire) { Err(StorageError::Unavailable) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The entries of the cache folder (plain files named as [`Self::entry_path`] names them, which
|
||||
/// leaves out the temporary files of writes) and their sizes, and whether the folder holds more
|
||||
/// than [`MAX_CACHE_SCAN`] items, in which case only the entries among the first that many are
|
||||
/// listed.
|
||||
fn cache_files(&self) -> Result<(Vec<(PathBuf, u64)>, bool), StorageError> {
|
||||
let dir = self.cache_dir();
|
||||
self.no_links(&dir)?;
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((vec![], false)),
|
||||
Err(e) => return Err(io(&e)),
|
||||
};
|
||||
let mut files = Vec::new();
|
||||
for (seen, e) in entries.enumerate() {
|
||||
if seen == MAX_CACHE_SCAN {
|
||||
return Ok((files, true));
|
||||
}
|
||||
let e = e.map_err(|e| io(&e))?;
|
||||
if is_entry_file(&e.file_name())
|
||||
&& let Ok(m) = std::fs::symlink_metadata(e.path())
|
||||
&& m.is_file()
|
||||
&& !reparse_point(&m)
|
||||
{
|
||||
files.push((e.path(), m.len()));
|
||||
}
|
||||
}
|
||||
Ok((files, false))
|
||||
}
|
||||
}
|
||||
|
||||
impl Storage for PluginStorage {
|
||||
fn setting(&self, key: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.open()?;
|
||||
Ok(self.read_settings(meter)?.remove(key))
|
||||
}
|
||||
|
||||
fn set_setting(&self, key: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> {
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.open()?;
|
||||
let mut settings = self.read_settings(meter)?;
|
||||
if value.len() > storage::MAX_SETTING_BYTES || (!settings.contains_key(key) && settings.len() >= storage::MAX_SETTINGS) {
|
||||
return Err(StorageError::Full);
|
||||
}
|
||||
settings.insert(key.to_string(), value.to_vec());
|
||||
self.write_settings(&settings, meter)
|
||||
}
|
||||
|
||||
fn delete_setting(&self, key: &str, meter: &mut Meter) -> Result<bool, StorageError> {
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.open()?;
|
||||
let mut settings = self.read_settings(meter)?;
|
||||
if settings.remove(key).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
self.write_settings(&settings, meter).map(|()| true)
|
||||
}
|
||||
|
||||
fn cache_entry(&self, name: &str, meter: &mut Meter) -> Result<Option<Vec<u8>>, StorageError> {
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.open()?;
|
||||
let path = self.entry_path(name);
|
||||
self.no_links(&path)?;
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) if m.is_file() && m.len() <= storage::MAX_CACHE_ENTRY as u64 => meter.take(m.len())?,
|
||||
Ok(_) => return Err(StorageError::Unavailable),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(io(&e)),
|
||||
}
|
||||
read_capped(&path, storage::MAX_CACHE_ENTRY as u64).map(Some).map_err(StorageError::Io)
|
||||
}
|
||||
|
||||
fn set_cache_entry(&self, name: &str, value: &[u8], meter: &mut Meter) -> Result<(), StorageError> {
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.open()?;
|
||||
let path = self.entry_path(name);
|
||||
let (files, overfull) = self.cache_files()?;
|
||||
if overfull {
|
||||
return Err(StorageError::Full);
|
||||
}
|
||||
let old = files.iter().find(|(p, _)| *p == path).map(|(_, n)| *n);
|
||||
let total = sum_sizes(&files).saturating_sub(old.unwrap_or(0)).saturating_add(value.len() as u64);
|
||||
let too_many = old.is_none() && files.len() >= storage::MAX_CACHE_ENTRIES;
|
||||
if value.len() > storage::MAX_CACHE_ENTRY || total > storage::MAX_CACHE_BYTES || too_many {
|
||||
return Err(StorageError::Full);
|
||||
}
|
||||
meter.take(value.len() as u64)?;
|
||||
std::fs::create_dir_all(self.cache_dir()).map_err(|e| io(&e))?;
|
||||
self.no_links(&path)?;
|
||||
write_atomic(&path, value).map_err(|e| io(&e))
|
||||
}
|
||||
|
||||
fn remove_cache_entry(&self, name: &str) -> Result<bool, StorageError> {
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.open()?;
|
||||
let path = self.entry_path(name);
|
||||
self.no_links(&path)?;
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(e) => Err(io(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_cache(&self) -> Result<u64, StorageError> {
|
||||
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.open()?;
|
||||
let dir = self.cache_dir();
|
||||
self.no_links(&dir)?;
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
|
||||
Err(e) => return Err(io(&e)),
|
||||
};
|
||||
// Every plain file, one at a time as the folder is read, past the ones a write looks at too.
|
||||
let mut freed = 0_u64;
|
||||
for e in entries {
|
||||
let path = e.map_err(|e| io(&e))?.path();
|
||||
if let Ok(m) = std::fs::symlink_metadata(&path)
|
||||
&& m.is_file()
|
||||
&& !reparse_point(&m)
|
||||
{
|
||||
std::fs::remove_file(&path).map_err(|e| io(&e))?;
|
||||
freed = freed.saturating_add(m.len());
|
||||
}
|
||||
}
|
||||
Ok(freed)
|
||||
}
|
||||
|
||||
fn cache_bytes(&self) -> u64 {
|
||||
if self.open().is_err() {
|
||||
return 0;
|
||||
}
|
||||
self.cache_files().map(|(files, _)| sum_sizes(&files)).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the grants file of a plug-in store holds.
|
||||
|
||||
@@ -3012,6 +3012,42 @@ fn a_trace_run_hands_its_trace_to_the_command() {
|
||||
assert!((bb.center().x - 160.0).abs() < 1.5, "traced where the image is now: {bb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_ignore_background_keeps_the_color_inside_the_art() {
|
||||
// A black field, a light panel on it and a black ring on the panel.
|
||||
let art = vectorcraft_trace::Raster::from_fn(200, 150, |x, y| {
|
||||
let r = (f64::from(x) + 0.5 - 100.0).hypot(f64::from(y) + 0.5 - 75.0);
|
||||
if !((20..180).contains(&x) && (20..130).contains(&y)) || (37.0..=43.0).contains(&r) { [0, 0, 0, 255] } else { [240, 240, 220, 255] }
|
||||
});
|
||||
let traced = |background: bool| {
|
||||
let mut s = session();
|
||||
add_image(&mut s, &art, Affine::IDENTITY);
|
||||
let params = json!({"params": {"mode": "color", "palette": "limited", "colors": 2, "ignoreWhite": true, "ignoreColor": "#000000", "ignoreBackground": background}});
|
||||
let r = s.execute("imageTrace.makeAndExpand", ¶ms).unwrap();
|
||||
let g = node(&s, NodeId(r["id"].as_u64().unwrap()));
|
||||
let black: Vec<_> = g
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|n| n.appearance.fill_paint() == Paint::solid(Color::rgb8(0, 0, 0)))
|
||||
.map(|n| outline(n).0.bounds().unwrap())
|
||||
.collect();
|
||||
(r["colors"].clone(), black)
|
||||
};
|
||||
// The whole color: the ring goes with the field.
|
||||
let (colors, black) = traced(false);
|
||||
assert_eq!(colors, 1);
|
||||
assert!(black.is_empty(), "{black:?}");
|
||||
// The background only: the ring stays, and nothing black reaches the image's edge.
|
||||
let (colors, black) = traced(true);
|
||||
assert_eq!(colors, 2);
|
||||
assert_eq!(black.len(), 1, "{black:?}");
|
||||
let bb = black[0];
|
||||
assert!((bb.x0 - 57.0).abs() < 1.5 && (bb.x1 - 143.0).abs() < 1.5 && (bb.y0 - 32.0).abs() < 1.5 && (bb.y1 - 118.0).abs() < 1.5, "{bb:?}");
|
||||
// The params doc tells agents about the option.
|
||||
assert!(find_command("imageTrace.make").unwrap().params.contains("ignoreBackground: false"));
|
||||
}
|
||||
|
||||
/// A Document Library trace made away from its command no longer applies once the library's
|
||||
/// colours change: the settings' JSON leaves them out (#4605 review).
|
||||
#[test]
|
||||
|
||||
@@ -374,7 +374,7 @@ fn rgb_art_separates_with_gcr() {
|
||||
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Black".into()]), ..Default::default() }), ..Default::default() },
|
||||
)
|
||||
.pixel(50, 50);
|
||||
assert!(k[0] < 20, "RGB black is mostly K: {k:?}");
|
||||
assert!((20..=30).contains(&k[0]), "RGB black carries 90% black: {k:?}");
|
||||
let c = render(
|
||||
&doc,
|
||||
RenderOptions { proof: Some(ProofSetup { separations: Some(vec!["Cyan".into()]), ..Default::default() }), ..Default::default() },
|
||||
|
||||
@@ -333,8 +333,8 @@ fn an_rgb_image_sampled_in_a_cmyk_document_separates_through_the_color_settings(
|
||||
let got = node(&s, dst).appearance.fill_paint().color().unwrap();
|
||||
assert_eq!(got, managed_cmyk(Color::rgb8(83, 170, 223)));
|
||||
let Color::Cmyk { c, m, y, k } = got else { panic!("{got:?}") };
|
||||
// Generic CMYK: about C59 M12 Y3 K0, with no black, where the formula gave K13.
|
||||
assert!((0.5..0.75).contains(&c) && (0.05..0.2).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}");
|
||||
// Generic CMYK: about C62 M19 Y0 K0, with no black, where the formula gave K13.
|
||||
assert!((0.5..0.75).contains(&c) && (0.1..0.25).contains(&m) && y < 0.05 && k < 0.02, "{c} {m} {y} {k}");
|
||||
// A light blue shows close to the color it was picked from; the formula's C12 M7 showed duller.
|
||||
click(&mut s, 450.0, 50.0, Mods::default());
|
||||
let got = node(&s, dst).appearance.fill_paint().color().unwrap();
|
||||
|
||||
@@ -14,7 +14,11 @@ fn names(v: &Value) -> Vec<String> {
|
||||
fn the_font_list_names_every_available_family_with_its_styles() {
|
||||
let mut s = Session::new();
|
||||
let fams = names(&s.execute("text.fontList", &json!({})).unwrap()["families"]);
|
||||
assert_eq!(fams, *FontDb::global().menu_family_list());
|
||||
// Other tests running at the same time add and remove composite fonts, which the lists show
|
||||
// first; the installed families are compared.
|
||||
let db = FontDb::global();
|
||||
let installed_only = |list: &[String]| list.iter().filter(|f| db.has_installed_family(f)).cloned().collect::<Vec<_>>();
|
||||
assert_eq!(installed_only(&fams), installed_only(&db.menu_family_list()));
|
||||
for f in ["Source Sans 3", "Source Serif 4", "Inter", "JetBrains Mono"] {
|
||||
assert!(fams.iter().any(|x| x == f), "{f}");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! History panel › New Document from State (`edit.newDocumentFromState`).
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -262,7 +263,72 @@ fn a_file_the_source_finds_in_its_folder_is_found_from_the_new_document() {
|
||||
s.execute(CMD, &json!({})).unwrap();
|
||||
assert_eq!(s.active_index(), Some(1), "the new document");
|
||||
let c = check(&mut s);
|
||||
assert_eq!((&c["links"][0]["status"], &c["links"][0]["path"]), (&json!("ok"), &json!(moved)), "{c}");
|
||||
assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}");
|
||||
}
|
||||
|
||||
/// A linked file missing from the moved source keeps the place its relative path names from the
|
||||
/// source's folder: the new document, saved beside the source, writes the same relative path.
|
||||
#[test]
|
||||
fn a_missing_file_keeps_its_relative_path_in_the_new_document() {
|
||||
let a = Folder::new("history-document-missing-a");
|
||||
let b = Folder::new("history-document-missing-b");
|
||||
let away = Folder::new("history-document-missing-away");
|
||||
write(&a.file("art/photo.png"), &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
place(&mut s, &a.file("art/photo.png"));
|
||||
s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap();
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap();
|
||||
let r = s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
|
||||
assert_eq!(r["missingLinks"][0]["name"], "photo.png", "{r}");
|
||||
s.execute(CMD, &json!({})).unwrap();
|
||||
let copy = b.file("copy.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": copy})).unwrap();
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap();
|
||||
let link = &saved["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"];
|
||||
assert_eq!(link["relative"].as_str(), Some("art/photo.png"), "{link}");
|
||||
}
|
||||
|
||||
/// A linked file that comes back beside the document the source was read from, after the source
|
||||
/// was saved in another folder, is found from the new document.
|
||||
#[test]
|
||||
fn a_file_back_beside_the_source_it_was_read_from_is_found_from_the_new_document() {
|
||||
let a = Folder::new("history-document-back-a");
|
||||
let b = Folder::new("history-document-back-b");
|
||||
let away = Folder::new("history-document-back-away");
|
||||
write(&a.file("art/photo.png"), &png(60, 30, RED));
|
||||
let mut s = session();
|
||||
place(&mut s, &a.file("art/photo.png"));
|
||||
s.execute("document.save", &json!({"path": a.file("poster.vectorcraft")})).unwrap();
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap();
|
||||
s.execute("document.open", &json!({"path": b.file("poster.vectorcraft")})).unwrap();
|
||||
std::fs::create_dir_all(b.0.join("saved")).unwrap();
|
||||
s.execute("document.save", &json!({"path": b.file("saved/poster.vectorcraft")})).unwrap();
|
||||
std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap();
|
||||
let source = s.active_index().unwrap();
|
||||
s.execute(CMD, &json!({})).unwrap();
|
||||
let c = s.execute("links.check", &json!({})).unwrap();
|
||||
let moved = b.file("art/photo.png");
|
||||
assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&moved))), "{c}");
|
||||
// A file beside the folder the source was last saved in is found too.
|
||||
std::fs::create_dir_all(b.0.join("saved/art")).unwrap();
|
||||
std::fs::rename(&moved, b.file("saved/art/photo.png")).unwrap();
|
||||
s.execute("document.activate", &json!({"index": source})).unwrap();
|
||||
s.execute(CMD, &json!({})).unwrap();
|
||||
let c = s.execute("links.check", &json!({})).unwrap();
|
||||
let saved = b.file("saved/art/photo.png");
|
||||
assert_eq!((&c["links"][0]["status"], c["links"][0]["path"].as_str().map(Path::new)), (&json!("ok"), Some(Path::new(&saved))), "{c}");
|
||||
// Missing again when the new document is saved, the file keeps the place it was found.
|
||||
std::fs::rename(&saved, away.file("photo.png")).unwrap();
|
||||
std::fs::create_dir_all(b.0.join("new")).unwrap();
|
||||
let copy = b.file("new/copy.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": copy})).unwrap();
|
||||
let file: Value = serde_json::from_slice(&std::fs::read(©).unwrap()).unwrap();
|
||||
let link = &file["document"]["layers"][0]["kind"]["children"][0]["kind"]["link"];
|
||||
assert_eq!(link["relative"].as_str(), Some("../saved/art/photo.png"), "{link}");
|
||||
}
|
||||
|
||||
/// The same holds for a linked image only a symbol shows.
|
||||
|
||||
@@ -135,8 +135,9 @@ fn id_does_what_ids_of_one_does_with_nothing_selected() {
|
||||
|
||||
/// Every command that takes `id` as `ids` of one ([`cmd::params::id_stands_for_ids`]), with an
|
||||
/// object selected: an `id` that is malformed (a string, negative, a fraction, an object) or names
|
||||
/// no object is refused under the command's id, as the same value in `ids` is, and the document
|
||||
/// and the selection stay as they were. It never stands for the selection instead. (A string `id`
|
||||
/// no object is refused under the command's id, as the same value in `ids` is (whose error for a
|
||||
/// malformed entry also names the key and the entry), and the document and the selection stay as
|
||||
/// they were. It never stands for the selection instead. (A string `id`
|
||||
/// given to `effect.apply` without `effect` names the effect, which "bad" isn't: refused too.)
|
||||
#[test]
|
||||
fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() {
|
||||
@@ -158,7 +159,10 @@ fn a_bad_id_is_refused_as_the_same_ids_are_and_changes_nothing() {
|
||||
let st = s.doc().unwrap();
|
||||
(r, st.doc == before && st.selection.objects == vec![NodeId(d)])
|
||||
};
|
||||
let (with_id, with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()])));
|
||||
let (with_id, mut with_ids) = (run("id", bad.clone()), run("ids", json!([bad.clone()])));
|
||||
// The error for an `ids` entry names the key and the entry; the one for `id` names the value.
|
||||
let entry = format!("`ids` entry 0 is {bad}, not a non-negative integer object id");
|
||||
with_ids.0 = with_ids.0.map_err(|e| e.replace(&entry, &format!("invalid object id {bad}: expected a non-negative integer")));
|
||||
let effect_named = spec.id == "effect.apply" && bad.is_string();
|
||||
if with_id.0.is_ok() || !with_id.1 || (with_id != with_ids && !effect_named) {
|
||||
failures.push(format!(
|
||||
@@ -301,3 +305,205 @@ fn ids_of_an_object_and_a_group_that_holds_it_do_what_that_selection_does() {
|
||||
assert!(failures.is_empty(), "{} differ:\n{}", failures.len(), failures.join("\n"));
|
||||
assert!(succeeded >= 150, "only {succeeded} runs succeeded");
|
||||
}
|
||||
|
||||
/// What `ids` (or `slices`) left out or null gives a command: the selection, or the command's own
|
||||
/// default.
|
||||
enum Unset {
|
||||
/// The selected objects: a result, the same as the list of the selection gives.
|
||||
Selected,
|
||||
/// The selected objects, which the command refuses with this error, as it refuses the list of
|
||||
/// the selection.
|
||||
SelectionRefused(&'static str),
|
||||
/// This error, which the list of the selection does not give.
|
||||
Fails(&'static str),
|
||||
/// This result, which the list of the selection does not give.
|
||||
Gives(serde_json::Value),
|
||||
}
|
||||
|
||||
/// The commands that read their `ids` (or `slices`) list themselves or pass it to a colour pass,
|
||||
/// with the rectangle apart (`d`): the key, the other params, and what leaving the key out gives.
|
||||
fn ids_cases(d: u64) -> Vec<(&'static str, &'static str, serde_json::Value, Unset)> {
|
||||
use Unset::*;
|
||||
vec![
|
||||
("appearance.copyFrom", "ids", json!({ "source": d }), Selected),
|
||||
("edit.colors.invert", "ids", json!({}), Selected),
|
||||
("edit.colors.toCMYK", "ids", json!({}), Selected),
|
||||
("edit.colors.toGrayscale", "ids", json!({}), Selected),
|
||||
("edit.colors.toRGB", "ids", json!({}), Selected),
|
||||
("edit.colors.saturate", "ids", json!({"intensity": 50}), Selected),
|
||||
("edit.colors.adjustBalance", "ids", json!({"r": 20}), Selected),
|
||||
("paint.invert", "ids", json!({}), Selected),
|
||||
("paint.complement", "ids", json!({}), Selected),
|
||||
("addon.color.filter", "ids", json!({"effect": "addon.color.invert"}), Selected),
|
||||
("addon.color.applyNearestSwatch", "ids", json!({}), Selected),
|
||||
("edit.colors.overprintBlack", "ids", json!({}), Selected),
|
||||
("edit.findReplace", "ids", json!({"find": "A", "replace": "B"}), Gives(json!({"count": 1}))),
|
||||
("layer.highlight", "ids", json!({}), Fails("missing ids")),
|
||||
("layer.move", "ids", json!({ "target": d }), Fails("missing ids")),
|
||||
("library.add", "ids", json!({"kind": "graphic"}), Selected),
|
||||
("library.add", "ids", json!({"kind": "fillColor"}), Selected),
|
||||
("links.check", "ids", json!({}), Selected),
|
||||
("links.update", "ids", json!({}), Selected),
|
||||
("links.relink", "ids", json!({"path": "missing.png"}), Fails("select the images to relink, or pass ids")),
|
||||
("links.embed", "ids", json!({}), Fails("select the linked images or placed documents, or pass ids")),
|
||||
("links.placementOptions", "ids", json!({}), Fails("select the images, or pass ids")),
|
||||
("object.transform", "ids", json!({"matrix": [1, 0, 0, 1, 10, 0]}), Selected),
|
||||
("object.pattern.make", "ids", json!({"name": "P"}), Selected),
|
||||
("object.repeat.radial", "ids", json!({}), Selected),
|
||||
("object.repeat.grid", "ids", json!({}), Selected),
|
||||
("object.repeat.mirror", "ids", json!({}), Selected),
|
||||
("object.slice.make", "ids", json!({}), Selected),
|
||||
("object.slice.move", "slices", json!({"dx": 1, "dy": 1}), Selected),
|
||||
("object.slice.delete", "slices", json!({}), Selected),
|
||||
("object.slice.select", "slices", json!({}), Gives(json!({"selected": []}))),
|
||||
("path.join", "ids", json!({}), SelectionRefused("path is already closed")),
|
||||
("path.averageJoin", "ids", json!({"to": "corner"}), SelectionRefused("select two open path ends, or two open paths, to average and join")),
|
||||
("symbol.edit", "ids", json!({}), Fails("the document has no symbols")),
|
||||
("transparency.makeOpacityMask", "ids", json!({}), Selected),
|
||||
]
|
||||
}
|
||||
|
||||
/// What `cmd` does in a fresh [`doc_with_type`] with `selected` selected (made an object slice
|
||||
/// first when `slice`): its result or error, the document, the selection and the undo history's
|
||||
/// length.
|
||||
fn outcome(
|
||||
cmd: &str,
|
||||
p: &serde_json::Value,
|
||||
selected: &[u64],
|
||||
slice: bool,
|
||||
) -> (std::result::Result<serde_json::Value, String>, vectorcraft_doc::Document, vectorcraft_doc::Selection, usize) {
|
||||
let (mut s, ..) = doc_with_type();
|
||||
if slice {
|
||||
s.execute("object.slice.make", &json!({ "ids": selected })).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({ "ids": selected })).unwrap();
|
||||
let r = s.execute(cmd, p).map_err(|e| e.to_string());
|
||||
let st = s.doc().unwrap();
|
||||
(r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len())
|
||||
}
|
||||
|
||||
/// With the rectangle apart (`d`) selected and with nothing selected, a list with an entry that is
|
||||
/// not a non-negative integer (a string, true, an object) is refused with an error naming the key,
|
||||
/// the entry and its value, a value that is not a list with an error naming the key, and the
|
||||
/// document, the selection and the undo history stay as they were. No entry is dropped. A list of
|
||||
/// the rectangle is not refused.
|
||||
#[test]
|
||||
fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() {
|
||||
let (_, [.., d], _) = doc_with_type();
|
||||
let mut failures = vec![];
|
||||
for (cmd, key, params, _) in &ids_cases(d) {
|
||||
let bads = [json!(["abc"]), json!([d, true]), json!([{}]), json!("abc")];
|
||||
for (bad, selected) in bads.iter().flat_map(|b| [(b, true), (b, false)]) {
|
||||
// A fresh document each time, so no mode one case enters carries into the next.
|
||||
let (mut s, ..) = doc_with_type();
|
||||
if selected {
|
||||
s.execute("select.set", &json!({ "ids": [d] })).unwrap();
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len());
|
||||
let mut p = params.clone();
|
||||
p[*key] = bad.clone();
|
||||
let r = s.execute(cmd, &p);
|
||||
let want = match bad.as_array() {
|
||||
Some(a) => {
|
||||
let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap();
|
||||
format!("`{key}` entry {index} is {value}, not a non-negative integer object id")
|
||||
}
|
||||
None => format!("{key} must be an array of non-negative integer object ids"),
|
||||
};
|
||||
match &r {
|
||||
Err(EngineError::BadParams { cmd: c, msg }) if c == cmd && *msg == want => {}
|
||||
r => failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): {r:.160?}")),
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
if (st.doc.clone(), st.selection.clone(), st.history.undo.len()) != before {
|
||||
failures.push(format!("{cmd} {key}: {bad} (selected: {selected}): the document, the selection or the undo history changed"));
|
||||
}
|
||||
}
|
||||
let (mut s, ..) = doc_with_type();
|
||||
s.execute("select.set", &json!({ "ids": [d] })).unwrap();
|
||||
let mut p = params.clone();
|
||||
p[*key] = json!([d]);
|
||||
if let Err(EngineError::BadParams { msg, .. }) = s.execute(cmd, &p)
|
||||
&& (msg.starts_with(&format!("`{key}` entry ")) || msg.starts_with(&format!("{key} must be an array")))
|
||||
{
|
||||
failures.push(format!("{cmd} {key}: [{d}] is refused: {msg}"));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and
|
||||
/// with it null do the same: what the selection's list does for the commands that take the
|
||||
/// selection, and the command's own default for the others (`object.slice.select` deselects the
|
||||
/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through
|
||||
/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes
|
||||
/// nothing.
|
||||
#[test]
|
||||
fn ids_left_out_or_null_take_the_selection_or_the_default() {
|
||||
let (_, [.., d], _) = doc_with_type();
|
||||
let mut failures = vec![];
|
||||
for (cmd, key, params, unset) in ids_cases(d) {
|
||||
// The commands on `slices` act on the selected object once it is an object slice.
|
||||
let slice = key == "slices";
|
||||
let left_out = outcome(cmd, ¶ms, &[d], slice);
|
||||
let mut null = params.clone();
|
||||
null[key] = serde_json::Value::Null;
|
||||
let with_null = outcome(cmd, &null, &[d], slice);
|
||||
if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") {
|
||||
let (mut s, ..) = doc_with_type();
|
||||
s.execute("select.set", &json!({ "ids": [d] })).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids"));
|
||||
if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) {
|
||||
failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0));
|
||||
}
|
||||
} else if with_null != left_out {
|
||||
failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0));
|
||||
}
|
||||
let mut listed = params.clone();
|
||||
listed[key] = json!([d]);
|
||||
let listed = outcome(cmd, &listed, &[d], slice);
|
||||
let ok = match &unset {
|
||||
Unset::Selected => left_out.0.is_ok() && listed == left_out,
|
||||
Unset::SelectionRefused(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed == left_out,
|
||||
Unset::Fails(want) => left_out.0.as_ref().is_err_and(|e| e.ends_with(want)) && listed.0 != left_out.0,
|
||||
Unset::Gives(want) => left_out.0.as_ref() == Ok(want) && listed.0 != left_out.0,
|
||||
};
|
||||
if !ok {
|
||||
failures.push(format!("{cmd} {key}: left out: {:.200?}; listed: {:.200?}", left_out.0, listed.0));
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// With no document open, every command that takes `ids` refuses a list with an entry that is not
|
||||
/// a non-negative integer, and a value that is not a list, with the same error as with a document
|
||||
/// open (`effect.list` and `links.findFiles` run without a document).
|
||||
#[test]
|
||||
fn an_ids_entry_that_is_not_an_id_is_refused_with_no_document() {
|
||||
let (mut failures, mut checked) = (vec![], 0);
|
||||
for spec in command_specs() {
|
||||
if !cmd::params::takes_key(spec.params, "ids") {
|
||||
continue;
|
||||
}
|
||||
checked += 1;
|
||||
for bad in [json!(["abc"]), json!([1, true]), json!("abc")] {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute(spec.id, &json!({ "ids": bad }));
|
||||
let want = match bad.as_array() {
|
||||
Some(a) => {
|
||||
let (index, value) = a.iter().enumerate().find(|(_, v)| !v.is_u64()).unwrap();
|
||||
format!("`ids` entry {index} is {value}, not a non-negative integer object id")
|
||||
}
|
||||
None => "ids must be an array of non-negative integer object ids".to_string(),
|
||||
};
|
||||
match &r {
|
||||
Err(EngineError::BadParams { cmd: c, msg }) if c == spec.id && *msg == want => {}
|
||||
r => failures.push(format!("{} ids: {bad}: {r:.160?}", spec.id)),
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n"));
|
||||
assert!(checked >= 100, "only {checked} commands take ids");
|
||||
}
|
||||
|
||||
@@ -85,6 +85,21 @@ fn linked_files_on_other_computers_are_never_read() {
|
||||
assert!(s.link_stamps.keys().all(|(_, p)| !far.contains(p)), "{:?}", s.link_stamps.keys());
|
||||
}
|
||||
|
||||
/// A place a link names from the file the document was read from is followed only when the
|
||||
/// document's own file follows it too: after a document read from a network share is saved on
|
||||
/// this computer, its links are not looked for on that share.
|
||||
#[test]
|
||||
fn places_named_from_a_share_are_not_followed_after_a_save_elsewhere() {
|
||||
use crate::cmd::links::follow_from;
|
||||
let resolve =
|
||||
|stored: &str, doc: Option<&str>| craft_docpath::resolve(stored, doc.map(std::path::Path::new)).map(|p| p.to_string_lossy().into_owned());
|
||||
let (link, on_share) = (r"\\server\share\a\art\photo.png", Some(r"\\server\share\a\poster.vectorcraft"));
|
||||
assert_eq!(follow_from(link, on_share, on_share, resolve).as_deref(), Some(link));
|
||||
assert_eq!(follow_from(link, on_share, Some(r"\\server\share\b\poster.vectorcraft"), resolve).as_deref(), Some(link));
|
||||
assert_eq!(follow_from(link, on_share, Some("/local/b/poster.vectorcraft"), resolve), None);
|
||||
assert_eq!(follow_from(link, on_share, Some(r"C:\b\poster.vectorcraft"), resolve), None);
|
||||
}
|
||||
|
||||
/// The final path Windows gives a folder of the drive `Z:` mapped to `\\server\share`. The
|
||||
/// share's root folder has no trailing separator (`\\?\UNC\server\share`).
|
||||
fn on_share(folder: &str) -> Option<std::path::PathBuf> {
|
||||
|
||||
@@ -244,6 +244,141 @@ fn the_relative_path_finds_the_file_after_the_folder_moves() {
|
||||
assert!(near(centre_colour(&s.doc().unwrap().doc), RED));
|
||||
}
|
||||
|
||||
/// A document moved to another folder while its linked file is missing keeps the file's relative
|
||||
/// path when it is saved again, there or in a subfolder, so the file is found once it is back
|
||||
/// beside the document.
|
||||
#[test]
|
||||
fn a_missing_link_keeps_its_relative_path_when_the_moved_document_is_saved() {
|
||||
let a = Folder::new("missing-save-a");
|
||||
let b = Folder::new("missing-save-b");
|
||||
let away = Folder::new("missing-save-away");
|
||||
let pic = a.file("art/photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
save(&mut s, &a.file("poster.vectorcraft"));
|
||||
// Move the document and its links, then take the links away.
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap();
|
||||
let r = open(&mut s, &b.file("poster.vectorcraft"));
|
||||
assert_eq!(r["missingLinks"][0]["name"].as_str(), Some("photo.png"), "{r}");
|
||||
save(&mut s, &b.file("poster.vectorcraft"));
|
||||
std::fs::create_dir_all(b.0.join("copies")).unwrap();
|
||||
save(&mut s, &b.file("copies/poster.vectorcraft"));
|
||||
let relative = |path: &str| {
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
saved_image(&mut saved)["link"]["relative"].as_str().map(str::to_string)
|
||||
};
|
||||
assert_eq!(relative(&b.file("poster.vectorcraft")).as_deref(), Some("art/photo.png"));
|
||||
assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png"));
|
||||
// Saved again where the last save put it, the file is still named from the folder it was read from.
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
assert_eq!(relative(&b.file("copies/poster.vectorcraft")).as_deref(), Some("../art/photo.png"));
|
||||
// The links come back: both saved documents find the file.
|
||||
std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap();
|
||||
let moved = b.file("art/photo.png");
|
||||
for doc in [b.file("poster.vectorcraft"), b.file("copies/poster.vectorcraft")] {
|
||||
let r = open(&mut s, &doc);
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "{doc}: {r}");
|
||||
assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&moved), "{doc}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A file found by name in the document's folder, then missing before the next save, keeps the
|
||||
/// place it was last found: the saved relative path names that place, not the folder its old
|
||||
/// relative path named.
|
||||
#[test]
|
||||
fn a_file_found_elsewhere_then_missing_keeps_the_place_it_was_found() {
|
||||
let dir = Folder::new("found-then-missing");
|
||||
let pic = dir.file("art/photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
let doc = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &doc);
|
||||
let beside = dir.file("photo.png");
|
||||
std::fs::rename(&pic, &beside).unwrap();
|
||||
let r = open(&mut s, &doc);
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "{r}");
|
||||
assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside));
|
||||
let away = dir.file("away.png");
|
||||
std::fs::rename(&beside, &away).unwrap();
|
||||
save(&mut s, &doc);
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap();
|
||||
assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png"));
|
||||
}
|
||||
|
||||
/// After a save in another folder, a file Update Links finds by name beside the saved document,
|
||||
/// then missing before the next save, keeps the place it was found.
|
||||
#[test]
|
||||
fn a_file_update_links_finds_after_a_save_elsewhere_keeps_its_place() {
|
||||
let a = Folder::new("update-after-save-as-a");
|
||||
let b = Folder::new("update-after-save-as-b");
|
||||
let pic = a.file("art/photo.png");
|
||||
write(&pic, &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
let id = place(&mut s, &pic);
|
||||
save(&mut s, &a.file("poster.vectorcraft"));
|
||||
open(&mut s, &a.file("poster.vectorcraft"));
|
||||
let doc = b.file("poster.vectorcraft");
|
||||
save(&mut s, &doc);
|
||||
let beside = b.file("photo.png");
|
||||
std::fs::rename(&pic, &beside).unwrap();
|
||||
let r = s.execute("links.update", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(&r["missing"], &json!([]), "{r}");
|
||||
assert_eq!(Path::new(&image(&s, id).link.unwrap().path), Path::new(&beside));
|
||||
std::fs::remove_file(&beside).unwrap();
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&doc).unwrap()).unwrap();
|
||||
assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("photo.png"));
|
||||
}
|
||||
|
||||
/// A file found at its relative path from the folder of the last save, away from its path and from
|
||||
/// the folder the document was read from, is written relative to where it was found.
|
||||
#[test]
|
||||
fn a_file_found_beside_the_last_save_is_written_where_it_was_found() {
|
||||
let a = Folder::new("found-beside-save-a");
|
||||
let b = Folder::new("found-beside-save-b");
|
||||
let c = Folder::new("found-beside-save-c");
|
||||
write(&a.file("art/photo.png"), &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
place(&mut s, &a.file("art/photo.png"));
|
||||
save(&mut s, &a.file("poster.vectorcraft"));
|
||||
open(&mut s, &a.file("poster.vectorcraft"));
|
||||
save(&mut s, &b.file("poster.vectorcraft"));
|
||||
std::fs::rename(a.0.join("art"), b.0.join("art")).unwrap();
|
||||
save(&mut s, &c.file("poster.vectorcraft"));
|
||||
let r = open(&mut s, &c.file("poster.vectorcraft"));
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "{r}");
|
||||
}
|
||||
|
||||
/// After a moved document with a missing link is saved in another folder, `links.check` finds the
|
||||
/// file once it is back at the place its relative path names from the folder the document was
|
||||
/// read from, which is where the saved file looks for it too.
|
||||
#[test]
|
||||
fn a_missing_link_comes_back_where_the_document_saved_elsewhere_looks_for_it() {
|
||||
let a = Folder::new("check-after-save-as-a");
|
||||
let b = Folder::new("check-after-save-as-b");
|
||||
let c = Folder::new("check-after-save-as-c");
|
||||
let away = Folder::new("check-after-save-as-away");
|
||||
write(&a.file("art/photo.png"), &png(600, 300, RED));
|
||||
let mut s = session();
|
||||
place(&mut s, &a.file("art/photo.png"));
|
||||
save(&mut s, &a.file("poster.vectorcraft"));
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
std::fs::rename(b.0.join("art"), away.0.join("art")).unwrap();
|
||||
open(&mut s, &b.file("poster.vectorcraft"));
|
||||
save(&mut s, &c.file("poster.vectorcraft"));
|
||||
std::fs::rename(away.0.join("art"), b.0.join("art")).unwrap();
|
||||
let r = s.execute("links.check", &json!({})).unwrap();
|
||||
let found = r["links"][0]["found"].as_str().map(Path::new);
|
||||
assert_eq!((r["missing"].as_u64(), found), (Some(0), Some(Path::new(&b.file("art/photo.png")))), "{r}");
|
||||
let r = open(&mut s, &c.file("poster.vectorcraft"));
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "{r}");
|
||||
}
|
||||
|
||||
/// Links in symbols, graph designs, patterns and opacity masks are saved with their relative
|
||||
/// paths, as links in the layers are: after the folder moves, each file is found and its link takes
|
||||
/// the new path, which the next save writes relative to the document again.
|
||||
@@ -781,7 +916,7 @@ fn low_res_proxy_eps_export_uses_full_resolution_from_the_linked_file() {
|
||||
assert_eq!((r["modifiedLinks"].clone(), r["updatedLinks"].clone()), (json!([]), json!([])), "{r}");
|
||||
let key = image(&s, id).key;
|
||||
assert!(s.doc().unwrap().doc.images[&key].is_proxy());
|
||||
let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None);
|
||||
let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default());
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
assert!(!full.images[&key].is_proxy());
|
||||
let dim =
|
||||
@@ -812,7 +947,7 @@ fn export_reads_a_found_svg_link_into_its_own_images_only() {
|
||||
|
||||
let bytes = png(20, 20, RED);
|
||||
write(&dir.file("a.png"), &bytes);
|
||||
let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, None);
|
||||
let (full, warnings) = cmd::links::full_linked_images(&s.doc().unwrap().doc, Default::default());
|
||||
assert_eq!(warnings.len(), 1, "b.png is still missing: {warnings:?}");
|
||||
assert_eq!(*full.images[&a.key].bytes, bytes, "a.png's pixels");
|
||||
assert_eq!(full.images[&b.key].bytes, placeholder, "b.png keeps its placeholder");
|
||||
@@ -845,7 +980,7 @@ fn export_reads_a_changed_svg_link_into_its_own_images_only() {
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.images[&a.key].is_proxy() && doc.images[&b.key].is_proxy(), "previews");
|
||||
let preview = doc.images[&a.key].bytes.clone();
|
||||
let (full, warnings) = cmd::links::full_linked_images(doc, None);
|
||||
let (full, warnings) = cmd::links::full_linked_images(doc, Default::default());
|
||||
assert_eq!(warnings.len(), 1, "a.png is missing: {warnings:?}");
|
||||
assert_eq!(*full.images[&b.key].bytes, blue, "b.png's new pixels");
|
||||
assert_eq!(full.images[&a.key].bytes, preview, "a.png keeps its preview");
|
||||
@@ -1002,7 +1137,7 @@ fn relative_paths_are_written_against_the_saved_file() {
|
||||
};
|
||||
d.insert(Some(lid), 0, vectorcraft_doc::Node::new(nid, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
let rel = |dest: String| {
|
||||
let d = cmd::links::with_relative_paths(&d, &dest).unwrap_or_else(|| d.clone());
|
||||
let d = cmd::links::with_relative_paths(&d, Default::default(), &dest).unwrap_or_else(|| d.clone());
|
||||
match &d.node(nid).unwrap().kind {
|
||||
NodeKind::Image(im) => im.link.as_ref().unwrap().relative.clone(),
|
||||
_ => None,
|
||||
@@ -1083,15 +1218,11 @@ fn links_of_an_imported_file_are_read_where_they_are_found() {
|
||||
assert!((im.width, im.height) == (600, 300) && !s.doc().unwrap().doc.images[&im.key].is_proxy(), "{im:?}");
|
||||
}
|
||||
|
||||
/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the
|
||||
/// file keeps relative to the document's (`src/`), and its link takes the size, modification time
|
||||
/// and hash of the file found there. Not found, the link keeps no such details.
|
||||
#[test]
|
||||
fn links_of_an_imported_file_are_found_by_their_relative_folder() {
|
||||
/// `poster.ai` in `dir`, a `.ai` file another app wrote, placing `src/photo.png` (relative to it)
|
||||
/// under a path that no longer exists → its path.
|
||||
fn imported_with_placed_file(dir: &Folder) -> String {
|
||||
use vectorcraft_testkit::ai;
|
||||
let dir = Folder::new("imported-relative");
|
||||
std::fs::create_dir_all(dir.0.join("src")).unwrap();
|
||||
let pic = dir.file("src/photo.png").replace('\\', "/");
|
||||
let place = "%AI5_BeginPlace\n%%FileType: 1\n[0.1 0 0 -0.1 20 80] 0 0 600 300 4 4 0\n(/no/such/folder/src/photo.png)`\n\
|
||||
%%IncludeFile:/no/such/folder/src/photo.png\n%AI17_Begin_Content_if_version_gt:24 12\n%AI28_BeginPlacedRelativePath\n(src/)\n\
|
||||
%AI28_EndPlacedRelativePath\n%AI17_Alternate_Content\n%AI17_End_Versioned_Content\n%AI26_BeginPlacedObjectPreview\n\
|
||||
@@ -1099,6 +1230,17 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() {
|
||||
let doc_path = dir.file("poster.ai");
|
||||
let data = ai::editing_data(200.0, 100.0, &ai::layer("Art", place));
|
||||
write(&doc_path, &ai::ai(data.as_bytes(), "0.56 0.56 0.87 rg 20 50 60 30 re f"));
|
||||
doc_path
|
||||
}
|
||||
|
||||
/// A placed file of a `.ai` file whose folder moved with the document is found by the folder the
|
||||
/// file keeps relative to the document's (`src/`), and its link takes the size, modification time
|
||||
/// and hash of the file found there. Not found, the link keeps no such details.
|
||||
#[test]
|
||||
fn links_of_an_imported_file_are_found_by_their_relative_folder() {
|
||||
let dir = Folder::new("imported-relative");
|
||||
let pic = dir.file("src/photo.png").replace('\\', "/");
|
||||
let doc_path = imported_with_placed_file(&dir);
|
||||
let link = |s: &Session| {
|
||||
let mut found = vec![];
|
||||
s.doc().unwrap().doc.visit_images(|_, im| found.push(im.clone()));
|
||||
@@ -1112,9 +1254,16 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() {
|
||||
let (l, proxy) = link(&s);
|
||||
assert_eq!((l.path.as_str(), l.relative.as_deref()), ("/no/such/folder/src/photo.png", Some("src/photo.png")));
|
||||
assert!(proxy && l.size.is_none() && l.modified.is_none() && l.hash.is_none(), "{l:?}");
|
||||
// Saved beside it while the file is missing, the document keeps the folder the file is in.
|
||||
let native = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &native);
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png"));
|
||||
|
||||
let bytes = png(600, 300, RED);
|
||||
write(&pic, &bytes);
|
||||
let r = open(&mut s, &native);
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "the native copy finds the file: {r}");
|
||||
let r = open(&mut s, &doc_path);
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "{r}");
|
||||
let (l, proxy) = link(&s);
|
||||
@@ -1123,3 +1272,27 @@ fn links_of_an_imported_file_are_found_by_their_relative_folder() {
|
||||
assert_eq!((l.size, l.hash.clone()), (Some(bytes.len() as u64), Some(vectorcraft_doc::links::hash_bytes(&bytes))));
|
||||
assert!(l.modified.is_some(), "{l:?}");
|
||||
}
|
||||
|
||||
/// A Data Recovery copy of such a document keeps the file its links' relative paths name places
|
||||
/// from: restored and saved beside the `.ai` file, it keeps the folder the placed file is in.
|
||||
#[test]
|
||||
fn a_recovered_copy_of_an_imported_file_keeps_its_links_folder() {
|
||||
let dir = Folder::new("imported-recovered");
|
||||
let doc_path = imported_with_placed_file(&dir);
|
||||
let store = std::sync::Arc::new(crate::cmd::recovery::MemoryStore::default());
|
||||
let mut s = session();
|
||||
s.recovery.set_store(store.clone());
|
||||
open(&mut s, &doc_path);
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
let r = s.execute("file.recovery.save", &json!({})).unwrap();
|
||||
assert_eq!(r["saved"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
drop(s);
|
||||
let mut s = Session::new();
|
||||
s.recovery.set_store(store);
|
||||
let r = s.execute("file.recovery.restore", &json!({})).unwrap();
|
||||
assert_eq!(r["restored"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let native = dir.file("poster.vectorcraft");
|
||||
save(&mut s, &native);
|
||||
let mut saved: Value = serde_json::from_slice(&std::fs::read(&native).unwrap()).unwrap();
|
||||
assert_eq!(saved_image(&mut saved)["link"]["relative"].as_str(), Some("src/photo.png"));
|
||||
}
|
||||
|
||||
@@ -2104,7 +2104,7 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() {
|
||||
let switch = |k: &str| format!("`{k}` must be true or false");
|
||||
let scope = "`scope` must be one of selection, document".to_string();
|
||||
let not_a_list = "ids must be an array of non-negative integer object ids".to_string();
|
||||
let not_an_id = "invalid object id \"x\": expected a non-negative integer".to_string();
|
||||
let not_an_id = |entry: usize| format!("`ids` entry {entry} is \"x\", not a non-negative integer object id");
|
||||
for (cmd, p, want) in [
|
||||
("edit.findReplace", json!({"find": "cat", "replace": 5}), string("replace")),
|
||||
("edit.findReplace", json!({"find": 5, "replace": "dog"}), string("find")),
|
||||
@@ -2112,7 +2112,8 @@ fn type_menu_and_find_commands_refuse_a_param_of_the_wrong_type() {
|
||||
("edit.findReplace", json!({"find": "cat", "replace": "dog", "wholeWord": "yes"}), switch("wholeWord")),
|
||||
("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": t.0}), not_a_list),
|
||||
("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, 999_999]}), "no such object id 999999".to_string()),
|
||||
("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id),
|
||||
("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": ["x"]}), not_an_id(0)),
|
||||
("edit.findReplace", json!({"find": "cat", "replace": "dog", "ids": [t.0, "x"]}), not_an_id(1)),
|
||||
("edit.findNext", json!({"find": ["cat"]}), string("find")),
|
||||
("edit.findNext", json!({"find": "cat", "matchCase": 1}), switch("matchCase")),
|
||||
("edit.findNext", json!({"find": "cat", "wholeWord": "false"}), switch("wholeWord")),
|
||||
@@ -4462,7 +4463,7 @@ fn k_only(c: Color) -> f32 {
|
||||
fn document_color_mode_separates_rgb_greys_through_the_profile() {
|
||||
let (mut s, ids, _, _) = greys_document();
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
// As Illustrator converts them: four-colour greys and a rich black.
|
||||
// Through the profile: a mid gray in four inks and a rich black.
|
||||
let Color::Cmyk { c, m, y, k } = fill_of(&s, ids[2]) else { panic!("not CMYK") };
|
||||
assert!(c > 0.05 && m > 0.05 && y > 0.05 && k > 0.0, "a four-colour mid grey: {c} {m} {y} {k}");
|
||||
let Color::Cmyk { c, k, .. } = fill_of(&s, ids[0]) else { panic!("not CMYK") };
|
||||
|
||||
@@ -925,11 +925,12 @@ fn a_freeform_gradient_expands_with_the_inks_of_its_points() {
|
||||
for c in expanded(&mut s, 1.0) {
|
||||
assert!(close_inks(inks(c), [0.5, 0.2, 0.0, 0.1]), "{c:?}");
|
||||
}
|
||||
// Cyan and black points mix as inks: cyan and black only, adding up to one.
|
||||
// Cyan and black points mix as inks: cyan and black only, adding up to one. The gradient blends
|
||||
// the points' display colors and the mesh blends their inks, so the two draw a little apart in between.
|
||||
let mut s = session();
|
||||
let (cyan, black) = (ink(1.0, 0.0, 0.0, 0.0), ink(0.0, 0.0, 0.0, 1.0));
|
||||
freeform_rect(&mut s, Rect::new(20.0, 20.0, 180.0, 180.0), [cyan.clone(), cyan.clone(), black.clone(), black]);
|
||||
for c in expanded(&mut s, 5.0) {
|
||||
for c in expanded(&mut s, 7.5) {
|
||||
let [c, m, y, k] = inks(c);
|
||||
assert!(m.abs() < 1e-5 && y.abs() < 1e-5 && (c + k - 1.0).abs() < 1e-5, "C{c} M{m} Y{y} K{k}");
|
||||
}
|
||||
|
||||
@@ -253,6 +253,103 @@ fn a_missing_link_is_reported_not_copied() {
|
||||
assert!(report.contains("blue.png: not found"), "{report}");
|
||||
}
|
||||
|
||||
/// A file missing from a document that moved keeps the place its relative path names from the
|
||||
/// document's folder: the packaged document writes its relative path from there.
|
||||
#[test]
|
||||
fn a_missing_link_of_a_moved_document_keeps_its_relative_place() {
|
||||
let a = Folder::new("package-moved-a");
|
||||
let b = Folder::new("package-moved-b");
|
||||
let mut s = poster(&a);
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
std::fs::remove_file(b.file("art/blue.png")).unwrap();
|
||||
open(&mut s, &b.file("poster.vectorcraft"));
|
||||
// Saved in another folder first: the relative path still names the folder the file was read from.
|
||||
std::fs::create_dir_all(b.0.join("saved")).unwrap();
|
||||
save(&mut s, &b.file("saved/poster.vectorcraft"));
|
||||
let out = b.file("out");
|
||||
let r = run(&mut s, "file.package", json!({"folder": out}));
|
||||
assert_eq!(r["missingLinks"], json!(["blue.png"]), "{r}");
|
||||
let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft");
|
||||
let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap();
|
||||
let mut rel = vec![];
|
||||
doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative));
|
||||
rel.sort();
|
||||
assert_eq!(rel, [Some("../../art/blue.png".to_string()), Some("Links/red.png".to_string())]);
|
||||
}
|
||||
|
||||
/// A missing link stays missing when another file sits where its relative path, written for the
|
||||
/// package folder, would name from the folder the document was last saved in.
|
||||
#[test]
|
||||
fn a_missing_link_is_not_collected_from_another_place() {
|
||||
let a = Folder::new("package-other-a");
|
||||
let b = Folder::new("package-other-b");
|
||||
let mut s = poster(&a);
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
std::fs::remove_file(b.file("art/blue.png")).unwrap();
|
||||
open(&mut s, &b.file("poster.vectorcraft"));
|
||||
std::fs::create_dir_all(b.0.join("s1/s2/s3")).unwrap();
|
||||
save(&mut s, &b.file("s1/s2/s3/poster.vectorcraft"));
|
||||
// `../../art/blue.png` from the package folder, read from `s1/s2/s3`.
|
||||
write(&b.file("s1/art/blue.png"), &png(10, 10, RED));
|
||||
let r = run(&mut s, "file.package", json!({"folder": b.file("out")}));
|
||||
assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(1), json!(["blue.png"])), "{r}");
|
||||
}
|
||||
|
||||
/// Without copying, a packaged document links a file found by name beside the document it was read
|
||||
/// from, after a save in another folder, where it was found.
|
||||
#[test]
|
||||
fn a_link_found_by_name_is_kept_where_it_was_found_without_copying() {
|
||||
let a = Folder::new("package-name-a");
|
||||
let b = Folder::new("package-name-b");
|
||||
let mut s = poster(&a);
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
let away = b.file("away.png");
|
||||
std::fs::rename(b.file("art/blue.png"), &away).unwrap();
|
||||
open(&mut s, &b.file("poster.vectorcraft"));
|
||||
std::fs::create_dir_all(b.0.join("saved")).unwrap();
|
||||
save(&mut s, &b.file("saved/poster.vectorcraft"));
|
||||
std::fs::rename(&away, b.file("blue.png")).unwrap();
|
||||
let out = b.file("out");
|
||||
run(&mut s, "file.package", json!({"folder": out, "copyLinks": false}));
|
||||
let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft");
|
||||
let r = open(&mut s, &packaged.to_string_lossy());
|
||||
assert_eq!(&r["missingLinks"], &json!([]), "{r}");
|
||||
}
|
||||
|
||||
/// A file that comes back beside the document it was read from, after the document was saved in
|
||||
/// another folder, is collected from there.
|
||||
#[test]
|
||||
fn a_link_that_comes_back_after_a_save_elsewhere_is_collected() {
|
||||
let a = Folder::new("package-back-a");
|
||||
let b = Folder::new("package-back-b");
|
||||
let away = Folder::new("package-back-away");
|
||||
let mut s = poster(&a);
|
||||
std::fs::remove_dir_all(&b.0).unwrap();
|
||||
std::fs::rename(&a.0, &b.0).unwrap();
|
||||
std::fs::rename(b.file("art/blue.png"), away.file("blue.png")).unwrap();
|
||||
open(&mut s, &b.file("poster.vectorcraft"));
|
||||
std::fs::create_dir_all(b.0.join("saved")).unwrap();
|
||||
save(&mut s, &b.file("saved/poster.vectorcraft"));
|
||||
std::fs::rename(away.file("blue.png"), b.file("art/blue.png")).unwrap();
|
||||
let out = b.file("out");
|
||||
let r = run(&mut s, "file.package", json!({"folder": out}));
|
||||
assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}");
|
||||
let packaged = std::path::Path::new(&out).join("poster Folder/poster.vectorcraft");
|
||||
let doc = vectorcraft_format::load(&std::fs::read(packaged).unwrap()).unwrap();
|
||||
let mut rel = vec![];
|
||||
doc.visit_images(|_, im| rel.push(im.link.clone().unwrap().relative));
|
||||
rel.sort();
|
||||
assert_eq!(rel, [Some("Links/blue.png".to_string()), Some("Links/red.png".to_string())]);
|
||||
// A file beside the folder the document was last saved in is collected too.
|
||||
std::fs::create_dir_all(b.0.join("saved/art")).unwrap();
|
||||
std::fs::rename(b.file("art/blue.png"), b.file("saved/art/blue.png")).unwrap();
|
||||
let r = run(&mut s, "file.package", json!({"folder": b.file("out2")}));
|
||||
assert_eq!((r["links"].as_u64(), r["missingLinks"].clone()), (Some(2), json!([])), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_font_named_by_another_of_its_names_is_copied() {
|
||||
let dir = Folder::new("package-alias");
|
||||
|
||||
@@ -151,6 +151,42 @@ fn return_in_the_type_tool_continues_the_paragraph_it_splits() {
|
||||
assert_eq!(justs(&s, id), [Justify::Right, Justify::Center]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_at_the_start_of_a_list_item_moves_it_a_level_down_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo::default();
|
||||
let id = area(&mut s, "one\ntwo");
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "list": "number"})).unwrap();
|
||||
s.select_tool("type", v).unwrap();
|
||||
// Click into the second line, then Home: the caret before "two".
|
||||
let t = text(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let l1 = &lay.lines[1];
|
||||
let at = t.xf * vectorcraft_geom::Point::new((l1.x0 + l1.x1) / 2.0, l1.baseline - 3.0);
|
||||
for k in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(k, at.x, at.y), v).unwrap();
|
||||
}
|
||||
s.tool_key(ToolKey::Home, Mods::default(), v).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.tool_key(ToolKey::Tab, Mods::default(), v).unwrap();
|
||||
let levels = |s: &Session| text(s, id).paragraph_styles().iter().map(|p| p.list.map(|l| l.level)).collect::<Vec<_>>();
|
||||
assert_eq!(levels(&s), [Some(1), Some(2)]);
|
||||
assert_eq!((text(&s, id).plain_text(), s.doc().unwrap().history.undo.len()), ("one\ntwo".to_string(), undo + 1));
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(&s, id));
|
||||
let starts: Vec<f64> = lay.markers.iter().map(|m| lay.glyphs[m.glyphs.start].origin.x).collect();
|
||||
assert!((starts[1] - starts[0] - 18.0).abs() < 1e-6, "the item moved 18 pt in: {starts:?}");
|
||||
assert_eq!(lay.markers.iter().map(|m| m.text.as_str()).collect::<Vec<_>>(), ["1.", "1."]);
|
||||
// Shift+Tab moves it back up, and typing then goes on at the caret.
|
||||
s.tool_key(ToolKey::Tab, Mods { shift: true, ..Mods::default() }, v).unwrap();
|
||||
assert_eq!(levels(&s), [Some(1), Some(1)]);
|
||||
s.tool_text("and ", v).unwrap();
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
assert_eq!(text(&s, id).plain_text(), "one\nand two");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((text(&s, id).plain_text(), levels(&s)), ("one\ntwo".to_string(), vec![Some(1), Some(2)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_applies_to_one_paragraph() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -489,7 +489,7 @@ fn a_placed_document_without_its_bytes_or_a_hash_is_reported_not_read() {
|
||||
let Some(NodeKind::PlacedDocument(p)) = d.node_mut(id).map(|n| &mut n.kind) else { panic!("a placed document") };
|
||||
p.link.hash = None;
|
||||
p.link.size = None;
|
||||
let r = crate::cmd::links::resolve(&mut d, None, false, false).to_json();
|
||||
let r = crate::cmd::links::resolve(&mut d, crate::cmd::links::LinkBase::file(None), false, false).to_json();
|
||||
assert_eq!(r["modifiedLinks"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
assert!(!d.images.contains_key(&key), "not read");
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
/// Installs `wasm`, in place of any module installed with its id (`replace: true`).
|
||||
fn install(s: &mut Session, wasm: &[u8]) -> Value {
|
||||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)}))
|
||||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true}))
|
||||
}
|
||||
|
||||
fn install_wat(s: &mut Session, src: &str) -> Value {
|
||||
@@ -74,6 +75,52 @@ fn object_filter_recolours_the_selection_as_one_undo_step() {
|
||||
assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
/// `plugin.run` refuses an `ids` list with an entry that is not an object id, with an error naming
|
||||
/// the key, the entry and its value, and changes nothing. With `ids` left out or null the filter
|
||||
/// runs on the selection, as it does on the list of the selection.
|
||||
#[test]
|
||||
fn object_filter_ids_entries_must_be_ids_and_ids_left_out_take_the_selection() {
|
||||
// A session with the plug-in, a red and a blue rectangle, and the red one selected. The
|
||||
// document has no creation date, so documents made in different seconds compare equal.
|
||||
let fresh = || {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.new", json!({"width": 400, "height": 200, "created": null}));
|
||||
install(&mut s, DESATURATE);
|
||||
let a = rect(&mut s, 10.0, "#ff0000");
|
||||
let b = rect(&mut s, 40.0, "#0000ff");
|
||||
run(&mut s, "select.set", json!({"ids": [a.0]}));
|
||||
(s, a, b)
|
||||
};
|
||||
let (_, a, b) = fresh();
|
||||
for (bad, entry) in [(json!(["abc"]), r#"entry 0 is "abc""#), (json!([a.0, true]), "entry 1 is true"), (json!([{}]), "entry 0 is {}")] {
|
||||
let (mut s, ..) = fresh();
|
||||
let st = s.doc().unwrap();
|
||||
let before = (st.doc.clone(), st.selection.clone(), st.history.undo.len());
|
||||
let r = s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": 100, "ids": bad}));
|
||||
let want = format!("`ids` {entry}, not a non-negative integer object id");
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { cmd, msg }) if cmd == "plugin.run" && *msg == want), "{bad}: {r:?}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!((st.doc.clone(), st.selection.clone(), st.history.undo.len()) == before, "{bad}: something changed");
|
||||
}
|
||||
let gray = |s: &Session, id: NodeId| {
|
||||
let [r, g, b] = node(s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated();
|
||||
(r - g).abs() < 1e-6 && (g - b).abs() < 1e-6
|
||||
};
|
||||
let mut docs = vec![];
|
||||
for ids in [None, Some(Value::Null), Some(json!([a.0]))] {
|
||||
let (mut s, ..) = fresh();
|
||||
let mut p = json!({"id": DESATURATE_ID, "amount": 100});
|
||||
if let Some(ids) = &ids {
|
||||
p["ids"] = ids.clone();
|
||||
}
|
||||
let r = run(&mut s, "plugin.run", p);
|
||||
assert_eq!(r["ids"], json!([a.0]), "{ids:?}");
|
||||
assert!(gray(&s, a) && !gray(&s, b), "{ids:?}: only the selected rectangle turns gray");
|
||||
docs.push(s.doc().unwrap().doc.clone());
|
||||
}
|
||||
assert!(docs.windows(2).all(|w| w[0] == w[1]), "left out, null and the selection's list give the same document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_filters_add_replace_and_generate_objects() {
|
||||
let mut s = session();
|
||||
@@ -532,8 +579,8 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
|
||||
|
||||
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
|
||||
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
|
||||
/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same
|
||||
/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the
|
||||
/// hash. The grant is recorded with that hash: replacing the plug-in (`replace: true`) with one
|
||||
/// that lists the same permissions needs no new grant, and one that lists another needs one. Removing it forgets the
|
||||
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
|
||||
#[test]
|
||||
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
@@ -574,15 +621,15 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
// grant now records the new module's hash.
|
||||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}));
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "replace": true}));
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
|
||||
// One that asks for more is refused until it is granted all of them.
|
||||
let more = data(r#"["doc.read","doc.write"]"#);
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true);
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string();
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": more, "replace": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}");
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays");
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]}));
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"], "replace": true}));
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded");
|
||||
// Removed, it is asked again.
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
@@ -603,7 +650,7 @@ fn a_filter_does_only_what_its_permissions_allow() {
|
||||
let id = rect(&mut s, 10.0, "#ff0000");
|
||||
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
|
||||
let grant = |s: &mut Session, wasm: &[u8], perms: Value| {
|
||||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms}));
|
||||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms, "replace": true}));
|
||||
};
|
||||
// Denied `doc.write`: renaming the object is refused, and the document and history stay.
|
||||
let reader = "test.engine.permissions.reader";
|
||||
@@ -747,6 +794,59 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() {
|
||||
run(&mut granted, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A grant covers the module it was given to: another module under the id, listing the same
|
||||
/// permissions, isn't installed from the store at the next start, a folder load reports it as
|
||||
/// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It
|
||||
/// doesn't get the settings kept for the id until the user grants it. A kept grant without a hash
|
||||
/// covers no module.
|
||||
#[test]
|
||||
fn a_grant_covers_only_the_module_it_was_given_to() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-grant-hash");
|
||||
let id = "test.engine.granthash";
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
install_storing(&mut s, id, WRITES);
|
||||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||||
// The stored module is replaced on disk by one that reads the settings.
|
||||
let other = storing(id, READS);
|
||||
let stored: Vec<_> =
|
||||
std::fs::read_dir(&folder.0).unwrap().map(|e| e.unwrap().path()).filter(|p| p.extension().is_some_and(|x| x == "wasm")).collect();
|
||||
assert_eq!(stored.len(), 1, "{stored:?}");
|
||||
std::fs::write(&stored[0], &other).unwrap();
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant");
|
||||
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
|
||||
std::fs::write(dir.0.join("other.wasm"), &other).unwrap();
|
||||
let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||||
assert_eq!(r["loaded"], json!([]));
|
||||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||||
assert!(why.contains("needs approval"), "{why}");
|
||||
let data = vectorcraft_format::base64_encode(&other);
|
||||
let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("`grant`"), "{e}");
|
||||
// Granted, it reads the settings the first module set.
|
||||
run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
|
||||
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
|
||||
|
||||
// A kept grant that records no hash, or an empty one, covers no module: the stored module isn't
|
||||
// installed at the next start and `plugin.inspect` asks for approval.
|
||||
for entry in [json!({"id": id, "permissions": ["settings", "cache"]}), json!({"id": id, "hash": "", "permissions": ["settings", "cache"]})] {
|
||||
std::fs::write(folder.0.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap();
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
let mut again = session();
|
||||
again.attach_plugin_store(folder.0.clone());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "a grant without a hash doesn't load the module: {entry}");
|
||||
assert_eq!(run(&mut again, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], json!(true), "{entry}");
|
||||
next = again;
|
||||
}
|
||||
run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}));
|
||||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is
|
||||
/// skipped without dropping the other grants, and recording a grant writes it back as it was,
|
||||
/// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a
|
||||
@@ -888,7 +988,7 @@ fn a_filter_run_made_elsewhere_keeps_its_document_data() {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
|
||||
let install = |s: &mut Session, src: String| {
|
||||
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
|
||||
run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||||
run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": true}));
|
||||
};
|
||||
// It first keeps the pens it found with the document.
|
||||
install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string()));
|
||||
@@ -954,3 +1054,379 @@ fn a_cancelled_filter_run_changes_nothing() {
|
||||
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
||||
assert_eq!(s.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
/// A filter `id` of API level 2 that lists `settings` and `cache`, whose `vc_run` runs `first`
|
||||
/// (instructions that trap unless each storage call returns what it should), with the keys "zoom"
|
||||
/// at 0x2000 and "tile" at 0x2004 and the value "hello" at 0x2100.
|
||||
fn storing(id: &str, first: &str) -> Vec<u8> {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
|
||||
let data = format!(r#"{} (data (i32.const 0x2000) "zoomtile") (data (i32.const 0x2100) "hello")"#, tpl::STORAGE_IMPORTS);
|
||||
wat::parse_str(tpl::with_imports(&manifest, &data, first, r#"{"objects":[]}"#)).unwrap()
|
||||
}
|
||||
|
||||
/// Sets "zoom" to "hello" and writes "hel" to the cache entry "tile".
|
||||
const WRITES: &str = "(if (call $vc_settings_set (i32.const 0x2000) (i32.const 4) (i32.const 0x2100) (i32.const 5)) (then unreachable))
|
||||
(if (call $vc_cache_write (i32.const 0x2004) (i32.const 4) (i32.const 0x2100) (i32.const 3)) (then unreachable))";
|
||||
|
||||
/// Traps unless "zoom" is "hello".
|
||||
const READS: &str =
|
||||
"(if (i32.ne (call $vc_settings_get (i32.const 0x2000) (i32.const 4) (i32.const 0x3000) (i32.const 16)) (i32.const 5)) (then unreachable))
|
||||
(if (i32.ne (i32.load (i32.const 0x3000)) (i32.load (i32.const 0x2100))) (then unreachable))";
|
||||
|
||||
fn install_storing(s: &mut Session, id: &str, first: &str) -> Value {
|
||||
let data = vectorcraft_format::base64_encode(&storing(id, first));
|
||||
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"]}))
|
||||
}
|
||||
|
||||
/// [`install_storing`] in place of another module with the same id (`replace: true`).
|
||||
fn replace_storing(s: &mut Session, id: &str, first: &str) -> Value {
|
||||
let data = vectorcraft_format::base64_encode(&storing(id, first));
|
||||
run(s, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true}))
|
||||
}
|
||||
|
||||
/// A plug-in's settings and cache are kept in the plug-in store under its id: the next session
|
||||
/// has them, replacing the plug-in keeps them, another plug-in can't read them, `plugin.clearCache`
|
||||
/// empties the cache alone and `plugin.remove` deletes both. The caps and the bytes each call is
|
||||
/// charged follow ([`plugin_storage_is_capped_and_survives_a_damaged_settings_file`],
|
||||
/// [`plugin_storage_calls_are_charged_the_files_they_read_and_write`]) in the same test, since
|
||||
/// clearing every plug-in's cache would empty theirs.
|
||||
#[test]
|
||||
fn plugins_keep_settings_and_a_cache_of_their_own_in_the_store() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-storage");
|
||||
let (id, other) = ("test.engine.storing", "test.engine.storing.other");
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
install_storing(&mut s, id, WRITES);
|
||||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||||
let dir = crate::store::plugin_data_dir(&folder.0, id);
|
||||
assert!(dir.join("settings.json").is_file());
|
||||
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3));
|
||||
|
||||
// A restart: installed again from the store, and replaced by a module that reads.
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
let listed = run(&mut next, "plugin.list", json!({}));
|
||||
let entry = listed["plugins"].as_array().unwrap().iter().find(|p| p["id"] == id).cloned().unwrap();
|
||||
assert_eq!(entry["cacheBytes"], json!(3));
|
||||
assert_eq!(replace_storing(&mut next, id, READS)["cacheBytes"], json!(3));
|
||||
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
|
||||
|
||||
// Another plug-in has settings of its own: "zoom" isn't set there.
|
||||
install_storing(&mut next, other, READS);
|
||||
assert!(next.execute("plugin.run", &json!({"id": other, "ids": []})).is_err());
|
||||
|
||||
// Clearing the cache leaves the settings.
|
||||
assert_eq!(run(&mut next, "plugin.clearCache", json!({"id": id})), json!({"cleared": [id], "bytesFreed": 3}));
|
||||
assert_eq!(run(&mut next, "plugin.info", json!({"id": id}))["cacheBytes"], json!(0));
|
||||
run(&mut next, "plugin.run", json!({"id": id, "ids": []}));
|
||||
replace_storing(&mut next, other, WRITES);
|
||||
run(&mut next, "plugin.run", json!({"id": other, "ids": []}));
|
||||
let all = run(&mut next, "plugin.clearCache", json!({}));
|
||||
let cleared = all["cleared"].as_array().unwrap();
|
||||
assert!(cleared.contains(&json!(id)) && cleared.contains(&json!(other)) && all["bytesFreed"] == json!(3), "{all}");
|
||||
assert_eq!(run(&mut next, "plugin.info", json!({"id": other}))["cacheBytes"], json!(0));
|
||||
assert!(next.execute("plugin.clearCache", &json!({"id": "test.engine.storing.missing"})).is_err());
|
||||
|
||||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||||
assert!(!dir.exists(), "removed with the plug-in");
|
||||
run(&mut next, "plugin.remove", json!({"id": other}));
|
||||
plugin_storage_is_capped_and_survives_a_damaged_settings_file();
|
||||
plugin_storage_calls_are_charged_the_files_they_read_and_write();
|
||||
}
|
||||
|
||||
/// A `storage` folder in the plug-in store that is a link leads out of the store: the plug-in
|
||||
/// installs without `replace` although a folder for its id is there, its storage calls fail as
|
||||
/// unavailable, and removing the plug-in deletes nothing the link leads to.
|
||||
#[test]
|
||||
fn plugin_storage_behind_a_link_is_left_alone() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-storage-link");
|
||||
let outside = crate::tests_links::Folder::new("plugin-storage-outside");
|
||||
#[cfg(unix)]
|
||||
let made = std::os::unix::fs::symlink(&outside.0, folder.0.join("storage")).is_ok();
|
||||
// Without Developer Mode or elevation Windows makes no symbolic link, but a junction.
|
||||
#[cfg(windows)]
|
||||
let made = std::os::windows::fs::symlink_dir(&outside.0, folder.0.join("storage")).is_ok()
|
||||
|| std::process::Command::new("cmd")
|
||||
.args(["/C", "mklink", "/J"])
|
||||
.arg(folder.0.join("storage"))
|
||||
.arg(&outside.0)
|
||||
.output()
|
||||
.is_ok_and(|o| o.status.success());
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let made = false;
|
||||
if !made {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
return;
|
||||
}
|
||||
let id = "test.engine.storagelink";
|
||||
let kept = crate::store::plugin_data_dir(&folder.0, id).join("settings.json");
|
||||
std::fs::create_dir_all(kept.parent().unwrap()).unwrap();
|
||||
std::fs::write(&kept, b"{}").unwrap();
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
install_storing(&mut s, id, "");
|
||||
let st = vectorcraft_plugins::registry::storage(id).unwrap();
|
||||
let m = &mut vectorcraft_plugins::storage::Meter::unlimited();
|
||||
assert_eq!(st.set_setting("zoom", b"hello", m), Err(vectorcraft_plugins::storage::StorageError::Unavailable));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
assert!(kept.is_file(), "left where the link leads");
|
||||
}
|
||||
|
||||
/// `plugin.remove` with the id of a plug-in that isn't installed deletes the settings and cache the
|
||||
/// plug-in store still keeps for it; with neither, it fails as before.
|
||||
#[test]
|
||||
fn removing_an_id_that_is_not_installed_deletes_its_storage() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-storage-orphan");
|
||||
let id = "test.engine.storageorphan";
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
let dir = crate::store::plugin_data_dir(&folder.0, id);
|
||||
std::fs::create_dir_all(dir.join("cache")).unwrap();
|
||||
std::fs::write(dir.join("settings.json"), b"{}").unwrap();
|
||||
std::fs::write(dir.join("cache").join("tile.bin"), b"abc").unwrap();
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
assert_eq!(run(&mut s, "plugin.remove", json!({"id": id})), json!({"removed": id}));
|
||||
assert!(!dir.exists(), "the storage of an id that isn't installed is deleted");
|
||||
assert!(s.execute("plugin.remove", &json!({"id": id})).is_err(), "nothing left to remove");
|
||||
}
|
||||
|
||||
/// The settings file and the cache are checked: a file that doesn't read is moved aside and the
|
||||
/// settings start empty; a write past a cap fails and changes nothing; cache entries whose names
|
||||
/// differ only in case are two files; a removed plug-in's storage takes no more writes.
|
||||
fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() {
|
||||
use vectorcraft_plugins::storage::{self, Meter, StorageError};
|
||||
let folder = crate::tests_links::Folder::new("plugin-storage-caps");
|
||||
let id = "test.engine.storagecaps";
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
install_storing(&mut s, id, "");
|
||||
let st = vectorcraft_plugins::registry::storage(id).unwrap();
|
||||
let dir = crate::store::plugin_data_dir(&folder.0, id);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let m = &mut Meter::unlimited();
|
||||
|
||||
// A truncated settings file.
|
||||
std::fs::write(dir.join("settings.json"), br#"{"zoom": "aGVsbG"#).unwrap();
|
||||
assert_eq!(st.setting("zoom", m).unwrap(), None);
|
||||
assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists());
|
||||
st.set_setting("zoom", b"hello", m).unwrap();
|
||||
assert_eq!(st.setting("zoom", m).unwrap(), Some(b"hello".to_vec()));
|
||||
// Damaged again, it replaces the file moved aside before.
|
||||
std::fs::write(dir.join("settings.json"), b"[").unwrap();
|
||||
assert_eq!(st.setting("zoom", m).unwrap(), None);
|
||||
assert_eq!(std::fs::read(dir.join("settings.unreadable.json")).unwrap(), b"[");
|
||||
st.set_setting("zoom", b"hello", m).unwrap();
|
||||
plugin_storage_writes_are_atomic(&*st, &dir, &folder.0);
|
||||
|
||||
// At most 256 keys: a new one is refused, an existing one still changes.
|
||||
let keys: std::collections::BTreeMap<String, String> = (0..storage::MAX_SETTINGS).map(|i| (format!("k{i}"), "eA==".to_string())).collect();
|
||||
std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap();
|
||||
assert_eq!(st.set_setting("one-more", b"x", m), Err(StorageError::Full));
|
||||
st.set_setting("k0", b"y", m).unwrap();
|
||||
assert_eq!(st.setting("k0", m).unwrap(), Some(b"y".to_vec()));
|
||||
plugin_storage_skips_oversize_files(&*st, &dir);
|
||||
|
||||
// At most 1 MiB in all: eleven values of 64 KiB fit, a twelfth doesn't, and nothing changes.
|
||||
let big = vec![7_u8; storage::MAX_SETTING_BYTES];
|
||||
let keys: std::collections::BTreeMap<String, String> = (0..11).map(|i| (format!("k{i}"), vectorcraft_format::base64_encode(&big))).collect();
|
||||
std::fs::write(dir.join("settings.json"), serde_json::to_vec(&keys).unwrap()).unwrap();
|
||||
assert_eq!(st.set_setting("k11", &big, m), Err(StorageError::Full));
|
||||
assert_eq!((st.setting("k11", m).unwrap(), st.setting("k0", m).unwrap()), (None, Some(big.clone())));
|
||||
|
||||
// The cache: entry size, then the total, here nearly reached by a file already there.
|
||||
assert_eq!(st.set_cache_entry("big", &vec![0; storage::MAX_CACHE_ENTRY + 1], m), Err(StorageError::Full));
|
||||
std::fs::create_dir_all(dir.join("cache")).unwrap();
|
||||
std::fs::File::create(dir.join("cache").join("filler.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES - 2).unwrap();
|
||||
// A temporary file a failed write left behind counts toward no cap, and clearing removes it.
|
||||
let leftover = dir.join("cache").join(".tile.bin.1-0.tmp");
|
||||
std::fs::write(&leftover, b"tmp").unwrap();
|
||||
assert_eq!(st.set_cache_entry("tile", b"abc", m), Err(StorageError::Full));
|
||||
assert_eq!(st.cache_entry("tile", m).unwrap(), None);
|
||||
st.set_cache_entry("tile", b"ab", m).unwrap();
|
||||
assert_eq!(st.cache_bytes(), storage::MAX_CACHE_BYTES);
|
||||
assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3);
|
||||
assert!(!leftover.exists());
|
||||
|
||||
// Names differing in case are two entries.
|
||||
st.set_cache_entry("Tile", b"1", m).unwrap();
|
||||
st.set_cache_entry("tile", b"22", m).unwrap();
|
||||
assert_eq!((st.cache_entry("Tile", m).unwrap(), st.cache_entry("tile", m).unwrap()), (Some(b"1".to_vec()), Some(b"22".to_vec())));
|
||||
assert_eq!(st.cache_bytes(), 3);
|
||||
|
||||
// More items in the cache folder than a write looks at: the write is refused whatever the
|
||||
// items it looked at hold, and clearing still reaches every entry.
|
||||
let cache = dir.join("cache");
|
||||
for i in 0..crate::store::MAX_CACHE_SCAN {
|
||||
std::fs::create_dir(cache.join(format!("d{i:05}"))).unwrap();
|
||||
}
|
||||
std::fs::File::create(cache.join("z.bin")).unwrap().set_len(storage::MAX_CACHE_BYTES).unwrap();
|
||||
assert_eq!(st.set_cache_entry("more", b"1", m), Err(StorageError::Full));
|
||||
assert_eq!(st.clear_cache().unwrap(), storage::MAX_CACHE_BYTES + 3);
|
||||
assert!(!cache.join("z.bin").exists());
|
||||
|
||||
// An id that differs only in case has storage of its own, which removing this one leaves.
|
||||
let upper_id = "test.engine.StorageCaps";
|
||||
s.attach_plugin_storage(upper_id);
|
||||
let upper = vectorcraft_plugins::registry::storage(upper_id).unwrap();
|
||||
assert_eq!(upper.setting("k0", m).unwrap(), None);
|
||||
upper.set_setting("k0", b"upper", m).unwrap();
|
||||
assert_eq!(st.setting("k0", m).unwrap(), Some(big));
|
||||
// So do ids whose `_` and `.` could be read as each other's escapes.
|
||||
let folders: std::collections::BTreeSet<_> =
|
||||
["a.b", "a_0b", "a_b", "A.b", "a__b", "a._b", "a_.b"].iter().map(|id| crate::store::plugin_data_dir(&folder.0, id)).collect();
|
||||
assert_eq!(folders.len(), 7, "{folders:?}");
|
||||
|
||||
// Removing the plug-in closes the storage a run still holds, even one from before the plug-in
|
||||
// was replaced, so nothing is written back.
|
||||
s.attach_plugin_storage(id);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
assert_eq!(st.set_setting("zoom", b"again", m), Err(StorageError::Unavailable));
|
||||
assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable));
|
||||
assert!(!dir.exists());
|
||||
assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec()));
|
||||
s.remove_plugin_storage(upper_id).unwrap();
|
||||
}
|
||||
|
||||
/// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although
|
||||
/// each of its values would fit, is moved aside and the settings start empty; a cache entry file
|
||||
/// larger than an entry may be fails as unavailable. `st` is the storage in `dir`.
|
||||
fn plugin_storage_skips_oversize_files(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path) {
|
||||
use vectorcraft_plugins::storage::{self, Meter, StorageError};
|
||||
let m = &mut Meter::unlimited();
|
||||
let value = vectorcraft_format::base64_encode(&vec![7_u8; storage::MAX_SETTING_BYTES]);
|
||||
let keys: std::collections::BTreeMap<String, String> = (0..13).map(|i| (format!("k{i}"), value.clone())).collect();
|
||||
let oversize = serde_json::to_vec(&keys).unwrap();
|
||||
assert!(oversize.len() > storage::MAX_SETTINGS_BYTES);
|
||||
std::fs::write(dir.join("settings.json"), &oversize).unwrap();
|
||||
assert_eq!(st.setting("k0", m).unwrap(), None);
|
||||
assert!(dir.join("settings.unreadable.json").is_file() && !dir.join("settings.json").exists());
|
||||
std::fs::create_dir_all(dir.join("cache")).unwrap();
|
||||
let huge = dir.join("cache").join("huge.bin");
|
||||
std::fs::File::create(&huge).unwrap().set_len(storage::MAX_CACHE_ENTRY as u64 + 1).unwrap();
|
||||
assert_eq!(st.cache_entry("huge", m), Err(StorageError::Unavailable));
|
||||
std::fs::remove_file(&huge).unwrap();
|
||||
}
|
||||
|
||||
/// Writes are atomic: a new file takes the old one's name, so another name for the old file (a hard
|
||||
/// link) keeps its content. `st` is the storage in `dir` and holds the setting "zoom"; `outside` is
|
||||
/// a folder outside it.
|
||||
fn plugin_storage_writes_are_atomic(st: &dyn vectorcraft_plugins::storage::Storage, dir: &std::path::Path, outside: &std::path::Path) {
|
||||
let m = &mut vectorcraft_plugins::storage::Meter::unlimited();
|
||||
let settings_copy = outside.join("settings-copy.json");
|
||||
let before = std::fs::read(dir.join("settings.json")).unwrap();
|
||||
std::fs::hard_link(dir.join("settings.json"), &settings_copy).unwrap();
|
||||
st.set_setting("zoom", b"world", m).unwrap();
|
||||
assert_eq!(std::fs::read(&settings_copy).unwrap(), before);
|
||||
assert_eq!(st.setting("zoom", m).unwrap(), Some(b"world".to_vec()));
|
||||
st.set_cache_entry("tile", b"abc", m).unwrap();
|
||||
let entry_copy = outside.join("tile-copy.bin");
|
||||
let entry = std::fs::read_dir(dir.join("cache")).unwrap().map(|e| e.unwrap().path()).find(|p| p.is_file()).unwrap();
|
||||
std::fs::hard_link(&entry, &entry_copy).unwrap();
|
||||
st.set_cache_entry("tile", b"xyz", m).unwrap();
|
||||
assert_eq!(std::fs::read(&entry_copy).unwrap(), b"abc");
|
||||
assert_eq!(st.cache_entry("tile", m).unwrap(), Some(b"xyz".to_vec()));
|
||||
assert!(st.remove_cache_entry("tile").unwrap());
|
||||
std::fs::remove_file(&settings_copy).unwrap();
|
||||
std::fs::remove_file(&entry_copy).unwrap();
|
||||
}
|
||||
|
||||
/// Each storage call is charged the bytes it reads and writes, before it does: a settings call the
|
||||
/// whole settings file it reads and the whole file it writes, a cache call the entry it reads or
|
||||
/// writes. A call that would pass what its meter has left fails and changes nothing.
|
||||
fn plugin_storage_calls_are_charged_the_files_they_read_and_write() {
|
||||
use vectorcraft_plugins::storage::{Meter, StorageError};
|
||||
let folder = crate::tests_links::Folder::new("plugin-storage-meter");
|
||||
let id = "test.engine.storagemeter";
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
install_storing(&mut s, id, "");
|
||||
let st = vectorcraft_plugins::registry::storage(id).unwrap();
|
||||
let file = crate::store::plugin_data_dir(&folder.0, id).join("settings.json");
|
||||
let size = || std::fs::metadata(&file).unwrap().len();
|
||||
st.set_setting("big", &[7; 60_000], &mut Meter::unlimited()).unwrap();
|
||||
st.set_setting("small", b"x", &mut Meter::unlimited()).unwrap();
|
||||
|
||||
// Reading a one-byte setting reads the whole file.
|
||||
let before = size();
|
||||
let mut m = Meter::unlimited();
|
||||
assert_eq!(st.setting("small", &mut m).unwrap(), Some(b"x".to_vec()));
|
||||
assert_eq!(m.used(), before);
|
||||
// Setting it reads the file and writes it again, and so does deleting one.
|
||||
let mut m = Meter::unlimited();
|
||||
st.set_setting("small", b"yz", &mut m).unwrap();
|
||||
assert_eq!(m.used(), before + size());
|
||||
let (before, mut m) = (size(), Meter::unlimited());
|
||||
assert!(st.delete_setting("small", &mut m).unwrap());
|
||||
assert_eq!(m.used(), before + size());
|
||||
st.set_setting("small", b"y", &mut Meter::unlimited()).unwrap();
|
||||
|
||||
// Less left than the file: nothing is read. Enough to read it but not to write it again:
|
||||
// nothing changes.
|
||||
let mut m = Meter::new(size() - 1);
|
||||
assert_eq!(st.setting("small", &mut m), Err(StorageError::Budget));
|
||||
assert_eq!(m.used(), 0);
|
||||
assert_eq!(st.set_setting("small", b"z", &mut Meter::new(size())), Err(StorageError::Budget));
|
||||
assert_eq!(st.setting("small", &mut Meter::unlimited()).unwrap(), Some(b"y".to_vec()));
|
||||
|
||||
// A cache write is charged the entry it writes, a read the entry it reads.
|
||||
let mut m = Meter::unlimited();
|
||||
st.set_cache_entry("tile", &[1; 1000], &mut m).unwrap();
|
||||
assert_eq!(m.used(), 1000);
|
||||
let mut m = Meter::unlimited();
|
||||
assert_eq!(st.cache_entry("tile", &mut m).unwrap().map(|v| v.len()), Some(1000));
|
||||
assert_eq!(m.used(), 1000);
|
||||
assert_eq!(st.cache_entry("tile", &mut Meter::new(999)), Err(StorageError::Budget));
|
||||
assert_eq!(st.set_cache_entry("tile", &[2; 10], &mut Meter::new(9)), Err(StorageError::Budget));
|
||||
assert_eq!(st.cache_entry("tile", &mut Meter::unlimited()).unwrap().map(|v| v.len()), Some(1000));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A module with other bytes than the plug-in installed with its id, or, with none installed, one
|
||||
/// whose id has settings and a cache in the plug-in store, installs only with `replace: true`, and
|
||||
/// then keeps them; the same module installs again without it, and `replace` must be true or false.
|
||||
#[test]
|
||||
fn another_module_under_an_id_with_settings_needs_replace() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-storage-replace");
|
||||
let id = "test.engine.storingreplace";
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
install_storing(&mut s, id, WRITES);
|
||||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||||
let hash = run(&mut s, "plugin.info", json!({"id": id}))["hash"].clone();
|
||||
// The same module again.
|
||||
install_storing(&mut s, id, WRITES);
|
||||
|
||||
let reads = vectorcraft_format::base64_encode(&storing(id, READS));
|
||||
let install = |s: &mut Session, extra: Value| {
|
||||
let mut p = json!({"dataBase64": reads, "grant": ["settings", "cache"]});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
s.execute("plugin.install", &p)
|
||||
};
|
||||
let Err(e) = install(&mut s, json!({})) else { panic!("another module took the plug-in's place") };
|
||||
assert!(e.to_string().contains(&format!("“Test {id}” is another module than the plug-in with id \"{id}\"")), "{e}");
|
||||
assert!(e.to_string().contains("`replace: true`"), "{e}");
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(true));
|
||||
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["hash"], hash);
|
||||
for bad in [json!({"replace": "yes"}), json!({"replace": 1})] {
|
||||
let Err(e) = install(&mut s, bad) else { panic!("a bad `replace` was taken") };
|
||||
assert!(e.to_string().contains("`replace` must be true or false"), "{e}");
|
||||
}
|
||||
assert!(install(&mut s, json!({"replace": false})).is_err());
|
||||
install(&mut s, json!({"replace": true})).unwrap();
|
||||
// It reads what the one it replaced set.
|
||||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||||
assert_eq!(run(&mut s, "plugin.info", json!({"id": id}))["cacheBytes"], json!(3));
|
||||
|
||||
// Not installed, but with settings in the store: another module needs `replace` too.
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
let writes = vectorcraft_format::base64_encode(&storing(id, WRITES));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": writes}))["needsReplace"], json!(true));
|
||||
assert!(s.execute("plugin.install", &json!({"dataBase64": writes, "grant": ["settings", "cache"]})).is_err());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": writes, "grant": ["settings", "cache"], "replace": true}));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
// Once removed, with its settings and cache, it installs as a first install does.
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsReplace"], json!(false));
|
||||
install(&mut s, json!({})).unwrap();
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ fn id_errors_name_the_command_run() {
|
||||
for (given, want) in [
|
||||
(json!([999]), "no such object id 999".to_string()),
|
||||
(json!([999, layer]), "no such object id 999".to_string()),
|
||||
(json!([ids[0], ids[1], -1]), "invalid object id -1: expected a non-negative integer".to_string()),
|
||||
(json!([ids[0], ids[1], -1]), "`ids` entry 2 is -1, not a non-negative integer object id".to_string()),
|
||||
(json!(ids[0]), "ids must be an array of non-negative integer object ids".to_string()),
|
||||
] {
|
||||
let mut p = match *cmd {
|
||||
|
||||
@@ -503,7 +503,7 @@ fn a_cmyk_document_starts_with_cmyk_swatches_and_stores_new_colours_as_cmyk() {
|
||||
// A swatch made from a hex colour is CMYK as well; the document round-trips natively.
|
||||
let name = run(&mut s, "swatch.new", json!({"color": "#00ff00"}))["name"].as_str().unwrap().to_string();
|
||||
assert_eq!(doc(&s).swatch(&name).and_then(|w| w.paint.color()), Some(managed(Color::rgb(0.0, 1.0, 0.0))));
|
||||
assert_eq!(name, "C=61 M=0 Y=100 K=0");
|
||||
assert_eq!(name, "C=65 M=0 Y=100 K=0");
|
||||
let json = serde_json::to_string(doc(&s)).unwrap();
|
||||
assert_eq!(serde_json::from_str::<vectorcraft_doc::Document>(&json).unwrap(), *doc(&s));
|
||||
}
|
||||
|
||||
@@ -1276,6 +1276,45 @@ fn start_list_command_removes_the_typed_characters_and_starts_the_list() {
|
||||
assert!(s.execute("text.startList", &json!({"at": 3})).is_err(), "id required");
|
||||
}
|
||||
|
||||
/// A list typed with "1. " starts at level 1. Tab at the start of the item Return makes moves it
|
||||
/// to level 2, where it is numbered from 1, Return continues the list at that level, and
|
||||
/// Shift+Tab moves an item back up, where it goes on with level 1's count. "1. " typed at the
|
||||
/// start of an item at level 2 stays text.
|
||||
#[test]
|
||||
fn a_typed_list_takes_its_levels_from_tab_and_shift_tab() {
|
||||
use vectorcraft_doc::text::{NumberStyle, ParaList};
|
||||
let v = ViewInfo::default();
|
||||
let mut s = session();
|
||||
s.select_tool("type", v).unwrap();
|
||||
click(&mut s, 200.0, 200.0);
|
||||
let id = s.doc().unwrap().selection.objects[0];
|
||||
let typed = |s: &mut Session, text: &str| {
|
||||
for c in text.chars() {
|
||||
s.tool_text(&c.to_string(), v).unwrap();
|
||||
}
|
||||
};
|
||||
let shift = Mods { shift: true, ..Mods::default() };
|
||||
typed(&mut s, "1. Apples");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, Mods::default());
|
||||
typed(&mut s, "Pears");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
typed(&mut s, "Plums");
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, shift);
|
||||
typed(&mut s, "Figs");
|
||||
let arabic = ParaList::numbered(NumberStyle::Arabic);
|
||||
let t = obj(&s, id);
|
||||
assert_eq!(t.plain_text(), "Apples\nPears\nPlums\nFigs");
|
||||
assert_eq!((0..4).map(|i| t.para_at(i).list).collect::<Vec<_>>(), [arabic, arabic.at_level(2), arabic.at_level(2), arabic].map(Some));
|
||||
assert_eq!(list_markers(&s, id), ["1.", "1.", "2.", "2."]);
|
||||
key(&mut s, ToolKey::Enter, Mods::default());
|
||||
key(&mut s, ToolKey::Tab, Mods::default());
|
||||
typed(&mut s, "1. ");
|
||||
let t = obj(&s, id);
|
||||
assert_eq!((t.plain_text().as_str(), t.para_at(4).list), ("Apples\nPears\nPlums\nFigs\n1. ", Some(arabic.at_level(2))));
|
||||
}
|
||||
|
||||
/// A byte offset given as a negative or fractional number is an error, not the end of the text
|
||||
/// (`at`) or its start or end (`start`, `end`), and the text is left as it was. Null keeps the default.
|
||||
#[test]
|
||||
@@ -2318,6 +2357,18 @@ fn convert_area_point_keeps_text_in_place_for_every_alignment() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_area_to_point_keeps_a_deeper_list_item_in_place() {
|
||||
let mut s = session();
|
||||
let r = s.execute("text.create", &json!({"x": 100, "y": 100, "text": "Item", "size": 18, "area": {"width": 150, "height": 200}})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("text.setFormat", &json!({"ids": [id.0], "list": "bullet", "listLevel": 3})).unwrap();
|
||||
let before = first_glyph_doc_pos(&obj(&s, id));
|
||||
s.execute("type.convertToPointType", &json!({"ids": [id.0]})).unwrap();
|
||||
let after = first_glyph_doc_pos(&obj(&s, id));
|
||||
assert!((after - before).hypot() < 0.01, "{before:?} → {after:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_are_listed_with_params() {
|
||||
let s = session();
|
||||
|
||||
@@ -4875,6 +4875,16 @@ fn arb_ai_token() -> impl Strategy<Value = String> {
|
||||
"\n%AI5_BeginRaster\n",
|
||||
"\n%AI5_EndRaster\n",
|
||||
"\n%%BeginData: 12\rXI\n",
|
||||
// Image operands past 2^25 pixels, of odd 1-bit rows, and past the hard limits: as
|
||||
// many pixels as a u32 side allows, more samples than the bytes allowed, sizes that
|
||||
// overflow when multiplied out, and a matrix that maps the box to nothing.
|
||||
"[1 0 0 1 0 0] 0 0 6000 6000 6000 6000 1 1 0 0 1 0 4 4 0 0 ",
|
||||
"[1 0 0 1 0 0] 0 0 17 13 17 13 1 1 0 0 1 0 4 4 0 0 ",
|
||||
"[1 0 0 1 0 0] 0 0 9 9 65536 32769 1 1 0 0 1 0 4 4 0 0 ",
|
||||
"[1 0 0 1 0 0] 0 0 9 9 23171 23171 8 4 1 0 1 0 4 4 0 0 ",
|
||||
"[1 0 0 1 0 0] 0 0 9 9 4294967295 4294967295 8 4 1 0 1 0 4 4 0 0 ",
|
||||
"[1 0 0 1 0 0] 0 0 9 9 1e308 1e308 8 3 1 0 0 0 4 4 0 0 ",
|
||||
"[0 0 0 0 0 0] 0 0 9 9 46341 46341 1 1 0 0 1 0 4 4 0 0 ",
|
||||
"\n%%EndData\n",
|
||||
"\n%AI5_BeginGradient: (G)\n",
|
||||
"\n%AI14_BeginSymbol\n",
|
||||
|
||||
@@ -30,8 +30,9 @@ fn config() -> ProptestConfig {
|
||||
c
|
||||
}
|
||||
|
||||
/// Installs `wasm`, in place of any module installed with its id before (`replace: true`).
|
||||
fn install(s: &mut Session, wasm: &[u8]) -> Result<Value, String> {
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm)})).map_err(|e| e.to_string())
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "replace": true})).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The document renders and exports without panicking.
|
||||
@@ -658,4 +659,66 @@ proptest! {
|
||||
prop_assert!(total == 0 || done <= total);
|
||||
}
|
||||
}
|
||||
|
||||
/// A plug-in that calls the storage imports with hostile keys (`..`, separators, device names,
|
||||
/// control characters, too long), values, lengths and pointers in and out of its memory runs to
|
||||
/// an output, never a panic; every call returns a length or a storage code, and the settings and
|
||||
/// cache of another plug-in in the same store stay as they were.
|
||||
#[test]
|
||||
fn hostile_storage_arguments_never_crash(
|
||||
calls in prop::collection::vec(
|
||||
(
|
||||
0..6usize,
|
||||
prop_oneof![any::<i32>(), 0x1ff00..0x20100i32, Just(0x2000)],
|
||||
prop_oneof![any::<i32>(), 0..80i32],
|
||||
prop_oneof![any::<i32>(), 0x1ff00..0x20100i32, Just(0x3000)],
|
||||
prop_oneof![any::<i32>(), 0..5000i32],
|
||||
),
|
||||
0..16,
|
||||
),
|
||||
key in prop::collection::vec(prop_oneof![Just(b'a'), Just(b'.'), Just(b'/'), Just(b'\\'), Just(b'_'), any::<u8>()], 0..80),
|
||||
) {
|
||||
use vectorcraft_plugins::host::STORAGE_BUDGET;
|
||||
use vectorcraft_plugins::storage::Meter;
|
||||
const FUNCS: [&str; 6] = ["vc_settings_get", "vc_settings_set", "vc_cache_read", "vc_cache_write", "vc_settings_delete", "vc_cache_clear"];
|
||||
let folder = std::env::temp_dir().join(format!("vectorcraft-plugin-fuzz-storage-{}", std::process::id()));
|
||||
let mut s = Session::new();
|
||||
s.attach_plugin_store(folder.clone());
|
||||
let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
|
||||
let grant = json!(["settings", "cache"]);
|
||||
let other = wat::parse_str(tpl::returning(&storing("fuzz.storage.other"), r#"{"objects":[]}"#)).unwrap();
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": grant})).unwrap();
|
||||
let theirs = vectorcraft_plugins::registry::storage("fuzz.storage.other").unwrap();
|
||||
let m = &mut Meter::unlimited();
|
||||
theirs.set_setting("mine", b"kept", m).unwrap();
|
||||
theirs.set_cache_entry("mine", b"kept", m).unwrap();
|
||||
// Each call's result is checked: a length (0 or more) or one of the storage codes.
|
||||
let check = |call: String| format!("(local.set $r {call}) (if (i32.lt_s (local.get $r) (i32.const {STORAGE_BUDGET})) (then unreachable))");
|
||||
let body: String = calls
|
||||
.iter()
|
||||
.map(|&(f, kp, kl, vp, vl)| match f {
|
||||
0..4 => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}) (i32.const {vp}) (i32.const {vl}))", FUNCS[f])),
|
||||
_ => check(format!("(call ${} (i32.const {kp}) (i32.const {kl}))", FUNCS[f])),
|
||||
})
|
||||
.collect();
|
||||
let text: String = key.iter().map(|b| format!("\\{b:02x}")).collect();
|
||||
let imports = format!(r#"{} (data (i32.const 0x2000) "{text}")"#, tpl::STORAGE_IMPORTS);
|
||||
let wasm = wat::parse_str(tpl::with_imports(&storing("fuzz.storage"), &imports, &format!("(local $r i32) {body}"), r#"{"objects":[]}"#)).unwrap();
|
||||
let r = catch_quiet(|| {
|
||||
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": grant})).map_err(|e| e.to_string())?;
|
||||
let p = vectorcraft_plugins::registry::get("fuzz.storage").ok_or("not installed")?;
|
||||
p.run(b"{\"objects\":[]}", b"{}").map_err(|e| e.to_string())
|
||||
});
|
||||
let out = r.map_err(|msg| TestCaseError::fail(format!("panicked on {calls:?}: {msg}")))?;
|
||||
prop_assert!(out.is_ok(), "{:?}", out);
|
||||
prop_assert_eq!(theirs.setting("mine", m).unwrap(), Some(b"kept".to_vec()));
|
||||
prop_assert_eq!(theirs.cache_entry("mine", m).unwrap(), Some(b"kept".to_vec()));
|
||||
prop_assert_eq!(theirs.cache_bytes(), 4);
|
||||
// The host names every file: the store holds the two modules, their grants and their folders.
|
||||
let names = |dir: &std::path::Path| std::fs::read_dir(dir).unwrap().filter_map(Result::ok).map(|e| e.file_name()).collect::<Vec<_>>();
|
||||
prop_assert!(names(&folder).len() <= 4, "{:?}", names(&folder));
|
||||
prop_assert!(names(&folder.join("storage")).len() <= 2, "{:?}", names(&folder.join("storage")));
|
||||
let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage"}));
|
||||
let _ = s.execute("plugin.remove", &json!({"id": "fuzz.storage.other"}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,13 @@ pub(crate) use envelope::MAX_CHECKED;
|
||||
pub(crate) use envelope::{WARPED_TYPE_DRAWN, WarpedType};
|
||||
pub(crate) use frames::text_frames;
|
||||
pub(crate) use paint::MAX_STOPS as MAX_GRADIENT_STOPS;
|
||||
#[cfg(test)]
|
||||
pub(crate) use read::with_image_limits;
|
||||
pub use read::{
|
||||
BindingRef, Brushes, IMAGE_TOO_LARGE, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles,
|
||||
palette, read, read_beside, variables,
|
||||
BindingRef, Brushes, PaletteSwatches, PlacedPage, Structure, active_artboard, artboards, bindings_note, brushes, graphic_styles, palette, read,
|
||||
read_beside, variables,
|
||||
};
|
||||
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, slot_frame, slot_of, symbol_art_member};
|
||||
pub(crate) use read::{MAX_SYMBOLS, join_and, read_art_style, read_over_page, slot_frame, slot_of, symbol_art_member};
|
||||
|
||||
pub(crate) use read::is_look_slot;
|
||||
|
||||
|
||||
@@ -34,7 +34,9 @@
|
||||
//! - Images: `%AI5_BeginRaster`, the colour space `XN`, `[matrix] bounds w h bits type alpha …`
|
||||
//! and the samples after `XI`. 8-bit CMYK samples keep their ink amounts in a CMYK TIFF, and
|
||||
//! an alpha channel that hides part of such an image becomes its opacity mask; other samples
|
||||
//! become an RGBA PNG.
|
||||
//! become an RGBA PNG. Images larger than 2^25 pixels keep their samples' channels (1-bit gray as
|
||||
//! a 1-bit PNG), and are averaged down past a target size ([`raster`]); past the hard limits, one
|
||||
//! is a box that the page's art replaces when there is a page ([`read_over_page`]).
|
||||
//! - Linked images in the version 7 form (§8.2, §8.3): `(path) modified XG`, then `XF` with the
|
||||
//! operands `XI` takes and no samples. Each is a linked image of that file showing a placeholder
|
||||
//! over the image's place until its file is found. An `XF` without a file name before it, or
|
||||
@@ -134,6 +136,7 @@ mod document;
|
||||
mod masks;
|
||||
mod on_path;
|
||||
mod ops;
|
||||
mod raster;
|
||||
mod types;
|
||||
|
||||
pub(crate) use art::join_and;
|
||||
@@ -143,10 +146,12 @@ pub use document::{
|
||||
};
|
||||
pub(crate) use document::{slot_frame, slot_of};
|
||||
pub(crate) use ops::read_art_style;
|
||||
#[cfg(test)]
|
||||
pub(crate) use raster::with_image_limits;
|
||||
pub use types::PlacedPage;
|
||||
use types::{BLEND_BUDGET, BrushArtColors, FLIP, Frame, GState, Graph, Kind, PatternHead, PendingEnvelope, Place, PlacedTail, Raster, SymbolHead};
|
||||
#[cfg(test)]
|
||||
pub use types::{GRAPH_PLAIN, GRAPH_UNENDED, LINKED_PLACEHOLDERS, NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, VERSIONED_UNENDED};
|
||||
pub use types::{IMAGE_TOO_LARGE, PlacedPage};
|
||||
pub(super) use types::{blend_mode, blend_name};
|
||||
|
||||
#[path = "legacy_text.rs"]
|
||||
@@ -235,19 +240,32 @@ pub(super) fn remember_xml_id(xml_ids: &mut HashMap<String, Option<NodeId>>, byt
|
||||
/// when `large_canvas` is set: an EPS's page is drawn at the full size, but a `.ai`'s PDF part may
|
||||
/// keep the reduced one, which the PDF import reads as it is (#2031).
|
||||
pub fn read(data: &[u8], large_canvas: bool) -> Result<Structure, String> {
|
||||
read_with(data, large_canvas, false)
|
||||
}
|
||||
|
||||
/// [`read`] for a file whose page art replaces the objects the reader doesn't read (see
|
||||
/// [`Reader::unread_box`]): an image past the hard limits ([`raster::Limits`]) is then such an
|
||||
/// object, where [`read`] leaves it out with a note.
|
||||
pub(crate) fn read_over_page(data: &[u8], large_canvas: bool) -> Result<Structure, String> {
|
||||
read_with(data, large_canvas, true)
|
||||
}
|
||||
|
||||
fn read_with(data: &[u8], large_canvas: bool, page_art: bool) -> Result<Structure, String> {
|
||||
let mut r = Reader::with_names(data);
|
||||
r.large_canvas = large_canvas.then_some(1.0);
|
||||
r.page_art = page_art;
|
||||
r.run()?;
|
||||
r.finish()
|
||||
}
|
||||
|
||||
/// [`read`] for editing data kept apart from the program `ps` it came with (an EPS's private
|
||||
/// data): the program's header gives the author and the custom colors the editing data's header
|
||||
/// doesn't.
|
||||
pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8]) -> Result<Structure, String> {
|
||||
/// doesn't. `page_art`: as [`read_over_page`] reads a file.
|
||||
pub fn read_beside(data: &[u8], large_canvas: bool, ps: &[u8], page_art: bool) -> Result<Structure, String> {
|
||||
let header = Reader::header_of(ps);
|
||||
let mut r = Reader::with_names(data);
|
||||
r.large_canvas = large_canvas.then_some(1.0);
|
||||
r.page_art = page_art;
|
||||
// The program's custom colors have swatches of their names, which no gradient or pattern takes.
|
||||
for n in &header.custom_colors {
|
||||
r.global_names.reserve(&n.name);
|
||||
@@ -587,6 +605,8 @@ struct Reader<'a> {
|
||||
/// (see [`Reader::ensure_space`]); 1 (no scaling) when the file doesn't declare one, `None`
|
||||
/// when the declared factor is not applied ([`read`]).
|
||||
large_canvas: Option<f64>,
|
||||
/// The file has page art to replace the objects that aren't read ([`read_over_page`]).
|
||||
page_art: bool,
|
||||
/// The declared `%AI24_LargeCanvasScale` (1 without one), applied or not.
|
||||
canvas_scale: f64,
|
||||
/// `%AI5_RulerUnits` from the header (Illustrator 7 spec table 3, plus centimeters and pixels).
|
||||
@@ -756,6 +776,7 @@ impl<'a> Reader<'a> {
|
||||
art_size: None,
|
||||
template_center: None,
|
||||
large_canvas: None,
|
||||
page_art: false,
|
||||
canvas_scale: 1.0,
|
||||
ruler_units: None,
|
||||
pattern: None,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use super::containers::close;
|
||||
use super::document::TEXT_SLOT;
|
||||
use super::raster::{self, Limits, Shape};
|
||||
use super::types::{
|
||||
FLIP, Frame, IMAGE_TOO_LARGE, Kind, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE, NON_NATIVE_ART,
|
||||
NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail,
|
||||
FLIP, Frame, IMAGE_TOO_LARGE, Kind, LARGE_IMAGES, LINKED_PLACEHOLDERS, LINKED_PREVIEWS, MAX_PIXELS, MAX_PLACED_ELSEWHERE, MAX_POINTS, MAX_SIDE,
|
||||
NON_NATIVE_ART, NON_NATIVE_ART_UNREAD, PLACED_CROP, PLACED_PAGE, Place, PlacedPage, PlacedTail,
|
||||
};
|
||||
use super::{
|
||||
Affine, AnchorKind, Appearance, AppearanceItem, Arc, BezPath, CANVAS_CENTRE, FillLayer, ImageBlob, ImageObject, Inks, Instance, LinkInfo, Node,
|
||||
@@ -377,7 +378,7 @@ impl<'a> Reader<'a> {
|
||||
Some(before) if !vals.iter().any(|v| matches!(v, V::Arr(_))) => before,
|
||||
_ => vals,
|
||||
};
|
||||
self.image_from(vals, data)
|
||||
self.image_from(vals, data, false)
|
||||
}
|
||||
|
||||
/// The matrix and the numbers of an image's operands (`[a b c d tx ty] llx lly urx ury w h bits
|
||||
@@ -411,8 +412,22 @@ impl<'a> Reader<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// An image of operands `vals` and samples `data`.
|
||||
pub(super) fn image_from(&mut self, vals: Vec<V>, data: &[u8]) -> Result<(), String> {
|
||||
/// The bytes of samples (colour and alpha) that the image of `XI` operands `vals` reads: 0 when
|
||||
/// it isn't read (operands missing, or past the hard limits).
|
||||
pub(super) fn sample_bytes(&self, vals: &[V]) -> usize {
|
||||
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
|
||||
let n = Self::nums(vals);
|
||||
let [_, _, _, _, w, h, bits, kind, alpha, ..] = n.as_slice() else { return 0 };
|
||||
let shape = Shape { w: *w as u64, h: *h as u64, channels: channels(&space, *kind) as u64, bits: *bits as u64, alpha: *alpha > 0.0 };
|
||||
match (shape.step(&Limits::get()), shape.lens()) {
|
||||
(Some(_), Some((colour, alpha))) => usize::try_from(colour + alpha).unwrap_or(0),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// An image of operands `vals` and samples `data` (`decoded`: bytes already, whatever the
|
||||
/// operands say).
|
||||
pub(super) fn image_from(&mut self, vals: Vec<V>, data: &[u8], decoded: bool) -> Result<(), String> {
|
||||
// A file `XG` named before this image isn't the file of a later `XF`.
|
||||
self.image_link = None;
|
||||
let space = self.raster.as_ref().and_then(|r| r.space.clone()).unwrap_or_default();
|
||||
@@ -427,41 +442,42 @@ impl<'a> Reader<'a> {
|
||||
// ImageMask 1: the image is transparent and takes a color (version 7 specification §8.1).
|
||||
let image_mask = rest.first() == Some(&1.0);
|
||||
let (w, h) = (*w as u64, *h as u64);
|
||||
if w.saturating_mul(h) > MAX_PIXELS {
|
||||
self.image_left_out(IMAGE_TOO_LARGE);
|
||||
return Ok(());
|
||||
}
|
||||
if w == 0 || h == 0 || ![1.0, 8.0].contains(bits) {
|
||||
self.image_left_out("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
}
|
||||
let channels: usize = match space.as_str() {
|
||||
"DeviceGray" => 1,
|
||||
"DeviceCMYK" => 4,
|
||||
"DeviceRGB" => 3,
|
||||
_ => match *kind as i64 {
|
||||
1 => 1,
|
||||
4 => 4,
|
||||
_ => 3,
|
||||
},
|
||||
let channels = channels(&space, *kind);
|
||||
// The size is checked before anything is decoded or allocated.
|
||||
let shape = Shape { w, h, channels: channels as u64, bits: *bits as u64, alpha: *alpha > 0.0 };
|
||||
let (Some(k), Some((need, alpha_len))) = (shape.step(&Limits::for_channels(channels)), shape.lens()) else {
|
||||
return self.too_large_image(&m, w, h);
|
||||
};
|
||||
let decoded;
|
||||
let data = if *binary == 0.0 {
|
||||
let digits: Vec<u8> = data.iter().copied().filter(|b| b.is_ascii_hexdigit()).collect();
|
||||
decoded = crate::import::lex::hex_decode(&digits).unwrap_or_default();
|
||||
decoded.as_slice()
|
||||
// Within the limits, these fit in memory (see `Limits::bytes`).
|
||||
let (need, alpha_len) = (need as usize, alpha_len as usize);
|
||||
let hex;
|
||||
let data = if *binary == 0.0 && !decoded {
|
||||
let (max, mut half) = (need + alpha_len, None);
|
||||
let mut out = Vec::with_capacity(max.min(data.len() / 2));
|
||||
raster::push_hex(&mut out, &mut half, data, max);
|
||||
raster::end_hex(&mut out, half, max);
|
||||
hex = out;
|
||||
hex.as_slice()
|
||||
} else {
|
||||
data
|
||||
};
|
||||
let row = if *bits == 1.0 { (w as usize * channels).div_ceil(8) } else { w as usize * channels };
|
||||
let need = row.saturating_mul(h as usize);
|
||||
let row = shape.row().unwrap_or(0) as usize;
|
||||
// An alpha channel follows the colour samples, one byte a pixel.
|
||||
let alpha_len = if *alpha > 0.0 { (w * h) as usize } else { 0 };
|
||||
let (Some(samples), opacity) = (data.get(..need), data.get(need..need.saturating_add(alpha_len))) else {
|
||||
self.image_left_out("an image whose data is cut short was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let opacity = opacity.filter(|_| alpha_len > 0);
|
||||
let gray1 = *bits == 1.0 && channels == 1 && opacity.is_none();
|
||||
if k > 1 || w * h > raster::SMALL || (gray1 && !image_mask) {
|
||||
// An image mask keeps the color that colorizes it.
|
||||
let colorize = (image_mask && channels == 1 && *bits == 1.0).then(|| self.mask_paint());
|
||||
return self.large_image(samples, opacity, shape, k, &m, colorize);
|
||||
}
|
||||
// CMYK samples keep their ink amounts as a CMYK TIFF, as the PDF importer keeps a CMYK
|
||||
// image's, and an alpha channel that hides part of the image becomes its opacity mask.
|
||||
if channels == 4
|
||||
@@ -595,6 +611,67 @@ impl<'a> Reader<'a> {
|
||||
self.add_image_colorized(im, None, colorize, hidden)
|
||||
}
|
||||
|
||||
/// An image of `s` past [`raster::SMALL`] pixels, or reduced by the step `k`, or 1-bit gray:
|
||||
/// 1-bit gray at full size is a 1-bit PNG of its rows as they are; other images keep their
|
||||
/// channels a byte each (gray or RGB in a PNG with the alpha channel, CMYK in a TIFF with the
|
||||
/// alpha channel as its opacity mask), averaged over blocks of `k` × `k` pixels. The matrix
|
||||
/// `m` places the image where its full size would be.
|
||||
fn large_image(&mut self, samples: &[u8], opacity: Option<&[u8]>, s: Shape, k: u64, m: &[f64], colorize: Option<Paint>) -> Result<(), String> {
|
||||
let (ow, oh) = (s.w.div_ceil(k) as u32, s.h.div_ceil(k) as u32);
|
||||
if k == 1 && s.bits == 1 && s.channels == 1 && opacity.is_none() {
|
||||
let Some(png) = raster::gray1_png(samples, ow, oh, s.row().unwrap_or(0) as usize) else {
|
||||
return self.too_large_image(m, s.w, s.h);
|
||||
};
|
||||
return self.add_raster(ImageBlob::new("image/png", png), None, m, (ow, oh), colorize);
|
||||
}
|
||||
let cmyk = s.channels == 4;
|
||||
let (pixels, mask) = raster::reduce(samples, opacity, s, k, !cmyk);
|
||||
let blob = if cmyk {
|
||||
Inks::new(ow, oh, pixels).and_then(|i| ImageBlob::cmyk_tiff(&i))
|
||||
} else {
|
||||
use image::ImageEncoder as _;
|
||||
let colour = match (s.channels, opacity.is_some()) {
|
||||
(1, false) => image::ExtendedColorType::L8,
|
||||
(1, true) => image::ExtendedColorType::La8,
|
||||
(_, false) => image::ExtendedColorType::Rgb8,
|
||||
(_, true) => image::ExtendedColorType::Rgba8,
|
||||
};
|
||||
let mut png = vec![];
|
||||
image::codecs::png::PngEncoder::new(&mut png).write_image(&pixels, ow, oh, colour).ok().map(|()| ImageBlob::new("image/png", png))
|
||||
};
|
||||
// One that can't be encoded is left to the page's art, as one past the hard limits.
|
||||
let Some(blob) = blob else {
|
||||
return self.too_large_image(m, s.w, s.h);
|
||||
};
|
||||
// Each pixel as read stands for a block of the full size's.
|
||||
let (sx, sy) = (s.w as f64 / f64::from(ow), s.h as f64 / f64::from(oh));
|
||||
let m: Vec<f64> = match m {
|
||||
[a, b, c, d, tx, ty] => vec![a * sx, b * sx, c * sy, d * sy, *tx, *ty],
|
||||
other => other.to_vec(),
|
||||
};
|
||||
if k > 1 {
|
||||
self.warn(&format!("an embedded image of {} × {} pixels was reduced to {ow} × {oh} pixels to keep the layers", s.w, s.h));
|
||||
}
|
||||
self.add_raster(blob, mask.as_deref(), &m, (ow, oh), colorize)
|
||||
}
|
||||
|
||||
/// An image of `w` × `h` pixels past the hard limits, which matrix `m` maps into art space: a
|
||||
/// box for the page's art to replace ([`Reader::unread_box`]), or, without page art, left out
|
||||
/// with a note.
|
||||
fn too_large_image(&mut self, m: &[f64], w: u64, h: u64) -> Result<(), String> {
|
||||
if !self.page_art {
|
||||
self.image_left_out(IMAGE_TOO_LARGE);
|
||||
return Ok(());
|
||||
}
|
||||
let [a, b, c, d, tx, ty] = m else {
|
||||
self.image_left_out("an image VectorCraft can't read was left out");
|
||||
return Ok(());
|
||||
};
|
||||
let xf = self.space() * Affine::new([*a, *b, *c, *d, *tx, *ty]) * FLIP;
|
||||
let bounds = xf.transform_rect_bbox(Rect::new(0.0, 0.0, w as f64, h as f64));
|
||||
self.unread_box(LARGE_IMAGES, bounds, false).map(|_| ())
|
||||
}
|
||||
|
||||
/// Add the image `blob` (`w` × `h` pixels), which the matrix `m` maps into art space, with
|
||||
/// `alpha` (its alpha channel, one byte a pixel) as its opacity mask when it hides part of the
|
||||
/// image, and `colorize` as the color that colorizes it.
|
||||
@@ -978,6 +1055,20 @@ fn cmyk_rgb([c, m, y, k]: [u8; 4]) -> [u8; 3] {
|
||||
[rgb[0], rgb[1], rgb[2]]
|
||||
}
|
||||
|
||||
/// The colour channels of an image in the colour space `space` (`XN`), else of image type `kind`.
|
||||
fn channels(space: &str, kind: f64) -> usize {
|
||||
match space {
|
||||
"DeviceGray" => 1,
|
||||
"DeviceCMYK" => 4,
|
||||
"DeviceRGB" => 3,
|
||||
_ => match kind as i64 {
|
||||
1 => 1,
|
||||
4 => 4,
|
||||
_ => 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `a`, `a and b`, `a, b and c`.
|
||||
pub(crate) fn join_and(items: &[&str]) -> String {
|
||||
match items {
|
||||
|
||||
@@ -3,8 +3,9 @@ use std::collections::HashMap;
|
||||
use super::super::brush;
|
||||
use super::art::{four, sane, two};
|
||||
use super::ops::Dictionaries;
|
||||
use super::raster::{end_hex, push_hex};
|
||||
use super::types::{
|
||||
FLIP, GState, Kind, MAX_DEPTH, MAX_HEX, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS,
|
||||
FLIP, GState, Kind, MAX_DEPTH, MAX_LISTED, MAX_NODES, MAX_NOTE, MAX_PATTERNS, MAX_PLACED_FOLDER, MAX_STACK, MAX_SUBREADERS, MAX_SYMBOLS,
|
||||
PatternHead, Place, Raster, SKIPPED, SymbolHead, VERSIONED_UNENDED,
|
||||
};
|
||||
use super::{Arc, BrushArtColors, Global, Lexer, Node, NodeKind, PatternDef, Reader, Rect, Tok, V, marker, obj};
|
||||
@@ -52,9 +53,7 @@ impl<'a> Reader<'a> {
|
||||
&& !line.is_empty()
|
||||
&& line.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
{
|
||||
if r.hex.len() < MAX_HEX {
|
||||
r.hex.extend_from_slice(line.as_bytes());
|
||||
}
|
||||
push_hex(&mut r.samples, &mut r.half, line.as_bytes(), r.max);
|
||||
return Ok(());
|
||||
}
|
||||
// The header's comments (AI7 specification §2.1.1; DSC 3.0).
|
||||
@@ -182,8 +181,9 @@ impl<'a> Reader<'a> {
|
||||
} else if line.starts_with("AI5_EndRaster") {
|
||||
// A file `XG` named in this raster object is no later one's.
|
||||
self.image_link = None;
|
||||
if let Some(Raster { operands: Some(vals), hex, .. }) = self.raster.take() {
|
||||
return self.image_from(vals, &hex);
|
||||
if let Some(Raster { operands: Some(vals), mut samples, half, max, .. }) = self.raster.take() {
|
||||
end_hex(&mut samples, half, max);
|
||||
return self.image_from(vals, &samples, true);
|
||||
}
|
||||
} else if line.starts_with("AI5_BeginPlace") {
|
||||
// One that is still open (no `~` ended it) isn't read.
|
||||
|
||||
@@ -390,8 +390,10 @@ impl<'a> Reader<'a> {
|
||||
"TZ" => self.tz(&vals),
|
||||
// An image whose samples follow in comment lines (read at `%AI5_EndRaster`).
|
||||
"XI" if self.raster.is_some() => {
|
||||
let max = self.sample_bytes(&vals);
|
||||
if let Some(r) = self.raster.as_mut() {
|
||||
r.operands = Some(vals);
|
||||
r.max = max;
|
||||
}
|
||||
}
|
||||
// A linked image (§8.2, §8.3): `(path) modified XG` names the file of the `XF` image after
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
//! The size of an embedded image (`XI`), checked before its samples are decoded: read at full size
|
||||
//! up to a target number of pixels, averaged down in whole steps past it, and not read at all past
|
||||
//! the hard limits ([`Limits`]).
|
||||
|
||||
use std::io::Write as _;
|
||||
|
||||
/// Images up to this many pixels read as they always have: an RGBA PNG, or a CMYK TIFF.
|
||||
pub(super) const SMALL: u64 = 1 << 25;
|
||||
/// Most bytes of one image's samples, its alpha channel included.
|
||||
pub(super) const MAX_BYTES: u64 = 512 << 20;
|
||||
/// Most pixels of one image.
|
||||
pub(super) const MAX_PIXELS: u64 = 1 << 31;
|
||||
/// Longest side of an image as it comes in (pixels).
|
||||
const MAX_SIDE: u64 = 65_535;
|
||||
|
||||
/// How large an image is read.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Limits {
|
||||
/// Most pixels of an image as it comes in; larger ones are reduced.
|
||||
pub(super) target: u64,
|
||||
/// Most pixels of an image read at all.
|
||||
pub(super) pixels: u64,
|
||||
/// Most bytes of an image's samples, its alpha channel included.
|
||||
pub(super) bytes: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_LIMITS: std::cell::Cell<Option<Limits>> = const { std::cell::Cell::new(None) };
|
||||
}
|
||||
|
||||
impl Limits {
|
||||
/// The limits on this machine: [`target`] pixels, [`MAX_PIXELS`] and [`MAX_BYTES`].
|
||||
pub(super) fn get() -> Self {
|
||||
#[cfg(test)]
|
||||
if let Some(l) = TEST_LIMITS.with(std::cell::Cell::get) {
|
||||
return l;
|
||||
}
|
||||
static TARGET: std::sync::OnceLock<u64> = std::sync::OnceLock::new();
|
||||
let target = *TARGET.get_or_init(|| target(vectorcraft_render::installed_memory()));
|
||||
Self { target, pixels: MAX_PIXELS, bytes: MAX_BYTES }
|
||||
}
|
||||
|
||||
/// The limits for an image of `channels` colour channels: a CMYK image comes in at most as
|
||||
/// large as a CMYK TIFF holds ([`vectorcraft_doc::cmyk::MAX_CMYK_PIXELS`]).
|
||||
pub(super) fn for_channels(channels: usize) -> Self {
|
||||
let mut l = Self::get();
|
||||
if channels == 4 {
|
||||
l.target = l.target.min(vectorcraft_doc::cmyk::MAX_CMYK_PIXELS);
|
||||
}
|
||||
l
|
||||
}
|
||||
}
|
||||
|
||||
/// `f` run with images read under `target` pixels, `pixels` and `bytes` in place of this machine's
|
||||
/// limits.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn with_image_limits<T>(target: u64, pixels: u64, bytes: u64, f: impl FnOnce() -> T) -> T {
|
||||
TEST_LIMITS.with(|l| l.set(Some(Limits { target, pixels, bytes })));
|
||||
let out = f();
|
||||
TEST_LIMITS.with(|l| l.set(None));
|
||||
out
|
||||
}
|
||||
|
||||
/// Most pixels of an image as it comes in, on a machine with `memory` bytes installed (`None`: not
|
||||
/// known): a thirty-second of it, from 2^26 to 2^28 pixels (an RGBA decode of at most 1 GiB); on the
|
||||
/// web, a quarter of the largest image the renderer decodes, at most 2^27.
|
||||
fn target(memory: Option<u64>) -> u64 {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
(1u64 << 27).min(vectorcraft_doc::MAX_RASTER_ALLOC / 4)
|
||||
} else {
|
||||
memory.map_or(1 << 26, |m| (m / 32).clamp(1 << 26, 1 << 28))
|
||||
}
|
||||
}
|
||||
|
||||
/// An image's layout from its `XI` operands.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Shape {
|
||||
pub(super) w: u64,
|
||||
pub(super) h: u64,
|
||||
/// Colour channels a pixel: 1, 3 or 4.
|
||||
pub(super) channels: u64,
|
||||
/// Bits a channel: 1 or 8.
|
||||
pub(super) bits: u64,
|
||||
/// An alpha channel, a byte a pixel, follows the colour samples.
|
||||
pub(super) alpha: bool,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
/// Bytes in a row of colour samples (a 1-bit row is padded to a whole byte).
|
||||
pub(super) fn row(&self) -> Option<u64> {
|
||||
Some(self.w.checked_mul(self.channels)?.checked_mul(self.bits)?.div_ceil(8))
|
||||
}
|
||||
|
||||
/// Bytes of colour samples, and of the alpha channel.
|
||||
pub(super) fn lens(&self) -> Option<(u64, u64)> {
|
||||
let colour = self.row()?.checked_mul(self.h)?;
|
||||
let alpha = if self.alpha { self.w.checked_mul(self.h)? } else { 0 };
|
||||
colour.checked_add(alpha)?;
|
||||
Some((colour, alpha))
|
||||
}
|
||||
|
||||
/// The step `k` the image is reduced by (1: full size): the smallest that brings it within
|
||||
/// `limits.target` pixels and [`MAX_SIDE`] a side. `None` past `limits.pixels` or
|
||||
/// `limits.bytes`.
|
||||
pub(super) fn step(&self, limits: &Limits) -> Option<u64> {
|
||||
let pixels = self.w.checked_mul(self.h)?;
|
||||
let (colour, alpha) = self.lens()?;
|
||||
if pixels > limits.pixels || colour + alpha > limits.bytes {
|
||||
return None;
|
||||
}
|
||||
let fits = |k: u64| {
|
||||
let (w, h) = (self.w.div_ceil(k), self.h.div_ceil(k));
|
||||
w <= MAX_SIDE && h <= MAX_SIDE && w.saturating_mul(h) <= limits.target.max(1)
|
||||
};
|
||||
// No step below these brings it within the limits.
|
||||
let mut k = ((pixels as f64 / limits.target.max(1) as f64).sqrt().floor() as u64)
|
||||
.max(self.w.div_ceil(MAX_SIDE))
|
||||
.max(self.h.div_ceil(MAX_SIDE))
|
||||
.max(1);
|
||||
while !fits(k) {
|
||||
k += 1;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples of the image `s` (rows of [`Shape::row`] bytes; a set bit is the channel at full
|
||||
/// strength) and its `alpha` channel averaged over blocks of `k` × `k` pixels into a byte a channel,
|
||||
/// each block at the right and bottom edges over the pixels it has. With `inline`, the alpha follows
|
||||
/// each pixel's channels; otherwise it comes back on its own.
|
||||
pub(super) fn reduce(samples: &[u8], alpha: Option<&[u8]>, s: Shape, k: u64, inline: bool) -> (Vec<u8>, Option<Vec<u8>>) {
|
||||
let (w, h, k) = (s.w as usize, s.h as usize, k.max(1) as usize);
|
||||
let (ch, row) = (s.channels as usize, s.row().unwrap_or(0) as usize);
|
||||
let (ow, oh) = (w.div_ceil(k), h.div_ceil(k));
|
||||
let n = ch + usize::from(alpha.is_some());
|
||||
let mut out = Vec::with_capacity(ow * oh * if inline { n } else { ch });
|
||||
let mut alpha_out = (alpha.is_some() && !inline).then(|| Vec::with_capacity(ow * oh));
|
||||
let mut sums = vec![0u64; ow * n];
|
||||
for oy in 0..oh {
|
||||
sums.fill(0);
|
||||
let rows = oy * k..((oy + 1) * k).min(h);
|
||||
let tall = rows.len() as u64;
|
||||
for y in rows {
|
||||
let line = samples.get(y * row..(y + 1) * row).unwrap_or_default();
|
||||
let opacity = alpha.and_then(|a| a.get(y * w..(y + 1) * w));
|
||||
for x in 0..w {
|
||||
let at = x / k * n;
|
||||
for c in 0..ch {
|
||||
let i = x * ch + c;
|
||||
let v =
|
||||
if s.bits == 1 { line.get(i / 8).map_or(0, |b| ((b >> (7 - i % 8)) & 1) * 255) } else { line.get(i).copied().unwrap_or(0) };
|
||||
sums[at + c] += u64::from(v);
|
||||
}
|
||||
if alpha.is_some() {
|
||||
sums[at + ch] += u64::from(opacity.and_then(|o| o.get(x)).copied().unwrap_or(255));
|
||||
}
|
||||
}
|
||||
}
|
||||
for ox in 0..ow {
|
||||
let count = ((((ox + 1) * k).min(w) - ox * k) as u64 * tall).max(1);
|
||||
let avg = |sum: u64| ((sum + count / 2) / count) as u8;
|
||||
out.extend(sums[ox * n..ox * n + ch].iter().map(|v| avg(*v)));
|
||||
if alpha.is_some() {
|
||||
let a = avg(sums[ox * n + ch]);
|
||||
match alpha_out.as_mut() {
|
||||
Some(o) => o.push(a),
|
||||
None => out.push(a),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, alpha_out)
|
||||
}
|
||||
|
||||
/// A grayscale PNG of 1-bit rows as they are (`row` bytes each, a set bit white): `w` × `h`
|
||||
/// pixels without expanding them.
|
||||
pub(super) fn gray1_png(samples: &[u8], w: u32, h: u32, row: usize) -> Option<Vec<u8>> {
|
||||
let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
for y in 0..h as usize {
|
||||
// Filter type 0 (none), then the row.
|
||||
z.write_all(&[0]).ok()?;
|
||||
z.write_all(samples.get(y * row..(y + 1) * row)?).ok()?;
|
||||
}
|
||||
let data = z.finish().ok()?;
|
||||
let mut png = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
// Bit depth 1, colour type 0 (gray), deflate, filter method 0, no interlacing.
|
||||
let ihdr = [&w.to_be_bytes()[..], &h.to_be_bytes(), &[1, 0, 0, 0, 0]].concat();
|
||||
for (ty, body) in [(b"IHDR", ihdr.as_slice()), (b"IDAT", data.as_slice()), (b"IEND", &[])] {
|
||||
png.extend_from_slice(&u32::try_from(body.len()).ok()?.to_be_bytes());
|
||||
png.extend_from_slice(ty);
|
||||
png.extend_from_slice(body);
|
||||
let mut crc = flate2::Crc::new();
|
||||
crc.update(ty);
|
||||
crc.update(body);
|
||||
png.extend_from_slice(&crc.sum().to_be_bytes());
|
||||
}
|
||||
Some(png)
|
||||
}
|
||||
|
||||
/// Decode the hex digits in `text` onto `out`, `half` holding a digit that waits for its pair, up
|
||||
/// to `max` bytes; other characters are skipped.
|
||||
pub(super) fn push_hex(out: &mut Vec<u8>, half: &mut Option<u8>, text: &[u8], max: usize) {
|
||||
for &b in text {
|
||||
if out.len() >= max {
|
||||
return;
|
||||
}
|
||||
let Some(v) = char::from(b).to_digit(16) else { continue };
|
||||
match half.take() {
|
||||
Some(high) => out.push(high << 4 | v as u8),
|
||||
None => *half = Some(v as u8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The last digit of [`push_hex`]'s, without its pair, as the byte it starts.
|
||||
pub(super) fn end_hex(out: &mut Vec<u8>, half: Option<u8>, max: usize) {
|
||||
if let Some(high) = half
|
||||
&& out.len() < max
|
||||
{
|
||||
out.push(high << 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A CMYK image is reduced to the size a CMYK TIFF holds, whatever the target.
|
||||
#[test]
|
||||
fn cmyk_images_come_in_at_most_as_large_as_a_cmyk_tiff() {
|
||||
let shape = |channels| Shape { w: 9000, h: 9000, channels, bits: 8, alpha: false };
|
||||
let (rgb, cmyk) = with_image_limits(1 << 28, MAX_PIXELS, MAX_BYTES * 4, || {
|
||||
(shape(3).step(&Limits::for_channels(3)), shape(4).step(&Limits::for_channels(4)))
|
||||
});
|
||||
assert_eq!(rgb, Some(1));
|
||||
let k = cmyk.unwrap();
|
||||
assert_eq!(k, 2);
|
||||
assert!(9000u64.div_ceil(k).pow(2) <= vectorcraft_doc::cmyk::MAX_CMYK_PIXELS);
|
||||
}
|
||||
|
||||
/// The step keeps each side within 65,535 pixels, and sizes past the limits or overflowing
|
||||
/// aren't read.
|
||||
#[test]
|
||||
fn steps_keep_sides_within_the_limit() {
|
||||
let limits = Limits { target: 1 << 28, pixels: MAX_PIXELS, bytes: MAX_BYTES };
|
||||
let long = Shape { w: 200_000, h: 1, channels: 1, bits: 1, alpha: false };
|
||||
assert_eq!(long.step(&limits), Some(4));
|
||||
let huge = Shape { w: u64::MAX, h: 2, channels: 3, bits: 8, alpha: true };
|
||||
assert_eq!(huge.step(&limits), None);
|
||||
let many = Shape { w: 1 << 16, h: 1 << 16, channels: 1, bits: 1, alpha: false };
|
||||
assert_eq!(many.step(&limits), None);
|
||||
}
|
||||
|
||||
/// Hex digits are decoded across calls, other characters skipped, up to the bytes asked for.
|
||||
#[test]
|
||||
fn hex_is_decoded_up_to_the_bytes_asked_for() {
|
||||
let (mut out, mut half) = (vec![], None);
|
||||
push_hex(&mut out, &mut half, b"F f0 1", 8);
|
||||
push_hex(&mut out, &mut half, b"2a", 8);
|
||||
end_hex(&mut out, half, 8);
|
||||
assert_eq!(out, [0xff, 0x01, 0x2a]);
|
||||
let (mut out, mut half) = (vec![], None);
|
||||
push_hex(&mut out, &mut half, b"0102030", 2);
|
||||
end_hex(&mut out, half, 2);
|
||||
assert_eq!(out, [1, 2]);
|
||||
}
|
||||
}
|
||||
@@ -21,17 +21,18 @@ pub(super) const BLEND_BUDGET: usize = 50_000;
|
||||
/// Most points in one path.
|
||||
pub(super) const MAX_POINTS: usize = 4_000_000;
|
||||
|
||||
/// Largest image read (pixels).
|
||||
/// Largest placed file's preview read (pixels).
|
||||
pub(super) const MAX_PIXELS: u64 = 1 << 25;
|
||||
/// Most placed files whose other placement ([`super::Structure::placed_elsewhere`]) is kept.
|
||||
pub(super) const MAX_PLACED_ELSEWHERE: usize = 4096;
|
||||
|
||||
/// The note for an image past [`MAX_PIXELS`]: the file then opens from its PDF part, which reads
|
||||
/// larger images (#1479), unless that part can't be read either; then the image is left out.
|
||||
pub const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out";
|
||||
/// The note for an image past the hard limits ([`super::raster::Limits`]) in a file without page
|
||||
/// art to replace it.
|
||||
pub(super) const IMAGE_TOO_LARGE: &str = "an image too large to read from the editing data was left out";
|
||||
|
||||
/// Most hex digits of an image's samples read from comment lines (CMYK and alpha at the largest size).
|
||||
pub(super) const MAX_HEX: usize = (MAX_PIXELS as usize) * 10;
|
||||
/// What an image past the hard limits is, as the notes name it: with page art, its box becomes
|
||||
/// that art (#1479).
|
||||
pub(super) const LARGE_IMAGES: &str = "very large images";
|
||||
|
||||
/// Largest side of the document and its artboards (points).
|
||||
pub(super) const MAX_SIDE: f64 = 1e6;
|
||||
@@ -281,8 +282,12 @@ pub(super) struct Raster {
|
||||
/// `XI`'s operands when its samples follow as hex in comment lines (`%FF00…`) up to
|
||||
/// `%AI5_EndRaster`, the form the version 7 specification documents (§8).
|
||||
pub(super) operands: Option<Vec<V>>,
|
||||
/// Those hex digits.
|
||||
pub(super) hex: Vec<u8>,
|
||||
/// The bytes of those hex digits, up to `max`.
|
||||
pub(super) samples: Vec<u8>,
|
||||
/// The bytes of samples the image reads (0 when it isn't read).
|
||||
pub(super) max: usize,
|
||||
/// A digit waiting for its pair.
|
||||
pub(super) half: Option<u8>,
|
||||
}
|
||||
|
||||
/// A placed file's operands, from `` ` `` and its preview, until `~`.
|
||||
|
||||
@@ -783,7 +783,7 @@ fn pattern_names_are_shown_up_to_their_newline() {
|
||||
// Nor the name of a custom color the program of an EPS gives, or None's.
|
||||
let ps = b"%!PS-Adobe-3.0\n%%CMYKCustomColor: 0 0 1 0 (Outer Ink)\n%%EndComments\n";
|
||||
let data = stream(&format!("{}{}{}", def("Outer Ink\\n1", 8), def("[None]\\n1", 8), layer(&filled("Outer Ink\\n1"))));
|
||||
let doc = super::read_beside(data.as_bytes(), true, ps).unwrap().doc;
|
||||
let doc = super::read_beside(data.as_bytes(), true, ps, false).unwrap().doc;
|
||||
assert_eq!(doc.patterns.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), ["Outer Ink 2", "[None] 2"]);
|
||||
assert!(matches!(doc.swatch("Outer Ink").unwrap().paint, Paint::Solid { .. }));
|
||||
assert!(matches!(fill(&art(&doc)[0]), Paint::Pattern { pattern, .. } if pattern == "Outer Ink 2"));
|
||||
@@ -2613,6 +2613,29 @@ fn a_drawn_look_that_is_an_image_stands_for_its_object() {
|
||||
assert!(r.warnings.iter().any(|w| w.contains("drawn look (1)")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Samples in hex comment lines after `XI` (the version 7 specification's form) are read up to the
|
||||
/// bytes the image needs; an image past the hard limits isn't read, whatever lines follow it.
|
||||
#[test]
|
||||
fn hex_comment_line_samples_are_read_up_to_the_image() {
|
||||
let raster = |(w, h): (u32, u32), lines: &str| {
|
||||
let s = format!(
|
||||
"%AI5_BeginLayer\n1 1 1 1 0 0 1 0 79 128 255 0 50 0 Lb\n(L) Ln\n%AI5_File:\n%AI5_BeginRaster\n() 1 XG\n/DeviceRGB XN\n\
|
||||
[ 1 0 0 1 0 100 ] {w} {h} 0 Xh\n[ 1 0 0 1 0 100 ] 0 0 {w} {h} {w} {h} 8 3 0 0 0 0 XI\n{lines}%AI5_EndRaster\nN\nLB\n%AI5_EndLayer--\n"
|
||||
);
|
||||
let mut out = stream("").into_bytes();
|
||||
let at = out.windows(14).position(|w| w == b"%%PageTrailer\n").unwrap();
|
||||
out.splice(at..at, s.into_bytes());
|
||||
out
|
||||
};
|
||||
// 2 × 1: red, green, then digits past the image's six bytes.
|
||||
let r = read(&raster((2, 1), "%FF00\n%0000FF\n%00FFFFFF\n")).unwrap();
|
||||
let img = pixels(&r.doc).0;
|
||||
assert_eq!((img.get_pixel(0, 0).0, img.get_pixel(1, 0).0), ([255, 0, 0, 255], [0, 255, 0, 255]));
|
||||
let r = read(&raster((70_000, 70_000), "%FF0000\n%00FF00\n")).unwrap();
|
||||
assert!(art(&r.doc).is_empty());
|
||||
assert!(r.warnings.iter().any(|w| w == "an image too large to read from the editing data was left out"), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The name and opacity mask after an image follow the operators that end its raster: `XH`, the
|
||||
/// versioned `Xr` and the `N` after `%AI5_EndRaster`.
|
||||
#[test]
|
||||
@@ -2911,7 +2934,7 @@ fn a_version_7_linked_image_without_its_file_name_is_left_out_with_a_warning() {
|
||||
let r = read(stream7(&layer7("L", &raster7("(a.tif) 0 XG\n", m, (100_000, 100_000), "XF", ""))).as_bytes()).unwrap();
|
||||
let a = art(&r.doc);
|
||||
assert!(matches!(&a[..], [n] if matches!(&n.kind, NodeKind::Image(im) if (im.width, im.height) == (100_000, 100_000))), "{a:?}");
|
||||
assert!(!r.warnings.iter().any(|w| w == read::IMAGE_TOO_LARGE), "{:?}", r.warnings);
|
||||
assert!(!r.warnings.iter().any(|w| w == "an image too large to read from the editing data was left out"), "{:?}", r.warnings);
|
||||
let twice = raster7("(a.tif) 0 XG\n(b.tif) 0 XG\n", m, (2, 2), "XF", "");
|
||||
let r = read(stream7(&layer7("L", &twice)).as_bytes()).unwrap();
|
||||
let a = art(&r.doc);
|
||||
|
||||
@@ -59,7 +59,9 @@ pub(super) fn build(
|
||||
) -> Result<(Document, Vec<String>), String> {
|
||||
// An EPS (its page is its art) draws a Large Canvas page at full size; a `.ai` keeps its art
|
||||
// space, as its PDF part may (#2031).
|
||||
build_from(ai::read(data, matches!(page, Page::Art))?, data, visible, page, outlined, trust, unit)
|
||||
let large_canvas = matches!(page, Page::Art);
|
||||
let structure = if visible.is_some() { ai::read_over_page(data, large_canvas)? } else { ai::read(data, large_canvas)? };
|
||||
build_from(structure, data, visible, page, outlined, trust, unit)
|
||||
}
|
||||
|
||||
/// [`build`] without a page, for a `.ai` whose PDF part can't be read, its first page `page`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user