Files
vectorcraft/crates/engine/src/session/documents.rs
T
dotCooCoo 1dc8b72bbf Type: hyphenated type lays out again when the hyphenation exceptions change
Changing Preferences › Hyphenation › Exceptions bumped each open
document's revision, but the caches of laid-out type outside the text
crate were keyed on the font generation alone. The renderer kept the
glyph geometry, raster-effect pixels, live-effect and envelope art,
pattern tiles and placed symbol art of unchanged type objects, so
hyphenated area type kept its old line breaks on the canvas. The
hit-test art of blends and envelopes, the Fit to Artwork Bounds answer,
the layout caches of the Type and Touch Type tools and the out port's
overflow mark were keyed the same way.

vectorcraft_text::layout_inputs() returns a number that changes with
the fonts and with the hyphenation exceptions. Every one of those
caches now keys on it instead of FontDb::generation(), and envelope ink
uses it in place of the private envelope_ink_inputs(). The hit-test key
hashes it together with the plug-in revision.

Tests: a renderer that drew hyphenated area type, alone, with a drop
shadow, in a group with a Transform effect, in a symbol instance and in
an envelope, draws it as a new renderer does after the exceptions
change; the out port of selected area type shows the overflow that the
new exceptions cause. The render test helpers that run a step again
when the fonts change during it now also run it again when the
exceptions change.
2026-11-13 18:56:48 -08:00

540 lines
25 KiB
Rust

use std::collections::HashSet;
use vectorcraft_doc::{Node, TextObject};
use super::{
Arc, Clipboard, Color, CommandInfo, DocState, Document, DrawMode, EngineError, GradientPaint, Journal, NodeKind, PaintDefaults, Prefs, Result,
Session, ViewInfo, cmd, command_specs, file_access,
};
impl Session {
/// A session with no document open, the default preferences and the Selection tool. It also
/// installs the hooks the document and effects crates call back into: the loader and file reader
/// of placed documents, where an image's effects draw its fills and strokes, for clicks, and how
/// Vector Halftone's dots are sampled, for exports.
pub fn new() -> Self {
// Placed documents are read by the native format's loader.
vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document);
vectorcraft_doc::placed_document::set_file_reader(cmd::place::document::read_file_again);
vectorcraft_doc::placed_document::set_read_check(|path| file_access::check_read(path).is_ok());
// The background workers of placed documents read files in the scope that asked.
vectorcraft_render::placed_document::set_scope_capture(|| {
let scope = file_access::ScopeHandle::capture();
Box::new(move |f: &mut dyn FnMut()| scope.run(f))
});
// Clicks find an image's own fills and strokes where their effects draw them.
vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint);
// Clicks find a blend's steps and an envelope's distorted art where the canvas draws them,
// evaluated again when the fonts, the hyphenation exceptions or the plug-ins change.
vectorcraft_doc::hit::set_live_art(vectorcraft_render::effects::expand_live, || {
let mut h = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&(vectorcraft_text::layout_inputs(), vectorcraft_render::effects::plugin_revision()), &mut h);
std::hash::Hasher::finish(&h)
});
// Envelopes map type from the bounds of its glyph outlines, underlines and strikethroughs.
vectorcraft_text::register_envelope_ink();
// Exports bake Vector Halftone into the dots the renderer samples.
vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art);
// OpenType SVG glyphs are drawn as the SVG importer reads their documents.
vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art);
Self {
docs: vec![],
active: None,
prefs: Prefs::default(),
caps_lock: false,
paint: PaintDefaults::default(),
fill_active: true,
clipboard: Clipboard::default(),
journal: Journal::default(),
history_budget: crate::default_history_budget(),
histories_seen: None,
tool: vectorcraft_tools::create("selection"),
lender: None,
held_input: Default::default(),
input_count: 0,
edit_started_by: None,
settling_tool_edit: false,
held_ui: vec![],
tool_step: None,
selection_at_lend: None,
lent_selection: None,
last_selection_tool: None,
last_view: ViewInfo::default(),
preview_memo: Default::default(),
depth: 0,
replaying: false,
tool_panic: None,
tool_resets: 0,
tool_set_since_reset: false,
draw_mode: DrawMode::Normal,
draw_inside: None,
untitled_counter: 0,
menu: Default::default(),
gradient_stop: None,
active_appearance_item: None,
eyedropper_sample: None,
last_solid: Color::WHITE,
last_gradient: GradientPaint::new(Default::default()),
recent_colors: vec![],
pending_paint: None,
swatch_libraries: Default::default(),
freeform_point: None,
style_libraries: Default::default(),
symbol_libraries: Default::default(),
brush_libraries: Default::default(),
arrowheads: Default::default(),
shown_library_ids: Default::default(),
libraries: Default::default(),
pdf_preset_dir: None,
dictionary_file: None,
profile_dir: None,
color_settings_file: None,
plugin_dir: None,
scripts_dir: None,
new_doc_profile_dir: None,
plugin_output: None,
plugin_install_approval: None,
plugin_grants_from_requests: false,
recent_urls: vec![],
trace_preset_changes: Default::default(),
ui_language: None,
journal_note: Default::default(),
note_depth: 1,
journal_query: false,
explicit_undo_group: None,
batch_stash: None,
batch_steps: None,
batch_paint_docs: Vec::new(),
composite_names: Vec::new(),
composite_names_registered: HashSet::new(),
reading: false,
place_hand_off: None,
queue_hand_off: None,
place_queue_roots: None,
image_hand_off: None,
trace_hand_off: None,
place_cursor_requests: false,
package_hand_off: None,
screens_hand_off: None,
recovery: Default::default(),
in_tool_actions: false,
settled_warnings: None,
previewing: false,
decoded_images: Default::default(),
envelope_defaults: None,
liquify_stroke: None,
plane_widget_press: false,
pointer_held: false,
press_options: None,
press_type_selection: None,
press_point: None,
ruler_guide: None,
ruler_origin: None,
port_marks: Default::default(),
font_search: None,
link_search: None,
search_rules: None,
deferred_overlay_cache: None,
search_threads: None,
link_stamps: Default::default(),
link_scan: None,
global_edit_options: Default::default(),
keyboard_text: false,
script_key: None,
script_engines: crate::script::EnginesKey::new(),
script_stop: vectorcraft_js::Interrupt::new(),
script_report: None,
script_console: Default::default(),
script_runs: 0,
script_frames: false,
script_high_water: Default::default(),
script_in_run: false,
script_copy_first: cfg!(target_arch = "wasm32"),
}
}
pub fn documents(&self) -> &[DocState] {
&self.docs
}
/// The open document with [`DocState::uid`] `uid` (it may have closed since it was looked up).
pub fn document_mut(&mut self, uid: u64) -> Option<&mut DocState> {
self.docs.iter_mut().find(|d| d.uid == uid)
}
pub fn active_index(&self) -> Option<usize> {
self.active
}
pub fn active(&self) -> Option<&DocState> {
self.active.and_then(|i| self.docs.get(i))
}
pub fn active_mut(&mut self) -> Option<&mut DocState> {
self.active.and_then(|i| self.docs.get_mut(i))
}
pub fn doc(&self) -> Result<&DocState> {
self.active().ok_or(EngineError::NoDocument)
}
pub fn doc_mut(&mut self) -> Result<&mut DocState> {
self.active_mut().ok_or(EngineError::NoDocument)
}
/// Finish the tool's pending work in the current document and give it fresh state, so nothing
/// (e.g. uncommitted typing) leaks into the document we are switching to.
fn reset_tool_for_doc_switch(&mut self) {
if self.active.is_none() {
return;
}
self.press_type_selection = None;
let view = self.last_view;
// The edit the tool computes on another thread and the input held for it belong to the
// document being left. The switch goes on whatever they did, as for the deactivation.
let _ = self.finish_tool_edit_later();
// The switch goes on whatever the lent tool's last actions did, as for the deactivation.
let _ = self.give_back_tool(view);
let acts = self.with_tool_cx(view, |t, cx| t.deactivate(cx));
let _ = self.apply_actions(acts);
// A batch leaves its interaction open in the document it leaves, to keep or roll back with
// the rest of the batch; anything else in progress (a drag) is cancelled.
if self.batch_stash.is_none() {
let _ = self.cancel_interaction();
}
self.keep_tool_settings();
self.tool = self.make_tool(self.tool.id());
self.tool_resets = self.tool_resets.wrapping_add(1);
self.tool_set_since_reset = false;
}
/// Finish the tool's pending work and give it fresh state, as a switch to another document
/// does, while the current document stays the same (a switch to another view of it).
pub fn reset_tool(&mut self) {
self.reset_tool_for_doc_switch();
}
pub fn set_active(&mut self, index: usize) -> bool {
if index < self.docs.len() {
let switched = self.active != Some(index);
if switched {
self.reset_tool_for_doc_switch();
}
self.active = Some(index);
if switched {
self.sync_front_composite_fonts();
self.sync_front_profiles();
}
true
} else {
false
}
}
/// Make the profiles the document in front is assigned the ones colors are drawn and converted
/// through, in place of the working spaces (with no document open, or none assigned: the working
/// spaces), after a switch, an open, a close, a revert or a restored document brought another
/// document or another assignment to the front. The open documents redraw when the active color
/// settings change.
pub(crate) fn sync_front_profiles(&mut self) {
cmd::colormgmt::use_front_profiles(self);
}
/// Register the composite fonts of the document now in front ([`cmd::compositefonts::sync`]),
/// after a switch or a close brought it there. Its type set in a composite font that differs
/// from the one of that name before (added, changed or removed) is measured again, and type not
/// measured yet is measured now. Type that [`Session::replace_document`] measured in the
/// document's own composite fonts while it was in the back is not measured again when the
/// document is unchanged since.
fn sync_front_composite_fonts(&mut self) {
let mut changed: HashSet<String> = cmd::compositefonts::sync(self).into_iter().collect();
let Some(index) = self.active else { return };
if let Some(st) = self.docs.get_mut(index)
&& let Some((doc, own)) = st.measured_in_own.take()
&& std::ptr::eq(doc.as_ptr(), Arc::as_ptr(&st.doc))
{
changed.retain(|n| !own.contains(n));
}
self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &changed));
}
/// The name [`Session::next_untitled`] gives next (New Document's Name field).
pub fn peek_untitled(&self) -> String {
format!("Untitled-{}", self.untitled_counter + 1)
}
pub fn next_untitled(&mut self) -> String {
self.untitled_counter += 1;
format!("Untitled-{}", self.untitled_counter)
}
/// Text layout bounds are a cache (not saved): compute them after its fonts are registered, or
/// selection boxes and hit testing would use the rough estimate until each text is edited, and
/// what is fitted to type's bounds (an envelope warping it) would come out differently. That
/// holds for the type in symbol definitions, pattern tiles, graph designs and opacity masks too,
/// not only in the layers (#1738).
pub(crate) fn refresh_text_bounds(doc: &mut Document) {
// Inline graphics in text take their size from their symbols' art (not saved either).
doc.resolve_inline_art();
let mut uncached = |t: &TextObject| t.cached_bounds.is_none();
for tree in text_trees_mut(doc) {
each_text_where(tree, &mut uncached, &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
// The symbols' type is measured now: the inline graphics showing those symbols take their
// art's measured size, a symbol's type before the type that shows that symbol inline.
doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame);
}
/// Measure the type in open document `index` that `stale` picks (in its trees as
/// [`Session::refresh_text_bounds`] walks them), as a change that is not an edit: a saved
/// document stays saved, and its undo history keeps its documents as they are. The document is
/// left as it is when `stale` picks nothing.
fn refresh_document_text_bounds(&mut self, index: usize, mut stale: impl FnMut(&TextObject) -> bool) {
let Some(st) = self.docs.get_mut(index) else { return };
if !text_trees(&st.doc).any(|n| has_text_where(n, &mut stale)) {
return;
}
st.change_unsaved(|doc| {
let doc = Arc::make_mut(doc);
for tree in text_trees_mut(doc) {
each_text_where(tree, &mut stale, &mut cmd::typecmd::refresh_bounds_for_first_frame);
}
// Inline graphics take their size from their symbols' art, whose type may just have
// been measured again: the text holding one whose size changed is measured again too,
// a symbol's type before the type that shows that symbol inline.
doc.resolve_inline_art_with(&mut cmd::typecmd::refresh_bounds_for_first_frame);
});
}
/// Register the composite fonts of open document `index`, which is in front, and measure its
/// type not measured yet and its type set in a font of a name that a composite font of an open
/// document or of the font database has, or had before: that type may have been laid out in
/// another document's composite fonts (those registered while its file was read for opening).
fn sync_opened_composite_fonts(&mut self, index: usize) {
let mut names = cmd::compositefonts::names_in_use(self);
names.extend(cmd::compositefonts::sync(self));
self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none() || sets_in_any(t, &names));
}
/// Add a document and make it active. Between `edit.undoGroup.begin` and `edit.undoGroup.end`,
/// an undo group with the begun group's name opens in it: here outside a command, and after the
/// command that adds it otherwise ([`Session::execute`]).
pub fn add_document(&mut self, mut doc: Document, path: Option<String>) -> usize {
// Every edit is checked against the document before it, so the first one is within the cap
// (importers and the native reader have seen to theirs already, with a warning).
doc.limit_depth();
cmd::compositefonts::filter_loaded(&mut doc);
// Inline graphics in text take their size from their symbols' art (not saved).
doc.resolve_inline_art();
// Ruler guides a reader left in the saved list become nodes on their layers.
doc.guides_into_nodes();
cmd::dimension::conform(&mut doc);
self.reset_tool_for_doc_switch();
let mut st = DocState::new(doc, path);
st.history.limit = self.prefs.history_states as usize;
st.opened_as_tab = self.prefs.open_documents_as_tabs;
self.docs.push(st);
let i = self.docs.len() - 1;
self.active = Some(i);
self.sync_opened_composite_fonts(i);
self.sync_front_profiles();
if self.depth == 0
&& let Some(label) = self.explicit_undo_group.clone()
{
self.begin_named_undo_group(label);
}
i
}
/// Replace the document in tab `index` (File → Revert): new content, cleared history and
/// selection, saved state. The tab keeps its place, path, format and view.
pub fn replace_document(&mut self, index: usize, mut doc: Document) -> bool {
if index >= self.docs.len() {
return false;
}
doc.limit_depth();
cmd::compositefonts::filter_loaded(&mut doc);
doc.resolve_inline_art();
doc.guides_into_nodes();
cmd::dimension::conform(&mut doc);
// In the back, type set in a font named like a composite font is measured again below, in
// the document's own composite fonts, not in the front document's.
let back = self.active != Some(index);
if back {
let mut names = cmd::compositefonts::names_in_use(self);
names.extend(doc.composite_fonts.iter().map(|f| f.name.to_lowercase()));
for tree in text_trees_mut(&mut doc) {
each_text_where(tree, &mut |t| t.cached_bounds.is_some() && sets_in_any(t, &names), &mut |t| {
t.cached_bounds = None;
t.cached_baselines.clear();
});
}
} else {
// Pending tool work (typing, a drag) belongs to the content being thrown away.
self.reset_tool_for_doc_switch();
}
let old = &self.docs[index];
let mut st = DocState::new(doc, old.path.clone());
st.history.limit = old.history.limit;
// Same open document (caches keyed by uid stay valid); a new revision redraws it.
st.uid = old.uid;
// Work started for the content being thrown away is dropped when it finishes.
st.reverts = old.reverts + 1;
st.opened_as_tab = old.opened_as_tab;
st.revision = old.revision + 1;
st.format = old.format;
st.save_options = old.save_options.clone();
// The remembered artboard pick follows the artboards it named when the file read back has
// the artboards the document had at the save, in the same order and with the same ids (a
// native file keeps them; a PDF's pages get new ids in page order); otherwise it is left
// out.
if old.save_artboards.is_empty() || st.doc.artboards.iter().map(|a| a.id).eq(old.save_artboards.iter().copied()) {
st.save_artboards = old.save_artboards.clone();
} else {
for k in cmd::fileio::ARTBOARD_PARAMS {
st.save_options.remove(k);
}
}
st.converted = old.converted;
st.imported_from = old.imported_from.clone();
st.import_losses = old.import_losses.clone();
st.view = old.view.clone();
st.layers_open = old.layers_open.clone();
let old = std::mem::replace(&mut self.docs[index], st);
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
}
if back {
self.measure_back_document(index);
} else {
self.sync_opened_composite_fonts(index);
self.sync_front_profiles();
}
true
}
/// Measure the type not measured yet in open document `index`, which is in the back, in its own
/// composite fonts, lent for the measuring, so its type has the bounds it will have when the
/// document comes to the front. The front document stays the one whose composite fonts are
/// registered. The names of the document's composite fonts its type was measured in are kept,
/// so its coming to the front does not measure that type again.
fn measure_back_document(&mut self, index: usize) {
let Some(st) = self.docs.get(index) else { return };
let fonts = st.doc.composite_fonts.clone();
let db = vectorcraft_text::FontDb::global();
let lent = db.lend_composite_fonts(&fonts);
// A name another loan holds with a different definition is not lent: type set in it is
// measured again when the document comes to the front.
let own: HashSet<String> =
fonts.iter().filter(|f| db.composite_font(&f.name).is_some_and(|have| *have == **f)).map(|f| f.name.to_lowercase()).collect();
self.refresh_document_text_bounds(index, |t| t.cached_bounds.is_none());
drop(lent);
if let Some(st) = self.docs.get_mut(index) {
st.measured_in_own = Some((Arc::downgrade(&st.doc), own));
}
}
/// Close the document in tab `index` (`file.close`). Closing a tab other than the current one
/// keeps the current document current, and its tool keeps its state: no document is switched
/// to. Closing the current document makes the tab now at `index` current, or the last tab when
/// it was the last one. False, changing nothing, when `index` is past the last document.
pub fn close_document(&mut self, index: usize) -> bool {
if index >= self.docs.len() {
return false;
}
let front = self.active().map(|d| d.uid).filter(|_| self.active != Some(index));
if front.is_none() {
self.reset_tool_for_doc_switch();
}
// Closed (saved or discarded): nothing left to recover.
let uid = self.docs[index].uid;
cmd::recovery::forget(self, uid);
let old = self.docs.remove(index);
if let Some(stash) = &mut self.batch_stash {
stash.push(old);
}
if let Some(uid) = front {
self.active = self.docs.iter().position(|d| d.uid == uid);
return true;
}
self.active = if self.docs.is_empty() { None } else { Some(index.min(self.docs.len() - 1)) };
self.sync_front_composite_fonts();
self.sync_front_profiles();
true
}
/// Move the document in tab `from` to tab `to` (a document tab dragged along the strip,
/// `document.move`); the documents between them shift by one. The current document stays the
/// current one, wherever it moves to, and its tool keeps its state: no document is switched to.
/// False, changing nothing, when either index is past the last document.
pub fn move_document(&mut self, from: usize, to: usize) -> bool {
let n = self.docs.len();
if from >= n || to >= n {
return false;
}
if from == to {
return true;
}
let active = self.active().map(|d| d.uid);
let d = self.docs.remove(from);
self.docs.insert(to, d);
if let Some(uid) = active {
self.active = self.docs.iter().position(|d| d.uid == uid);
}
true
}
/// Commands with enablement (for menus, palette, MCP `list_commands`), the installed plug-ins'
/// commands last.
pub fn commands(&self) -> Vec<CommandInfo> {
command_specs().iter().chain(crate::cmd::plugin::command_specs()).map(|c| c.info(self)).collect()
}
}
/// Whether some of type `t` is set in a font named (in any case) one of `names`, lowercased.
fn sets_in_any(t: &TextObject, names: &HashSet<String>) -> bool {
!names.is_empty() && t.runs.iter().any(|r| names.contains(&r.style.font_family.to_lowercase()))
}
/// The trees of `doc` that hold type: its layers, symbol definitions, graph designs and pattern
/// tiles.
fn text_trees(doc: &Document) -> impl Iterator<Item = &Arc<Node>> {
let trees = doc.layers.iter().chain(doc.symbols.iter().map(|s| &s.art));
let trees = trees.chain(doc.graph_designs.iter().map(|g| &g.art));
trees.chain(doc.patterns.iter().flat_map(|p| p.art.iter()))
}
/// [`text_trees`], to change.
fn text_trees_mut(doc: &mut Document) -> impl Iterator<Item = &mut Arc<Node>> {
let trees = doc.layers.iter_mut().chain(doc.symbols.iter_mut().map(|s| &mut s.art));
let trees = trees.chain(doc.graph_designs.iter_mut().map(|g| &mut g.art));
trees.chain(doc.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
}
/// Whether tree `n` (opacity mask art included) holds type that `pick` picks.
fn has_text_where(n: &Node, pick: &mut dyn FnMut(&TextObject) -> bool) -> bool {
matches!(&n.kind, NodeKind::Text(t) if pick(t))
|| n.extras().mask.as_deref().is_some_and(|m| has_text_where(&m.art, pick))
|| n.children().is_some_and(|ch| ch.iter().any(|c| has_text_where(c, pick)))
}
/// Run `act` on the type in tree `n` (opacity mask art included) that `pick` picks. Only the nodes
/// on the way to such type are copied.
fn each_text_where(n: &mut Arc<Node>, pick: &mut dyn FnMut(&TextObject) -> bool, act: &mut dyn FnMut(&mut TextObject)) {
if !has_text_where(n, pick) {
return;
}
let n = Arc::make_mut(n);
if let NodeKind::Text(t) = &mut n.kind
&& pick(t)
{
act(t);
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
each_text_where(&mut m.art, pick, act);
}
for c in n.children_mut().into_iter().flatten() {
each_text_where(c, pick, act);
}
}