Merge main into fix/i18n-plugin-limit-messages

This commit is contained in:
dotCooCoo
2026-11-11 11:42:06 -08:00
55 changed files with 709 additions and 89 deletions
Generated
+2
View File
@@ -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]]
+6 -2
View File
@@ -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<Vec<NodeId>> {
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<NodeId> {
+1 -1
View File
@@ -806,7 +806,7 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
};
// 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.
@@ -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"][..])] {
File diff suppressed because one or more lines are too long
@@ -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, &params, 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).
+40
View File
@@ -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();
+11
View File
@@ -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();
@@ -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.<src>` as `app.<name>`.
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
+9 -11
View File
@@ -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<Value = Value> {
/// 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<Value = u32> {
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<Value = BulkCase> {
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
}
};
+6
View File
@@ -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 }
+11
View File
@@ -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]
+8 -5
View File
@@ -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.
+6 -5
View File
@@ -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<Option<PdfSettings>, 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;
+4
View File
@@ -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,
+73
View File
@@ -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::<f64>::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::<f64>::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();
+2
View File
@@ -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.<id>`, [`effect`]) or its file format ([`export`], [`import`]).
+4 -1
View File
@@ -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.
+3
View File
@@ -9,5 +9,8 @@ rust-version.workspace = true
[lints]
workspace = true
[dependencies]
unicode-general-category = "1"
[dev-dependencies]
proptest = { workspace = true }
+8 -10
View File
@@ -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<String> {
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);
+105 -16
View File
@@ -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<char> {
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())) {
+93
View File
@@ -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 = <a>{\u{2118}b + c\u{203F}d}</a>;").iter().filter(|t| t.0 == T::Ident).any(|t| t.1 == "c\u{203F}d"));
assert!(tokens("var x = <a>{\u{2118}b}</a>;").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!(
+2 -2
View File
@@ -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());
+37 -3
View File
@@ -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::WidgetText>) -> 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::WidgetText>) -> 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);
}
}
+122 -16
View File
@@ -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<egui::Event>) -> Vec<StatusText> {
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<f64>,
events: Vec<egui::Event>,
draw: DrawBar,
) -> (Vec<StatusText>, Vec<egui::accesskit::Node>) {
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<StatusText>, Vec<egui::accesskit::Node>, Vec<StatusText>) {
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() {
+4
View File
@@ -478,6 +478,10 @@ fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
let layers = standard.allows_layers() && choice::<Compatibility>(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::<Compatibility>(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) {
+1
View File
@@ -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… Издуване…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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… Φούσκωμα…
Can't render this file because it is too large.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 टिंट्स के लिए स्वॉचेस शामिल करें
Can't render this file because it is too large.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2327 and column 5.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 ドキュメントを調査
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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 ნიშნები და ჭრისპირა არეები
Can't render this file because it is too large.
+1
View File
@@ -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 문서 검사
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1
View File
@@ -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… Надувание…
Can't render this file because it is too large.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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…
Can't render this file because it contains an unexpected character in line 5 and column 25.
+1
View File
@@ -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… Надування…
Can't render this file because it contains an unexpected character in line 182 and column 5.
+1
View File
@@ -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ữ
Can't render this file because it contains an unexpected character in line 204 and column 5.
+1
View File
@@ -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… 膨胀…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+1
View File
@@ -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… 膨脹…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -1
View File
@@ -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
+4 -1
View File
@@ -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 <subcommand>` 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.