PDF.W1: reconcile shared writer with current main

This commit is contained in:
skylar taylor-barrick
2026-10-14 01:23:19 -06:00
121 changed files with 3694 additions and 524 deletions
+1 -1
View File
@@ -265,7 +265,7 @@ fn undo_group_end(s: &mut Session, p: &Value) -> Result<Value> {
/// under an interaction that would then preview on, or cancel to, the document from before (#1719).
/// A typing session also ends in the Type tool, whose caret and marked text (IME) must follow the
/// document.
fn settle_interaction(s: &mut Session) -> Result<()> {
pub(crate) fn settle_interaction(s: &mut Session) -> Result<()> {
let typing = typing_in_progress(s);
s.commit_interaction()?;
if typing {
+22 -19
View File
@@ -13,14 +13,22 @@ pub(super) fn batch(s: &mut Session, p: &Value) -> Result<Value> {
// What an error rolls back besides the documents: session-level state a step may change
// (paint defaults, drawing mode, clipboard, the Untitled-N count).
let saved = (s.paint.clone(), s.fill_active, s.draw_mode, s.draw_inside, s.clipboard.clone(), s.untitled_counter);
let (start, active): (Vec<u64>, _) = (s.docs.iter().map(|d| d.uid).collect(), s.active);
// A drag or typing still in progress is a step of its own, as before Undo: the batch's steps
// would otherwise run inside it and take its baseline as theirs (#1697).
if s.active().is_some() {
super::super::edit::settle_interaction(s)?;
}
// The documents as they are now, whole: what a failed step brings back. Their art, history,
// file, format and saved state share their data with the open ones until a step changes them.
let start = s.docs.clone();
let active = s.active;
s.batch_stash = Some(vec![]);
let r = run_steps(s, &label, artboard, &mut cmds);
let stash = s.batch_stash.take().unwrap_or_default();
let results = match r {
Ok(results) => results,
Err(e) => {
roll_back(s, &start, stash);
roll_back(s, start, &stash);
s.active = active;
(s.paint, s.fill_active, s.draw_mode, s.draw_inside, s.clipboard, s.untitled_counter) = saved;
s.pending_paint = None;
@@ -81,22 +89,17 @@ fn run_steps(s: &mut Session, label: &str, artboard: Option<u64>, cmds: &mut [Va
Ok(results)
}
/// The documents as they were before the batch (`start`: their uids in order): the ones it opened
/// close, the ones it closed or replaced (`stash`) come back, and each one's interaction is undone.
fn roll_back(s: &mut Session, start: &[u64], mut stash: Vec<DocState>) {
let mut open = std::mem::take(&mut s.docs);
for &uid in start {
// The newest revision the document reached, so the restored one redraws.
let newest = open.iter().chain(&stash).filter(|d| d.uid == uid).map(|d| d.revision).max().unwrap_or_default();
// Its state before the batch: the first one a step closed or replaced, else the open one.
let st = match stash.iter().position(|d| d.uid == uid) {
Some(i) => Some(stash.remove(i)),
None => open.iter().position(|d| d.uid == uid).map(|i| open.remove(i)),
};
if let Some(mut st) = st {
st.undo_interaction();
st.revision = newest.saturating_add(1);
s.docs.push(st);
}
/// The documents as they were before the batch (`start`), whatever the steps did to them: opened
/// ones close, closed or replaced ones (`stash`) come back, and each has its art, selection,
/// history, file, format and saved state again (#1697: undoing the batch's interaction alone left
/// a Save As step's new file and an Undo step's history in place). Files the steps wrote stay
/// written.
fn roll_back(s: &mut Session, start: Vec<DocState>, stash: &[DocState]) {
let open = std::mem::take(&mut s.docs);
s.docs = start;
for st in &mut s.docs {
// Past the newest revision the document reached, so the restored one redraws.
let newest = open.iter().chain(stash).filter(|d| d.uid == st.uid).map(|d| d.revision).max().unwrap_or(st.revision);
st.revision = newest.saturating_add(1);
}
}
+11 -6
View File
@@ -273,12 +273,17 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
/// own): rasters stay linked, SVG files become art, missing ones placeholders (see
/// [`vectorcraft_svg::ImportOptions`]).
pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
// The importer reads on a thread of its own (for its stack): what may be read is what may be
// read here.
let scope = crate::file_access::ScopeHandle::capture();
let read = |path: &str| {
// Only files (not folders or devices) are read.
file_stamp(path)?;
let bytes = read_file(path).ok()?;
let link = crate::cmd::links::link_info(path, &bytes);
Some((bytes, link))
scope.clone().run(|| {
// Only files (not folders or devices) are read.
file_stamp(path)?;
let bytes = read_file(path).ok()?;
let link = crate::cmd::links::link_info(path, &bytes);
Some((bytes, link))
})
};
vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err)
}
@@ -419,7 +424,7 @@ pub(super) fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
/// The most memory decoding one image may take: a 60 in print sheet at 300 ppi (18000 × 6600 px)
/// is ~475 MB as RGBA, past the decoder's 512 MB default once it is converted. The web app keeps
/// that default: wasm32 has 4 GB to address and aborts when an allocation fails.
pub(crate) const MAX_RASTER_ALLOC: u64 = if cfg!(target_arch = "wasm32") { 512 << 20 } else { 2 << 30 };
pub(crate) use vectorcraft_doc::MAX_RASTER_ALLOC;
/// What to say of encoded image `bytes` that need more than [`MAX_RASTER_ALLOC`] to decode (its
/// header says so; the pixels aren't read): the renderer, which has the same allowance, draws
+1
View File
@@ -457,6 +457,7 @@ pub(crate) fn unique_name(base: &str, taken: impl Fn(&str) -> bool) -> String {
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
}
pub(crate) use edit::{settle_interaction, typing_in_progress};
pub(crate) use vectorcraft_doc::range::parse_range;
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
+38 -10
View File
@@ -202,7 +202,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale)",
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?, strokes?, corners?, copy?: bool, patterns?: bool (Transform Patterns), typeAreas?: bool (area type: the frame is reshaped and the text reflows at its size), objects?: bool (Transform Objects, as object.move)} (Transform panel; width/height and the reference point follow the bounding box, rotated with rotated objects; x, y are page coordinates; with Use Preview Bounds the values measure the visual bounds; strokes/corners as object.scale; with anchor points selected (Direct Selection) and no copy, the values are the box around those points, square to the page, and only they move or scale, as with object.move (#1824))",
has_selection,
set_bounds
),
@@ -1400,6 +1400,13 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let st = s.doc()?;
if transforms_objects(p)
&& !bool_or(p, "copy", false)
&& let Some(b) = st.selection.anchor_bounds(&st.doc, s.prefs.use_preview_bounds)
{
return set_anchor_bounds(s, p, ids, b);
}
let d = &s.doc()?.doc;
// Sizes are measured square to the bounding box, which a rotation turns with the objects:
// the math below runs in its frame.
@@ -1419,15 +1426,7 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let fixed = [all[0] - m[0], all[1] - m[1], all[2] - m[2], all[3] - m[3]];
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(v, refi);
let mut w = f64_or(p, "width", v.width());
let mut h = f64_or(p, "height", v.height());
if bool_or(p, "proportional", false) {
if p.get("width").is_some() && v.width() > 0.0 {
h = v.height() * w / v.width();
} else if p.get("height").is_some() && v.height() > 0.0 {
w = v.width() * h / v.height();
}
}
let (w, h) = wanted_size(p, v);
// The geometric scale that gives the visual size: margins grow by the strokes' scale `f`. It is
// exact for proportional scales of one path; [`Spans::refit`] checks it, and solves for the
// scales where it misses.
@@ -1477,6 +1476,35 @@ fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
apply_transform(s, "Transform", ids, frame.conjugate(Affine::translate(to - new_rp) * scale), p, false)
}
/// The `width` and `height` `object.setBounds` asks for a box now `b`: the one not given stays, or
/// with `proportional` follows the one given.
fn wanted_size(p: &Value, b: Rect) -> (f64, f64) {
let mut w = f64_or(p, "width", b.width());
let mut h = f64_or(p, "height", b.height());
if bool_or(p, "proportional", false) {
if p.get("width").is_some() && b.width() > 0.0 {
h = b.height() * w / b.width();
} else if p.get("height").is_some() && b.height() > 0.0 {
w = b.width() * h / b.height();
}
}
(w, h)
}
/// `object.setBounds` with anchors selected with Direct Selection (#1824): the values are those of
/// `b`, the box around the anchors (and the objects selected whole) square to the page, and only
/// the anchors, their handles with them, and those objects move or scale.
fn set_anchor_bounds(s: &mut Session, p: &Value, ids: Vec<NodeId>, b: Rect) -> Result<Value> {
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = vectorcraft_geom::reference_point(b, refi);
let (w, h) = wanted_size(p, b);
// A single anchor, or anchors in a line, have no size to scale on that side.
let ratio = |size: f64, now: f64| if now > 1e-9 { size / now } else { 1.0 };
let scale = about(rp, Affine::scale_non_uniform(ratio(w, b.width()), ratio(h, b.height())));
let to = Point::new(f64_or(p, "x", rp.x), f64_or(p, "y", rp.y));
apply_transform(s, "Transform", ids, Affine::translate(to - rp) * scale, p, true)
}
/// The visual box of `ids` square to `frame`, as [`Document::oriented_bounds`] measures it, with
/// symbol instances measured without the strokes in their art
/// ([`vectorcraft_doc::SymbolBoxes::without_art_strokes`]).
+1 -1
View File
@@ -594,7 +594,7 @@ pub(crate) fn outlined_stroke(
st: &StrokeLayer,
) -> Option<Node> {
if let Some(b) = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name)) {
let mut pieces = vectorcraft_brush::stroke_pieces(b, &path.to_bezpath(), st);
let mut pieces = vectorcraft_brush::stroke_pieces(d, b, &path.to_bezpath(), st);
let art = match pieces.len() {
0 => return None,
1 => pieces.remove(0),
+4
View File
@@ -90,9 +90,13 @@ fn map_of(src: &[Arc<Node>]) -> Result<BuilderMap> {
}
let arrangement = po::shape_builder(&shapes, true);
let bounded = |pieces: Vec<po::Shape>| pieces.into_iter().filter_map(|p| p.path.bounds().map(|b| (p, b))).collect();
// A Shaper Group's faces are what its shapes fill and what its open paths enclose: the gaps
// closed paths enclose are the Shape Builder's only.
let open = po::has_open_path(&shapes);
let regions = arrangement
.regions
.into_iter()
.filter(|r| open || !r.sources.is_empty())
.filter_map(|r| {
let bounds = r.path.bounds()?;
let path = r.path.to_bezpath();
+34
View File
@@ -113,4 +113,38 @@ mod tests {
s.execute("text.tabs.clear", &json!({})).unwrap();
assert_eq!(s.execute("text.tabs.get", &json!({})).unwrap()["stops"], json!([]));
}
/// #1877: a leader set the way the Tabs panel sets it fills the tab's gap with its dots.
#[test]
fn tab_leader_fills_the_gap_with_its_characters() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 20, "text": "AAA\t20", "size": 12})).unwrap()["id"].as_u64().unwrap();
s.execute("text.tabs.set", &json!({"stops": [{"position": 200, "align": "left", "leader": ".", "alignOn": "."}]})).unwrap();
let st = s.doc().unwrap();
let Some(vectorcraft_doc::NodeKind::Text(t)) = st.doc.node(vectorcraft_doc::NodeId(id)).map(|n| &n.kind) else { panic!() };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let x_of = |b: usize| lay.glyphs.iter().find(|g| g.byte == b).unwrap().origin.x;
let (from, to) = (x_of(3), x_of(4));
assert!(to - from > 150.0, "{from}..{to}");
// Contours drawn inside the gap: only the leader's dots can be there.
let mut dots = 0;
for g in &lay.glyphs {
let mut contours: Vec<kurbo::BezPath> = vec![];
for el in g.outline.elements() {
match (el, contours.last_mut()) {
(kurbo::PathEl::MoveTo(_), _) | (_, None) => contours.push(kurbo::BezPath::from_vec(vec![*el])),
(_, Some(c)) => c.push(*el),
}
}
for c in contours {
let r = kurbo::Shape::bounding_box(&c);
if r.x0 > from - 1e-6 && r.x1 < to + 1e-6 {
assert!(r.width() < 3.0 && r.height() < 3.0, "not a dot: {r:?}");
dots += 1;
}
}
}
assert!(dots > 30, "{dots} dots in a {} pt gap", to - from);
}
}