mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
VectorCraft listed a variable font's named instances as styles, but
type could not be set between or past them. The reference app's
Character panel has a Variable Font control that adjusts a variable
font's axes, such as Weight, Width and Slant.
Type now keeps Variable Font settings by axis tag
(CharStyle::font_axes, saved as fontAxes only when set). text.setFormat
and text.setRangeStyle take fontAxes: each value is clamped to its
axis's range, a tag the font has no axis for is left out, and null
goes back to the style's own setting. Another font or style clears
them, including one from a character style that does not set them. A
character style captured from type sets them, with {} for none.
text.fontList {family, style} lists a font's axes.
Layout, outlines, the substituted-glyph highlight and PDF export use
the font instanced at the settings, and neighboring runs at different
settings are shaped apart, as runs in different fonts are. SVG export
writes them as font-variation-settings, the wght setting as the weight
and the wdth setting as font-stretch, and embeds the font instanced
there; SVG import reads font-variation-settings back. Legacy .ai export
warns that the settings are left out.
The Character panel shows a Variable Font button for a variable font.
It opens a slider and a field for each axis the font does not hide,
labeled with the font's name for the axis, and screen readers get each
slider under that name. A drag is one undo step. Reset Panel leaves
the settings, as it leaves the font and style.
Missing Glyph Protection now keeps the settings and the font version
on the characters that keep their font; before, it kept only the
family and style. A character style applied or redefined with another
font now clears the old font's version, as text.setStyle does, unless
the style sets one. SVG export with embedded fonts warns when faces of
one weight, style and width share characters, which browsers then draw
in one of the faces.
Docs: docs/mcp.md, docs/target-app-parity.md, docs/type-parity.md.
624 lines
29 KiB
Rust
624 lines
29 KiB
Rust
//! Type → Find Font: the fonts a document uses (flagging missing ones), replacing one font with
|
||
//! another everywhere or in the selection, and selecting the text that uses a font. Also the fonts
|
||
//! available (the bundled and installed ones) and rescanning the installed fonts.
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
use std::sync::Arc;
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::Paint;
|
||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextRun};
|
||
use vectorcraft_geom::Rect;
|
||
|
||
use super::typecmd::refresh_bounds;
|
||
use super::*;
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
query "text.fonts",
|
||
"Fonts in Document",
|
||
[],
|
||
None,
|
||
"{selectionOnly?} → [{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)",
|
||
has_doc,
|
||
fonts
|
||
),
|
||
cmd!(
|
||
"text.replaceFont",
|
||
"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) → {runs}",
|
||
has_doc,
|
||
replace
|
||
),
|
||
cmd!(
|
||
"select.font",
|
||
"Select Text by Font",
|
||
[],
|
||
None,
|
||
"{family, style?} select the text objects using the font → {count}",
|
||
has_doc,
|
||
select_font
|
||
),
|
||
cmd!(
|
||
query "text.fontList",
|
||
"Font List",
|
||
[],
|
||
None,
|
||
"{family?, style?} → {families: [names]} sorted: the bundled fonts, fonts added and the fonts installed on the system (none on the web), each the family's English (canonical) name for `text.setStyle` and documents (without the system's hidden families, whose names start with a dot; they still resolve by name). Preferences › Type › Show Font Names in English only changes the UI menu labels, not this list; with family: {family, styles: [names] (upright styles by weight, then italics; an error when the family isn't available), axes: [{tag, name, min, default, max, value, hidden}] (a variable font's variation axes, the Character panel's Variable Font sliders, with `value` the setting of `style`, else of the first style; empty for a static font; `hidden`: the font asks for the axis not to be shown)}",
|
||
always,
|
||
font_list
|
||
),
|
||
cmd!(
|
||
"text.rescanFonts",
|
||
"Refresh Font List",
|
||
[],
|
||
None,
|
||
"{} scan the system font folders again, for fonts installed or removed since the app started; text in a font that became available redraws in it, and text in a family that was removed redraws in the fallback font (no fonts are installed on the web) → {families, faces} (families listed, installed faces found)",
|
||
always,
|
||
rescan
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Text objects in scope: the selection's (and their descendants') or the whole document's.
|
||
fn scope(s: &Session, selection_only: bool) -> Result<Vec<NodeId>> {
|
||
let st = s.doc()?;
|
||
let mut v = vec![];
|
||
let mut add = |n: &vectorcraft_doc::Node| {
|
||
n.walk(&mut |c| {
|
||
if matches!(c.kind, NodeKind::Text(_)) && !v.contains(&c.id) {
|
||
v.push(c.id);
|
||
}
|
||
})
|
||
};
|
||
if selection_only {
|
||
for id in &st.selection.objects {
|
||
if let Some(n) = st.doc.node(*id) {
|
||
add(n);
|
||
}
|
||
}
|
||
} else {
|
||
for l in &st.doc.layers {
|
||
add(l);
|
||
}
|
||
}
|
||
Ok(v)
|
||
}
|
||
|
||
/// A font as type names it: family, style and, when it names one, the installed version.
|
||
pub(super) type UsedFont = (String, String, Option<String>);
|
||
|
||
/// How a font is named in reports: family and style, then the version the type names, if any.
|
||
pub(super) fn font_label((family, style, version): &UsedFont) -> String {
|
||
match version {
|
||
Some(v) => format!("{family} {style} ({v})"),
|
||
None => format!("{family} {style}"),
|
||
}
|
||
}
|
||
|
||
/// The fonts (family, style, version) of the type in the layers and symbols of `d`.
|
||
pub(super) fn used_fonts(d: &Document) -> 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())));
|
||
}
|
||
};
|
||
d.walk(&mut add);
|
||
for s in &d.symbols {
|
||
s.art.walk(&mut add);
|
||
}
|
||
fonts
|
||
}
|
||
|
||
/// The type of `d` that prints on pages showing `areas` (document space): the fonts (family, style,
|
||
/// version) it is set in, and the ids of the area type and type on a path among the objects of the
|
||
/// document whose text doesn't fit (not those in the art of symbols, opacity masks, patterns,
|
||
/// brushes or placed documents).
|
||
/// The type that prints is visible type on `layers` (the layers that print) but not on a template
|
||
/// layer (of area type and type on a path that don't fit, the runs that their layout draws),
|
||
/// where what it paints ([`vectorcraft_render::painted_bounds`], its effects applied) meets an area
|
||
/// inside the clipping paths around it, the type in the opacity masks and patterns of the objects
|
||
/// that print and in the pieces their brushes draw, and the type of the symbols that their
|
||
/// instances and inline graphics show.
|
||
/// The objects of a blend, envelope, repeat or compound path, or of a layer or group with effects,
|
||
/// fills or strokes of its own, are taken with it when what it paints meets an area, and so is the
|
||
/// art of a placed document (`d` as [`Document::with_placed_art`] gives it, for that art's
|
||
/// resources).
|
||
pub(super) fn printed_type(d: &Document, layers: &[Arc<Node>], areas: &[Rect]) -> (BTreeSet<UsedFont>, BTreeSet<NodeId>) {
|
||
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||
for l in layers {
|
||
walk.node(l, Some(areas));
|
||
}
|
||
(walk.fonts, walk.overflow)
|
||
}
|
||
|
||
/// The warning of an export that draws `layers` of `d` over `areas` (document space; `None`: the
|
||
/// whole of them) about the area type and type on a path that it draws and whose text doesn't fit,
|
||
/// found as [`printed_type`] finds them for Print: visible objects of the document that aren't on
|
||
/// a template layer.
|
||
pub(super) fn export_overflow_warning(d: &Document, layers: &[Arc<Node>], areas: Option<&[Rect]>) -> Option<String> {
|
||
let mut walk = Printed { d, own: true, fonts: BTreeSet::new(), overflow: BTreeSet::new(), symbols: BTreeSet::new(), patterns: BTreeSet::new() };
|
||
for l in layers {
|
||
walk.node(l, areas);
|
||
}
|
||
let overflow: Vec<NodeId> = walk.overflow.into_iter().collect();
|
||
super::typecmd::export_overflow_warning(&overflow)
|
||
}
|
||
|
||
/// The walk of [`printed_type`].
|
||
struct Printed<'a> {
|
||
d: &'a Document,
|
||
/// Whether the walk is among the objects of the document, not in art drawn for one.
|
||
own: bool,
|
||
fonts: BTreeSet<UsedFont>,
|
||
/// The objects of the document whose text doesn't fit.
|
||
overflow: BTreeSet<NodeId>,
|
||
/// The symbols and patterns whose art was walked.
|
||
symbols: BTreeSet<String>,
|
||
patterns: BTreeSet<String>,
|
||
}
|
||
|
||
impl Printed<'_> {
|
||
/// Adds the fonts of `n` and its descendants that print, and the ids of those that don't fit
|
||
/// while [`Printed::own`] is set: those whose bounds meet one of `areas`, or with `None` (in the
|
||
/// art of a symbol, opacity mask, pattern or placed document, in brush pieces, or in a live
|
||
/// object) all of them but those outside the clipping path of a clip group.
|
||
fn node(&mut self, n: &Node, areas: Option<&[Rect]>) {
|
||
if !n.visible || matches!(n.kind, NodeKind::Layer { template: true, .. }) {
|
||
return;
|
||
}
|
||
// A layer or group with effects, fills or strokes of its own prints where they paint (moved,
|
||
// shadowed…), drawn from its members' shapes.
|
||
let moved = vectorcraft_render::effects::has_container_appearance(n);
|
||
let open = !moved && matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. });
|
||
if let Some(a) = areas
|
||
&& !open
|
||
&& !self.meets(n, a)
|
||
{
|
||
return;
|
||
}
|
||
// The art that its opacity mask, pattern paints and brushed strokes draw.
|
||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
|
||
let (runs, overflows) = match &n.kind {
|
||
NodeKind::Text(t) => drawn_runs(t),
|
||
_ => (vec![], false),
|
||
};
|
||
let items: Vec<&AppearanceItem> =
|
||
n.appearance.items.iter().filter(|i| i.visible() && !matches!(i, AppearanceItem::Stroke(s) if s.width <= 0.0)).collect();
|
||
let patterns: Vec<&str> = items
|
||
.iter()
|
||
.map(|i| i.paint())
|
||
.chain(runs.iter().flat_map(|r| [Some(&r.style.fill), (r.style.stroke_width > 0.0).then_some(&r.style.stroke)]).flatten())
|
||
.filter_map(|p| match p {
|
||
Paint::Pattern { pattern, .. } => Some(pattern.as_str()),
|
||
_ => None,
|
||
})
|
||
.collect();
|
||
let brushed = vectorcraft_brush::has_brush(n);
|
||
if (mask.is_some() || !patterns.is_empty() || brushed) && areas.is_none_or(|a| !open || self.meets(n, a)) {
|
||
if let Some(m) = mask {
|
||
self.art(&m.art);
|
||
}
|
||
let d = self.d;
|
||
for name in patterns {
|
||
if self.patterns.insert(name.to_string())
|
||
&& let Some(p) = d.pattern(name)
|
||
{
|
||
for art in &p.art {
|
||
self.art(art);
|
||
}
|
||
}
|
||
}
|
||
// The pieces its brushes draw along the path (art and pattern brushes leave type out).
|
||
if brushed {
|
||
for piece in vectorcraft_brush::node_pieces(d, n).unwrap_or_default() {
|
||
self.art(&piece);
|
||
}
|
||
}
|
||
}
|
||
match &n.kind {
|
||
NodeKind::Text(_) => {
|
||
if overflows && self.own {
|
||
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()));
|
||
// Inline graphics draw their symbols' art.
|
||
if let Some(art) = &r.inline {
|
||
self.symbol(&art.symbol);
|
||
}
|
||
}
|
||
}
|
||
NodeKind::SymbolInstance { symbol, .. } => self.symbol(symbol),
|
||
NodeKind::PlacedDocument(p) => {
|
||
for art in p.art(n) {
|
||
self.art(&art);
|
||
}
|
||
}
|
||
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } if !moved => {
|
||
// Each object prints where it lies, inside the clipping path of a clip group.
|
||
let clipped = match (clip, areas) {
|
||
(false, _) => None,
|
||
(true, Some(a)) => Some(self.clip(n, a)),
|
||
(true, None) => vectorcraft_render::painted_bounds(self.d, n).map(|c| vec![c]),
|
||
};
|
||
for c in children {
|
||
self.node(c, clipped.as_deref().or(areas));
|
||
}
|
||
}
|
||
_ => {
|
||
for c in n.children().map(Vec::as_slice).unwrap_or_default() {
|
||
self.node(c, None);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Adds the fonts of symbol `name`'s art, the first time it is shown.
|
||
fn symbol(&mut self, name: &str) {
|
||
let d = self.d;
|
||
if self.symbols.insert(name.to_string())
|
||
&& let Some(s) = d.symbols.iter().find(|s| s.name == name)
|
||
{
|
||
self.art(&s.art);
|
||
}
|
||
}
|
||
|
||
/// Adds the fonts of `art`, drawn for an object of the document but not one of its objects (the
|
||
/// art of a symbol, opacity mask, pattern or placed document, or a brush piece), with
|
||
/// [`Printed::node`]'s `None`.
|
||
fn art(&mut self, art: &Node) {
|
||
let own = std::mem::replace(&mut self.own, false);
|
||
self.node(art, None);
|
||
self.own = own;
|
||
}
|
||
|
||
/// Whether what `n` paints, its effects applied, meets one of `areas`.
|
||
fn meets(&self, n: &Node, areas: &[Rect]) -> bool {
|
||
vectorcraft_render::painted_bounds(self.d, n).is_some_and(|b| areas.iter().any(|r| b.intersect(*r).area() > 0.0 || r.contains_rect(b)))
|
||
}
|
||
|
||
/// The parts of `areas` inside the bounds of clip group `n` (its clipping path's).
|
||
fn clip(&self, n: &Node, areas: &[Rect]) -> Vec<Rect> {
|
||
let Some(c) = vectorcraft_render::painted_bounds(self.d, n) else { return areas.to_vec() };
|
||
areas.iter().filter(|r| c.intersect(**r).area() > 0.0 || r.contains_rect(c)).map(|r| r.intersect(c)).collect()
|
||
}
|
||
}
|
||
|
||
/// The runs of `t` that print, and whether its text doesn't fit: all of them, or of area type and
|
||
/// type on a path that don't fit, the runs that their layout draws glyphs or inline graphics of.
|
||
fn drawn_runs(t: &vectorcraft_doc::TextObject) -> (Vec<&TextRun>, bool) {
|
||
let drawn: Option<BTreeSet<usize>> = (!matches!(t.kind, TextKind::Point))
|
||
.then(|| vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t))
|
||
.filter(|lay| lay.overflow)
|
||
.map(|lay| lay.glyphs.iter().map(|g| g.run).chain(lay.inlines.iter().map(|i| i.run)).collect());
|
||
let runs = t.runs.iter().enumerate().filter(|(i, _)| drawn.as_ref().is_none_or(|d| d.contains(i))).map(|(_, r)| r).collect();
|
||
(runs, drawn.is_some())
|
||
}
|
||
|
||
/// What an export that draws type (as outlines, embedded fonts or pixels) says when `d` uses font
|
||
/// families that aren't available: their type is drawn in the fallback font, not the font it names.
|
||
pub(super) fn substitution_warning(d: &Document) -> Option<String> {
|
||
substitution_warning_of(used_fonts(d))
|
||
}
|
||
|
||
/// [`substitution_warning`] for type set in `fonts`.
|
||
pub(super) fn substitution_warning_of(fonts: BTreeSet<UsedFont>) -> Option<String> {
|
||
let db = vectorcraft_text::FontDb::global();
|
||
let mut missing: Vec<String> = fonts
|
||
.into_iter()
|
||
.filter(|(family, style, _)| db.resolve(family, style).is_none_or(|(_, m)| m == vectorcraft_text::FontMatch::Missing))
|
||
.map(|(family, _, _)| family)
|
||
.collect();
|
||
// Sorted by family: its styles and versions are neighbours.
|
||
missing.dedup();
|
||
(!missing.is_empty()).then(|| {
|
||
format!(
|
||
"fonts that aren't available were written in the fallback font, {}: {} (install them, or replace them with Find Font)",
|
||
vectorcraft_text::FALLBACK_FAMILY,
|
||
missing.join(", ")
|
||
)
|
||
})
|
||
}
|
||
|
||
fn text(d: &Document, id: NodeId) -> Option<&vectorcraft_doc::TextObject> {
|
||
match d.node(id).map(|n| &n.kind) {
|
||
Some(NodeKind::Text(t)) => Some(t),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||
let d = &s.doc()?.doc;
|
||
let db = vectorcraft_text::FontDb::global();
|
||
let mut found: BTreeMap<UsedFont, (usize, Vec<NodeId>, String)> = BTreeMap::new();
|
||
for id in ids {
|
||
for r in text(d, id).map(|t| t.runs.as_slice()).unwrap_or_default() {
|
||
let e = found.entry((r.style.font_family.clone(), r.style.font_style.clone(), r.style.font_version.clone())).or_default();
|
||
e.0 += 1;
|
||
e.2.push_str(&r.text);
|
||
if !e.1.contains(&id) {
|
||
e.1.push(id);
|
||
}
|
||
}
|
||
}
|
||
let list: Vec<Value> = found
|
||
.into_iter()
|
||
.map(|((family, style, version), (runs, objects, chars))| {
|
||
// 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 row = json!({
|
||
"family": family, "style": style, "runs": runs, "objects": objects.len(),
|
||
"missing": status == vectorcraft_text::FontMatch::Missing,
|
||
"status": status.as_str(),
|
||
"resolved": resolved.as_ref().map(|(f, _)| json!({ "family": f.family, "style": f.style })),
|
||
"missingGlyphs": lacking.len(),
|
||
});
|
||
if let Some(v) = version {
|
||
row["version"] = json!(v);
|
||
}
|
||
row
|
||
})
|
||
.collect();
|
||
Ok(json!(list))
|
||
}
|
||
|
||
fn font_param(p: &Value, k: &str, c: &str) -> Result<(String, Option<String>)> {
|
||
let v = p.get(k).ok_or_else(|| bad(c, format!("missing `{k}`")))?;
|
||
let family =
|
||
v.get("family").and_then(Value::as_str).filter(|f| !f.trim().is_empty()).ok_or_else(|| bad(c, format!("`{k}.family` is required")))?;
|
||
Ok((family.to_string(), v.get("style").and_then(Value::as_str).map(str::to_string)))
|
||
}
|
||
|
||
fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const C: &str = "text.replaceFont";
|
||
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"))),
|
||
};
|
||
let ids = scope(s, bool_or(p, "selectionOnly", false))?;
|
||
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))
|
||
};
|
||
let n = s.edit("Replace Font", |d, _| {
|
||
let mut n = 0;
|
||
for id in &ids {
|
||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||
let mut changed = false;
|
||
for r in &mut t.runs {
|
||
if matches(&r.style) {
|
||
// An explicit style, else the closest the new family has to the old one.
|
||
let style = ts
|
||
.clone()
|
||
.or_else(|| db.face(&tf, &r.style.font_style).map(|f| f.style.clone()))
|
||
.unwrap_or_else(|| r.style.font_style.clone());
|
||
r.style.font_family = tf.clone();
|
||
r.style.font_style = style;
|
||
// Another font: none of the old one's versions or Variable Font settings.
|
||
r.style.font_version = None;
|
||
r.style.font_axes.clear();
|
||
changed = true;
|
||
n += 1;
|
||
}
|
||
}
|
||
if changed {
|
||
vectorcraft_text::edit::normalize(&mut t.runs);
|
||
refresh_bounds(t);
|
||
}
|
||
}
|
||
Ok(n)
|
||
})?;
|
||
Ok(json!({ "runs": n }))
|
||
}
|
||
|
||
fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let family = str_param(p, "family").ok_or_else(|| bad("select.font", "missing `family`"))?.to_string();
|
||
let style = str_param(p, "style").map(str::to_string);
|
||
let ids = scope(s, false)?;
|
||
let d = &s.doc()?.doc;
|
||
let hits: Vec<NodeId> = ids
|
||
.into_iter()
|
||
.filter(|id| {
|
||
text(d, *id).is_some_and(|t| {
|
||
t.runs.iter().any(|r| {
|
||
r.style.font_family.eq_ignore_ascii_case(&family) && style.as_ref().is_none_or(|x| r.style.font_style.eq_ignore_ascii_case(x))
|
||
})
|
||
})
|
||
})
|
||
.collect();
|
||
let n = hits.len();
|
||
s.select(|_, sel| sel.set(hits))?;
|
||
Ok(json!({ "count": n }))
|
||
}
|
||
|
||
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||
let db = vectorcraft_text::FontDb::global();
|
||
match str_param(p, "family") {
|
||
Some(family) => {
|
||
if !db.has_family(family) {
|
||
return Err(bad("text.fontList", format!("font family `{family}` is not available")));
|
||
}
|
||
// The name as the font spells it.
|
||
let name = db.family_list().iter().find(|f| f.eq_ignore_ascii_case(family)).cloned().unwrap_or_else(|| family.to_string());
|
||
let styles = db.styles(family);
|
||
let style = str_param(p, "style").map(str::to_string).or_else(|| styles.first().cloned()).unwrap_or_else(|| "Regular".into());
|
||
let axes: Vec<Value> = db
|
||
.face(family, &style)
|
||
.map(|face| {
|
||
let axes = face.axes();
|
||
axes.iter()
|
||
.map(|a| {
|
||
json!({
|
||
"tag": String::from_utf8_lossy(&a.tag), "name": a.name, "min": a.min, "default": a.default, "max": a.max,
|
||
"value": face.axis_value(a.tag).unwrap_or(a.default), "hidden": a.hidden,
|
||
})
|
||
})
|
||
.collect()
|
||
})
|
||
.unwrap_or_default();
|
||
Ok(json!({ "family": name, "styles": styles, "axes": axes }))
|
||
}
|
||
None => Ok(json!({ "families": *db.menu_family_list() })),
|
||
}
|
||
}
|
||
|
||
pub(crate) fn rescan(s: &mut Session, _: &Value) -> Result<Value> {
|
||
let db = vectorcraft_text::FontDb::global();
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
let faces = db.load_system_fonts();
|
||
#[cfg(target_arch = "wasm32")]
|
||
let faces = 0;
|
||
// The canvas redraws type in the fonts available now (renderers lay it out again when the
|
||
// fonts change), and the bounds follow; that isn't an edit to save.
|
||
for d in &mut s.docs {
|
||
let mut texts = vec![];
|
||
d.doc.walk(|n| {
|
||
if matches!(n.kind, NodeKind::Text(_)) {
|
||
texts.push(n.id);
|
||
}
|
||
});
|
||
if !texts.is_empty() {
|
||
d.change_unsaved(|doc| {
|
||
let doc = std::sync::Arc::make_mut(doc);
|
||
for id in texts {
|
||
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
|
||
refresh_bounds(t);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
d.revision += 1;
|
||
}
|
||
Ok(json!({ "families": db.menu_family_list().len(), "faces": faces }))
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Type › Options › Additional Fonts Folder (#683) adds its folder to the system font scan,
|
||
/// trimmed; clearing it takes the folder out again.
|
||
#[test]
|
||
fn the_additional_fonts_folder_joins_the_font_scan() {
|
||
let mut s = Session::new();
|
||
let dir = std::env::temp_dir().join("vectorcraft-fonts-folder-pref");
|
||
s.execute("prefs.set", &json!({"key": "fontsFolder", "value": format!(" {} ", dir.display())})).unwrap();
|
||
assert_eq!(vectorcraft_text::user_font_dirs(), std::slice::from_ref(&dir));
|
||
if !cfg!(target_arch = "wasm32") {
|
||
assert!(vectorcraft_text::system_font_dirs().contains(&dir));
|
||
}
|
||
s.execute("prefs.set", &json!({"key": "fontsFolder", "value": ""})).unwrap();
|
||
assert!(vectorcraft_text::user_font_dirs().is_empty() && !vectorcraft_text::system_font_dirs().contains(&dir));
|
||
}
|
||
|
||
#[test]
|
||
fn list_flag_missing_replace_and_select() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
let a = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap();
|
||
let b = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "Gone", "font": "No Such Font Family"})).unwrap()["id"].as_u64().unwrap();
|
||
let list = s.execute("text.fonts", &json!({})).unwrap();
|
||
let missing: Vec<&str> = list.as_array().unwrap().iter().filter(|f| f["missing"] == true).map(|f| f["family"].as_str().unwrap()).collect();
|
||
assert_eq!(missing, ["No Such Font Family"]);
|
||
assert_eq!(s.execute("select.font", &json!({"family": "No Such Font Family"})).unwrap()["count"], 1);
|
||
assert_eq!(s.doc().unwrap().selection.objects, [NodeId(b)]);
|
||
let r = s.execute("text.replaceFont", &json!({"from": {"family": "No Such Font Family"}, "to": {"family": "Source Sans 3"}})).unwrap();
|
||
assert_eq!(r["runs"], 1);
|
||
let fams: Vec<String> =
|
||
s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().map(|f| f["family"].as_str().unwrap().to_string()).collect();
|
||
assert_eq!(fams, ["Source Sans 3", "Source Serif 4"]);
|
||
assert!(s.execute("text.replaceFont", &json!({"from": {"family": "Source Serif 4"}, "to": {"family": "Nope"}})).is_err());
|
||
let _ = a;
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod open_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn list_says_how_each_font_resolved() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||
// "Black Wide" is a style no Source Sans 3 has, bundled or installed, so it is always a
|
||
// substitute (the installed family can hold a real Black).
|
||
for (text, font, style) in
|
||
[("Exact", "Source Sans 3", "Semibold"), ("Closest", "Source Sans 3", "Black Wide"), ("Gone", "No Such Font Family", "Regular")]
|
||
{
|
||
s.execute("text.create", &json!({"x": 10, "y": 20, "text": text, "font": font, "style": style})).unwrap();
|
||
}
|
||
let list = s.execute("text.fonts", &json!({})).unwrap();
|
||
let rows: Vec<(String, String, String, String)> = list
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.map(|f| {
|
||
(
|
||
f["style"].as_str().unwrap().into(),
|
||
f["status"].as_str().unwrap().into(),
|
||
f["resolved"]["family"].as_str().unwrap().into(),
|
||
f["resolved"]["style"].as_str().unwrap().into(),
|
||
)
|
||
})
|
||
.collect();
|
||
let row = |style: &str| rows.iter().find(|r| r.0 == style).cloned().unwrap();
|
||
assert_eq!(row("Semibold").1, "exact");
|
||
assert_eq!(row("Black Wide").1, "substitute", "{rows:?}");
|
||
assert_eq!(row("Black Wide").2, "Source Sans 3");
|
||
assert_eq!((row("Regular").1.as_str(), row("Regular").2.as_str()), ("missing", vectorcraft_text::FALLBACK_FAMILY));
|
||
s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Ab 雅楽", "font": "Inter"})).unwrap();
|
||
let inter = s.execute("text.fonts", &json!({})).unwrap().as_array().unwrap().iter().find(|f| f["family"] == "Inter").cloned().unwrap();
|
||
assert_eq!(inter["missingGlyphs"], 2, "雅 and 楽 come from a fallback font: {inter}");
|
||
}
|
||
|
||
#[test]
|
||
fn opened_documents_have_exact_text_bounds() {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||
let id = s.execute("text.create", &json!({"x": 10, "y": 60, "text": "WIDE TRACKED", "size": 40})).unwrap()["id"].as_u64().unwrap();
|
||
s.execute("text.setStyle", &json!({"tracking": 400})).unwrap();
|
||
let want = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
// Save and reopen: the cache isn't in the file, yet the bounds must match.
|
||
let bytes = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||
let reopened = vectorcraft_format::load(&bytes).unwrap();
|
||
let mut t = Session::new();
|
||
t.add_document(reopened, None);
|
||
let got = t.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap();
|
||
assert!((got.width() - want.width()).abs() < 1e-6, "{got:?} vs {want:?}");
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod dirty_tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn selection_never_marks_modified_and_undo_to_saved_is_clean() {
|
||
let mut s = Session::new();
|
||
s.add_document(Document::new(100.0, 100.0), None);
|
||
let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||
s.doc_mut().unwrap().mark_saved();
|
||
s.execute("select.none", &json!({})).unwrap();
|
||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||
assert!(!s.doc().unwrap().is_dirty(), "selecting is not an edit");
|
||
s.execute("object.move", &json!({"dx": 5, "dy": 0})).unwrap();
|
||
assert!(s.doc().unwrap().is_dirty());
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert!(!s.doc().unwrap().is_dirty(), "back to the saved state");
|
||
}
|
||
}
|