mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
Merge main into feat/plugin-importers
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +564,8 @@ 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`]). A file an importer plug-in
|
||||
/// reads (`importer` in `p`, or an extension only an importer lists) is read by it
|
||||
@@ -575,7 +576,7 @@ pub fn read_for_open(name: &str, bytes: &[u8], path: Option<String>, p: &Value,
|
||||
Some(plugin) => super::importer::load(name, bytes, &plugin, p)?,
|
||||
None => 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 })
|
||||
}
|
||||
@@ -595,7 +596,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)
|
||||
}
|
||||
|
||||
@@ -634,6 +636,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;
|
||||
@@ -653,6 +656,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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+147
-60
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1083,11 +1169,11 @@ fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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);
|
||||
}
|
||||
@@ -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) => {}
|
||||
@@ -1232,7 +1318,7 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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))));
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user