Browse / github.com/storytold/vectorcraft / patch
Exports warn when type runs over an artboard edge
Export doesn't warn when type runs over the artboard edge and is cut off Point type that runs over the edge of an artboard is cut off in the export, and nothing says so. On main (4a7cba0d), headless: ```sh vectorcraft-cli run \ --cmd file.new --params '{"width":300,"height":200}' \ --cmd text.create --params '{"x":20,"y":100,"text":"This line is much too long for a 300 pt wide artboard","size":24}' \ --export long.pdf ``` `text.create` returns `bounds.width` 516, so the line ends at x 536 on a 300 pt artboard. The PDF export (and `convert` to PDF or PNG) reports `"warnings": []`, and the PDF shows the line cut off at the right edge. I ran into this while
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- switch87 (imported from pull request #3802, open)
- Base
5037a1ac4bb8339fd8e9eb88342c6a230f8067c2- Size
- +524 −28 in 10 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a4a4fe5a39f59314cf7f71bd
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/fileio/encode.rs crates/engine/src/cmd/fileio/eps.rs crates/engine/src/cmd/fileio/metafile.rs crates/engine/src/cmd/fileio/pdf.rs crates/engine/src/cmd/fileio/svg.rs crates/engine/src/cmd/fonts.rs crates/engine/src/cmd/typecmd.rs crates/engine/src/lib.rs crates/engine/src/tests_typeedge.rs docs/type-parity.md
@@ -395,7 +395,8 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> { /// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet, /// features approximated or left out; every format that draws type: fonts written in the fallback-/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).+/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame, and type+/// that runs over the edge of an artboard the export crops to). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> { let f = super::writable(C, Some(format), None)?; encode_all(doc, f.id, p)?.single(f)@@ -569,7 +570,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> { return Err(bad(C, "the document has no artboard")); } let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();- enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));+ enc.warnings.extend(crate::cmd::fonts::export_type_warnings(doc, &doc.layers, Some(®ions))); if format == RasterFormat::WebP && o.lossless == Some(false) { enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into()); }@@ -303,7 +303,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> { } } }- enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));+ enc.warnings.extend(crate::cmd::fonts::export_type_warnings(doc, &doc.layers, Some(®ions))); Ok(enc) } @@ -43,7 +43,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kin return Err(bad(C, "the document has no artboard")); } let regions: Vec<vectorcraft_geom::Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();- enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));+ enc.warnings.extend(crate::cmd::fonts::export_type_warnings(doc, &doc.layers, Some(®ions))); Ok(enc) } @@ -265,19 +265,20 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Stri let (full, mut warnings) = crate::cmd::place::document::full_documents(doc); let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?; warnings.extend(drawn_warnings);- warnings.extend(overflow_warning(cmd, &full, p)?);+ warnings.extend(type_warnings(cmd, &full, p)?); Ok((bytes, warnings)) } -/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and-/// whose text doesn't fit (see [`crate::cmd::fonts::export_overflow_warning`]): on the pages-/// chosen, leaving out the layers that don't print unless the PDF writes them.-fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<String>> {+/// The warnings about the type that the pages of a PDF of `doc` with the options in `p` draw:+/// text that doesn't fit and type over a page's edge (see+/// [`crate::cmd::fonts::export_type_warnings`]), on the pages chosen, leaving out the layers that+/// don't print unless the PDF writes them.+fn type_warnings(cmd: &str, doc: &Document, p: &Value) -> Result<Vec<String>> { let opts = options(cmd, doc, p)?; let areas = vectorcraft_pdf::page_areas(doc, &opts).map_err(|e| pdf_error(cmd, e))?; let set = &opts.settings; let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };- Ok(crate::cmd::fonts::export_overflow_warning(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas)))+ Ok(crate::cmd::fonts::export_type_warnings(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas))) } /// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the@@ -173,7 +173,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco // A chosen artboard's file holds the art over it; otherwise the file holds all of it. let regions: Option<Vec<vectorcraft_geom::Rect>> = chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());- let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());+ let overflow = crate::cmd::fonts::export_type_warnings(doc, &doc.layers, regions.as_deref()); // SVG has no filters for the Photoshop-style effects: their objects go in as images. let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc); let doc = flat.as_ref().unwrap_or(doc);@@ -8,7 +8,7 @@ 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 vectorcraft_geom::{FillRule, PathData, Rect, Shape}; use super::typecmd::refresh_bounds; use super::*;@@ -138,24 +138,27 @@ pub(super) fn art_fonts(d: &Document, art: &[Node]) -> BTreeSet<UsedFont> { /// 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() };+ let mut walk = Printed::new(d, &[]); 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() };+/// The warnings of an export that draws `layers` of `d` over `areas` (document space, each one a+/// page or file the export crops to; `None`: the whole of them) about the type it draws, found as+/// [`printed_type`] finds it for Print (visible objects of the document that aren't on a template+/// layer): area type and type on a path whose text doesn't fit, and type whose glyphs run over the+/// edge of an area they are drawn on (#3784), where the part past it isn't drawn. Type wholly+/// outside every area, or cut only by its own clipping paths, is left alone.+pub(super) fn export_type_warnings(d: &Document, layers: &[Arc<Node>], areas: Option<&[Rect]>) -> Vec<String> {+ let mut walk = Printed::new(d, areas.unwrap_or_default()); for l in layers { walk.node(l, areas); } let overflow: Vec<NodeId> = walk.overflow.into_iter().collect();- super::typecmd::export_overflow_warning(&overflow)+ let cut: Vec<NodeId> = walk.cut.into_iter().collect();+ super::typecmd::export_overflow_warning(&overflow).into_iter().chain(super::typecmd::export_edge_warning(&cut)).collect() } /// The walk of [`printed_type`].@@ -169,9 +172,28 @@ struct Printed<'a> { /// The symbols and patterns whose art was walked. symbols: BTreeSet<String>, patterns: BTreeSet<String>,+ /// The areas an export crops to (empty: none is checked), the bounds of the clip groups around+ /// the object walked, and the objects of the document whose glyphs run over an area's edge.+ pages: &'a [Rect],+ clips: Vec<Clip>,+ cut: BTreeSet<NodeId>, } -impl Printed<'_> {+impl<'a> Printed<'a> {+ fn new(d: &'a Document, pages: &'a [Rect]) -> Self {+ Printed {+ d,+ own: true,+ fonts: BTreeSet::new(),+ overflow: BTreeSet::new(),+ symbols: BTreeSet::new(),+ patterns: BTreeSet::new(),+ pages,+ clips: vec![],+ cut: BTreeSet::new(),+ }+ }+ /// 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@@ -229,11 +251,26 @@ impl Printed<'_> { } } }+ // The members of a clip group show inside its clipping path's bounds, with effects or not+ // (a shadow of the group painting past the path shows no more of them).+ let clipping = match &n.kind {+ NodeKind::Layer { children, clip: true, .. } | NodeKind::Group { children, clip: true } => children.first().and_then(|c| {+ let bounds = c.geometric_bounds_with(&self.d.symbol_boxes())?;+ let shape = vectorcraft_render::effects::clip_outline(c).map(|(bp, rule)| (PathData::from_bezpath(&bp), rule));+ Some(Clip { bounds, shape })+ }),+ _ => None,+ };+ let clipped = clipping.is_some();+ self.clips.extend(clipping); match &n.kind {- NodeKind::Text(_) => {+ NodeKind::Text(t) => { if overflows && self.own { self.overflow.insert(n.id); }+ if self.own && self.runs_over_a_page(t) {+ self.cut.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.@@ -265,6 +302,9 @@ impl Printed<'_> { } } }+ if clipped {+ self.clips.pop();+ } } /// Adds the fonts of symbol `name`'s art, the first time it is shown.@@ -291,6 +331,34 @@ impl Printed<'_> { 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))) } + /// Whether the glyphs of `t` that show inside the clip groups around it meet one of the pages+ /// without lying inside it: that page cuts them off (also when another page holds them).+ fn runs_over_a_page(&self, t: &vectorcraft_doc::TextObject) -> bool {+ // Glyph edges that touch a page's edge aren't cut by it.+ const SLACK: f64 = 0.01;+ if self.pages.is_empty() {+ return false;+ }+ let outline = super::pathops::text_outline(t);+ let Some(glyphs) = outline.bounds() else { return false };+ // What the characters' strokes paint round the glyphs, their miter spikes included, at the+ // weight the type's transform scales them to.+ let k = vectorcraft_doc::Scaling::factor(t.xf).unwrap_or(1.0);+ let mut strokes: Vec<vectorcraft_doc::StrokeLayer> = vec![];+ for r in t.runs.iter().filter(|r| r.style.has_stroke()) {+ let mut st = r.style.stroke_layer();+ st.width *= k;+ if !strokes.contains(&st) {+ strokes.push(st);+ }+ }+ let bp = (!strokes.is_empty()).then(|| outline.to_bezpath());+ let ink = bp.map_or(glyphs, |bp| strokes.iter().fold(glyphs, |b, st| b.union(st.bounds_on(&bp, glyphs))));+ let shown = self.clips.iter().try_fold(ink, |b, c| c.shown(b));+ let Some(shown) = shown else { return false };+ self.pages.iter().any(|p| shown.intersect(*p).area() > 0.0 && !p.inflate(SLACK, SLACK).contains_rect(shown))+ }+ /// 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() };@@ -298,6 +366,28 @@ impl Printed<'_> { } } +/// A clip group's clipping path, as [`Printed`] judges what it shows.+struct Clip {+ bounds: Rect,+ /// Its outline and fill rule (None when it has nothing to clip by, as a symbol instance or a+ /// guide: the clipped art is then hidden).+ shape: Option<(PathData, FillRule)>,+}++impl Clip {+ /// The bounds of what this clipping path shows of box `b` (None when it shows none of it):+ /// the box cut to the path's shape, so a compound or curved path is judged by where it lies+ /// over the box, not by its own bounds. With nothing to clip by it shows nothing, as the+ /// renderer and the PDF and SVG writers hide the clipped art.+ fn shown(&self, b: Rect) -> Option<Rect> {+ let b = Some(b.intersect(self.bounds)).filter(|r| r.area() > 0.0)?;+ let (path, rule) = self.shape.as_ref()?;+ let rect = PathData::from_bezpath(&b.to_path(0.1));+ let cut = vectorcraft_pathops::boolean(&rect, FillRule::NonZero, path, *rule, vectorcraft_pathops::BoolOp::Intersect);+ cut.bounds().filter(|r| r.area() > 0.0)+ }+}+ /// 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) {@@ -469,14 +469,32 @@ pub(crate) fn export_overflow_warning(ids: &[NodeId]) -> Option<String> { overflow_note(ids, "isn't drawn in the export") } -/// [`overflow_warning`], saying that the text past the end of the frames `left_out`.-fn overflow_note(ids: &[NodeId], left_out: &str) -> Option<String> {+/// The warning of an export that crops to artboards about `ids`, type whose glyphs run over the+/// edge of an artboard it is drawn on (#3784): the part past the edge isn't drawn. It names the+/// first 20 ids and counts the rest; `None` when `ids` is empty.+pub(crate) fn export_edge_warning(ids: &[NodeId]) -> Option<String> {+ let list = id_list(ids);+ match ids {+ [] => None,+ [_] => Some(format!("1 text object runs over the edge of its artboard: the part past the edge isn't drawn in the export (id {list})")),+ _ => Some(format!(+ "{} text objects run over the edges of their artboards: the parts past the edges aren't drawn in the export (ids {list})",+ ids.len()+ )),+ }+}++/// The first 20 of `ids`, with a count of the rest.+fn id_list(ids: &[NodeId]) -> String { const NAMED: usize = 20; let (named, rest) = ids.split_at(ids.len().min(NAMED));- let mut list = named.iter().map(|i| i.0.to_string()).collect::<Vec<_>>().join(", ");- if !rest.is_empty() {- list = format!("{list} and {} more", rest.len());- }+ let list = named.iter().map(|i| i.0.to_string()).collect::<Vec<_>>().join(", ");+ if rest.is_empty() { list } else { format!("{list} and {} more", rest.len()) }+}++/// [`overflow_warning`], saying that the text past the end of the frames `left_out`.+fn overflow_note(ids: &[NodeId], left_out: &str) -> Option<String> {+ let list = id_list(ids); match ids { [] => None, [_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame {left_out} (id {list})")),@@ -2686,6 +2686,8 @@ mod tests_trap; #[cfg(test)] mod tests_typearea; #[cfg(test)]+mod tests_typeedge;+#[cfg(test)] mod tests_typescale; #[cfg(test)] mod tests_units;new file mode 100644@@ -0,0 +1,383 @@+//! #3784: exports that crop to artboards say when type runs over an artboard's edge, where the+//! part past it isn't drawn.++use serde_json::{Value, json};+use vectorcraft_doc::NodeId;++use super::*;++const LONG: &str = "This line is much too long for a 300 pt wide artboard";++/// A 300 × 200 pt document.+fn session() -> Session {+ let mut s = Session::new();+ s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap();+ s+}++fn id_of(v: &Value) -> NodeId {+ NodeId(v["id"].as_u64().unwrap())+}++/// Point type at (x, y) in 24 pt.+fn point(s: &mut Session, x: f64, y: f64, text: &str) -> NodeId {+ id_of(&s.execute("text.create", &json!({"x": x, "y": y, "text": text, "size": 24})).unwrap())+}++/// The warnings of `document.export {format, …options}` about type over an artboard's edge.+fn edge_warnings(s: &mut Session, format: &str, options: Value) -> Vec<String> {+ let mut p = json!({ "format": format });+ if let (Some(p), Some(o)) = (p.as_object_mut(), options.as_object()) {+ p.extend(o.clone());+ }+ let v = s.execute("document.export", &p).unwrap();+ v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains("edge")).map(str::to_string).collect()+}++/// The exports that crop to the artboards, with the options that pick the first one.+fn cropping() -> [(&'static str, Value); 9] {+ [+ ("pdf", json!({})),+ ("png", json!({})),+ ("jpg", json!({})),+ ("tiff", json!({})),+ ("emf", json!({})),+ ("wmf", json!({})),+ ("svg", json!({"range": "1"})),+ ("eps", json!({"useArtboards": true, "range": "1"})),+ ("pdf", json!({"range": "1"})),+ ]+}++fn one(id: NodeId) -> String {+ format!("1 text object runs over the edge of its artboard: the part past the edge isn't drawn in the export (id {})", id.0)+}++#[test]+fn type_over_the_artboard_edge_is_named_by_every_export_that_crops() {+ let mut s = session();+ let id = point(&mut s, 20.0, 100.0, LONG);+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), [one(id)], "{format} {options}");+ }+}++#[test]+fn exports_that_hold_all_the_art_say_nothing() {+ let mut s = session();+ point(&mut s, 20.0, 100.0, LONG);+ // An SVG of the whole document, an EPS of the art's bounds and a .ai file (its editing data)+ // hold the whole line.+ for format in ["svg", "eps", "ai"] {+ assert_eq!(edge_warnings(&mut s, format, json!({})), Vec::<String>::new(), "{format}");+ }+}++#[test]+fn type_inside_or_wholly_outside_the_artboard_is_left_alone() {+ let mut s = session();+ point(&mut s, 20.0, 100.0, "Fits");+ // A note on the pasteboard, past the right edge.+ point(&mut s, 400.0, 100.0, LONG);+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), Vec::<String>::new(), "{format} {options}");+ }+}++#[test]+fn hidden_type_and_type_on_a_template_layer_are_left_alone() {+ let mut s = session();+ let id = point(&mut s, 20.0, 100.0, LONG);+ s.execute("object.setProps", &json!({"ids": [id.0], "visible": false})).unwrap();+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), Vec::<String>::new());+ s.execute("object.setProps", &json!({"ids": [id.0], "visible": true})).unwrap();+ let layer = s.doc().unwrap().doc.layers[0].id;+ s.execute("layer.setProps", &json!({"id": layer.0, "template": true})).unwrap();+ assert_eq!(edge_warnings(&mut s, "png", json!({})), Vec::<String>::new());+}++#[test]+fn type_on_a_layer_that_does_not_print_is_named_only_where_it_is_drawn() {+ let mut s = session();+ point(&mut s, 20.0, 100.0, LONG);+ let layer = s.doc().unwrap().doc.layers[0].id;+ s.execute("layer.setProps", &json!({"id": layer.0, "printable": false})).unwrap();+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), Vec::<String>::new());+ assert_eq!(edge_warnings(&mut s, "pdf", json!({"includeNonPrinting": true})).len(), 1);+ assert_eq!(edge_warnings(&mut s, "png", json!({})).len(), 1);+}++#[test]+fn type_cut_by_its_own_clipping_path_inside_the_artboard_is_left_alone() {+ let mut s = session();+ let text = point(&mut s, 20.0, 100.0, LONG);+ let mask = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 200, "height": 100})).unwrap());+ s.execute("select.set", &json!({"ids": [text.0, mask.0]})).unwrap();+ s.execute("object.clippingMask.make", &json!({})).unwrap();+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), Vec::<String>::new(), "{format} {options}");+ }+ // A clipping path that itself reaches past the edge lets the type run over it.+ s.execute("object.move", &json!({"ids": [mask.0], "dx": 150, "dy": 0})).unwrap();+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(text)]);+}++#[test]+fn area_type_is_judged_by_its_glyphs_not_its_frame() {+ let mut s = session();+ // The frame reaches past the right edge, the words stay well inside it.+ let v = s.execute("text.create", &json!({"x": 20, "y": 40, "text": "Short", "size": 24, "area": {"width": 400, "height": 60}})).unwrap();+ assert!(v.get("overflow").is_none(), "{v}");+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), Vec::<String>::new());+ // A word longer than the artboard is wide does cross it.+ let id = id_of(&v);+ s.execute("text.setText", &json!({"id": v["id"], "text": "Overlongwordthatrunsfarpasttheedge"})).unwrap();+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(id)]);+}++#[test]+fn type_flush_with_the_edges_is_inside() {+ let mut s = session();+ // The glyphs' box, not the line's: a cap height under the top edge, a baseline on the bottom+ // one (nothing below it in "HI").+ let top = point(&mut s, 0.0, 18.0, "HI");+ let bottom = point(&mut s, 0.0, 200.0, "HI");+ let ink = |s: &Session, id: NodeId| {+ crate::cmd::pathops::text_outline(match &s.doc().unwrap().doc.node(id).unwrap().kind {+ vectorcraft_doc::NodeKind::Text(t) => t,+ k => panic!("not text: {k:?}"),+ })+ };+ let (t, b) = (ink(&s, top).bounds().unwrap(), ink(&s, bottom).bounds().unwrap());+ assert!(t.y0 >= 0.0 && b.y1 <= 200.0, "{t:?} {b:?}");+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), Vec::<String>::new());+}++#[test]+fn type_across_two_artboards_is_named_on_both_and_once() {+ let mut s = Session::new();+ s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap();+ let (a, b) = {+ let d = &s.doc().unwrap().doc;+ (d.artboards[0].rect, d.artboards[1].rect)+ };+ assert!(b.x0 > a.x1, "side by side: {a:?} {b:?}");+ let v = s.execute("text.create", &json!({"x": a.x1 - 40.0, "y": 100, "text": LONG, "size": 24})).unwrap();+ let id = id_of(&v);+ let right = v["bounds"]["x"].as_f64().unwrap() + v["bounds"]["width"].as_f64().unwrap();+ assert!(right > b.x0 + 10.0, "reaches into the second artboard: {v}");+ // Both pages cut it: one warning naming it once.+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(id)]);+ assert_eq!(edge_warnings(&mut s, "pdf", json!({"range": "1"})), [one(id)]);+ assert_eq!(edge_warnings(&mut s, "pdf", json!({"range": "2"})), [one(id)]);+}++#[test]+fn many_objects_are_counted_past_twenty() {+ let mut s = session();+ let ids: Vec<NodeId> = (0..23).map(|i| point(&mut s, 20.0, 20.0 + 8.0 * f64::from(i), LONG)).collect();+ let w = edge_warnings(&mut s, "pdf", json!({}));+ let named = ids[..20].iter().map(|i| i.0.to_string()).collect::<Vec<_>>().join(", ");+ assert_eq!(+ w,+ [format!(+ "23 text objects run over the edges of their artboards: the parts past the edges aren't drawn in the export (ids {named} and 3 more)"+ )]+ );+}++#[test]+fn two_objects_read_in_the_plural() {+ let mut s = session();+ let a = point(&mut s, 20.0, 60.0, LONG);+ let b = point(&mut s, -100.0, 140.0, LONG);+ assert_eq!(+ edge_warnings(&mut s, "png", json!({})),+ [format!("2 text objects run over the edges of their artboards: the parts past the edges aren't drawn in the export (ids {}, {})", a.0, b.0)]+ );+}++#[test]+fn type_in_a_group_with_an_effect_of_its_own_is_named() {+ let mut s = session();+ let id = point(&mut s, 20.0, 100.0, LONG);+ s.execute("select.set", &json!({"ids": [id.0]})).unwrap();+ s.execute("object.group", &json!({})).unwrap();+ // The group prints where its effect paints, so its members are walked without the areas.+ s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), [one(id)], "{format} {options}");+ }+}++#[test]+fn type_inside_one_artboard_and_over_the_edge_of_an_overlapping_one_is_named() {+ let mut s = session();+ s.execute("artboard.new", &json!({"x": 150, "y": 0, "width": 300, "height": 200})).unwrap();+ let v = s.execute("text.create", &json!({"x": 20, "y": 100, "text": "Twenty characters ok", "size": 24})).unwrap();+ let id = id_of(&v);+ let right = v["bounds"]["x"].as_f64().unwrap() + v["bounds"]["width"].as_f64().unwrap();+ assert!(right > 160.0 && right < 290.0, "inside the first artboard, into the second: {v}");+ // The second page cuts it, though the first holds all of it.+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(id)]);+ assert_eq!(edge_warnings(&mut s, "pdf", json!({"range": "2"})), [one(id)]);+ assert_eq!(edge_warnings(&mut s, "pdf", json!({"range": "1"})), Vec::<String>::new());+}++#[test]+fn type_cut_by_the_clipping_path_of_a_clip_group_with_an_effect_is_left_alone() {+ let mut s = session();+ let text = point(&mut s, 20.0, 100.0, LONG);+ let mask = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 200, "height": 100})).unwrap());+ s.execute("select.set", &json!({"ids": [text.0, mask.0]})).unwrap();+ let group = s.execute("object.clippingMask.make", &json!({})).unwrap();+ s.execute("select.set", &json!({"ids": [group["id"]]})).unwrap();+ s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), Vec::<String>::new(), "{format} {options}");+ }+}++#[test]+fn a_drop_shadow_past_the_edge_does_not_widen_the_clipping_path() {+ let mut s = session();+ let text = point(&mut s, 20.0, 100.0, LONG);+ // The clipping path ends 4 pt short of the right edge; the group's shadow paints past it.+ let mask = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 286, "height": 100})).unwrap());+ s.execute("select.set", &json!({"ids": [text.0, mask.0]})).unwrap();+ let group = s.execute("object.clippingMask.make", &json!({})).unwrap();+ s.execute("select.set", &json!({"ids": [group["id"]]})).unwrap();+ s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();+ let painted = {+ let d = &s.doc().unwrap().doc;+ vectorcraft_render::painted_bounds(d, d.node(id_of(&group)).unwrap()).unwrap()+ };+ assert!(painted.x1 > 300.0, "the shadow paints past the edge: {painted:?}");+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), Vec::<String>::new(), "{format} {options}");+ }+}++#[test]+fn a_miter_spike_past_the_edge_is_named() {+ let mut s = session();+ let id = point(&mut s, 100.0, 100.0, "k");+ s.execute(+ "text.setRangeStyle",+ &json!({"id": id.0, "stroke": "#000000", "strokeWidth": 6, "strokeOptions": {"join": "miter", "miterLimit": 10}}),+ )+ .unwrap();+ let t = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {+ vectorcraft_doc::NodeKind::Text(t) => (**t).clone(),+ k => panic!("not text: {k:?}"),+ };+ // Put the glyph's right side 1 pt inside the right edge with the stroke's even reach.+ let ink = crate::cmd::pathops::text_outline(&t(&s)).bounds().unwrap();+ let dx = 300.0 - t(&s).char_stroke_outset() - 1.0 - ink.x1;+ s.execute("object.move", &json!({"ids": [id.0], "dx": dx, "dy": 0})).unwrap();+ let t = t(&s);+ let outline = crate::cmd::pathops::text_outline(&t);+ let ink = outline.bounds().unwrap();+ assert!(ink.x1 + t.char_stroke_outset() < 300.0, "{ink:?}");+ let stroked = t.runs[0].style.stroke_layer().bounds_on(&outline.to_bezpath(), ink);+ assert!(stroked.x1 > 300.5, "the spike at the arm of the k crosses the edge: {stroked:?}");+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(id)]);+}++#[test]+fn a_round_joined_stroke_inside_the_edge_is_left_alone() {+ let mut s = session();+ let id = point(&mut s, 100.0, 100.0, "k");+ s.execute(+ "text.setRangeStyle",+ &json!({"id": id.0, "stroke": "#000000", "strokeWidth": 6, "strokeOptions": {"join": "round", "miterLimit": 10}}),+ )+ .unwrap();+ let t = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {+ vectorcraft_doc::NodeKind::Text(t) => (**t).clone(),+ k => panic!("not text: {k:?}"),+ };+ // Within the miter limit's reach of the right edge, but no spike to cross it.+ let ink = crate::cmd::pathops::text_outline(&t(&s)).bounds().unwrap();+ let dx = 300.0 - t(&s).char_stroke_outset() - 1.0 - ink.x1;+ s.execute("object.move", &json!({"ids": [id.0], "dx": dx, "dy": 0})).unwrap();+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), Vec::<String>::new());+}++/// A clipping path whose bounds cross the edge but whose shape doesn't reach past it over the type+/// (a compound path of two rectangles, the second off the artboard beside the line, not over it)+/// shows none of the type past the edge.+#[test]+fn a_compound_clipping_path_is_judged_by_its_shape_not_its_bounds() {+ let mut s = session();+ let text = point(&mut s, 20.0, 100.0, LONG);+ let inside = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 200, "height": 100})).unwrap());+ let away = id_of(&s.execute("shape.rectangle", &json!({"x": 350, "y": 0, "width": 50, "height": 20})).unwrap());+ s.execute("select.set", &json!({"ids": [inside.0, away.0]})).unwrap();+ let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());+ let b = s.doc().unwrap().doc.node(compound).unwrap().geometric_bounds().unwrap();+ assert!(b.x1 > 300.0 && b.y1 > 100.0, "its bounds reach past the edge, over the line: {b:?}");+ s.execute("select.set", &json!({"ids": [text.0, compound.0]})).unwrap();+ s.execute("object.clippingMask.make", &json!({})).unwrap();+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), Vec::<String>::new(), "{format} {options}");+ }+ // Moved so the second rectangle lies over the line across the edge, it shows the type there.+ s.execute("object.move", &json!({"ids": [compound.0], "dx": -60, "dy": 80})).unwrap();+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(text)]);+}++/// Type whose glyphs and even stroke lie past the left edge, with only a miter spike reaching onto+/// the artboard, is cut by that edge too: what type paints (the cull before the edge check) takes+/// its strokes' full miter reach, so the spike is never culled with it.+#[test]+fn a_miter_spike_alone_reaching_onto_the_artboard_is_named() {+ let mut s = session();+ let id = point(&mut s, 100.0, 100.0, "k");+ s.execute(+ "text.setRangeStyle",+ &json!({"id": id.0, "stroke": "#000000", "strokeWidth": 6, "strokeOptions": {"join": "miter", "miterLimit": 10}}),+ )+ .unwrap();+ let t = |s: &Session| match &s.doc().unwrap().doc.node(id).unwrap().kind {+ vectorcraft_doc::NodeKind::Text(t) => (**t).clone(),+ k => panic!("not text: {k:?}"),+ };+ // The glyph's right side, with the stroke's even reach, 1 pt left of the left edge.+ let ink = crate::cmd::pathops::text_outline(&t(&s)).bounds().unwrap();+ let dx = -t(&s).char_stroke_outset() - 1.0 - ink.x1;+ s.execute("object.move", &json!({"ids": [id.0], "dx": dx, "dy": 0})).unwrap();+ let painted = vectorcraft_render::painted_bounds(&s.doc().unwrap().doc, s.doc().unwrap().doc.node(id).unwrap()).unwrap();+ let t = t(&s);+ let outline = crate::cmd::pathops::text_outline(&t);+ let ink = outline.bounds().unwrap();+ assert!(ink.x1 + t.char_stroke_outset() < 0.0, "the glyph and its even stroke are off the artboard: {ink:?}");+ let stroked = t.runs[0].style.stroke_layer().bounds_on(&outline.to_bezpath(), ink);+ assert!(stroked.x1 > 0.5, "the spike at the arm of the k reaches onto the artboard: {stroked:?}");+ assert!(painted.x1 >= stroked.x1, "the painted bounds hold the spike: {painted:?} {stroked:?}");+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(id)]);+}++/// A clip group whose clipping path has nothing to clip by (here one turned into a guide, as a+/// file can hold it; a symbol instance is another) hides the type it clips, as the renderer and the+/// PDF and SVG writers do: nothing of it is drawn, so nothing is cut off, even where the clipping+/// path's box crosses the edge.+#[test]+fn type_hidden_by_a_clipping_path_with_nothing_to_clip_by_is_left_alone() {+ let mut s = session();+ let text = point(&mut s, 20.0, 100.0, LONG);+ let mask = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 50, "width": 400, "height": 100})).unwrap());+ s.execute("select.set", &json!({"ids": [text.0, mask.0]})).unwrap();+ s.execute("object.clippingMask.make", &json!({})).unwrap();+ assert_eq!(edge_warnings(&mut s, "pdf", json!({})), [one(text)], "a clipping path past the edge shows the type there");+ let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);+ if let vectorcraft_doc::NodeKind::Path { guide, .. } = &mut d.node_mut(mask).unwrap().kind {+ *guide = true;+ }+ let clip = s.doc().unwrap().doc.node(mask).unwrap();+ assert!(clip.geometric_bounds().is_some_and(|b| b.x1 > 300.0));+ assert!(vectorcraft_render::effects::clip_outline(clip).is_none(), "nothing to clip by");+ for (format, options) in cropping() {+ assert_eq!(edge_warnings(&mut s, format, options.clone()), Vec::<String>::new(), "{format} {options}");+ }+}@@ -76,6 +76,7 @@ Character Styles menu New/Redefine and footer New capture the first selected cha | 2026-10-23 | minor | Variable fonts done: the font menu's Font Technology › Variable lists only variable font families, together with the script, kind and favourites filters; scores unchanged | | 2026-10-23 | minor | Colour fonts: colour glyphs (COLR, SVG) go with type that effects reshape, into Expand Appearance and EMF/WMF export, where they were left out with the type's outlines; bitmap glyphs not yet; scores unchanged | | 2026-10-23 | minor | Colour fonts: OpenType SVG glyphs follow the `SVG ` table's coordinate rules: a root size without a `viewBox` sets up the em, a `viewBox` that isn't square is centred in it, and a glyph element nested in others is drawn on its own; scores unchanged |+| 2026-10-23 | minor | Exports that crop to artboards (PDF, EPS and SVG of artboards, the raster formats, EMF, WMF) warn when type's glyphs run over an artboard's edge and the part past it isn't drawn, naming the objects as the overflow warning does; type wholly off the artboards or cut only by its own clipping path is left alone (#3784, `crates/engine/src/tests_typeedge.rs`); scores unchanged | | 2026-10-23 | minor | Colour fonts: OpenType SVG colour fonts draw and export as vector art: each glyph's SVG document read by the SVG importer, in the run's colours where it uses `currentColor` (or the legacy `context-fill`/`context-stroke`); drawn, exported and outlined as COLR glyphs are; scores unchanged | | 2026-10-23 | minor | Hanging punctuation: Glyph Scaling of justified lines no longer widens the marks that hang (they hang exactly their published share), and Standard burasagari counts what hangs at a line's start before deciding a comma overflows; paragraph-level tests with both composers (`crates/text/src/tests_hang.rs`); scores unchanged | | 2026-10-20 | minor | Auto Leading outside the layout: the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text follow the paragraph's Auto Leading; scores unchanged |