diff --git a/Cargo.lock b/Cargo.lock index b3ec5a0b9..d018aa027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6589,6 +6589,7 @@ name = "vectorcraft-js" version = "0.24.0" dependencies = [ "chrono", + "getrandom 0.4.3", "proptest", "unicode-general-category", ] @@ -6726,6 +6727,7 @@ name = "vectorcraft-scriptcheck" version = "0.24.0" dependencies = [ "proptest", + "unicode-general-category", ] [[package]] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 7b6cdcc58..72e33b899 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -519,9 +519,13 @@ pub(crate) fn checked_ids_param(s: &Session, p: &Value, key: &str, command: &str } /// The objects a command acts on: `ids` when given (every one checked, and those inside another -/// given one dropped), else the selected objects. +/// given one dropped), else (`ids` left out or null) the selected objects. pub(crate) fn checked_ids_or_selection(s: &Session, p: &Value, command: &str) -> Result> { - if p.get("ids").is_some() { Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) } else { edit::selected_roots(s) } + if p.get("ids").is_some_and(|v| !v.is_null()) { + Ok(edit::outermost(&s.doc()?.doc, checked_ids_param(s, p, "ids", command)?)) + } else { + edit::selected_roots(s) + } } pub(crate) fn checked_id(s: &Session, value: &Value, command: &str) -> Result { diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 6e6abfaa4..10fd432df 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -806,7 +806,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { }; // The characters a Type tool has selected (after Select All in threaded text, all the text of // every frame of the story), else all the text of the selected type. - let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; + let editing = if p.get("ids").is_some_and(|v| !v.is_null()) { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) }; let story = if editing.is_some() { editing_story(s) } else { vec![] }; let d = &s.doc()?.doc; // Each type object with the breaks that go from it. diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index 1916d9dd2..0af24be36 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -288,6 +288,18 @@ fn remove_breaks_in_the_characters_a_type_tool_selected() { assert_eq!(obj(&s, id).plain_text(), "a b\nc"); } +/// `ids: null` is `ids` left out: the characters the Type tool selected lose their breaks, and +/// the rest of the text keeps them. +#[test] +fn remove_breaks_with_ids_null_in_the_characters_a_type_tool_selected() { + let mut s = session(); + let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12})); + select(&mut s, &[id]); + edit_text(&mut s, id, 0, 2); + s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph", "ids": null})).unwrap(); + assert_eq!(obj(&s, id).plain_text(), "a b\nc"); +} + #[test] fn split_at_the_cursor_and_around_a_selection() { for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index ccafc96e5..365edbedb 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -95,6 +95,12 @@ pub(crate) const OPTIONS: &[FormatOption] = &[ default: "false", description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)", }, + FormatOption { + name: "longPagesInPoints", + ty: "boolean", + default: "false", + description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version", + }, FormatOption { name: "createLayers", ty: "boolean", @@ -122,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption; PDF/X files, 1.3 and 1.4 write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5; apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/tests/ids_without_selection.rs b/crates/engine/src/tests/ids_without_selection.rs index 8e26577f9..1e13c468c 100644 --- a/crates/engine/src/tests/ids_without_selection.rs +++ b/crates/engine/src/tests/ids_without_selection.rs @@ -436,9 +436,7 @@ fn an_ids_entry_that_is_not_an_id_is_refused_and_changes_nothing() { /// With the rectangle apart (`d`) selected, the same commands with `ids` (or `slices`) left out and /// with it null do the same: what the selection's list does for the commands that take the /// selection, and the command's own default for the others (`object.slice.select` deselects the -/// slices). `addon.color.filter` and `addon.color.applyNearestSwatch` read `ids` through -/// [`cmd::checked_ids_or_selection`], which refuses null as a value that is not a list and changes -/// nothing. +/// slices). #[test] fn ids_left_out_or_null_take_the_selection_or_the_default() { let (_, [.., d], _) = doc_with_type(); @@ -450,15 +448,7 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { let mut null = params.clone(); null[key] = serde_json::Value::Null; let with_null = outcome(cmd, &null, &[d], slice); - if matches!(cmd, "addon.color.filter" | "addon.color.applyNearestSwatch") { - let (mut s, ..) = doc_with_type(); - s.execute("select.set", &json!({ "ids": [d] })).unwrap(); - let st = s.doc().unwrap(); - let refused = with_null.0.as_ref().is_err_and(|e| e.ends_with("ids must be an array of non-negative integer object ids")); - if !refused || (&with_null.1, &with_null.2, with_null.3) != (&*st.doc, &st.selection, st.history.undo.len()) { - failures.push(format!("{cmd} {key}: null is not refused with nothing changed ({:.160?})", with_null.0)); - } - } else if with_null != left_out { + if with_null != left_out { failures.push(format!("{cmd} {key}: null does not do what leaving it out does ({:.160?})", left_out.0)); } let mut listed = params.clone(); @@ -477,6 +467,65 @@ fn ids_left_out_or_null_take_the_selection_or_the_default() { assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); } +/// The commands that read `ids` through [`cmd::checked_ids_or_selection`] take `ids: null` as left +/// out: with the rectangle apart (`d`) or the type object (`t`) selected, null gives the same +/// result or error, document, selection and undo history as leaving `ids` out. Print Ink Fixes +/// runs in a CMYK document. +#[test] +fn ids_null_takes_the_selection_in_every_command_that_falls_back_to_it() { + let (_, [.., d], t) = doc_with_type(); + let mut failures = vec![]; + let (rect, both, text) = (&[d][..], &[d, t][..], &[t][..]); + let cases = [ + ("addon.arrange.randomizeOpacity", json!({"seed": 1}), rect), + ("addon.arrange.sort", json!({"by": "area", "apply": "x"}), both), + ("addon.cleanup.simplify", json!({}), rect), + ("addon.cleanup.deepUngroup", json!({}), rect), + ("addon.cleanup.rejoin", json!({}), rect), + ("addon.color.nearestSwatch", json!({}), rect), + ("addon.pathtools.cornerSet", json!({"radius": 2}), rect), + ("addon.pathtools.cornerRemove", json!({}), rect), + ("addon.pathtools.shape.convert", json!({}), rect), + ("addon.pathtools.erase", json!({"points": [[0, 20], [200, 20]]}), rect), + ("addon.pathtools.crossings", json!({}), rect), + ("addon.pathtools.expandDashes", json!({}), rect), + ("object.path.smooth", json!({}), rect), + ("object.textWrap.make", json!({}), rect), + ("object.textWrap.release", json!({}), rect), + ("object.textWrap.options", json!({}), rect), + ("variable.bind", json!({"variable": "v"}), rect), + ("variable.unbind", json!({}), rect), + ("addon.cleanup.breakText", json!({}), text), + ("addon.cleanup.combineText", json!({}), text), + ("addon.cleanup.joinText", json!({}), text), + ("addon.cleanup.removeBreaks", json!({"kind": "line"}), text), + ("addon.cleanup.resetTextTransform", json!({}), text), + ("addon.cleanup.alignInPlace", json!({"align": "left"}), text), + ]; + for (cmd, params, selected) in cases { + let left_out = outcome(cmd, ¶ms, selected, false); + let mut null = params.clone(); + null["ids"] = serde_json::Value::Null; + let with_null = outcome(cmd, &null, selected, false); + if with_null != left_out { + failures.push(format!("{cmd}: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + } + let print_fixes = |p: &serde_json::Value| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200, "colorMode": "cmyk", "created": null})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 40})).unwrap(); + let r = s.execute("addon.color.printFixes", p).map_err(|e| e.to_string()); + let st = s.doc().unwrap(); + (r, (*st.doc).clone(), st.selection.clone(), st.history.undo.len()) + }; + let (left_out, with_null) = (print_fixes(&json!({"inkLimit": 200})), print_fixes(&json!({"inkLimit": 200, "ids": null}))); + if with_null != left_out { + failures.push(format!("addon.color.printFixes: null gives {:.160?}; left out gives {:.160?}", with_null.0, left_out.0)); + } + assert!(failures.is_empty(), "{} fail:\n{}", failures.len(), failures.join("\n")); +} + /// With no document open, every command that takes `ids` refuses a list with an entry that is not /// a non-negative integer, and a value that is not a list, with the same error as with a document /// open (`effect.list` and `links.findFiles` run without a document). diff --git a/crates/engine/src/tests/pdfpresets.rs b/crates/engine/src/tests/pdfpresets.rs index eee88c5d1..7de08463f 100644 --- a/crates/engine/src/tests/pdfpresets.rs +++ b/crates/engine/src/tests/pdfpresets.rs @@ -123,6 +123,46 @@ fn presets_export_and_import_round_trip() { assert!(is_bad_params(Session::new().execute("pdf.preset.export", &json!({}))), "nothing saved to export"); } +/// `longPagesInPoints` is saved with a preset, comes back through a presets file and the +/// preferences, shows in `document.pdfSettings`, and a 10 m page exported with it reopens at 10 m. +/// A value that isn't a boolean is refused. +#[test] +fn long_pages_in_points_round_trips_through_presets_and_reopens_at_full_size() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 28_346.456_692_913_386, "height": 2_834.645_669_291_338_5})).unwrap(); + s.execute("pdf.preset.save", &json!({"name": "Banner", "longPagesInPoints": true, "preserveEditing": false})).unwrap(); + assert!(s.prefs.pdf_presets[0].settings.long_pages_in_points); + let back: crate::Prefs = serde_json::from_value(s.prefs.to_json()).unwrap(); + assert_eq!(back.pdf_presets, s.prefs.pdf_presets); + let text = s.execute("pdf.preset.export", &json!({"names": ["Banner"]})).unwrap()["data"].as_str().unwrap().to_string(); + let mut t = session(); + t.execute("pdf.preset.import", &json!({"data": text})).unwrap(); + assert_eq!(t.prefs.pdf_presets, s.prefs.pdf_presets); + let v = s.execute("document.pdfSettings", &json!({"longPagesInPoints": true})).unwrap(); + assert_eq!((&v["settings"]["longPagesInPoints"], &v["changed"]), (&json!(true), &json!([{"option": "longPagesInPoints", "value": true}])), "{v}"); + assert_eq!(s.execute("document.pdfSettings", &json!({})).unwrap()["settings"]["longPagesInPoints"], json!(false)); + // The preset writes the page in points; the file reopens at 10 m. + let v = s.execute("document.exportPdf", &json!({"preset": "Banner"})).unwrap(); + let bytes = pdf(&v); + assert!(!String::from_utf8_lossy(&bytes).contains("/UserUnit")); + assert!( + v["warnings"] + .as_array() + .unwrap() + .iter() + .any(|w| w.as_str().unwrap().ends_with("written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in")), + "{v}" + ); + let mut o = Session::new(); + o.execute("document.open", &json!({"name": "banner.pdf", "dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap(); + let rect = o.doc().unwrap().doc.artboards[0].rect; + assert!((rect.width() - 28_346.46).abs() < 0.01 && (rect.height() - 2_834.65).abs() < 0.01, "{rect:?}"); + for bad in [json!("yes"), json!(1)] { + assert!(is_bad_params(s.execute("document.exportPdf", &json!({"longPagesInPoints": bad}))), "{bad}"); + assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "Bad", "longPagesInPoints": bad}))), "{bad}"); + } +} + #[test] fn saved_presets_persist_with_the_preferences() { let mut s = session(); diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 3c647deb0..a4be6e554 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -13,6 +13,11 @@ use crate::*; const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm"); const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate"; +/// Held by the tests that set the Additional Plug-ins Folder preference or run `plugin.reload`: +/// the folder last loaded and its report are kept for the whole process, so another test loading +/// its own folder between two steps would change what the next step loads or lists. +static PREFS_FOLDER: std::sync::Mutex<()> = std::sync::Mutex::new(()); + fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } @@ -1278,6 +1283,7 @@ fn filters_keep_their_data_with_the_document() { #[test] fn plugin_management_commands() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); install(&mut s, DESATURATE); let list = run(&mut s, "plugin.list", json!({})); @@ -1319,6 +1325,7 @@ fn plugin_management_commands() { /// installs what is in it, as setting any other folder does. #[test] fn plugins_folder_set_again_after_clearing_installs_its_plugins() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let dir = crate::tests_links::Folder::new("plugin-folder-cleared"); let id = "test.engine.foldercleared"; let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#); @@ -1998,6 +2005,7 @@ fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() { /// the plug-in store stays, with the stored module it covers. #[test] fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderstale"; let empty = json!({"objects": []}); @@ -2175,6 +2183,7 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { /// grants held by the process. #[test] fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let mut s = session(); let id = "test.engine.permissions.folderbytes"; let plain = "test.engine.permissions.folderplain"; @@ -2403,6 +2412,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() { /// until it is granted on its own. #[test] fn granted_permissions_are_kept_and_folders_skip_the_rest() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grants"); let id = "test.engine.permissions.stored"; let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); @@ -2491,6 +2501,7 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() { /// covers no module. #[test] fn a_grant_covers_only_the_module_it_was_given_to() { + let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner()); let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); let id = "test.engine.granthash"; let mut s = session(); diff --git a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs index 6c84c66b3..c9b5ac466 100644 --- a/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs +++ b/crates/engine/tests/it/js_lexer_vs_scriptcheck.rs @@ -1,8 +1,8 @@ //! The JavaScript parser's lexer (`vectorcraft-js`) and the script checker's (`vectorcraft-scriptcheck`) //! read the same scripts the same way: both crates stand alone, so each has its own lexer, and these //! tests run both over one set of scripts and fail on any disagreement about the lines a script has, -//! its preprocessor lines, and where a string, comment or regular expression that is never closed -//! starts. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which +//! its preprocessor lines, where a string, comment or regular expression that is never closed +//! starts, and the characters a name may hold. The scripts stay inside ECMAScript 3, the language both read (no XML literals, which //! only the checker reads). use vectorcraft_js::{Options, TokenKind, parse, tokenize}; @@ -88,6 +88,42 @@ fn both_lexers_find_the_same_unclosed_token_on_the_same_line() { } } +/// Characters on both sides of the identifier rule: `Other_ID_Start`, `Other_ID_Continue`, connector +/// punctuation, marks, digits and letters of other scripts, and a superscript digit, a +/// `Pattern_Syntax` letter and punctuation that no name holds. +const NAME_CHARS: &[char] = &[ + '\u{AA}', '\u{B2}', '\u{B7}', '\u{D7}', '\u{300}', '\u{387}', '\u{660}', '\u{1369}', '\u{1885}', '\u{19DA}', '\u{200C}', '\u{200D}', '\u{203F}', + '\u{2040}', '\u{2118}', '\u{212E}', '\u{2160}', '\u{2E2F}', '\u{3005}', '\u{309B}', '\u{30FB}', '\u{FE33}', '\u{FF10}', '\u{FF65}', +]; + +/// Whether the parser reads `src` as one identifier spelling `name`. +fn one_identifier(src: &str, name: &str) -> bool { + let want = TokenKind::Ident(name.to_string()); + tokenize(src, &js_options()).is_ok_and(|tokens| tokens.iter().filter(|t| t.kind != TokenKind::Eof).map(|t| &t.kind).eq([&want])) +} + +/// Whether the checker lists the member `app.` as `app.`. +fn checker_member(src: &str, name: &str) -> bool { + let report = check("script.jsx", format!("app.{src};\n").as_bytes(), None, &Limits::default()); + report.members.iter().any(|m| m.name == format!("app.{name}")) +} + +#[test] +fn both_lexers_take_the_same_characters_into_names() { + assert!(checker_member("a\u{2118}b", "a\u{2118}b") && one_identifier("a\u{2118}b", "a\u{2118}b")); + for &c in NAME_CHARS { + let escape = format!("\\u{:04X}", u32::from(c)); + for (src, name) in [ + (format!("a{c}b"), format!("a{c}b")), + (format!("{c}b"), format!("{c}b")), + (format!("a{escape}b"), format!("a{c}b")), + (format!("{escape}b"), format!("{c}b")), + ] { + assert_eq!(checker_member(&src, &name), one_identifier(&src, &name), "{src:?} (U+{:04X})", u32::from(c)); + } + } +} + #[test] fn the_parser_reads_a_regexp_wherever_the_checker_does() { // Each `/` that divides has no second `/` after it on its line, so a lexer that read one as a diff --git a/crates/engine/tests/plugin_fuzz.rs b/crates/engine/tests/plugin_fuzz.rs index 0bc8615d6..58cc30227 100644 --- a/crates/engine/tests/plugin_fuzz.rs +++ b/crates/engine/tests/plugin_fuzz.rs @@ -15,7 +15,7 @@ use proptest::prelude::*; use serde_json::{Map, Value, json}; use vectorcraft_engine::Session; use vectorcraft_plugins::wat as tpl; -use vectorcraft_plugins::{Error, Kind, Limits, Plugin}; +use vectorcraft_plugins::{BYTES_COPIED_PER_FUEL, Error, FUEL_SLICE, Kind, Limits, Plugin}; use vectorcraft_testkit::catch_quiet; use vectorcraft_testkit::fixtures::rich_session; @@ -187,9 +187,6 @@ fn arb_paint() -> impl Strategy { /// Bytes in the passive data segment that bulk memory cases initialize memory from. const SEGMENT_LEN: u32 = 4096; -/// Fuel the host hands a run per slice (`FUEL_SLICE` in `vectorcraft_plugins`' runtime). -const FUEL_SLICE: u64 = 20_000_000; - /// Where a bulk memory plug-in keeps its output (the manifest sits at 16). const BULK_OUTPUT_AT: u32 = 0x8000; @@ -319,10 +316,10 @@ impl BulkCase { } } - /// A generous estimate of the fuel the instructions use: a unit per 64 bytes moved, and a few - /// per pass. + /// A generous estimate of the fuel the instructions use: a unit per [`BYTES_COPIED_PER_FUEL`] + /// bytes moved, and a few per pass. fn fuel(self) -> u64 { - u64::from(self.runs()) * (u64::from(self.len) / 64 + 16) + u64::from(self.runs()) * (u64::from(self.len) / u64::from(BYTES_COPIED_PER_FUEL) + 16) } /// The plug-in module in WAT, with `manifest`. @@ -352,11 +349,12 @@ impl BulkCase { } } -/// Lengths: zero, a byte, around the 64 bytes a unit of fuel pays for, the data segment's length, -/// a page, a mebibyte, or anything up to four pages. +/// Lengths: zero, a byte, around the bytes a unit of fuel pays for, the data segment's length, a +/// page, a mebibyte, or anything up to four pages. fn arb_bulk_len() -> impl Strategy { + let per = BYTES_COPIED_PER_FUEL; prop_oneof![ - 3 => prop::sample::select(vec![0u32, 1, 63, 64, 65, SEGMENT_LEN - 1, SEGMENT_LEN, SEGMENT_LEN + 1, 0xffff, 0x1_0000, 0x1_0001, 1 << 20]), + 3 => prop::sample::select(vec![0u32, 1, per - 1, per, per + 1, SEGMENT_LEN - 1, SEGMENT_LEN, SEGMENT_LEN + 1, 0xffff, 0x1_0000, 0x1_0001, 1 << 20]), 1 => 0u32..0x4_0000, ] } @@ -394,7 +392,7 @@ fn arb_bulk_case() -> impl Strategy { let repeat = match repeat { Repeat::Times(n) => n, Repeat::AroundSlice(k) => { - let slice = (FUEL_SLICE * 64 / u64::from(len.max(1))) as i64; + let slice = (FUEL_SLICE * u64::from(BYTES_COPIED_PER_FUEL) / u64::from(len.max(1))) as i64; (slice + k).clamp(1, 2_000) as u32 } }; diff --git a/crates/js/Cargo.toml b/crates/js/Cargo.toml index 5848c1a91..610c8f9bd 100644 --- a/crates/js/Cargo.toml +++ b/crates/js/Cargo.toml @@ -12,6 +12,12 @@ workspace = true [dependencies] chrono = { workspace = true } unicode-general-category = "1" +# The seed of Math.random (interp.rs). +getrandom = "0.4" + +[target.'cfg(all(target_arch = "wasm32", target_os = "unknown"))'.dependencies] +# The browser's crypto.getRandomValues. +getrandom = { version = "0.4", features = ["wasm_js"] } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/js/src/builtins_tests.rs b/crates/js/src/builtins_tests.rs index e0593d1dd..9d917ae32 100644 --- a/crates/js/src/builtins_tests.rs +++ b/crates/js/src/builtins_tests.rs @@ -605,6 +605,17 @@ fn math_functions_follow_the_special_cases() { truth("var n = 0; Math.max({valueOf: function () { n++; return NaN; }}, {valueOf: function () { n++; return 1; }}); n === 2"); } +#[test] +fn math_random_is_seeded_from_the_os_random_source() { + // On wasm32-unknown-unknown `RandomState`'s keys come from memory addresses, which are the + // same on every page load: the seed must come from the OS (the browser's crypto) instead. + let src = include_str!("interp.rs"); + let Some(start) = src.find("fn random_seed()") else { panic!("interp.rs has no random_seed") }; + let body = &src[start..]; + let body = &body[..body.find("\n}").unwrap_or(body.len())]; + assert!(body.contains("getrandom::u64()"), "Math.random isn't seeded from the OS random source"); +} + // ---- JSON ---- #[test] diff --git a/crates/js/src/interp.rs b/crates/js/src/interp.rs index 64204423f..5d59d92d2 100644 --- a/crates/js/src/interp.rs +++ b/crates/js/src/interp.rs @@ -307,13 +307,16 @@ fn index_keys_bytes(len: usize) -> usize { total } -/// A seed for `Math.random` that differs from realm to realm, from the standard library's -/// randomly keyed hasher; never zero. +/// A seed for `Math.random` that differs from realm to realm and from run to run, from the OS +/// random source (the browser's crypto on the web), or the standard library's randomly keyed +/// hasher when that fails; never zero. fn random_seed() -> u64 { use std::hash::{BuildHasher, Hasher}; - let mut h = std::collections::hash_map::RandomState::new().build_hasher(); - h.write_u64(0x9e37_79b9_7f4a_7c15); - h.finish() | 1 + getrandom::u64().unwrap_or_else(|_| { + let mut h = std::collections::hash_map::RandomState::new().build_hasher(); + h.write_u64(0x9e37_79b9_7f4a_7c15); + h.finish() + }) | 1 } /// The estimated bytes of a binding or property named by `len` code units. diff --git a/crates/pdf/src/export/entry.rs b/crates/pdf/src/export/entry.rs index 143fa848f..fc4848521 100644 --- a/crates/pdf/src/export/entry.rs +++ b/crates/pdf/src/export/entry.rs @@ -192,9 +192,10 @@ const MIN_PAGE_SIDE: f64 = 3.0; const MAX_PDF14_REAL: f64 = 32_767.0; /// Pages with a side longer than [`MAX_PAGE_SIDE`] are written with a `UserUnit`: in PDF 1.6 and -/// later files that aren't PDF/X files. Other files have every page in points. +/// later files that aren't PDF/X files, unless `long_pages_in_points` is on. Other files have +/// every page in points. fn writes_user_units(set: &PdfSettings) -> bool { - set.compatibility.has_user_unit() && !set.standard.is_pdfx() + set.compatibility.has_user_unit() && !set.standard.is_pdfx() && !set.long_pages_in_points } /// The `UserUnit` (PDF 1.6) of a page `w` × `h` points large: 1 when both sides are at most @@ -220,15 +221,15 @@ pub fn check_page_sizes(doc: &Document, opts: &PdfOptions) -> Result<(), PdfErro /// The settings `set` is written with when one of `pages` has a side longer than /// [`MAX_PAGE_SIDE`]: a PDF 1.5 file that isn't a PDF/X file is written as PDF 1.6, which has a -/// page's `UserUnit` (`None`: `set` as it is). Other files without a `UserUnit` have the page in -/// points. The writer puts a whole number as an integer and fails a PDF 1.3 or 1.4 file that has +/// page's `UserUnit`, unless `long_pages_in_points` is on (`None`: `set` as it is). Other files +/// without a `UserUnit` have the page in points. The writer puts a whole number as an integer and fails a PDF 1.3 or 1.4 file that has /// a real number past [`MAX_PDF14_REAL`]: a page whose side is past it and not a whole number of /// points is refused here, before anything is drawn ([`page_too_long`]). fn for_long_pages(set: &PdfSettings, pages: &[(usize, PageBoxes)]) -> Result, PdfError> { if !pages.iter().any(|(_, b)| b.media.width().max(b.media.height()) > MAX_PAGE_SIDE) { return Ok(None); } - let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx()) + let raised = (set.compatibility == Compatibility::Pdf15 && !set.standard.is_pdfx() && !set.long_pages_in_points) .then(|| PdfSettings { compatibility: Compatibility::Pdf16, ..set.clone() }); // The writer's test for a whole number, on the `f32` it writes. let real = |side: f64| side > MAX_PDF14_REAL && (side as f32) as i32 as f32 != side as f32; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 17f37f90b..b4b25baeb 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -236,6 +236,10 @@ pub struct PdfSettings { /// Keep the layers whose Print option is off (they are left out otherwise, unless /// `create_layers` is on). pub include_non_printing: bool, + /// Write a page with a side longer than 14,400 pt (200 in) at its size in points, without a + /// `UserUnit`, at every PDF version (off: PDF 1.6 and later files that aren't PDF/X files + /// write it with a `UserUnit`, and PDF 1.5 files are written as PDF 1.6 for it). + pub long_pages_in_points: bool, pub compression: CompressionSettings, pub marks: MarkSettings, pub bleed: BleedSettings, diff --git a/crates/pdf/src/tests/marks.rs b/crates/pdf/src/tests/marks.rs index 19031505d..c829ed8d5 100644 --- a/crates/pdf/src/tests/marks.rs +++ b/crates/pdf/src/tests/marks.rs @@ -418,6 +418,79 @@ fn a_page_within_14400_pt_is_written_as_before_at_every_version() { assert!(r.bytes.starts_with(b"%PDF-1.5") && has(b"/CFM/V2") && !has(b"AESV2")); } +/// 10 m in points. +const TEN_M: f64 = 10.0 / 0.0254 * 72.0; + +/// With `long_pages_in_points`, a 10 m page at PDF 1.7 is written at its size in points, with no +/// `UserUnit`, the warning says so, and the page reads back at 10 m. Without it the page keeps +/// its `UserUnit`. +#[test] +fn long_pages_in_points_writes_a_10_m_page_in_points() { + let d = sized(TEN_M, TEN_M / 10.0); + let r = report(&d, PdfSettings { long_pages_in_points: true, ..PdfSettings::default() }).unwrap(); + assert!(r.bytes.starts_with(b"%PDF-1.7")); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new()); + let media = raw_numbers(&r.bytes, "/MediaBox", 4); + assert!((media[2] - media[0] - 28_346.46).abs() < 0.01 && (media[3] - media[1] - 2_834.65).abs() < 0.01, "{media:?}"); + assert_eq!( + long_page_warnings(&r), + [ + "the page of artboard 1 is 28347 × 2835 pt (10.00 × 1.00 m), longer than PDF's page limit of 14,400 pt (200 in): PDF 1.7 pages are written without a UserUnit, in points, and apps that keep to the limit cut the page to 200 in" + ], + "{:?}", + r.warnings + ); + let page = &info(&r.bytes, None).unwrap().pages[0]; + assert!((page.width - TEN_M).abs() < 0.01 && (page.height - TEN_M / 10.0).abs() < 0.01, "{page:?}"); + let back = import(&r.bytes).unwrap(); + let rect = back.artboards[0].rect; + assert!((rect.width() - TEN_M).abs() < 0.01 && (rect.height() - TEN_M / 10.0).abs() < 0.01, "{rect:?}"); + // Off (the default), the page has a `UserUnit` of 28,346.46 / 14,400, rounded up. + let off = report(&d, PdfSettings::default()).unwrap(); + assert_eq!(raw_numbers(&off.bytes, "/UserUnit", 1), [1.969]); + assert!( + long_page_warnings(&off)[0].ends_with("it is written with a UserUnit of 1.969, so apps that ignore UserUnit show it that many times smaller") + ); + assert!(!settings(json!({})).long_pages_in_points, "off by default"); +} + +/// With `long_pages_in_points`, every PDF version writes a 10 m page in points: PDF 1.5 stays +/// PDF 1.5, and versions that write it in points anyway (PDF 1.3 and 1.4, PDF/X-4) give the same +/// file as without it. +#[test] +fn long_pages_in_points_keeps_every_version_and_changes_nothing_where_pages_are_in_points_already() { + let d = sized(TEN_M, TEN_M / 10.0); + let with = |set: &PdfSettings| PdfSettings { long_pages_in_points: true, ..set.clone() }; + for c in Compatibility::ALL.iter().copied() { + let set = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let r = report(&d, with(&set)).unwrap(); + assert!(r.bytes.starts_with(format!("%PDF-{}", c.id()).as_bytes()), "{}", c.label()); + assert_eq!(raw_numbers(&r.bytes, "/UserUnit", 1), Vec::::new(), "{}", c.label()); + assert!((raw_meters(&r.bytes).0 - 10.0).abs() < 0.001, "{}", c.label()); + assert!(!r.warnings.iter().any(|w| w.starts_with("PDF 1.5 has no UserUnit")), "{}: {:?}", c.label(), r.warnings); + assert_eq!(long_page_warnings(&r).len(), 1, "{}: {:?}", c.label(), r.warnings); + if matches!(c, Compatibility::Pdf13 | Compatibility::Pdf14) { + assert_eq!(r.bytes, report(&d, set).unwrap().bytes, "{}", c.label()); + } + } + let x4 = crate::builtin_presets().into_iter().find(|p| p.name == "PDF/X-4:2010").unwrap().settings; + assert_eq!(report(&d, with(&x4)).unwrap().bytes, report(&d, x4).unwrap().bytes); +} + +/// A page of 14,400 pt or less gives the same file with `long_pages_in_points` on or off, at every +/// PDF version. +#[test] +fn long_pages_in_points_leaves_a_page_within_14400_pt_as_it_is() { + let d = sized(14_400.0, 300.0); + for c in Compatibility::ALL.iter().copied() { + let off = PdfSettings { compatibility: c, ..PdfSettings::default() }; + let on = PdfSettings { long_pages_in_points: true, ..off.clone() }; + let (off, on) = (report(&d, off).unwrap(), report(&d, on).unwrap()); + assert_eq!(on.bytes, off.bytes, "{}", c.label()); + assert_eq!(on.warnings, off.warnings, "{}", c.label()); + } +} + #[test] fn art_in_the_bleed_is_kept_and_clipped_to_the_bleed_box() { let d = doc(); diff --git a/crates/plugins/src/lib.rs b/crates/plugins/src/lib.rs index 8b57adbe6..c71200187 100644 --- a/crates/plugins/src/lib.rs +++ b/crates/plugins/src/lib.rs @@ -54,6 +54,8 @@ pub use dialog::ParamUi; pub use host::{LogLevel, LogLine, Report}; pub use manifest::{Budget, Command, InputKind, Kind, Manifest, Param, ParamSpec}; pub use runtime::{ABI_VERSION, HEAVY_FUEL, HEAVY_WALL_TIME_MS, Limits, Plugin, heavy_terms}; +#[doc(hidden)] +pub use runtime::{BYTES_COPIED_PER_FUEL, FUEL_SLICE}; /// What the host puts before a plug-in's id to name what it adds: its live effect /// (`plugin.`, [`effect`]) or its file format ([`export`], [`import`]). diff --git a/crates/plugins/src/runtime.rs b/crates/plugins/src/runtime.rs index 863ec0a3a..5a2db2ff0 100644 --- a/crates/plugins/src/runtime.rs +++ b/crates/plugins/src/runtime.rs @@ -14,7 +14,7 @@ use crate::{Error, Kind, Result}; pub const ABI_VERSION: i32 = 1; /// Fuel handed out per slice; between slices the host checks the deadline. -const FUEL_SLICE: u64 = 20_000_000; +pub const FUEL_SLICE: u64 = 20_000_000; /// wasmi's fuel costs, but compiling costs nothing. wasmi compiles a function when it is first /// called, and charges the run that calls it by the function's size. When the slice a run was in @@ -24,6 +24,9 @@ const FUEL_SLICE: u64 = 20_000_000; /// the next. Copying keeps wasmi's default cost, a unit for every 64 bytes. const FUEL_COSTS: CustomFuelCosts = CustomFuelCosts { bytes_copied_per_fuel: 64, fuel_per_bytes_translated: 0, fuel_per_bytes_validated: 0 }; +/// Bytes a bulk memory instruction copies or fills for each unit of fuel. +pub const BYTES_COPIED_PER_FUEL: u32 = FUEL_COSTS.bytes_copied_per_fuel; + /// Instructions a heavy filter's run may use besides [`Limits::fuel_per_byte`] for each byte of /// input ([`crate::Budget::Heavy`]): 20 times a light one's. Its output's size can't add to it: /// the host learns that only once the run has ended. diff --git a/crates/scriptcheck/Cargo.toml b/crates/scriptcheck/Cargo.toml index d0101be96..2e9dac687 100644 --- a/crates/scriptcheck/Cargo.toml +++ b/crates/scriptcheck/Cargo.toml @@ -9,5 +9,8 @@ rust-version.workspace = true [lints] workspace = true +[dependencies] +unicode-general-category = "1" + [dev-dependencies] proptest = { workspace = true } diff --git a/crates/scriptcheck/src/analyze.rs b/crates/scriptcheck/src/analyze.rs index 1e5b21fc2..9a637274d 100644 --- a/crates/scriptcheck/src/analyze.rs +++ b/crates/scriptcheck/src/analyze.rs @@ -116,8 +116,9 @@ pub(crate) fn scan<'s>(src: &'s str, declared: &Declared<'s>, file: Option<&str> } /// `src` with the `\uXXXX` escapes in its names written as the characters they stand for (ECMA-262 -/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character no -/// name may hold stays as written. Line breaks don't change, so every line keeps its number. +/// 3rd edition, section 7.6), and `src` itself when no name has one. An escape for a character a +/// name may not hold where the escape stands stays as written. Line breaks don't change, so every +/// line keeps its number. pub(crate) fn unescaped<'a>(src: Cow<'a, str>, acc: &mut Acc) -> Cow<'a, str> { match unescape_names(&src, acc) { Some(out) => Cow::Owned(out), @@ -145,20 +146,17 @@ fn unescape_names(src: &str, acc: &mut Acc) -> Option { changed.then_some(out) } -/// The name `text` with each `\uXXXX` escape for a character a name may hold written as that -/// character. +/// The name `text` with each `\uXXXX` escape for a character a name may hold where it stands written +/// as that character: a character a name may start with first, and one a name may hold after that. fn unescape_name(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut rest = text; while let Some(i) = rest.find("\\u") { out.push_str(rest.get(..i).unwrap_or_default()); let after = rest.get(i + 2..).unwrap_or_default(); - let c = after - .get(..4) - .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) - .and_then(|hex| u32::from_str_radix(hex, 16).ok()) - .and_then(char::from_u32) - .filter(|&c| crate::lex::ident_part(c)); + let first = out.is_empty(); + let c = crate::lex::escaped(rest.get(i..).unwrap_or_default()) + .filter(|&c| if first { crate::lex::ident_start(c) } else { crate::lex::ident_part(c) }); match c { Some(c) => { out.push(c); diff --git a/crates/scriptcheck/src/lex.rs b/crates/scriptcheck/src/lex.rs index be66dd4b1..92d8c72e0 100644 --- a/crates/scriptcheck/src/lex.rs +++ b/crates/scriptcheck/src/lex.rs @@ -8,6 +8,10 @@ //! level and the levels of XML literals and their code are kept on stacks capped at the nesting //! limit. +use std::cmp::Ordering; + +use unicode_general_category::{GeneralCategory as G, get_general_category}; + use crate::catalog; use crate::report::{TOO_DEEP, UNTERMINATED_COMMENT, UNTERMINATED_REGEXP, UNTERMINATED_STRING, UNTERMINATED_TEMPLATE}; @@ -130,13 +134,105 @@ fn is_blank(c: char) -> bool { !is_break(c) && (c.is_whitespace() || c == '\u{feff}') } -fn ident_start(c: char) -> bool { - c == '$' || c == '_' || c.is_ascii_alphabetic() || (!c.is_ascii() && c.is_alphabetic()) +/// A character a name may start with: a Unicode `ID_Start` character, `$` or `_` (the Names and +/// Keywords clause of current ECMA-262 editions). `ID_Start` is the letters (categories Lu, Ll, Lt, +/// Lm, Lo and Nl) and `Other_ID_Start`, less `Pattern_Syntax` and `Pattern_White_Space` (Unicode +/// Standard Annex #31). The categories come from Unicode 16.0. +pub(crate) fn ident_start(c: char) -> bool { + match c { + '$' | '_' => true, + _ if c.is_ascii() => c.is_ascii_alphabetic(), + _ if is_other_id_start(c) => true, + _ => { + matches!( + get_general_category(c), + G::UppercaseLetter | G::LowercaseLetter | G::TitlecaseLetter | G::ModifierLetter | G::OtherLetter | G::LetterNumber + ) && !is_pattern_syntax(c) + } + } } -/// A character a name may hold after its first. +/// A character a name may hold after its first: a Unicode `ID_Continue` character or `$`. +/// `ID_Continue` adds to `ID_Start` the decimal digits (Nd), combining marks (Mn, Mc), connector +/// punctuation (Pc, which holds `_`) and `Other_ID_Continue` (which holds the zero width non-joiner +/// and joiner), less `Pattern_Syntax` and `Pattern_White_Space`. pub(crate) fn ident_part(c: char) -> bool { - ident_start(c) || c.is_ascii_digit() || (!c.is_ascii() && c.is_alphanumeric()) + match c { + '0'..='9' => true, + _ if ident_start(c) => true, + _ if c.is_ascii() => false, + _ if is_other_id_continue(c) => true, + _ => { + matches!(get_general_category(c), G::NonspacingMark | G::SpacingMark | G::DecimalNumber | G::ConnectorPunctuation) + && !is_pattern_syntax(c) + } + } +} + +/// The character the `\uXXXX` escape at the start of `s` stands for: `None` when `s` doesn't start +/// with `\u` and four hexadecimal digits, or when they give a lone surrogate. +pub(crate) fn escaped(s: &str) -> Option { + s.strip_prefix("\\u")? + .get(..4) + .filter(|hex| hex.bytes().all(|b| b.is_ascii_hexdigit())) + .and_then(|hex| u32::from_str_radix(hex, 16).ok()) + .and_then(char::from_u32) +} + +/// `Other_ID_Start` (Unicode PropList 16.0): characters kept in `ID_Start` though their category is +/// not a letter. +fn is_other_id_start(c: char) -> bool { + matches!(c, '\u{1885}' | '\u{1886}' | '\u{2118}' | '\u{212E}' | '\u{309B}' | '\u{309C}') +} + +/// `Other_ID_Continue` (Unicode PropList 16.0): characters kept in `ID_Continue` though their +/// category is not a letter, mark, digit or connector. +fn is_other_id_continue(c: char) -> bool { + matches!(c, '\u{B7}' | '\u{387}' | '\u{1369}'..='\u{1371}' | '\u{19DA}' | '\u{200C}' | '\u{200D}' | '\u{30FB}' | '\u{FF65}') +} + +/// Non-ASCII `Pattern_Syntax` ranges (Unicode PropList). Unicode does not change this set, and no +/// identifier character belongs to it. `Pattern_White_Space` outside ASCII (U+0085, U+200E, U+200F, +/// U+2028 and U+2029) holds no letter, mark, digit or connector, so the category checks leave it out. +const PATTERN_SYNTAX: [(char, char); 23] = [ + ('\u{A1}', '\u{A7}'), + ('\u{A9}', '\u{A9}'), + ('\u{AB}', '\u{AC}'), + ('\u{AE}', '\u{AE}'), + ('\u{B0}', '\u{B1}'), + ('\u{B6}', '\u{B6}'), + ('\u{BB}', '\u{BB}'), + ('\u{BF}', '\u{BF}'), + ('\u{D7}', '\u{D7}'), + ('\u{F7}', '\u{F7}'), + ('\u{2010}', '\u{2027}'), + ('\u{2030}', '\u{203E}'), + ('\u{2041}', '\u{2053}'), + ('\u{2055}', '\u{205E}'), + ('\u{2190}', '\u{245F}'), + ('\u{2500}', '\u{2775}'), + ('\u{2794}', '\u{2BFF}'), + ('\u{2E00}', '\u{2E7F}'), + ('\u{3001}', '\u{3003}'), + ('\u{3008}', '\u{3020}'), + ('\u{3030}', '\u{3030}'), + ('\u{FD3E}', '\u{FD3F}'), + ('\u{FE45}', '\u{FE46}'), +]; + +/// Whether a non-ASCII character is `Pattern_Syntax`. +fn is_pattern_syntax(c: char) -> bool { + PATTERN_SYNTAX + .binary_search_by(|&(lo, hi)| { + if hi < c { + Ordering::Less + } else if lo > c { + Ordering::Greater + } else { + Ordering::Equal + } + }) + .is_ok() } /// After `<`, the start of an XML literal: a name, `>` (a list), `!` (a comment or CDATA), `?` (a @@ -300,20 +396,13 @@ impl<'s> Lexer<'s> { } } - /// The rest of a name (its first character included): letters, digits, `$`, `_` and `\u` - /// escapes. + /// The rest of a name (its first character included): the characters [`ident_part`] allows, + /// written out or as `\uXXXX` escapes. An escape for any other character, or one without four + /// hexadecimal digits, ends the name. fn name(&mut self) { loop { match self.peek() { - Some('\\') if self.peek_at(1) == Some('u') => { - self.skip(2); - for _ in 0..4 { - match self.peek() { - Some(h) if h.is_ascii_hexdigit() => self.bump(h), - _ => break, - } - } - } + Some('\\') if escaped(self.rest()).is_some_and(ident_part) => self.skip(6), Some(c) if ident_part(c) => self.bump(c), _ => break, } @@ -820,7 +909,7 @@ impl<'s> Iterator for Lexer<'s> { self.close_level(); self.bump('}'); return Some(self.markup_token(start, line, T::XmlMore, stmt, expr)); - } else if ident_start(c) || (c == '\\' && self.peek_at(1) == Some('u')) { + } else if ident_start(c) || (c == '\\' && escaped(self.rest()).is_some_and(ident_start)) { self.name(); T::Ident } else if c.is_ascii_digit() || (c == '.' && self.peek_at(1).is_some_and(|d| d.is_ascii_digit())) { diff --git a/crates/scriptcheck/src/tests.rs b/crates/scriptcheck/src/tests.rs index e7ead63a4..c7eed0c1a 100644 --- a/crates/scriptcheck/src/tests.rs +++ b/crates/scriptcheck/src/tests.rs @@ -268,6 +268,99 @@ fn names_written_with_escapes_are_read_as_the_names_they_spell() { assert_eq!(r.lines, 3); } +/// `src` with the escapes in its names written as the characters they stand for. +fn unescaped(src: &str) -> String { + let limits = Limits::default(); + crate::analyze::unescaped(std::borrow::Cow::Borrowed(src), &mut crate::report::Acc::new(&limits)).into_owned() +} + +#[test] +fn names_are_made_of_the_unicode_identifier_characters() { + // U+2118 (Other_ID_Start) starts a name, and U+203F (connector punctuation), U+00B7 + // (Other_ID_Continue) and the zero width non-joiner and joiner continue one. + for name in ["\u{2118}a", "a\u{203F}b", "a\u{B7}b", "a\u{200C}b", "a\u{200D}b", "_\u{2118}", "$_a1", "\u{E9}t\u{E9}"] { + assert_eq!(tokens(name), [(T::Ident, name)], "{name:?}"); + } + for (src, want) in [ + // Characters that only continue a name, and digits, don't start one. + ("\u{203F}a", [(T::Other, "\u{203F}"), (T::Ident, "a")]), + ("\u{B7}a", [(T::Other, "\u{B7}"), (T::Ident, "a")]), + ("\u{200C}a", [(T::Other, "\u{200C}"), (T::Ident, "a")]), + ("0a", [(T::Number, "0"), (T::Ident, "a")]), + // A superscript digit (category No) and a letter that is Pattern_Syntax are in no name. + ("a\u{B2}", [(T::Ident, "a"), (T::Other, "\u{B2}")]), + ("a\u{2E2F}", [(T::Ident, "a"), (T::Other, "\u{2E2F}")]), + ("\u{2E2F}a", [(T::Other, "\u{2E2F}"), (T::Ident, "a")]), + // `#` and a name away from the start of a line, and a regular expression's flags. + ("a #\u{2118}b", [(T::Ident, "a"), (T::Other, "#\u{2118}b")]), + ("=/a/g\u{B2}", [(T::RegExp, "/a/g"), (T::Other, "\u{B2}")]), + ] { + let mut got = tokens(src); + if src.starts_with('=') { + got.remove(0); + } + assert_eq!(got, want, "{src:?}"); + } + assert_eq!(tokens("a #\u{B2}"), [(T::Ident, "a"), (T::Other, "#"), (T::Other, "\u{B2}")]); + assert_eq!(tokens("=/a/g\u{200D}"), [(T::Punct, "="), (T::RegExp, "/a/g\u{200D}")]); + // The code in an XML literal's braces reads names by the same rule. + assert!(tokens("var x = {\u{2118}b + c\u{203F}d};").iter().filter(|t| t.0 == T::Ident).any(|t| t.1 == "c\u{203F}d")); + assert!(tokens("var x = {\u{2118}b};").contains(&(T::Ident, "\u{2118}b"))); +} + +#[test] +fn an_escape_stands_for_a_character_only_where_a_name_may_hold_it() { + for (src, want) in [ + ("\\u2118a", "\u{2118}a"), + ("a\\u203Fb", "a\u{203F}b"), + ("a\\u00B7b", "a\u{B7}b"), + ("a\\u200Cb", "a\u{200C}b"), + ("a\\u200Db", "a\u{200D}b"), + ("a\\u0030", "a0"), + ("\\u0024\\u005F", "$_"), + // Not at the start of a name. + ("\\u0030abc", "\\u0030abc"), + ("\\u203Fa", "\\u203Fa"), + ("\\u00B7a", "\\u00B7a"), + // In no name. + ("a\\u00B2", "a\\u00B2"), + ("a\\u2E2F", "a\\u2E2F"), + ("\\u2E2F", "\\u2E2F"), + // Malformed escapes and a lone surrogate. + ("\\u", "\\u"), + ("a\\u12;", "a\\u12;"), + ("a\\uZZZZ", "a\\uZZZZ"), + ("a\\uD800", "a\\uD800"), + ("", ""), + ] { + assert_eq!(unescaped(src), want, "{src:?}"); + } + let r = report("\\u0061pp.a\\u203Fb;\napp.\\u2118c;\n"); + let members: Vec<&str> = r.members.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(members, ["app.a\u{203F}b", "app.\u{2118}c"]); + // An escape a name may not hold where it stands, or a malformed one, is not part of a name. + for (src, want) in [ + ("\\u0030a", [(T::Other, "\\"), (T::Ident, "u0030a")]), + ("a\\u00B2", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\u12", [(T::Ident, "a"), (T::Other, "\\")]), + ("a\\uD800", [(T::Ident, "a"), (T::Other, "\\")]), + ] { + assert_eq!(tokens(src).get(..2), Some(&want[..]), "{src:?}"); + } + assert_eq!(tokens("a\\u0030\\u200C"), [(T::Ident, "a\\u0030\\u200C")]); + let r = report("app.\\u0030abc;\napp.a\\u00B2;\n"); + assert!(r.members.iter().all(|m| !m.name.contains('\\') && !m.name.contains("0abc")), "{:?}", r.members); +} + +#[test] +fn malformed_escapes_in_bulk_are_read_once_through() { + let src = format!("{}\n{}\nalert(1);\n", "\\u\\uD800\\u12\\u0030\\u2118".repeat(20_000), "a\u{203F}".repeat(20_000)); + let r = report(&src); + assert_eq!(r.lines, 3); + assert_eq!(feature(&r, "notify").map(|f| f.lines.as_slice()), Some(&[3][..])); + assert!(r.steps <= 5 * src.len() + 16, "{} steps for {} bytes", r.steps, src.len()); +} + #[test] fn the_scripts_own_platform_names_are_not_the_platforms() { let r = report(concat!( diff --git a/crates/ui-egui/src/chrome/hints.rs b/crates/ui-egui/src/chrome/hints.rs index 062059e6d..02f34438a 100644 --- a/crates/ui-egui/src/chrome/hints.rs +++ b/crates/ui-egui/src/chrome/hints.rs @@ -1,4 +1,4 @@ -use super::status_bar::ANIMATED_ZOOM_HINT; +use super::status_bar::{ANIMATED_ZOOM_HINT, clipped_label}; use super::{Cow, Sense, Stroke, Tokens, Ui, VectorcraftApp, menus, theme, vec2, widgets}; fn hint_for(tool: &str) -> Option<&'static [(&'static str, bool)]> { @@ -249,7 +249,7 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) }; job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() }); } - ui.label(job); + clipped_label(ui, job); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); diff --git a/crates/ui-egui/src/chrome/status_bar.rs b/crates/ui-egui/src/chrome/status_bar.rs index 899508021..1ed8df873 100644 --- a/crates/ui-egui/src/chrome/status_bar.rs +++ b/crates/ui-egui/src/chrome/status_bar.rs @@ -80,13 +80,48 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) { if crate::i18n::current() == crate::i18n::Lang::EN { shown.trim_end_matches(" Tool") } else { shown } }) .unwrap_or(""); - ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim)); + // The tool name leaves the status message its least width and ends in an ellipsis first. + let room = (ui.available_width() - MESSAGE_MIN - ui.spacing().item_spacing.x).max(0.0); + ui.scope(|ui| { + ui.set_max_width(room); + clipped_label(ui, egui::RichText::new(tool).size(11.5).color(t.text_dim)); + }); status_readouts(app, ui, &t); }); }); widgets::set_menu_floor(ui.ctx(), panel.response.rect.top()); } +/// The least width the status message keeps when the window is narrow. +const MESSAGE_MIN: f32 = 180.0; + +/// A label that ends in an ellipsis when it is wider than the room left in `ui`. Only a cut label +/// shows its full text in a tooltip; screen readers always receive the full text. +pub(super) fn clipped_label(ui: &mut Ui, text: impl Into) -> egui::Response { + let galley = text.into().into_galley(ui, Some(egui::TextWrapMode::Truncate), ui.available_width(), egui::FontSelection::Default); + let full = galley.elided.then(|| egui::text::LayoutJob { + sections: galley.job.sections.clone(), + text: galley.job.text.clone(), + language: galley.job.language.clone(), + ..Default::default() + }); + let response = ui.add(egui::Label::new(galley).show_tooltip_when_elided(false)); + match full { + Some(job) => full_text_tooltip(response, job), + None => response, + } +} + +/// Show `text` on hover in a tooltip that wraps to fit inside the window. +fn full_text_tooltip(response: egui::Response, text: impl Into) -> egui::Response { + let text = text.into(); + response.on_hover_ui(|ui| { + let margins = ui.spacing().menu_margin.sum().x + 2.0 * ui.spacing().item_spacing.x; + ui.set_max_width(ui.spacing().tooltip_width.min(ui.ctx().content_rect().width() - margins).max(0.0)); + ui.add(egui::Label::new(text).wrap()); + }) +} + /// The most recently measured status widths, scoped to this UI and display settings. pub(super) struct StatusReadoutWidths { pub(super) key: (crate::i18n::Lang, vectorcraft_doc::Unit, f32), @@ -125,7 +160,6 @@ fn status_readout_widths(ui: &Ui, lang: crate::i18n::Lang, unit: vectorcraft_doc /// Allocate the remaining row once, then paint each field inside its own bounded rectangle. /// Child UIs requested with a desired width can shrink to their contents; these rectangles cannot. pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) { - const MESSAGE_MIN: f32 = 180.0; let mono = theme::mono(10.5); let proportional = egui::FontId::proportional(10.5); let unit = app.session.general_unit(); @@ -229,7 +263,7 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover()); response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text)); if elided { - response.on_hover_text(text); + full_text_tooltip(response, text); } } diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs index d0b06cf32..745c477c7 100644 --- a/crates/ui-egui/src/chrome/tests.rs +++ b/crates/ui-egui/src/chrome/tests.rs @@ -175,22 +175,7 @@ fn status_readout_frame(app: &mut crate::VectorcraftApp, ctx: &egui::Context, wi /// Collect status-bar text, positions, clips and elision state with explicit pointer events. fn status_readout_frame_events(app: &mut crate::VectorcraftApp, ctx: &egui::Context, width: f32, events: Vec) -> Vec { - let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); - let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| super::status_bar(app, ui)); - out.textures_delta.clear(); - out.shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Text(t) => Some(StatusText { - text: t.galley.job.text.clone(), - at: t.pos, - clip: s.clip_rect, - bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), - elided: t.galley.elided, - }), - _ => None, - }) - .collect() + bar_frame(app, ctx, width, None, events, super::status_bar).0 } /// Find a required text record by prefix, failing the test when it is absent. @@ -385,6 +370,127 @@ fn narrow_status_bars_preserve_the_message_and_hide_optional_readouts() { assert!(message.clip.left() >= status_text(&texts, "Selection").bounds.right()); } +/// A function that draws one of the bars at the bottom of the window. +type DrawBar = fn(&mut crate::VectorcraftApp, &mut egui::Ui); + +/// The texts (positions, clips and elision state) and the screen-reader tree of one frame of +/// `draw` in a window `width` points wide, at `time` with `events`. +fn bar_frame( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + time: Option, + events: Vec, + draw: DrawBar, +) -> (Vec, Vec) { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)); + let input = egui::RawInput { screen_rect: Some(screen), time, events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| draw(app, ui)); + out.textures_delta.clear(); + let texts = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Text(t) => Some(StatusText { + text: t.galley.job.text.clone(), + at: t.pos, + clip: s.clip_rect, + bounds: egui::Rect::from_min_size(t.pos, t.galley.size()), + elided: t.galley.elided, + }), + _ => None, + }) + .collect(); + let nodes = out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default(); + (texts, nodes) +} + +/// The texts of `draw` with the pointer away, the screen-reader tree, and the texts after the +/// pointer has rested on the first text that starts with `prefix` for longer than the tooltip delay. +fn bar_hover( + app: &mut crate::VectorcraftApp, + ctx: &egui::Context, + width: f32, + prefix: &str, + draw: DrawBar, +) -> (Vec, Vec, Vec) { + let t = ctx.input(|i| i.time) + 1.0; + bar_frame(app, ctx, width, Some(t), vec![egui::Event::PointerGone], draw); + let (texts, nodes) = bar_frame(app, ctx, width, Some(t + 0.1), vec![], draw); + let at = status_text(&texts, prefix); + let pos = at.clip.intersect(at.bounds).center(); + bar_frame(app, ctx, width, Some(t + 1.0), vec![egui::Event::PointerMoved(pos)], draw); + bar_frame(app, ctx, width, Some(t + 3.0), vec![], draw); + let (hovered, _) = bar_frame(app, ctx, width, Some(t + 3.1), vec![], draw); + (texts, nodes, hovered) +} + +/// Checks the field whose full text is `full`: in a window `narrow` wide it ends in an ellipsis +/// inside the window, screen readers get the full text, and resting the pointer on it shows the +/// full text in a tooltip no wider than the window; in a window `wide` wide it shows in full with no +/// tooltip. +fn assert_clipped_text_shows_on_hover(app: &mut crate::VectorcraftApp, ctx: &egui::Context, full: &str, narrow: f32, wide: f32, draw: DrawBar) { + let prefix: String = full.chars().take(4).collect(); + let (texts, nodes, hovered) = bar_hover(app, ctx, narrow, &prefix, draw); + let field = status_text(&texts, &prefix); + assert_eq!(field.text, full); + assert!(field.elided, "{narrow}: the text is clipped: {field:?}"); + assert!(field.bounds.right() <= narrow, "{narrow}: the ellipsis is inside the window: {field:?}"); + assert!(nodes.iter().any(|n| n.value() == Some(full)), "{narrow}: screen readers receive the full text"); + let tip: Vec<_> = hovered.iter().filter(|t| t.text == full && !t.elided).collect(); + assert_eq!(tip.len(), 1, "{narrow}: the tooltip shows the full text: {hovered:?}"); + assert!(tip[0].bounds.left() >= 0.0 && tip[0].bounds.right() <= narrow, "{narrow}: the tooltip wraps within the window: {tip:?}"); + + let (texts, _, hovered) = bar_hover(app, ctx, wide, &prefix, draw); + assert!(!status_text(&texts, &prefix).elided, "{wide}: the text fits"); + assert_eq!(hovered.iter().filter(|t| t.text == full).count(), 1, "{wide}: no tooltip: {hovered:?}"); +} + +/// A status message too long for the bar ends in an ellipsis and shows in full on hover, in a +/// tooltip that wraps within the window (#6622). +#[test] +fn a_clipped_status_message_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + let long = format!("Saved recovery data for {}illustration.vectorcraft", "a-long-folder-name/".repeat(12)); + app.ui.status = long.clone(); + assert_clipped_text_shows_on_hover(&mut app, &ctx, &long, 700.0, 20000.0, super::status_bar); + app.ui.status = "Saved recovery data for Untitled-1".to_owned(); + let (texts, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Saved", super::status_bar); + assert!(!status_text(&texts, "Saved").elided); + assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}"); +} + +/// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on +/// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider +/// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0. +#[test] +fn a_clipped_help_bar_hint_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + app.select_tool("dimension"); + let hint: String = super::hint_segments(app.session.tool_id()).into_iter().map(|(s, _)| s).collect(); + for narrow in [800.0, 400.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, &hint, narrow, 3000.0, super::hint_bar); + } +} + +/// In a narrow status bar the tool name ends in an ellipsis, shows in full on hover, and leaves +/// the status message its room (#6622). 640 and 500 points are the smallest main window (800 +/// points) at UI Scaling 1.25 and 1.6. +#[test] +fn a_clipped_tool_name_shows_in_full_on_hover() { + let (mut app, ctx) = status_readout_app(); + ctx.enable_accesskit(); + for narrow in [640.0, 500.0] { + assert_clipped_text_shows_on_hover(&mut app, &ctx, "Selection", narrow, 1400.0, super::status_bar); + let (texts, _) = bar_frame(&mut app, &ctx, narrow, None, vec![], super::status_bar); + let tool = status_text(&texts, "Selection"); + let message = status_text(&texts, "Saved recovery data"); + assert!(message.clip.left() >= tool.bounds.right() && message.clip.width() >= 24.0, "{narrow}: the message keeps room: {texts:?}"); + } +} + /// Busy-job transitions preserve message placement, labels and accessibility. #[test] fn background_activity_keeps_the_status_message_origin() { diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index ee68105a6..ba6d20607 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -478,6 +478,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) { let layers = standard.allows_layers() && choice::(d, "compatibility").unwrap_or_default().has_layers(); flag(ui, d, "createLayers", tl!("Create PDF layers from layers and sublayers"), layers); flag(ui, d, "includeNonPrinting", tl!("Include non-printing layers"), true); + // PDF/X files and PDF 1.3 and 1.4 files always write long pages in points. + let compatibility = choice::(d, "compatibility").unwrap_or_default(); + let scales = !standard.is_pdfx() && !matches!(compatibility, Compatibility::Pdf13 | Compatibility::Pdf14); + flag(ui, d, "longPagesInPoints", tl!("Write pages longer than 200 in at full size in points"), scales); if editor { heading(ui, tl!("Description")); if let Some(text) = widgets::text_field_named(ui, "pdf-preset-description", Some(tl!("Description")), Some(&d.str("description")), 460.0, 3) { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index a76f144b3..4cf2b995e 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1604,6 +1604,7 @@ font-technology Variable Променливи Include Linked Files Включване на свързаните файлове Include Metadata Включване на метаданни Include non-printing layers Включване на слоеве, които не се печат + Write pages longer than 200 in at full size in points Записване на страници, по-дълги от 200 in, в пълен размер в точки Include SVG Code Включване на SVG код Include Swatches for Tints Включване на образци за тонове Inflate… Издуване… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 0fee3a8c4..a9b34f852 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -2878,6 +2878,7 @@ antiAlias Sharp Oštro General › Thumbnails Općenito › Sličice Grayscale images Slike u nijansama sive Include non-printing layers Uključi slojeve koji se ne štampaju + Write pages longer than 200 in at full size in points Upiši stranice duže od 200 in u punoj veličini, u tačkama Left: Lijevo: Mark as trapped Označi kao zarobljeno Marks and Bleeds Oznake i napusti diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 63c7221bc..f6c680cc1 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -2056,6 +2056,7 @@ digits Malayalam Malaiàlam Include Linked Files Inclou els fitxers enllaçats Include Metadata Inclou les metadades Include non-printing layers Inclou les capes no imprimibles + Write pages longer than 200 in at full size in points Escriu les pàgines de més de 200 in a mida real, en punts Include Objects On Canvas Inclou els objectes del llenç Include SVG Code Inclou codi SVG Include Swatches for Tints Inclou mostres per als tons diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index adffc014c..12172a567 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2908,6 +2908,7 @@ antiAlias Sharp Ostré General › Thumbnails Obecné › Miniatury Grayscale images Obrazy ve stupních šedi Include non-printing layers Zahrnout netisknutelné vrstvy + Write pages longer than 200 in at full size in points Zapsat stránky delší než 200 in v plné velikosti v bodech Left: Vlevo: Mark as trapped Označit jako s přesahy barev Marks and Bleeds Značky a spadávky diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index ef0d4b9e5..5cb2bcb6b 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2698,6 +2698,7 @@ antiAlias Sharp Skarp General › Thumbnails Generelt › Miniaturer Grayscale images Gråtonebilleder Include non-printing layers Medtag lag, der ikke udskrives + Write pages longer than 200 in at full size in points Skriv sider længere end 200 in i fuld størrelse i punkter Left: Venstre: Mark as trapped Markér som trappet Marks and Bleeds Mærker og udløb diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 83ef9bb91..fa5c5d3fe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1655,6 +1655,7 @@ file-filter Templates Vorlagen Include Linked Files Verknüpfte Dateien einschließen Include Metadata Metadaten einschließen Include non-printing layers Nicht druckende Ebenen einschließen + Write pages longer than 200 in at full size in points Seiten über 200 in in voller Größe in Punkt schreiben Include SVG Code SVG-Code einschließen Include Swatches for Tints Farbfelder für Tonwerte einschließen Inflate… Aufblasen… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index a06f21d46..d983850ad 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1578,6 +1578,7 @@ font-technology Variable Μεταβλητές Include Linked Files Συμπερίληψη συνδεδεμένων αρχείων Include Metadata Συμπερίληψη μεταδεδομένων Include non-printing layers Συμπερίληψη μη εκτυπώσιμων επιπέδων + Write pages longer than 200 in at full size in points Εγγραφή σελίδων μεγαλύτερων από 200 in σε πλήρες μέγεθος, σε στιγμές Include SVG Code Συμπερίληψη κώδικα SVG Include Swatches for Tints Συμπερίληψη δειγμάτων για αποχρώσεις Inflate… Φούσκωμα… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2effb34a3..7a6ec6c28 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1659,6 +1659,7 @@ font-technology Variable Variables Include Linked Files Incluir archivos vinculados Include Metadata Incluir metadatos Include non-printing layers Incluir capas no imprimibles + Write pages longer than 200 in at full size in points Escribir las páginas de más de 200 in a tamaño real, en puntos Include SVG Code Incluir código SVG Include Swatches for Tints Incluir muestras para matices Inflate… Inflar… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index 6f7d04cb7..7f6fdc075 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -2852,6 +2852,7 @@ antiAlias Sharp Terävä General › Thumbnails Yleiset › Pikkukuvat Grayscale images Harmaasävykuvat Include non-printing layers Sisällytä tulostumattomat tasot + Write pages longer than 200 in at full size in points Kirjoita yli 200 in pitkät sivut täysikokoisina pisteinä Left: Vasen: Mark as trapped Merkitse ylitäytetyksi Marks and Bleeds Merkit ja leikkuuvarat diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f12ea2318..2410db094 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1653,6 +1653,7 @@ font-technology Variable Variables Include Linked Files Inclure les fichiers liés Include Metadata Inclure les métadonnées Include non-printing layers Inclure les calques non imprimables + Write pages longer than 200 in at full size in points Écrire les pages de plus de 200 in en taille réelle, en points Include SVG Code Inclure le code SVG Include Swatches for Tints Inclure les nuances des teintes Inflate… Gonfler… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index dfacf8974..2ed5d96c1 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -2268,6 +2268,7 @@ antiAlias Sharp शार्प Include Linked Files लिंक्ड फ़ाइलें शामिल करें Include Metadata मेटाडाटा शामिल Include non-printing layers गैर-प्रिंटिंग लेयर्स को शामिल करें + Write pages longer than 200 in at full size in points 200 in से लंबे पेज पूरे आकार में, पॉइंट में लिखें Include Objects On Canvas कैनवास पर ऑब्जेक्ट्स शामिल करें Include SVG Code SVG कोड शामिल करें Include Swatches for Tints टिंट्स के लिए स्वॉचेस शामिल करें diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 3b4fbd30b..d18965325 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2069,6 +2069,7 @@ file-filter Templates Sablonok Include Linked Files Csatolt fájlok hozzáadása Include Metadata Metaadatok hozzáadása Include non-printing layers Nem nyomtatandó rétegek hozzáadása + Write pages longer than 200 in at full size in points 200 in-nél hosszabb oldalak írása teljes méretben, pontban Include SVG Code SVG-kód hozzáadása Include Swatches for Tints Tónusok színmintáinak felvétele Include tagged source profiles Címkézett forrásprofilok beágyazása diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bfc9b611d..3d35ceb45 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2698,6 +2698,7 @@ antiAlias Sharp Tajam General › Thumbnails Umum › Miniatur Grayscale images Gambar skala abu-abu Include non-printing layers Sertakan lapisan non-cetak + Write pages longer than 200 in at full size in points Tulis halaman yang lebih panjang dari 200 in dalam ukuran penuh, dalam poin Left: Kiri: Mark as trapped Tandai sebagai ter-trap Marks and Bleeds Tanda dan bleed diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 32b41b249..4f2794949 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1686,6 +1686,7 @@ file-filter Templates Modelli Include Linked Files Includi file collegati Include Metadata Includi metadati Include non-printing layers Includi livelli non stampabili + Write pages longer than 200 in at full size in points Scrivi le pagine più lunghe di 200 in a grandezza reale, in punti Include SVG Code Includi codice SVG Include Swatches for Tints Includi campioni per tinte Inflate… Gonfia… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 61bf8c0d0..4a9a0e7ee 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2002,6 +2002,7 @@ antiAlias Sharp シャープ Include SVG Code SVG コードを含める Include Swatches for Tints ティント用のスウォッチを含める Include non-printing layers 印刷しないレイヤーを含む + Write pages longer than 200 in at full size in points 200 in より長いページを実寸のポイントで書き出す Ink インキ Insert Character 文字を挿入 Inspect Document ドキュメントを調査 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index e567dba4d..6a788e06f 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2590,6 +2590,7 @@ antiAlias Sharp მკვეთრი General › Thumbnails ზოგადი › ესკიზები Grayscale images ნაცრისფერი გამოსახულებები Include non-printing layers არაბეჭდვადი შრეების ჩართვა + Write pages longer than 200 in at full size in points 200 in-ზე გრძელი გვერდების სრული ზომით, პუნქტებში ჩაწერა Left: მარცხნივ: Mark as trapped ტრაპინგის მარკირება Marks and Bleeds ნიშნები და ჭრისპირა არეები diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 2dfcf3f97..64c839040 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2031,6 +2031,7 @@ antiAlias Sharp 날카롭게 Include SVG Code SVG 코드 포함 Include Swatches for Tints 농도별 견본 포함 Include non-printing layers 인쇄되지 않는 레이어 포함 + Write pages longer than 200 in at full size in points 200 in보다 긴 페이지를 실제 크기의 포인트로 기록 Ink 잉크 Insert Character 문자 삽입 Inspect Document 문서 검사 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 59fb283ac..573f3b6e5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2712,6 +2712,7 @@ antiAlias Sharp Scherp General › Thumbnails Algemeen › Miniaturen Grayscale images Grijswaardenafbeeldingen Include non-printing layers Niet-afdrukbare lagen opnemen + Write pages longer than 200 in at full size in points Pagina's langer dan 200 in op ware grootte in punten schrijven Left: Links: Mark as trapped Markeren als getrapt Marks and Bleeds Steker en afloop diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index c13b992b8..bfc49c70b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2798,6 +2798,7 @@ antiAlias Sharp Ostre General › Thumbnails Ogólne › Miniatury Grayscale images Obrazy w skali szarości Include non-printing layers Uwzględnij warstwy niedrukowane + Write pages longer than 200 in at full size in points Zapisuj strony dłuższe niż 200 in w pełnym rozmiarze, w punktach Left: Lewy: Mark as trapped Oznacz jako z zalewkami Marks and Bleeds Znaczniki i spady diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 041f58bb3..7ac22f2fc 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1456,6 +1456,7 @@ antiAlias Sharp Nítido Include SVG Code Incluir Código SVG Include Swatches for Tints Incluir Amostras para Tons Include non-printing layers Incluir camadas de não impressão + Write pages longer than 200 in at full size in points Gravar páginas com mais de 200 in em tamanho real, em pontos Inflate… Inflar… Info Informações Ink Tinta diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 017f158c6..40928a8ae 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1683,6 +1683,7 @@ file-filter Templates Шаблоны Include Linked Files Включать связанные файлы Include Metadata Включать метаданные Include non-printing layers Включать непечатаемые слои + Write pages longer than 200 in at full size in points Записывать страницы длиннее 200 in в полном размере, в пунктах Include SVG Code Включать код SVG Include Swatches for Tints Включать образцы для оттенков Inflate… Надувание… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 8c213d520..902772bca 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1617,6 +1617,7 @@ font-technology Variable Variabla Include Linked Files Inkludera länkade filer Include Metadata Inkludera metadata Include non-printing layers Inkludera lager som inte skrivs ut + Write pages longer than 200 in at full size in points Skriv sidor längre än 200 in i full storlek i punkter Include SVG Code Inkludera SVG-kod Include Swatches for Tints Inkludera färgrutor för nyanser Inflate… Blås upp… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index a601ee6c5..b11130179 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1497,6 +1497,7 @@ font-technology Variable Değişken Include Linked Files Bağlantılı Dosyaları Ekle Include Metadata Meta Verileri Ekle Include non-printing layers Yazdırılmayan katmanları dahil et + Write pages longer than 200 in at full size in points 200 in'den uzun sayfaları tam boyutta, punto olarak yaz Include SVG Code SVG Kodunu Ekle Include Swatches for Tints Renk Tonları için Renk Örneklerini Ekle Inflate… Şişir… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 8ee30242e..9ee885815 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1680,6 +1680,7 @@ file-filter Templates Шаблони Include Linked Files Включати пов’язані файли Include Metadata Включати метадані Include non-printing layers Включати недруковані шари + Write pages longer than 200 in at full size in points Записувати сторінки, довші за 200 in, у повному розмірі, у пунктах Include SVG Code Вмикати код SVG Include Swatches for Tints Включати зразки для відтінків Inflate… Надування… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a26198360..5806e693c 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1950,6 +1950,7 @@ digits Hindi Hindi Include Linked Files Bao gồm tệp được liên kết Include Metadata Bao gồm siêu dữ liệu Include non-printing layers Bao gồm các lớp không in + Write pages longer than 200 in at full size in points Ghi các trang dài hơn 200 in ở kích thước đầy đủ, theo điểm Include SVG Code Bao gồm mã SVG Include Swatches for Tints Bao gồm mẫu màu cho sắc pha Increase Font Size Tăng cỡ chữ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index e34d048d4..e51803540 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1613,6 +1613,7 @@ file-filter Templates 模板 Include Linked Files 包含链接文件 Include Metadata 包含元数据 Include non-printing layers 包含非打印图层 + Write pages longer than 200 in at full size in points 以实际尺寸(点)写入长于 200 in 的页面 Include SVG Code 包含 SVG 代码 Include Swatches for Tints 包含淡色色板 Inflate… 膨胀… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 5997dfb37..d6d530e19 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1651,6 +1651,7 @@ file-filter Templates 範本 Include Linked Files 包含連結檔案 Include Metadata 包含中繼資料 Include non-printing layers 包含不列印的圖層 + Write pages longer than 200 in at full size in points 以實際尺寸(點)寫入長於 200 in 的頁面 Include SVG Code 包含 SVG 程式碼 Include Swatches for Tints 包含色調的色票 Inflate… 膨脹… diff --git a/docs/mcp.md b/docs/mcp.md index 33a7e2a68..9ef266332 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -875,7 +875,9 @@ A page with a side longer than 14,400 pt is written with a `UserUnit` (PDF 1.6): rounded up to the next thousandth, with the page's sizes and coordinates in those units, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2). A PDF 1.5 file with such a page is written as PDF 1.6, with a warning, and keeps PDF 1.5's encryption (128-bit RC4). PDF/X files and PDF 1.3 and 1.4 files write such a page in points, and apps -that keep to the limit cut it to 200 in. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can +that keep to the limit cut it to 200 in. `longPagesInPoints: true` (off by default; "Write pages longer than 200 in at +full size in points" in the Save PDF dialog's General section) writes such a page in points at every PDF version, without +a `UserUnit`, and a PDF 1.5 file stays PDF 1.5; the warning then says the page is written in points. In PDF 1.3 and 1.4 files, PDF/X-1a and PDF/X-3 among them, a real number can be at most 32,767, and whole numbers are written as integers: a page longer than 32,767 pt whose sides aren't whole numbers of points fails the export with an error naming the page before the thumbnails are drawn and the transparency is flattened, and one whose art needs a real number past 32,767 fails with the same error when the file is written. A page whose short side is 3 pt or longer but would be under 3 units at that diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5dcc013da..af8562c08 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -574,6 +574,7 @@ since 2026-10-01. **Review follow-up:** The fit-query follow-up covers fitting after draft dimensions change, updated artwork after a document revision, cache scope and new-artboard fitting/history. Selection uses the existing application bounds cache; global artwork queries include all drawn document art and are cached by active document and revision; draft geometry and target artboards do not limit membership. This is not a native timing claim. - **Stable status-bar readouts:** The status message keeps its left anchor and clipping allocation as live values, canvas hover and background-job state change. Coordinate and spinner slots stay reserved; narrow windows hide optional diagnostics, then coordinates, to preserve message space. Widths are measured from the active font, translated labels and fixed numeric samples, never current readings. Coordinates through +/-9999.99 selected units, timings through 9999.9 ms and FPS through 9999 remain readable. Extreme values are elided with full tooltip/accessibility text; only elided fields show a tooltip. Coordinate inspection retains the last point per document and clears on PointerGone. Regressions cover message origin/clip/bounds/elision invariance, all registered languages and ten units, responsive layouts, coordinate inspection and document switching, and accessible busy transitions. The contributor rebuilt and confirmed the original status-bar issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. Sample widths use a bounded single-entry UI cache keyed by language, unit and pixels per point; two regressions verify reuse during live updates and invalidation after each display-setting change. +- **Clipped bottom-bar text (#6622):** A Help Bar hint wider than the window, such as the Dimension tool's hint in an 800-point window, ends in an ellipsis, as the status message does. In a narrow status bar the tool name ends in an ellipsis and leaves the status message its room. Resting the pointer on a clipped hint, tool name or message shows the full text in a tooltip that wraps within the window. Text that fits shows no tooltip, and screen readers receive the full text. Scores and estimates are unchanged. - **Canvas review follow-up:** Cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission. Controlled-worker regression checks oversized width and height, high DPI and the render transform without allocating oversized images. Scores and estimates are unchanged. - **Cached canvas frames during viewport changes:** Cached screen textures use their own raster dimensions and pixels-per-point when reprojected. `canvas::tests::cached_frames_keep_their_scale_during_resize_rotation_and_dpi_changes` holds the worker and checks every UV corner against the cached render transform, including isolated viewport-only resize at zero/30-degree rotation, fractional raster dimensions, zoom/pan, changed rotation, monitor-DPI changes in both directions, Pixel Preview and replacement-frame delivery. The contributor rebuilt and confirmed the original resize issue is fixed with their own artwork on 2026-10-19; the subsequent upstream rebase is checked by regressions and full CI. The artwork stays local. Scores and estimates are unchanged. @@ -747,6 +748,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), `document.inspect` and the `inspect_document` tool slice the same way so a depth-0 read is the layer skeleton (~9.7 KB instead of ~2 MB on a 2,000-object document), `document.find` locates nodes by name/kind/text with ancestor paths, and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). A `run` step, like a `command_batch` step over MCP, can use an earlier step's result (`"$1.id"`, #785); `run --commands FILE` (or `-` for standard input) reads the steps from a JSON array or JSON Lines, past the Windows command-line limit (#3147, `apps/vectorcraft-cli/tests/it/cli.rs` `run_reads_*`, `run_refuses_malformed_commands_naming_the_step`). - File › Scripts › Check Script… reads a script written for Illustrator (`.jsx`, `.jsxinc` or a compiled `.jsxbin`) without running any of it and reports what it needs (the document model, files, dialogs, menu commands, features only Illustrator has), what it would do outside the document, the menu commands and document-model members it uses, its directives and included files, and the problems that would stop it loading (`script.check`, which also reads `.js` files); File › Open and drag-and-drop show the report for `.jsx`, `.jsxinc` and `.jsxbin` files. + - Check Script reads names by the same identifier rule as `vectorcraft-js`: Unicode 16.0 `ID_Start` characters, `$` and `_` start a name, and `ID_Continue` characters and `$` continue it (https://www.unicode.org/reports/tr31/, https://tc39.es/ecma262/#sec-names-and-keywords). U+2118, U+203F, U+00B7 and the zero width non-joiner and joiner are name characters, and U+00B2 and U+2E2F are not. A `\uXXXX` escape is read as its character only where a name may hold it, so `0abc` does not become a name that starts with a digit. Tests: `crates/scriptcheck/src/tests.rs` (`names_are_made_of_the_unicode_identifier_characters`, `an_escape_stands_for_a_character_only_where_a_name_may_hold_it`), `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs` (`both_lexers_take_the_same_characters_into_names`). - `vectorcraft-js` parses JavaScript as ECMAScript 5.1 defines it, with the legacy octal forms, block functions and lenient regular expressions of Annex B, into a syntax tree with source spans; it reads the `#target`, `#include` and other preprocessor lines as directives without running them, and a script that breaks the grammar or an early-error rule gives a `SyntaxError` with its line and column. Nesting past the parser's depth limit (`Options::max_depth`, 200 by default) gives a `SyntaxError` instead of exhausting the stack. `cargo xtask test262` fetches the conformance suite at a pinned commit and parses its ES5 tests: 5,411 of 5,418 runs pass (the 7 others use `let` and `const`). This entry covers parsing; the next entry covers running scripts. Tests: `crates/js/src/tests.rs`, `crates/engine/tests/it/js_lexer_vs_scriptcheck.rs`. - `vectorcraft-js` identifiers, written out or as `\uXXXX` and `\u{...}` escapes, take the characters Unicode's `ID_Start` and `ID_Continue` properties allow (https://www.unicode.org/reports/tr31/), plus `$` and, at the start, `_`, as later ECMAScript editions define them (https://tc39.es/ecma262/#sec-names-and-keywords). `Other_ID_Start` characters such as U+2118 and `Other_ID_Continue` characters such as U+00B7 and the zero width non-joiner and joiner are accepted, and no `Pattern_Syntax` or `Pattern_White_Space` character is. Letter categories come from Unicode 16.0, so characters assigned in later Unicode versions are not yet identifier characters. Tests: `crates/js/src/tests.rs` (`identifier_characters_are_unicode_16_id_start_and_id_continue`, which checks every code point, `identifiers_take_the_other_id_start_and_continue_characters`, `identifiers_refuse_pattern_syntax_surrogates_and_bad_escapes`, `no_pattern_syntax_or_white_space_character_is_an_identifier_character`). - `vectorcraft-js`'s `Realm` runs ECMAScript 5.1 scripts: the parsed script compiles to verified bytecode that a non-recursive interpreter runs, with closures, `this`, `arguments`, `with`, direct and indirect `eval`, `new Function`, `try`/`catch`/`finally`, labeled `break` and `continue`, and `for`-`in` in insertion order, over a mark-and-sweep heap, with the built-ins the next two entries list. Every run is bounded by fuel, heap bytes, frames, nested host calls and an interrupt flag (smaller defaults in the wasm build), and a run that reaches a limit stops with a limit error that `try` cannot catch. `cargo xtask test262` also runs the suite's ES5 language tests that need only the built-ins present: 4,280 of 4,286 runs pass (4 of the others expect the completion values of later editions, and 2 expect a configurable function `length`). The app does not call it yet. Tests: `crates/js/src/interp_tests.rs`, `crates/js/tests/test262.rs`. @@ -1438,7 +1440,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur In a `.ai` opened from its editing data, an envelope made with a warp, a mesh or a top object opens as a live envelope of its contents, with its warp style, bend and distortions, its mesh points and handles or its top object's outline, its Envelope Options, its Fidelity and whether its contents are being edited. When Distort Appearance is on and the size the file saves for the contents matches their bounds with strokes, the contents map from that stroked box, until an edit in Edit Contents changes how far the strokes reach; VectorCraft's new envelopes map from the geometric box, as do contents with no width or no height, such as a single straight line. An envelope that can't be read, or that VectorCraft draws differently from the file's drawn art, keeps that drawn art, with a note. An envelope of type opens live, its type laid out from the text document as its contents, where VectorCraft's own envelope of that type draws as the drawn art the file saves (compared as for the other envelopes, seen on white and on black, so white type counts, and only where the layers with it are compared with the page and draw as it, the envelope weighed as the art it draws); elsewhere, and where the type's font isn't available, a text or visibility variable binds the type, or the envelope is in a symbol's or a pattern's art, it keeps its drawn art, with its type hidden and a note that says the type isn't editable (#3179; `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). A warp of type whose own warp, VectorCraft's, doesn't draw as its drawn art is tried again as a mesh envelope on the grid the file saves for it: its type stays editable and keeps the file's shape, but the warp's settings (style, bend) are lost, as the document has no place for them on a mesh. Type with strokes in an envelope with Distort Appearance on is also tried mapped from its glyphs grown by as far as its strokes paint along a side (half the weight of a centred stroke), without what miter joins add at corners, where the size the file saves for it doesn't already give that box; the drawn-art check decides which one opens. An envelope's grid reads its way round from all its rows and columns, so a mesh bent far round reads the way it runs. While an envelope's contents are edited, its mesh is kept in its dictionary, and in some files that mesh lies away from the drawn art by a move, the contents with the drawn art (as if the mesh hadn't followed the envelope when it moved): a mesh or top object saved in Edit Contents whose mesh draws the contents as large as the drawn art but elsewhere is tried again, after every other way, with its mesh moved onto that art, and opens there where it then draws as it. In a `.ai` opened from its editing data, Convert to Shape on a whole path or type object opens as a live Rectangle, Rounded Rectangle or Ellipse effect with its Absolute or Relative size, its corner radius, its place among the object's effects and whether it is hidden. A relative size in the file is added to each side. A relative Ellipse, or a setting VectorCraft doesn't read, keeps the drawn art (`crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`, `crates/eps/src/import/tests_native.rs`). In a `.ai` opened from its editing data, Bas Relief in an art style takes its Detail, Smoothness and Light direction from the style's settings block, and an object other than type with it opens as itself with the live effect. A settings block that VectorCraft can't read (malformed, another effect's, a value out of range or a key it doesn't know) keeps the object's drawn look (part of #637; [raster effects reference](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/apply-filter-effects/raster-effects-reference.html), [Photoshop File Formats Specification](https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/), [scripting the filter gallery](https://community.adobe.com/questions-712/scripting-support-for-the-filter-gallery-1064646); `crates/eps/src/import/ai/style/tests.rs`, `crates/eps/src/import/ai/tests.rs`). - PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). + PDF export and `.ai` saves write a page with a side longer than 14,400 pt with a `UserUnit` (PDF 1.6), its longer side divided by 14,400 and rounded up to the next thousandth, so that no side is longer than 14,400 units (ISO 32000-1, Annex C.2); a PDF 1.5 file with such a page is written as PDF 1.6 with PDF 1.5's encryption, PDF/X files and PDF 1.3 and 1.4 files write it in points (in PDF 1.3 and 1.4, a page past 32,767 pt that needs a real number past 32,767 is refused with an error naming it), a page whose short side is 3 pt or longer but would be under 3 units at that `UserUnit` gets a smaller one that keeps the short side at 3 units, and the export's warnings give each such page's size and how it is written (#6382). The Save PDF dialog's "Write pages longer than 200 in at full size in points" (`longPagesInPoints`, off by default) writes such pages in points without a `UserUnit` at every PDF version. PDF export warns when a PDF/X file has no bleed on a side where the document's bleed has one, and writes the file as asked. PDF/X-1a, PDF/X-3 and PDF 1.3 files keep the art under the hidden part of a picture, gradient, pattern, mesh or raster effect that a clipping mask or clipping layer crops: flattening rasterizes only the part of such art that its clipping path shows, on its own page and on the next one. Layers the file leaves out are left out of the flattening, and transparent art that a clipping path hides entirely is removed (`crates/engine/src/cmd/fileio/tests_pdfx.rs`). Placed (linked) files in a `.ai`'s editing data come in as linked images in their place, each showing the low-resolution preview the file keeps of it until its file is found or relinked in the Links panel (a file found next to the document, at its path, or in the folder the editing data names relative to the document's, such as `src/`, is read as the document opens, and its link takes that file's size, modification time and hash). A file with them no longer falls back to its PDF part, so its guides, sublayers and hidden objects open (#1264; `crates/eps/src/import/ai/tests.rs`, `crates/engine/src/tests/links.rs`). In a file opened that way, a gradient with stops in the middle runs its whole ramp across its shape and a gradient object no longer passes its fill to the next path (#1390); text objects with an appearance keep their own text and frame, type of hidden layers stays hidden, and type on later artboards lands on its text objects (#1391); area type whose frame is too short for a line of the fallback font shows its first line, the frame made taller with a warning in the open result (#1392). @@ -1650,6 +1652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode ` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. +- **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged. - **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module that lists permissions and has other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.