diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index d9a62e1f0..8dda63ee4 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -385,7 +385,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { /// 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). +/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame). pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec, Vec)> { let f = super::writable(C, Some(format), None)?; encode_all(doc, f.id, p)?.single(f) @@ -529,6 +529,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); } + let regions: Vec = 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))); 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()); } diff --git a/crates/engine/src/cmd/fileio/eps.rs b/crates/engine/src/cmd/fileio/eps.rs index 4d899439a..023a1a050 100644 --- a/crates/engine/src/cmd/fileio/eps.rs +++ b/crates/engine/src/cmd/fileio/eps.rs @@ -270,6 +270,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { if pages.is_empty() { return Err(bad(C, "the document has no artboard")); } + let regions: Vec = pages.iter().map(|(_, region, _)| *region).collect(); let transparent = doc.layers.iter().any(|l| l.shows_transparency()); let flat = flatten_document(doc, &set.flatten)?; let drawn = flat.as_ref().unwrap_or(doc); @@ -302,6 +303,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result { } } } + enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions))); Ok(enc) } diff --git a/crates/engine/src/cmd/fileio/metafile.rs b/crates/engine/src/cmd/fileio/metafile.rs index abff8357e..2c54d72e4 100644 --- a/crates/engine/src/cmd/fileio/metafile.rs +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -42,6 +42,8 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option, kin if enc.files.is_empty() { return Err(bad(C, "the document has no artboard")); } + let regions: Vec = 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))); Ok(enc) } diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 4af802a0a..7c2d2bd5f 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -254,9 +254,21 @@ pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec Result> { + 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))) +} + /// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the /// native document with its save options, whose pages may be left blank). pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result>) -> Result<(Vec, Vec)> { diff --git a/crates/engine/src/cmd/fileio/svg.rs b/crates/engine/src/cmd/fileio/svg.rs index de130666f..62c2d1a3a 100644 --- a/crates/engine/src/cmd/fileio/svg.rs +++ b/crates/engine/src/cmd/fileio/svg.rs @@ -157,6 +157,10 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result> = + 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()); // 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); @@ -184,5 +188,6 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result], areas: &[Rect]) - (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], areas: Option<&[Rect]>) -> Option { + 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 = walk.overflow.into_iter().collect(); + super::typecmd::export_overflow_warning(&overflow) +} + /// The walk of [`printed_type`]. struct Printed<'a> { d: &'a Document, diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 736b65235..56f2f4747 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -315,6 +315,17 @@ pub(crate) fn with_bounds(s: &Session, id: Option, mut result: Value) -> /// (as [`with_overflow`] finds it): the text past the end of their frames doesn't print. It names /// the first 20 ids and counts the rest; `None` when `ids` is empty. pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option { + overflow_note(ids, "doesn't print") +} + +/// [`overflow_warning`] as an export that draws type gives it: the text past the end of the frames +/// isn't drawn in the export. +pub(crate) fn export_overflow_warning(ids: &[NodeId]) -> Option { + 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 { 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::>().join(", "); @@ -323,8 +334,8 @@ pub(crate) fn overflow_warning(ids: &[NodeId]) -> Option { } match ids { [] => None, - [_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame doesn't print (id {list})")), - _ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame doesn't print (ids {list})", ids.len())), + [_] => Some(format!("1 text object doesn't fit its frame: the text past the end of the frame {left_out} (id {list})")), + _ => Some(format!("{} text objects don't fit their frames: the text past the end of each frame {left_out} (ids {list})", ids.len())), } } diff --git a/crates/engine/src/tests_areafit.rs b/crates/engine/src/tests_areafit.rs index 68634432b..dd4dadb37 100644 --- a/crates/engine/src/tests_areafit.rs +++ b/crates/engine/src/tests_areafit.rs @@ -483,3 +483,71 @@ fn making_and_restyling_type_reports_its_overflow() { let inside = s.execute("text.createInPath", &json!({"path": frame["id"], "mode": "area", "text": STORY, "size": 12, "fit": "none"})).unwrap(); assert_eq!(inside["overflow"], json!([inside["id"]]), "{inside}"); } + +/// The formats whose exports draw type. +const DRAWING_FORMATS: [&str; 8] = ["pdf", "eps", "svg", "png", "jpg", "tiff", "emf", "wmf"]; + +/// The warnings of `document.export {format, …options}` about type that doesn't fit its frame. +fn export_overflow(s: &mut Session, format: &str, options: Value) -> Vec { + 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(); + let over = |w: &&str| w.contains("fit its frame") || w.contains("fit their frames"); + v["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(over).map(str::to_string).collect() +} + +#[test] +fn exports_that_draw_type_name_the_type_that_does_not_fit() { + let mut s = session(); + let id = area(&mut s, 100.0, 20.0, "Ab\nCd\nEf\nGh", json!({})); + assert!(overflows(&s, id)); + let one = format!("1 text object doesn't fit its frame: the text past the end of the frame isn't drawn in the export (id {})", id.0); + for format in DRAWING_FORMATS { + assert_eq!(export_overflow(&mut s, format, json!({})), [one.as_str()], "{format}"); + } + // A .ai file carries the whole text in its editing data. + assert_eq!(export_overflow(&mut s, "ai", json!({})), Vec::::new()); + // A frame that holds its text, or a hidden object, leaves nothing out. + s.execute("text.areaOptions", &json!({"ids": [id.0], "height": 150})).unwrap(); + for format in DRAWING_FORMATS { + assert_eq!(export_overflow(&mut s, format, json!({})), Vec::::new(), "{format}"); + } + s.execute("text.areaOptions", &json!({"ids": [id.0], "height": 20})).unwrap(); + s.execute("object.setProps", &json!({"ids": [id.0], "visible": false})).unwrap(); + // The type is hidden, so a rectangle gives the EPS of the visible art something to export. + s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 50, "height": 50})).unwrap(); + for format in DRAWING_FORMATS { + assert_eq!(export_overflow(&mut s, format, json!({})), Vec::::new(), "{format}"); + } +} + +#[test] +fn exports_name_only_the_type_on_the_artboards_and_layers_they_draw() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200, "artboards": 2})).unwrap(); + let r = s.doc().unwrap().doc.artboards[1].rect; + let at = json!({"x": r.x0 + 10.0, "y": r.y0 + 10.0, "text": "Ab\nCd\nEf\nGh", "size": 12, "area": {"width": 100, "height": 20}}); + let v = s.execute("text.create", &at).unwrap(); + assert_eq!(v["overflow"], json!([v["id"]]), "{v}"); + for (format, first, second) in [ + ("pdf", json!({"range": "1"}), json!({"range": "2"})), + ("eps", json!({"useArtboards": true, "range": "1"}), json!({"useArtboards": true, "range": "2"})), + ("svg", json!({"range": "1"}), json!({"range": "2"})), + ("png", json!({"artboard": 0}), json!({"artboard": 1})), + ("emf", json!({"artboard": 0}), json!({"artboard": 1})), + ] { + assert_eq!(export_overflow(&mut s, format, first), Vec::::new(), "{format}"); + assert_eq!(export_overflow(&mut s, format, second).len(), 1, "{format}"); + } + // An EPS of the visible art and an SVG of the whole document hold it too. + assert_eq!(export_overflow(&mut s, "eps", json!({})).len(), 1); + assert_eq!(export_overflow(&mut s, "svg", json!({})).len(), 1); + // On a layer that doesn't print: a PDF leaves it out unless asked to keep it; a PNG draws it. + let layer = s.doc().unwrap().doc.layers[0].id; + s.execute("layer.setProps", &json!({"id": layer.0, "printable": false})).unwrap(); + assert_eq!(export_overflow(&mut s, "pdf", json!({"range": "2"})), Vec::::new()); + assert_eq!(export_overflow(&mut s, "pdf", json!({"range": "2", "includeNonPrinting": true})).len(), 1); + assert_eq!(export_overflow(&mut s, "png", json!({"artboard": 1})).len(), 1); +} diff --git a/docs/mcp.md b/docs/mcp.md index b2a23b9a0..76cb4a294 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -3364,6 +3364,12 @@ left out when everything fits. `text.create` and `text.createInPath` also return as `document.inspect` reports it (after `justify`), so a script can place the type without a helper object (#2413). The warnings of `file.print` and `print.preview` give the ids of the objects that print and whose text doesn't fit: the first 20 ids, then how many more. +In PDF, EPS, SVG, EMF, WMF and the raster formats, the warnings of `document.export`, `document.exportPdf` and +`document.exportEps` give the ids in the same way for the visible objects that the export draws and whose text doesn't +fit. The export draws the objects on the pages or artboards it writes; an EPS of the visible art and an SVG without +chosen artboards draw all of them. A PDF leaves out the layers that don't print unless `includeNonPrinting` or +`createLayers` is set. A `.ai` file keeps the whole text in its editing data and gives no such warning. + ```json {"name":"run_command","arguments":{"command":"text.areaOptions","params":{"fit":"shrinkText","fitMinPercent":60}}} ``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3a38353db..00a36f050 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -595,6 +595,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Character panel's Small Caps, Superscript and Subscript buttons and the panel menu's items of the same names turn small caps and the superscript or subscript position on and off for the selected type, or for the text the Type tool has selected (`text.setFormat` or `text.setRangeStyle` `{smallCaps, position}`), in Document Setup's proportions. Turning Superscript on turns Subscript off, and turning Subscript on turns Superscript off. A button or item shows as on when the first selected character (at a caret, the character before it) has its attribute. - **Paragraph alignment:** the shared Paragraph/Properties alignment row leaves every button unselected when the paragraphs it acts on differ. Auto resolves per paragraph; choosing an alignment uses the existing command. Headless paragraph_mixed_alignment_* regressions exercise mixed → Center → Undo, caret/uniform states, Unicode range boundaries, Auto/direction, split runs and grouped text. These regressions and full integration CI pass. Original captures confirm mixed → Center → Undo in the existing alignment-button layout. The [public Paragraph panel guide](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/paragraph-panel-overview.html) lists the seven controls, but does not specify mixed-state rendering. Other paragraph fields remain outside this change; scores and hours unchanged. For Auto Size area type in columns, the bisection for the frame's height runs on the lines of the one-column layout, and its result is checked with at most two layouts of the columns. When the text wraps around objects or does not fit the one-column layout, or when the check fails, the columns are laid out at every step of the bisection, as before. Typing at the start of a story of 50,000 characters in 2 columns took 1,670 ms a keystroke and now takes 292 ms (release build, `auto_height_in_columns_typing_timing`). + Exports to PDF, EPS, SVG, EMF, WMF and the raster formats warn about the area type and type on a path that they draw without the text that doesn't fit, and give their ids, as Print and Print Preview do. An edit copies a thread's story only when it changes one of the thread's frames: with a story of 50,000 characters threaded through 8 frames with a style change at every word, moving another object took 7.8 ms and now takes 0.013 ms, and a frame of a drag took 7.2 ms and now takes 0.012 ms (release build, `threaded_story_edit_timing`). Type › Optical Margin Alignment (`type.opticalMarginAlignment`) hangs the punctuation and letter edges at the ends of a type object's lines outside its margins: at the left of left-aligned lines, at the right of right-aligned lines, and at both ends of centered and justified lines, in point and area type of left-to-right paragraphs. Quotation marks, hyphens, full stops and commas hang wholly; asterisks, tildes, ellipses, en and em dashes, colons and semicolons hang by half; and A, V, W, Y, v, w and y hang by a tenth of their width. It is off by default and saved with the type object. The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options.