mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 18:10:01 -06:00
M4.17: File menu details: converted files, 30 recent files, one colour-mode command, Show in Folder, saved view
Older native files (.drawcraft or an older format version) open as "<name> [Converted]" and Save asks for a .vectorcraft name instead of overwriting them (preference appendConverted). Open Recent Files honours the 0-30 preference; empty slots are hidden (the native menu rebuilds when they, saved views or User Defined libraries change). File > Document Color Mode is one command, file.documentColorMode, converting through the colour settings; choosing the current mode is not an edit, and object.convertDocumentColorMode stays as an alias the command palette leaves out. New File > Show in Folder (file.reveal; paths with spaces work on Windows). Native files store the view they were saved with (Document.last_view, written at save time so it never dirties the document) and reopen there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
70e2726321
commit
489bb84755
@@ -51,9 +51,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(
|
||||
"object.convertDocumentColorMode",
|
||||
"Convert Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
[],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", intent?} set the document colour mode and convert every colour (art, symbols, swatches; swatch links kept; Gray and Lab colours stay) through the colour settings → {changed}",
|
||||
"same as file.documentColorMode: {mode: \"cmyk\"|\"rgb\", convert?: true, intent?} → {changed} (an alias kept for older scripts; the command palette leaves it out)",
|
||||
has_doc,
|
||||
convert_mode
|
||||
),
|
||||
@@ -235,54 +235,75 @@ fn assign_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"rgb": rgb, "cmyk": cmyk}))
|
||||
}
|
||||
|
||||
fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.convertDocumentColorMode";
|
||||
/// File → Document Color Mode (`file.documentColorMode` and its alias
|
||||
/// `object.convertDocumentColorMode`): set the mode and, unless `convert: false`, convert every
|
||||
/// colour through the colour settings.
|
||||
pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let c = cms::active();
|
||||
let intent = intent_param(p, C)?.unwrap_or(c.settings().intent);
|
||||
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
|
||||
// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
|
||||
// definitions) are device independent and fit either mode.
|
||||
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
|
||||
let intent = intent_param(p, C)?;
|
||||
// Choosing the mode the document already has (the checked menu item) is not an edit.
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return Ok(json!({ "changed": 0 }));
|
||||
}
|
||||
let convert = bool_or(p, "convert", true);
|
||||
let mut changed = 0usize;
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
d.color_mode = mode;
|
||||
proof::map_document_colors(d, &mut |col, _| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
});
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = conv(&st.color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tints are their converted swatch's colour at their tint (exact ink percentages).
|
||||
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
|
||||
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
|
||||
Some((_, base)) if *tint < 1.0 => {
|
||||
*c = base.tinted(*tint);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
changed = set_color_mode(d, mode, convert, intent);
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols and
|
||||
/// swatches through the colour settings (`intent`, default: theirs) → how many colours changed.
|
||||
/// Greys stay greys (they print on the black plate in either mode); Lab colours (spot colour
|
||||
/// definitions) are device independent and fit either mode. Document Color Mode and
|
||||
/// `document.open {colorMode}` both convert this way.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>) -> usize {
|
||||
d.color_mode = mode;
|
||||
if !convert {
|
||||
return 0;
|
||||
}
|
||||
let c = cms::active();
|
||||
let intent = intent.unwrap_or(c.settings().intent);
|
||||
let model = if mode == ColorMode::Cmyk { Model::Cmyk } else { Model::Rgb };
|
||||
let conv = |col: &Color| if matches!(col, Color::Gray { .. } | Color::Lab { .. }) { *col } else { c.convert(col, model, intent) };
|
||||
let mut changed = 0usize;
|
||||
proof::map_document_colors(d, &mut |col, _| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
});
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = conv(&st.color);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Tints are their converted swatch's colour at their tint (exact ink percentages).
|
||||
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
|
||||
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
|
||||
Some((_, base)) if *tint < 1.0 => {
|
||||
*c = base.tinted(*tint);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
fn values(c: &Color) -> Vec<f32> {
|
||||
match *c {
|
||||
Color::Rgb { r, g, b } => vec![r, g, b],
|
||||
|
||||
@@ -6,9 +6,8 @@ use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, ColorMode, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
use vectorcraft_doc::{CharStyle, Guide, Node, NodeId, NodeKind, TextKind};
|
||||
|
||||
use super::colorcmds::{Recolor, Scope, to_cmyk};
|
||||
use super::edit::selected_roots;
|
||||
use super::*;
|
||||
|
||||
@@ -149,9 +148,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour and swatch to the mode)}",
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols and swatches through the colour settings; swatch links kept; greys stay greys), intent?} → {changed}",
|
||||
has_doc,
|
||||
color_mode
|
||||
super::colormgmt::convert_mode
|
||||
),
|
||||
cmd!(
|
||||
"file.info",
|
||||
@@ -444,43 +443,3 @@ fn close_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({ "closed": n }))
|
||||
}
|
||||
|
||||
fn color_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "file.documentColorMode";
|
||||
let mode = match str_param(p, "mode").map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("cmyk") => ColorMode::Cmyk,
|
||||
Some("rgb") => ColorMode::Rgb,
|
||||
_ => return Err(bad(C, "mode must be \"cmyk\" or \"rgb\"")),
|
||||
};
|
||||
let convert = bool_or(p, "convert", true);
|
||||
if s.doc()?.doc.color_mode == mode {
|
||||
return ok();
|
||||
}
|
||||
s.edit("Document Color Mode", |d, _| {
|
||||
set_color_mode(d, mode, convert);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode`; with `convert`, every colour of its art and swatches too.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool) {
|
||||
d.color_mode = mode;
|
||||
if !convert {
|
||||
return;
|
||||
}
|
||||
let f = move |c: vectorcraft_color::Color| match mode {
|
||||
ColorMode::Cmyk => to_cmyk(c),
|
||||
ColorMode::Rgb => {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
vectorcraft_color::Color::rgb(r, g, b)
|
||||
}
|
||||
};
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
Recolor::new(Scope { fill: true, stroke: true, images: false, patterns: false }, &f).run(d, &layers);
|
||||
for sw in d.swatches_iter_mut() {
|
||||
if let vectorcraft_color::Paint::Solid { color, .. } = &mut sw.paint {
|
||||
*color = f(*color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ pub struct Loaded {
|
||||
pub warnings: Vec<String>,
|
||||
/// The document is the native one the file carries (SVG or PDF saved with Preserve Editing).
|
||||
pub restored: bool,
|
||||
/// An older native file (the former `.drawcraft` name or format version): saving it again
|
||||
/// rewrites it in today's format.
|
||||
pub converted: bool,
|
||||
}
|
||||
|
||||
/// An image ready to embed: PNG/JPEG/GIF/WebP keep their bytes, other formats are stored as PNG
|
||||
@@ -113,8 +116,13 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
}
|
||||
let format = detect(name, bytes).ok_or_else(|| err(format!("can't open `{name}`: not a format VectorCraft reads (see document.formats)")))?;
|
||||
let title = file_name(name);
|
||||
let mut converted = false;
|
||||
let (mut doc, warnings, restored) = match format.id {
|
||||
"vectorcraft" | "template" => (vectorcraft_format::load(bytes).map_err(err)?, vec![], false),
|
||||
"vectorcraft" | "template" => {
|
||||
let (d, info) = vectorcraft_format::load_info(bytes).map_err(err)?;
|
||||
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
|
||||
(d, vec![], false)
|
||||
}
|
||||
"svg" | "svgz" => {
|
||||
let text = vectorcraft_svg::text_of(bytes).map_err(err)?;
|
||||
let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data)));
|
||||
@@ -129,14 +137,14 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
if let Some(mode) = opts.color_mode.filter(|m| *m != doc.color_mode) {
|
||||
super::super::docmenu::set_color_mode(&mut doc, mode, true);
|
||||
super::super::colormgmt::set_color_mode(&mut doc, mode, true, None);
|
||||
}
|
||||
// Imports are named after the file; a native document keeps its own title (the tab shows the
|
||||
// file name once it has a path).
|
||||
if !matches!(format.id, "vectorcraft" | "template") || doc.title.is_empty() {
|
||||
doc.title = title;
|
||||
}
|
||||
Ok(Loaded { doc, format, warnings, restored })
|
||||
Ok(Loaded { doc, format, warnings, restored, converted })
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
@@ -162,7 +170,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
||||
|
||||
/// Make a loaded file the new active document; `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored } = loaded;
|
||||
let Loaded { mut doc, format, warnings, restored, converted } = loaded;
|
||||
let links = crate::cmd::links::resolve(&mut doc, path.as_deref(), s.prefs.update_links == "automatically");
|
||||
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
|
||||
// document.
|
||||
@@ -178,10 +186,13 @@ fn open_loaded(s: &mut Session, loaded: Loaded, path: Option<String>, opts: &Loa
|
||||
let lossy_ai = format.id == "ai" && !restored;
|
||||
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;
|
||||
let index = s.add_document(doc, path);
|
||||
let st = s.doc_mut()?;
|
||||
if saves_back {
|
||||
s.doc_mut()?.format = format.id;
|
||||
st.format = format.id;
|
||||
}
|
||||
st.converted = converted;
|
||||
let title = s.documents()[index].title();
|
||||
Ok(super::merge(json!({ "index": index, "title": title, "format": format.id, "warnings": warnings, "restored": restored }), links.to_json()))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! the caller's writer (the file system here; a save panel or a browser download in the apps) and
|
||||
//! makes the file the document's own (path, format, options, saved state) for Save and Save As.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
|
||||
@@ -62,8 +64,8 @@ impl SaveMode {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SavePlan {
|
||||
pub mode: SaveMode,
|
||||
/// Where to write. `None` when no path is known (never saved, or a format other than the
|
||||
/// document's own): the caller asks for one or takes the bytes.
|
||||
/// Where to write. `None` when no path is known (never saved, converted from an older version,
|
||||
/// or a format other than the document's own): the caller asks for one or takes the bytes.
|
||||
pub path: Option<String>,
|
||||
pub format: &'static Format,
|
||||
/// The format's options (only those its encoder reads).
|
||||
@@ -159,7 +161,7 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
// None given: the ones the document was last saved with in this format.
|
||||
let options = if given.is_empty() && format.id == st.format { st.save_options.clone() } else { given };
|
||||
// Save writes the document's own file; the other modes only where they are told to.
|
||||
let path = path.or_else(|| (mode == SaveMode::Save && format.id == st.format).then(|| st.path.clone()).flatten());
|
||||
let path = path.or_else(|| (mode == SaveMode::Save && !st.converted && format.id == st.format).then(|| st.path.clone()).flatten());
|
||||
let stem = file_stem(st.path.as_deref().unwrap_or(&st.doc.title));
|
||||
let ext = format.extensions[0];
|
||||
let name = match mode {
|
||||
@@ -197,6 +199,17 @@ fn is_svg(f: &Format) -> bool {
|
||||
matches!(f.id, "svg" | "svgz")
|
||||
}
|
||||
|
||||
/// The document as written: native files carry the view to reopen at.
|
||||
fn doc_to_save<'a>(st: &'a DocState, f: &Format) -> Cow<'a, Document> {
|
||||
if is_native(f) && st.doc.last_view != st.view {
|
||||
let mut d = (*st.doc).clone();
|
||||
d.last_view = st.view.clone();
|
||||
Cow::Owned(d)
|
||||
} else {
|
||||
Cow::Borrowed(&st.doc)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a format loses against a native file (reported whenever a save writes it).
|
||||
fn fidelity_warning(f: &Format) -> Option<String> {
|
||||
(!is_native(f)).then(|| {
|
||||
@@ -218,10 +231,10 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
|
||||
if plan.path.is_some() && retargets {
|
||||
stamp_save_dates(s.doc_mut()?);
|
||||
}
|
||||
let own: &Document = &s.doc()?.doc;
|
||||
let own = doc_to_save(s.doc()?, plan.format);
|
||||
// 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 doc = relative.as_ref().unwrap_or(own);
|
||||
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
|
||||
let doc: &Document = relative.as_ref().unwrap_or(&own);
|
||||
let mut params = plan.options.clone();
|
||||
if is_svg(plan.format) {
|
||||
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
|
||||
@@ -269,6 +282,7 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
|
||||
st.path = Some(path.clone());
|
||||
st.format = plan.format.id;
|
||||
st.save_options = plan.options;
|
||||
st.converted = false;
|
||||
st.mark_saved();
|
||||
}
|
||||
Ok(out)
|
||||
@@ -325,7 +339,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (SVG options may also be given flat; an SVG save keeps hidden layers, display:none, unless hiddenLayers is false)} → {path, format, bytes, warnings, linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -49,7 +49,9 @@ fn color_mode_opens_the_file_in_the_mode_asked_for() {
|
||||
s.execute("document.open", &json!({"name": "press.pdf", "dataBase64": cmyk_pdf(), "colorMode": "RGB"})).unwrap();
|
||||
assert_eq!(s.active().unwrap().doc.color_mode, ColorMode::Rgb);
|
||||
let f = fills(&s);
|
||||
assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. }))), "converted as Document Color Mode does: {f:?}");
|
||||
// Converted as Document Color Mode does: no CMYK left, greys stay grey.
|
||||
assert!(f.iter().all(|p| matches!(p.color(), Some(Color::Rgb { .. } | Color::Gray { .. }))), "{f:?}");
|
||||
assert!(matches!(f[0].color(), Some(Color::Rgb { .. })) && matches!(f[1].color(), Some(Color::Gray { .. })), "{f:?}");
|
||||
// Any format opens in the mode asked for.
|
||||
let mut svg = Session::new();
|
||||
svg.execute("file.new", &json!({"width": 20, "height": 20})).unwrap();
|
||||
|
||||
@@ -394,3 +394,12 @@ pub use flatten::FlattenOptions;
|
||||
|
||||
/// A built-in or saved flattener preset ([`crate::Prefs::flattener_presets`]).
|
||||
pub use flatten::FlattenerPreset;
|
||||
|
||||
/// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`.
|
||||
/// They run as before but the command palette leaves them out (it lists the command once).
|
||||
pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")];
|
||||
|
||||
/// Is `id` an [`ALIASES`] entry?
|
||||
pub fn is_alias(id: &str) -> bool {
|
||||
ALIASES.iter().any(|(a, _)| *a == id)
|
||||
}
|
||||
|
||||
@@ -317,6 +317,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("newArtBasic", "General", "Appearance Panel", "New Art Has Basic Appearance", bool),
|
||||
// File Handling (continued)
|
||||
p!("templatesFolder", "File Handling", "Files", "Templates Folder", text),
|
||||
p!("appendConverted", "File Handling", "Files", "Mark Older Files as [Converted] When Opened", bool),
|
||||
];
|
||||
|
||||
pub fn spec(key: &str) -> Option<&'static PrefSpec> {
|
||||
|
||||
Reference in New Issue
Block a user