mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 13:07:41 +00:00
Merge remote-tracking branch 'upstream/main' into feat-4936-custom-dictionary
# Conflicts: # docs/gaps.md # docs/roadmap.md # docs/target-app-parity.md
This commit is contained in:
@@ -78,7 +78,7 @@ USAGE:
|
||||
Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and
|
||||
does not take --connect.
|
||||
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]... [--allow-plugin-grants]
|
||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
--automation-read-root / --automation-write-root confine the files the agent's commands read
|
||||
@@ -86,8 +86,18 @@ USAGE:
|
||||
DIR, links followed; each may be given more than once, a path inside any of its folders being
|
||||
allowed, and a kind left out grants none of its access. They imply --headless: a running app
|
||||
enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
|
||||
--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this session,
|
||||
so whoever sends the commands can give a plug-in the permissions it lists, and a heavy budget,
|
||||
with nobody approving them. Without it they are refused, and a plug-in that lists permissions
|
||||
installs only when its hash is already recorded in grants.json, by a person's approval in the
|
||||
desktop app or by a grant accepted under this flag. A grant accepted under this flag stays
|
||||
recorded and counts as approved in later sessions. It implies --headless. It can't be used
|
||||
with --automation-read-root or --automation-write-root: a server confined to folders has no
|
||||
plug-in store, so it reads no grants.json, and a plug-in that lists permissions or asks for a
|
||||
heavy budget does not install there. With VECTORCRAFT_NO_PREFS set there is no store either,
|
||||
and a grant accepted under this flag lasts for that session only.
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N]
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N] [--ppi N] [--allow-plugin-grants]
|
||||
Headless batch: open FILE (any readable format) or start a new document, run commands in
|
||||
order, export each FILE in the format its extension picks (see Writable formats; a .ait FILE
|
||||
is a template, written as file.saveAsTemplate writes it). Raster exports use the document's
|
||||
@@ -97,6 +107,8 @@ USAGE:
|
||||
--cmd and --export steps: a JSON array of {\"command\": ID, \"params\": {...}} (or \"id\" for
|
||||
\"command\"), or one such object per line (JSON Lines), at most 64 MiB and 100000 steps.
|
||||
Errors name the step, counting from 0 as \"$N\" references do.
|
||||
--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect in this run, as
|
||||
for mcp.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
@@ -235,6 +247,14 @@ fn with_stores(mut h: Headless) -> Headless {
|
||||
h
|
||||
}
|
||||
|
||||
/// Makes `h`'s `plugin.install` accept `grant` and `allowHeavy` when `allow` (`--allow-plugin-grants`).
|
||||
fn accepting_grants(mut h: Headless, allow: bool) -> Headless {
|
||||
if allow {
|
||||
h.session.allow_plugin_grants_from_requests();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own
|
||||
/// plug-ins, so `--connect` is refused and the default does not connect.
|
||||
fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
@@ -243,6 +263,7 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
}
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
let mut allow_grants = false;
|
||||
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
@@ -254,6 +275,7 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
match flag {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
|
||||
"--headless" => headless = true,
|
||||
"--allow-plugin-grants" => allow_grants = true,
|
||||
"--automation-read-root" | "--automation-write-root" => {
|
||||
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
|
||||
@@ -264,6 +286,12 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
if allow_grants && connect.is_some() {
|
||||
return Err("--allow-plugin-grants applies to the headless server; in a running app a person approves each plug-in".into());
|
||||
}
|
||||
if allow_grants && !(read_roots.is_empty() && write_roots.is_empty()) {
|
||||
return Err("--allow-plugin-grants can't be used with --automation-read-root or --automation-write-root: a server confined to folders has no plug-in store to record a grant in".into());
|
||||
}
|
||||
if safe_mode && connect.is_some() {
|
||||
return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control <port>`".into());
|
||||
}
|
||||
@@ -278,9 +306,9 @@ fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless || safe_mode {
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
Box::new(accepting_grants(Headless::with_document().with_automation_roots(Some(roots)), allow_grants))
|
||||
} else if headless || safe_mode || allow_grants {
|
||||
Box::new(accepting_grants(with_stores(Headless::with_document()), allow_grants))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
|
||||
@@ -563,6 +591,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut stdin_read = false;
|
||||
let mut scale = None::<f64>;
|
||||
let mut ppi = None::<f64>;
|
||||
let mut allow_grants = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
||||
@@ -597,6 +626,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
steps.push(Step::Export(val("a file")?));
|
||||
params_for = None;
|
||||
}
|
||||
"--allow-plugin-grants" => allow_grants = true,
|
||||
"--scale" | "-s" => {
|
||||
let raw = val("a number")?;
|
||||
scale = Some(raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?);
|
||||
@@ -614,7 +644,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
let mut h = with_stores(Headless::new());
|
||||
let mut h = accepting_grants(with_stores(Headless::new()), allow_grants);
|
||||
let mut out = std::io::stdout().lock();
|
||||
// A reader that stopped early (a closed pipe) isn't an error, and the batch goes on: its later
|
||||
// steps (an export…) still run, unprinted.
|
||||
|
||||
@@ -19,6 +19,77 @@ fn empty_dir(dir: std::path::PathBuf) -> std::path::PathBuf {
|
||||
dir
|
||||
}
|
||||
|
||||
/// `run` and `mcp --headless` refuse `plugin.install` with this param unless the process was started
|
||||
/// with `--allow-plugin-grants`.
|
||||
fn approval_param_needs_the_start_up_flag(key: &str, value: Value) {
|
||||
let module = vectorcraft_format::base64_encode(include_bytes!("../../../../crates/plugins/tests/fixtures/desaturate.wasm"));
|
||||
let steps = tmp(&format!("{key}.json"));
|
||||
std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap();
|
||||
let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id())));
|
||||
let run = |extra: &[&str]| {
|
||||
Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap()
|
||||
};
|
||||
let out = run(&[]);
|
||||
assert!(!out.status.success());
|
||||
let shown = String::from_utf8_lossy(&out.stderr).into_owned() + &String::from_utf8_lossy(&out.stdout);
|
||||
assert!(shown.contains("--allow-plugin-grants"), "{shown}");
|
||||
let out = run(&["--allow-plugin-grants"]);
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id())));
|
||||
let mcp_install = |extra: &[&str]| -> Value {
|
||||
let mut child = Command::new(BIN)
|
||||
.env("VECTORCRAFT_CONFIG_DIR", &mcp_config)
|
||||
.args(["mcp", "--headless"])
|
||||
.args(extra)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
{
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let params = json!({"dataBase64": module, key: value});
|
||||
for m in [
|
||||
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
|
||||
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
|
||||
json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"run_command","arguments":{"command":"plugin.install","params":params}}}),
|
||||
] {
|
||||
writeln!(stdin, "{m}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
replies.into_iter().find(|r| r["id"] == 2).unwrap()["result"].take()
|
||||
};
|
||||
let refused = mcp_install(&[]);
|
||||
assert_eq!(refused["isError"], true, "without the flag: {refused}");
|
||||
assert!(refused["content"][0]["text"].as_str().unwrap().contains("--allow-plugin-grants"), "{refused}");
|
||||
let accepted = mcp_install(&["--allow-plugin-grants"]);
|
||||
assert_eq!(accepted["isError"], false, "with the flag: {accepted}");
|
||||
}
|
||||
|
||||
/// `run` and `mcp --headless` refuse `plugin.install`'s `grant` and `allowHeavy` unless the process was
|
||||
/// started with `--allow-plugin-grants`, which `mcp` won't take with `--connect` or the automation roots.
|
||||
#[test]
|
||||
fn plugin_grants_need_the_start_up_flag() {
|
||||
approval_param_needs_the_start_up_flag("grant", json!(["doc.read"]));
|
||||
approval_param_needs_the_start_up_flag("allowHeavy", json!(true));
|
||||
|
||||
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap();
|
||||
assert!(!out.status.success());
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server"));
|
||||
let root = std::env::temp_dir();
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap();
|
||||
assert!(!out.status.success(), "{flag}");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}");
|
||||
}
|
||||
let usage = Command::new(BIN).arg("help").output().unwrap();
|
||||
assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_prints_catalogue() {
|
||||
let out = Command::new(BIN).arg("commands").output().unwrap();
|
||||
|
||||
@@ -904,12 +904,16 @@ pub struct Spine {
|
||||
cum: Vec<f64>,
|
||||
anchors: Vec<f64>,
|
||||
closed: bool,
|
||||
path: PathData,
|
||||
/// The subpath the spine was flattened from, kept so [`Spine::at_length`] can read the
|
||||
/// original curves' tangents. Only the first subpath is stored: the others never affect a
|
||||
/// spine.
|
||||
subpath: SubPath,
|
||||
}
|
||||
|
||||
impl Spine {
|
||||
/// Build a spine from the first subpath of `path`: flatten it to a polyline, record cumulative
|
||||
/// arc lengths, and the arc length at each anchor.
|
||||
/// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when
|
||||
/// the first one flattens to fewer than two points (so it has no length to walk).
|
||||
pub fn new(path: &PathData) -> Option<Self> {
|
||||
let sp = path.subpaths.first()?;
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
@@ -946,7 +950,7 @@ impl Spine {
|
||||
if pts.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
Some(Self { pts, cum, anchors, closed: sp.closed, path: path.clone() })
|
||||
Some(Self { pts, cum, anchors, closed: sp.closed, subpath: sp.clone() })
|
||||
}
|
||||
/// The spine's flattened length in arc length.
|
||||
pub fn length(&self) -> f64 {
|
||||
@@ -1012,7 +1016,7 @@ impl Spine {
|
||||
/// `Align to Path` jump between chords instead of following the curve smoothly.
|
||||
pub fn at_length(&self, s: f64) -> (Point, f64) {
|
||||
let s = if s.is_finite() { s.clamp(0.0, self.length()) } else { 0.0 };
|
||||
let Some(sp) = self.path.subpaths.first() else { return (self.pts.first().copied().unwrap_or_default(), 0.0) };
|
||||
let sp = &self.subpath;
|
||||
let n = sp.segment_count();
|
||||
let mut start = 0.0;
|
||||
for i in 0..n {
|
||||
|
||||
@@ -169,7 +169,9 @@ pub(super) fn fmt_value(v: f64) -> String {
|
||||
// `{:.0}` prints its exact digits.
|
||||
if v.abs() < 9e18 { format!("{}", v.round() as i64) } else { format!("{v:.0}") }
|
||||
} else {
|
||||
format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string()
|
||||
let s = format!("{:.2}", v).trim_end_matches('0').trim_end_matches('.').to_string();
|
||||
// A tiny negative rounds to "-0.00"; show it as plain zero like -0.0.
|
||||
if s == "-0" { "0".to_string() } else { s }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,13 @@ fn fmt_value_does_not_saturate_beyond_i64() {
|
||||
assert_eq!(super::fmt_value(2.5), "2.5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_value_tiny_negative_is_zero() {
|
||||
assert_eq!(super::fmt_value(-0.004), "0");
|
||||
assert_eq!(super::fmt_value(-0.0049), "0");
|
||||
assert_eq!(super::fmt_value(-0.006), "-0.01");
|
||||
}
|
||||
|
||||
fn graph(s: &mut Session, ty: &str) -> NodeId {
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4\nQ3,-1,6";
|
||||
NodeId(
|
||||
|
||||
@@ -217,13 +217,15 @@ impl Session {
|
||||
/// After a command changed the selection, with New Art Has Basic Appearance on: the fill and
|
||||
/// stroke proxies and the weight take what the selection shows in them, so new art looks like
|
||||
/// it. A paint the selected objects don't share (a "?" proxy) stays as it was, and art without
|
||||
/// paint (a placed image, whose None would make new art invisible) changes nothing. The
|
||||
/// template is left alone, and without a stroke to show the weight stays too.
|
||||
/// paint (a placed image that isn't colorized, whose None would make new art invisible) changes
|
||||
/// nothing. The template is left alone, and without a stroke to show the weight stays too.
|
||||
pub(crate) fn load_selection_paints(&mut self) {
|
||||
if !self.prefs.new_art_basic {
|
||||
return;
|
||||
}
|
||||
let has_paint = self.proxy_source(false).is_some_and(|(n, _)| matches!(n.kind, NodeKind::Text(_)) || !n.appearance.items.is_empty());
|
||||
let has_paint = self
|
||||
.proxy_source(false)
|
||||
.is_some_and(|(n, _)| matches!(n.kind, NodeKind::Text(_)) || !n.appearance.items.is_empty() || n.extras().colorize.is_some());
|
||||
if !has_paint {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@
|
||||
//! `document.open` reads (`fileio::plugin_formats`).
|
||||
//!
|
||||
//! A filter may ask for a heavy budget (`Budget::Heavy`: minutes rather than a minute, for routing,
|
||||
//! packing or simulation). It installs only once the user agrees (`plugin.install {allowHeavy}`),
|
||||
//! which loading a folder doesn't ask, so folders skip it; the app runs it in the background with
|
||||
//! Cancel ([`FilterRun`]).
|
||||
//! packing or simulation). It installs only once a person agrees, which loading a folder doesn't
|
||||
//! ask, so folders skip it; the app runs it in the background with Cancel ([`FilterRun`]).
|
||||
//!
|
||||
//! A plug-in whose manifest lists permissions installs once the user grants them
|
||||
//! (`plugin.install {grant}`, recorded with the module's hash and kept in the plug-in store), and
|
||||
//! a filter does only what they allow: without `doc.read` it is given no objects, without
|
||||
//! `doc.write` an output that changes the document is refused, and without `state` it neither
|
||||
//! reads nor writes the data it keeps with the document.
|
||||
//! A plug-in whose manifest lists permissions installs once a person grants them (recorded with
|
||||
//! the module's hash and kept in the plug-in store), and a filter does only what they allow:
|
||||
//! without `doc.read` it is given no objects, without `doc.write` an output that changes the
|
||||
//! document is refused, and without `state` it neither reads nor writes the data it keeps with
|
||||
//! the document.
|
||||
//!
|
||||
//! `plugin.install {grant, allowHeavy}` takes effect only with an [`InstallApproval`], which no
|
||||
//! command can set, or in a session started to accept them from requests
|
||||
//! ([`Session::allow_plugin_grants_from_requests`]).
|
||||
//!
|
||||
//! A filter or a live effect may add menu commands of its own (its manifest's `commands`). Each
|
||||
//! is the command `plugin.<plug-in id>.<command id>` while the plug-in is installed
|
||||
@@ -555,6 +558,45 @@ fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// What a person approved for one install: the module they were shown (its id, SHA-256 and the
|
||||
/// permissions it lists) and whether they agreed to its heavy budget. Only the code that asked the
|
||||
/// person sets it ([`Session::approve_plugin_install`]); no command can.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InstallApproval {
|
||||
pub id: String,
|
||||
pub hash: String,
|
||||
pub permissions: Vec<String>,
|
||||
pub heavy: bool,
|
||||
}
|
||||
|
||||
impl InstallApproval {
|
||||
/// Is `plugin` the module this approval was given for?
|
||||
fn covers(&self, plugin: &Plugin) -> bool {
|
||||
let m = plugin.manifest();
|
||||
self.id == m.id && self.hash == plugin.hash() && self.permissions.iter().map(String::as_str).eq(permissions::names(&m.permissions()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Lets the next `plugin.install` take its `grant` and `allowHeavy`, for the module `approval`
|
||||
/// describes. Call it only with what a person approved, just before that install.
|
||||
pub fn approve_plugin_install(&mut self, approval: InstallApproval) {
|
||||
self.plugin_install_approval = Some(approval);
|
||||
}
|
||||
|
||||
/// Drops an approval that no install has spent.
|
||||
pub fn clear_plugin_install_approval(&mut self) {
|
||||
self.plugin_install_approval = None;
|
||||
}
|
||||
|
||||
/// Makes `plugin.install` accept `grant` and `allowHeavy` from any request in this session.
|
||||
/// For the person starting the process (`vectorcraft-cli --allow-plugin-grants`), never for
|
||||
/// a request.
|
||||
pub fn allow_plugin_grants_from_requests(&mut self) {
|
||||
self.plugin_grants_from_requests = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Would installing `plugin` take another module's place: one installed with its id and other
|
||||
/// bytes, or, with none installed, the settings and cache the plug-in store keeps for its id?
|
||||
fn takes_over(s: &Session, plugin: &Plugin) -> bool {
|
||||
@@ -564,14 +606,17 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
|
||||
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted this very
|
||||
/// module unless `grant` lists them all, one that would take another module's place ([`takes_over`])
|
||||
/// unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records the grant with
|
||||
/// the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. A
|
||||
/// module that lists no permissions ends any grant held for its id.
|
||||
/// `plugin.install`: loads and checks the module, refuses `grant` and `allowHeavy` that neither an
|
||||
/// [`InstallApproval`] for this module nor the session allows, one that asks for a heavy budget
|
||||
/// unless `allowHeavy` says a person agreed, one that lists permissions no person has granted this
|
||||
/// very module unless `grant` lists them all, one that would take another module's place
|
||||
/// ([`takes_over`]) unless `replace` is true, and one whose SHA-256 isn't the `hash` given; records
|
||||
/// the grant with the module's hash, keeps it for the next start and installs it → its
|
||||
/// `plugin.list` entry. A module that lists no permissions ends any grant held for its id.
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
// One use: spent whatever this install does.
|
||||
let approval = s.plugin_install_approval.take();
|
||||
let replace = match p.get("replace") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(switch_or(p, "replace", false, C)?),
|
||||
@@ -600,25 +645,38 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
));
|
||||
}
|
||||
// A heavy plug-in is installed once the user agrees: whoever installs it asks them first.
|
||||
// `grant` and `allowHeavy` say that a person agreed, and a request alone can't say that.
|
||||
let approval = approval.filter(|a| a.covers(&plugin));
|
||||
if !s.plugin_grants_from_requests && ((grant.is_some() && approval.is_none()) || (allow_heavy && !approval.is_some_and(|a| a.heavy))) {
|
||||
return Err(bad(
|
||||
C,
|
||||
"`grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app",
|
||||
));
|
||||
}
|
||||
// A heavy plug-in is installed once a person agrees.
|
||||
if plugin.manifest().budget == Budget::Heavy && !allow_heavy {
|
||||
let (minutes, times) = vectorcraft_plugins::heavy_terms();
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"“{}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees",
|
||||
"“{}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants",
|
||||
plugin.manifest().name
|
||||
),
|
||||
));
|
||||
}
|
||||
// A plug-in that lists permissions installs once the user grants them: whoever installs it
|
||||
// asks them first, unless they already granted this very module all of them.
|
||||
// A plug-in that lists permissions installs once they are granted, unless a grant recorded for
|
||||
// this very module already covers all of them.
|
||||
let m = plugin.manifest();
|
||||
let wanted = m.permissions();
|
||||
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
|
||||
let name = permissions::display_text(&m.name, permissions::MAX_SHOWN_NAME);
|
||||
let asked = permissions::names(&wanted).join(", ");
|
||||
return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees")));
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"“{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants"
|
||||
),
|
||||
));
|
||||
}
|
||||
// Installed plug-ins stay installed after a restart (#2541). The module is written first and its
|
||||
// grant only once it is there; when the grant can't be written, the store gets the module it
|
||||
@@ -939,7 +997,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
@@ -948,7 +1006,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inspect Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions no person has granted this very module, its bytes included: it then needs a person's approval, see plugin.install) and needsReplace: bool (true when it would take the place of another module with its id: plugin.install then needs replace: true)",
|
||||
always,
|
||||
inspect
|
||||
),
|
||||
|
||||
@@ -164,6 +164,12 @@ pub struct Session {
|
||||
/// A filter's output that a run made in the background handed in for `plugin.run` to apply
|
||||
/// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it.
|
||||
plugin_output: Option<cmd::plugin::HandedIn>,
|
||||
/// What a person approved for the next `plugin.install` ([`Session::approve_plugin_install`]);
|
||||
/// that install spends it, whatever it does.
|
||||
plugin_install_approval: Option<cmd::plugin::InstallApproval>,
|
||||
/// `plugin.install` takes `grant` and `allowHeavy` from any request
|
||||
/// ([`Session::allow_plugin_grants_from_requests`]).
|
||||
plugin_grants_from_requests: bool,
|
||||
/// URLs recently given in the Attributes panel (`attributes.set {url}`), newest first; not saved.
|
||||
pub recent_urls: Vec<String>,
|
||||
/// The saved tracing presets' revision and latest changes ([`Session::trace_presets_revision`]).
|
||||
|
||||
@@ -85,6 +85,8 @@ impl Session {
|
||||
plugin_dir: None,
|
||||
ignored_plugin_grants: Default::default(),
|
||||
plugin_output: None,
|
||||
plugin_install_approval: None,
|
||||
plugin_grants_from_requests: false,
|
||||
recent_urls: vec![],
|
||||
trace_preset_changes: Default::default(),
|
||||
ui_language: None,
|
||||
|
||||
@@ -173,6 +173,22 @@ fn blend_recolors_a_colorized_gray_image_without_alpha() {
|
||||
assert_eq!(colorize(&s, middle), Some(Paint::solid(Color::rgb(0.5, 0.5, 0.5))));
|
||||
}
|
||||
|
||||
/// A colorized image shows a fill and no stroke, so selecting it loads them for new art as any
|
||||
/// object without a stroke does (the weight stays).
|
||||
#[test]
|
||||
fn selecting_a_colorized_image_gives_new_art_its_fill_and_no_stroke() {
|
||||
let mut s = session();
|
||||
let id = image(&mut s, "gray", gray(false));
|
||||
s.execute("paint.setFill", &json!({"color": "#336699"})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#00ff00"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 5.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(s.paint.fill.color().map(|c| c.to_hex()), Some("#336699".into()));
|
||||
assert_eq!((s.paint.stroke.clone(), s.paint.stroke_width), (Paint::None, 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_proxy_invert_recolors_a_colorized_gray_image_and_undo_restores_it() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -26,7 +26,15 @@ fn install_wat(s: &mut Session, src: &str) -> Value {
|
||||
install(s, &wat::parse_str(src).unwrap())
|
||||
}
|
||||
|
||||
/// A session started to take `grant` and `allowHeavy` from requests, as most tests here install.
|
||||
fn session() -> Session {
|
||||
let mut s = strict_session();
|
||||
s.allow_plugin_grants_from_requests();
|
||||
s
|
||||
}
|
||||
|
||||
/// A session as MCP and the CLI have by default: `grant` and `allowHeavy` need an approval.
|
||||
fn strict_session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||||
s
|
||||
@@ -2542,6 +2550,132 @@ fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() {
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// An approval of the module `wasm` with id `id`, its `permissions` and, if `heavy`, its budget.
|
||||
fn approval(id: &str, wasm: &[u8], permissions: &[&str], heavy: bool) -> crate::cmd::plugin::InstallApproval {
|
||||
crate::cmd::plugin::InstallApproval {
|
||||
id: id.into(),
|
||||
hash: vectorcraft_plugins::permissions::sha256_hex(wasm),
|
||||
permissions: permissions.iter().map(|p| p.to_string()).collect(),
|
||||
heavy,
|
||||
}
|
||||
}
|
||||
|
||||
/// In a session that wasn't started to accept them, `grant` and `allowHeavy` are refused whichever
|
||||
/// way the command runs, a batch included, until an approval covers the module, its id, SHA-256 and
|
||||
/// permissions. An approval is spent by the first install, whatever it does. A hash already approved
|
||||
/// still installs.
|
||||
#[test]
|
||||
fn a_request_cannot_grant_what_only_a_person_approves() {
|
||||
let mut s = strict_session();
|
||||
let id = "test.engine.approval";
|
||||
let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let data = vectorcraft_format::base64_encode(&wasm);
|
||||
let refused = "take effect only in a session started with --allow-plugin-grants";
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "{e}");
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
|
||||
assert!(e.contains("approve them in the desktop app") && e.contains("--allow-plugin-grants"), "{e}");
|
||||
let batch = json!({"commands": [{"command": "plugin.install", "params": {"dataBase64": data, "grant": ["doc.read"]}}]});
|
||||
let e = s.execute("command.batch", &batch).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "a batch can't grant: {e}");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
|
||||
// Approved for other bytes, for another id or for other permissions: refused, and spent.
|
||||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [{"name": "x"}]}));
|
||||
for wrong in [
|
||||
approval(id, &other, &["doc.read"], false),
|
||||
approval("test.engine.approval.other", &wasm, &["doc.read"], false),
|
||||
approval(id, &wasm, &["doc.read", "state"], false),
|
||||
] {
|
||||
s.approve_plugin_install(wrong);
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "{e}");
|
||||
}
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
|
||||
// The approval for this module installs it once.
|
||||
s.approve_plugin_install(approval(id, &wasm, &["doc.read"], false));
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]}));
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm)));
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "the approval was spent: {e}");
|
||||
// A hash already approved installs without one.
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": data}));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
|
||||
// A refused install spends the approval too.
|
||||
s.approve_plugin_install(approval(id, &wasm, &["doc.read"], false));
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"], "hash": "0"})).unwrap_err().to_string();
|
||||
assert!(e.contains("SHA-256"), "{e}");
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "{e}");
|
||||
// So does one refused for its params.
|
||||
s.approve_plugin_install(approval(id, &wasm, &["doc.read"], false));
|
||||
s.execute("plugin.install", &json!({"dataBase64": data, "grant": "doc.read"})).unwrap_err();
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read"]})).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "{e}");
|
||||
|
||||
// The heavy budget needs its own agreement.
|
||||
let heavy_id = "test.engine.approval.heavy";
|
||||
let heavy_wasm = heavy(heavy_id, 1);
|
||||
let heavy_data = vectorcraft_format::base64_encode(&heavy_wasm);
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": heavy_data, "allowHeavy": true})).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "{e}");
|
||||
// Without a `permissions` list it has the ones its kind implies.
|
||||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": heavy_data}));
|
||||
let implied: Vec<&str> = inspected["permissions"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
s.approve_plugin_install(approval(heavy_id, &heavy_wasm, &implied, false));
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": heavy_data, "allowHeavy": true})).unwrap_err().to_string();
|
||||
assert!(e.contains(refused), "an approval without the heavy budget doesn't cover it: {e}");
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": heavy_data})).unwrap_err().to_string();
|
||||
assert!(e.contains("approve it in the desktop app"), "{e}");
|
||||
s.approve_plugin_install(approval(heavy_id, &heavy_wasm, &implied, true));
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": heavy_data, "allowHeavy": true}));
|
||||
run(&mut s, "plugin.remove", json!({"id": heavy_id}));
|
||||
|
||||
// A session started to accept them takes them from requests.
|
||||
s.allow_plugin_grants_from_requests();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]}));
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A module a person approved in the desktop app, recorded in the store's `grants.json`, installs
|
||||
/// from another session with that store and no flag and no `grant`. Other bytes under its id are
|
||||
/// refused there, with or without `grant`.
|
||||
#[test]
|
||||
fn a_module_approved_in_the_app_installs_from_a_strict_session_with_its_store() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-approval-store");
|
||||
let id = "test.engine.approval.store";
|
||||
let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let data = vectorcraft_format::base64_encode(&wasm);
|
||||
let mut desktop = strict_session();
|
||||
desktop.attach_plugin_store(folder.0.clone());
|
||||
desktop.approve_plugin_install(approval(id, &wasm, &["doc.read"], false));
|
||||
run(&mut desktop, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read"]}));
|
||||
assert!(std::fs::read_to_string(folder.0.join("grants.json")).unwrap().contains(id), "recorded in the store");
|
||||
|
||||
// Another process starts with an empty registry and the store's grants.
|
||||
let forget = || {
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
};
|
||||
forget();
|
||||
let mut headless = strict_session();
|
||||
headless.attach_plugin_store(folder.0.clone());
|
||||
forget();
|
||||
run(&mut headless, "plugin.install", json!({"dataBase64": data}));
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm)));
|
||||
|
||||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [{"name": "x"}]}));
|
||||
let other_data = vectorcraft_format::base64_encode(&other);
|
||||
let e = headless.execute("plugin.install", &json!({"dataBase64": other_data, "replace": true})).unwrap_err().to_string();
|
||||
assert!(e.contains("approve them in the desktop app"), "{e}");
|
||||
let e = headless.execute("plugin.install", &json!({"dataBase64": other_data, "replace": true, "grant": ["doc.read"]})).unwrap_err().to_string();
|
||||
assert!(e.contains("take effect only in a session started with --allow-plugin-grants"), "{e}");
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm)));
|
||||
run(&mut headless, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
|
||||
/// two slices of its budget and changes nothing.
|
||||
#[test]
|
||||
|
||||
@@ -2415,6 +2415,64 @@ proptest! {
|
||||
let text = type42_composite_eps(&font, gid);
|
||||
survive("damaged Type 42 glyphs", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// CID-keyed fonts with hostile CMaps (codespace ranges of any length, mappings past them)
|
||||
/// and Type 0 CIDFonts with damaged binary data (offset table, charstrings, subroutine table,
|
||||
/// any FDBytes and GDBytes), shown with any bytes: drawn, measured and outlined, or refused.
|
||||
#[test]
|
||||
fn eps_cid_fonts_never_panic(
|
||||
space in prop::collection::vec((prop::collection::vec(any::<u8>(), 0..6), prop::collection::vec(any::<u8>(), 0..6)), 0..4),
|
||||
cids in prop::collection::vec((prop::collection::vec(any::<u8>(), 1..5), any::<u16>()), 0..6),
|
||||
data in prop::collection::vec(any::<u8>(), 0..200),
|
||||
fd_bytes in 0u8..6,
|
||||
gd_bytes in 0u8..6,
|
||||
len_iv in -2i8..8,
|
||||
shown in prop::collection::vec(any::<u8>(), 0..24),
|
||||
) {
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
let ranges: String = space.iter().map(|(lo, hi)| format!("<{}> <{}>\n", hex(lo), hex(hi))).collect();
|
||||
let maps: String = cids.iter().map(|(c, cid)| format!("<{}> <{}> {cid}\n", hex(c), hex(c))).collect();
|
||||
let text = format!(
|
||||
"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
|
||||
/CIDInit /ProcSet findresource begin 20 dict begin /CIDFontName /F def /CIDFontType 0 def\n\
|
||||
/FDArray [4 dict begin /FontMatrix [0.001 0 0 0.001 0 0] def /Private 4 dict dup begin /lenIV {len_iv} def\n\
|
||||
/SubrMapOffset 3 def /SDBytes 2 def /SubrCount 4 def end def currentdict end] def\n\
|
||||
/CIDMapOffset 0 def /FDBytes {fd_bytes} def /GDBytes {gd_bytes} def /CIDCount 40 def\n\
|
||||
(Hex) {} StartData\n{}\n\
|
||||
/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /M def\n\
|
||||
{} begincodespacerange\n{ranges}endcodespacerange\n{} begincidrange\n{maps}endcidrange\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/C /M [/F] composefont 40 scalefont setfont 10 20 moveto <{}> show <{}> stringwidth pop pop\n\
|
||||
10 60 moveto <{}> true charpath fill\nshowpage\n%%EOF\n",
|
||||
data.len(), hex(&data), space.len(), cids.len(), hex(&shown), hex(&shown), hex(&shown)
|
||||
);
|
||||
survive("hostile CID-keyed fonts", || vectorcraft_eps::import(text.as_bytes()).ok().map(|r| r.document))?;
|
||||
}
|
||||
|
||||
/// Embedded Type 1 fonts whose encrypted part is damaged or hostile (any bytes, binary or
|
||||
/// hexadecimal), shown, measured and outlined: drawn, or left out.
|
||||
#[test]
|
||||
fn eps_type1_programs_never_panic(
|
||||
part in prop::collection::vec(any::<u8>(), 0..400),
|
||||
hex in any::<bool>(),
|
||||
shown in prop::collection::vec(any::<u8>(), 0..12),
|
||||
) {
|
||||
let hexed = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
// Bytes that would end the part early are left out, as `cleartomark` ends it.
|
||||
let part: Vec<u8> = part.into_iter().filter(|b| *b != b'c').collect();
|
||||
let mut bytes = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n\
|
||||
12 dict begin /FontName /T1 def /FontType 1 def /FontMatrix [0.001 0 0 0.001 0 0] def /FontBBox [0 0 1000 1000] def\n\
|
||||
/Encoding StandardEncoding def currentdict end currentfile eexec\n"
|
||||
.to_vec();
|
||||
if hex {
|
||||
bytes.extend(hexed(&part).into_bytes());
|
||||
} else {
|
||||
bytes.extend(part);
|
||||
}
|
||||
bytes.extend(format!("\n{}\ncleartomark\n/T1 findfont 40 scalefont setfont 10 20 moveto <{}> show <{}> stringwidth pop pop\n\
|
||||
10 60 moveto <{}> true charpath fill\nshowpage\n%%EOF\n", "0".repeat(512), hexed(&shown), hexed(&shown), hexed(&shown)).into_bytes());
|
||||
survive("hostile Type 1 programs", || vectorcraft_eps::import(&bytes).ok().map(|r| r.document))?;
|
||||
}
|
||||
}
|
||||
|
||||
/// The TrueType font [`type42_composite_eps`] carries.
|
||||
|
||||
@@ -40,6 +40,7 @@ fn clearing_every_cache_empties_each_plugins_cache_and_keeps_its_settings() {
|
||||
std::fs::create_dir_all(&folder).unwrap();
|
||||
let ids = ["test.clearcache.a", "test.clearcache.b"];
|
||||
let mut s = Session::new();
|
||||
s.allow_plugin_grants_from_requests();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||||
s.attach_plugin_store(folder.clone());
|
||||
for id in ids {
|
||||
|
||||
@@ -695,6 +695,7 @@ proptest! {
|
||||
prop_assert!(shown.chars().count() <= vectorcraft_plugins::permissions::MAX_SHOWN_NAME);
|
||||
}
|
||||
let mut s = rich_session();
|
||||
s.allow_plugin_grants_from_requests();
|
||||
let module = wat::parse_str(tpl::returning(&text, r#"{"objects":[]}"#)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&module);
|
||||
let r = catch_quiet(|| {
|
||||
@@ -862,6 +863,7 @@ proptest! {
|
||||
prop_assert!(kinds.is_empty() || (man.asks_permissions() && reads), "{}", text);
|
||||
}
|
||||
let mut s = rich_session();
|
||||
s.allow_plugin_grants_from_requests();
|
||||
let module = wat::parse_str(tpl::module(&text, tpl::ECHO, "", 1)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&module);
|
||||
let grant = json!(["doc.read", "doc.write", "state", "effect.render", "export.write"]);
|
||||
@@ -915,6 +917,7 @@ proptest! {
|
||||
let _ = std::fs::remove_dir_all(&store.0);
|
||||
let folder = store.0.clone();
|
||||
let mut s = Session::new();
|
||||
s.allow_plugin_grants_from_requests();
|
||||
s.attach_plugin_store(folder.clone());
|
||||
let storing = |id: &str| format!(r#"{{"id":"{id}","name":"F","kind":"filter","api":2,"permissions":["settings","cache"]}}"#);
|
||||
let grant = json!(["settings", "cache"]);
|
||||
|
||||
@@ -204,6 +204,7 @@ fn a_disabled_plug_in_whose_new_grant_cant_be_stored_stays_disabled() {
|
||||
let _f = fresh();
|
||||
let dir = tmp_dir("reinstall-no-grant");
|
||||
let mut s = launch(&dir);
|
||||
s.allow_plugin_grants_from_requests();
|
||||
let manifest = r#"{"id":"test.sm.g","name":"Test","version":"1","kind":"filter","permissions":["doc.read"]}"#;
|
||||
let module = |out: &str| vectorcraft_format::base64_encode(&wat::parse_str(tpl::returning(manifest, out)).unwrap());
|
||||
s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[]}"#), "grant": ["doc.read"]})).unwrap();
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
|
||||
mod ai;
|
||||
mod ate;
|
||||
mod cid;
|
||||
mod data;
|
||||
mod graphics;
|
||||
mod interp;
|
||||
@@ -44,11 +45,14 @@ mod obj;
|
||||
mod preview;
|
||||
mod shading;
|
||||
mod text;
|
||||
mod type1;
|
||||
mod type42;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_cid;
|
||||
#[cfg(test)]
|
||||
mod tests_composite;
|
||||
#[cfg(test)]
|
||||
mod tests_fontnames;
|
||||
@@ -60,6 +64,10 @@ mod tests_illustrator;
|
||||
mod tests_images;
|
||||
#[cfg(test)]
|
||||
mod tests_native;
|
||||
#[cfg(test)]
|
||||
mod tests_type1;
|
||||
#[cfg(test)]
|
||||
mod tests_type42;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
//! CID-keyed fonts (PLRM 3rd ed., §5.11): CMaps built with the operators of the `CIDInit`
|
||||
//! procedure set (and the predefined `Identity-H` and `Identity-V`), `composefont`, Type 0
|
||||
//! fonts with `FMapType` 9 that map a show string's codes through their CMap to CIDs, and the
|
||||
//! glyphs of the CIDFonts they select: Type 0 CIDFonts (Type 1 charstrings in the binary data
|
||||
//! `StartData` reads, or in a `GlyphDirectory`: PLRM 3rd ed., §5.11.3, and Adobe Technical Note
|
||||
//! #5014, "Adobe CMap and CIDFont Files Specification") and Type 2 CIDFonts (TrueType glyphs in
|
||||
//! `sfnts`, picked through `CIDMap`). CIDs carry no characters: the glyphs are drawn from the
|
||||
//! font's outlines, as a composite font's are.
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use skrifa::raw::ps::cff::CffFontRef;
|
||||
use skrifa::raw::ps::cff::index::Index;
|
||||
use skrifa::raw::ps::cs::{CharstringContext, CharstringKind, CommandSink, NopFilterSink, TransformSink};
|
||||
use skrifa::raw::ps::error::Error as CsError;
|
||||
use skrifa::raw::types::{Fixed, GlyphId};
|
||||
use vectorcraft_geom::BezPath;
|
||||
|
||||
use super::interp::{Interp, matrix_obj};
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, Shared, ps_err};
|
||||
|
||||
/// Most codespace ranges, character and notdef mappings a CMap may hold.
|
||||
const MAX_RANGES: usize = 1 << 16;
|
||||
/// Most bytes of glyph data a Type 0 CIDFont may carry.
|
||||
const MAX_GLYPH_DATA: usize = 64 << 20;
|
||||
/// Most CMaps whose mappings are kept read between shows.
|
||||
const MAX_CMAPS: usize = 8;
|
||||
/// Longest code a codespace range may give (PLRM 3rd ed., §5.11.5: the bytes of an integer).
|
||||
const MAX_CODE: usize = 4;
|
||||
|
||||
/// The CMap entries the CIDInit operators fill: codespace ranges (`[lo hi …]`, strings), and
|
||||
/// character and notdef mappings to CIDs (`[lo hi cid font …]`, the codes as strings).
|
||||
const CODESPACE: &str = "CodeSpaceRange";
|
||||
const CIDS: &str = "CIDRange";
|
||||
const NOTDEFS: &str = "NotDefRange";
|
||||
/// Mappings to a base font's character codes or glyph names (`[lo hi dst font …]`: `dst` a
|
||||
/// string, a name, or an array of them, one a code).
|
||||
const BASES: &str = "BFRange";
|
||||
|
||||
/// The mappings of the CMaps shown with so far, by their character mapping, codespace, notdef
|
||||
/// mapping and base-font mapping arrays, the most recent last.
|
||||
pub(super) type CMaps = Vec<([Option<Shared<Obj>>; 4], Rc<CMap>)>;
|
||||
|
||||
/// A CMap's mappings, read from its dictionary.
|
||||
pub(super) struct CMap {
|
||||
/// Codespace ranges: each byte's bounds.
|
||||
space: Vec<Vec<(u8, u8)>>,
|
||||
/// Character mappings (code length, first code, last code, first CID, font), latest first.
|
||||
cids: Vec<(usize, u32, u32, u32, usize)>,
|
||||
/// Notdef mappings, latest first.
|
||||
notdefs: Vec<(usize, u32, u32, u32, usize)>,
|
||||
/// Mappings to base-font codes or glyph names (code length, first code, last code, what
|
||||
/// the first code maps to, font), latest first.
|
||||
bases: Vec<(usize, u32, u32, Obj, usize)>,
|
||||
}
|
||||
|
||||
/// What a code maps to in a base font (`beginbfchar`, `beginbfrange`).
|
||||
#[derive(Clone)]
|
||||
pub(super) enum Base {
|
||||
Code(u8),
|
||||
Name(Rc<str>),
|
||||
}
|
||||
|
||||
/// A character of an `FMapType` 9 font: its code's descendant font and CID, and the notdef
|
||||
/// mapping's CID for when that font has no glyph for it.
|
||||
pub(super) struct CidChar {
|
||||
pub font: usize,
|
||||
pub cid: u32,
|
||||
pub notdef: Option<u32>,
|
||||
/// The base-font code or glyph name a `bfchar` or `bfrange` mapping gives it, instead of a
|
||||
/// CID.
|
||||
pub base: Option<Base>,
|
||||
}
|
||||
|
||||
impl CMap {
|
||||
/// The mappings of CMap dictionary `d`.
|
||||
fn read(d: &Dict) -> Self {
|
||||
let items = |k: &str| d.get(&Key::name(k)).and_then(|a| a.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
let bytes = |o: &Obj| match o {
|
||||
Obj::Str(s) => Some(s.to_vec()),
|
||||
_ => None,
|
||||
};
|
||||
let space = items(CODESPACE)
|
||||
.as_chunks::<2>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|[lo, hi]| {
|
||||
let (lo, hi) = (bytes(lo)?, bytes(hi)?);
|
||||
(!lo.is_empty() && lo.len() == hi.len() && lo.len() <= MAX_CODE).then(|| lo.into_iter().zip(hi).collect())
|
||||
})
|
||||
.collect();
|
||||
let maps = |k: &str| -> Vec<(usize, u32, u32, u32, usize)> {
|
||||
items(k)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|[lo, hi, cid, font]| {
|
||||
let (lo, hi) = (bytes(lo)?, bytes(hi)?);
|
||||
let cid = cid.as_num().filter(|c| (0.0..=f64::from(u32::MAX)).contains(c))? as u32;
|
||||
let font = font.as_num().filter(|f| (0.0..=255.0).contains(f))? as usize;
|
||||
(!lo.is_empty() && lo.len() == hi.len() && lo.len() <= MAX_CODE).then(|| (lo.len(), code(&lo), code(&hi), cid, font))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let bases = items(BASES)
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|[lo, hi, dst, font]| {
|
||||
let (lo, hi) = (bytes(lo)?, bytes(hi)?);
|
||||
let font = font.as_num().filter(|f| (0.0..=255.0).contains(f))? as usize;
|
||||
(!lo.is_empty() && lo.len() == hi.len() && lo.len() <= MAX_CODE).then(|| (lo.len(), code(&lo), code(&hi), dst.clone(), font))
|
||||
})
|
||||
.collect();
|
||||
Self { space, cids: maps(CIDS), notdefs: maps(NOTDEFS), bases }
|
||||
}
|
||||
|
||||
/// The characters of show string `s` (PLRM 3rd ed., §5.11.5 "CMap Mapping" and "Handling
|
||||
/// Undefined Characters"), and how many ranges and mappings were compared to find them. It
|
||||
/// stops once that passes `limit` (the characters found so far, and the work done).
|
||||
pub fn decode(&self, s: &[u8], limit: u64) -> (Vec<CidChar>, u64) {
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
let mut work = 0u64;
|
||||
let mut i = 0;
|
||||
while let Some(rest) = s.get(i..).filter(|r| !r.is_empty() && work <= limit) {
|
||||
let matched = (1..=MAX_CODE.min(rest.len())).find(|&n| {
|
||||
work += self.space.len() as u64;
|
||||
self.space.iter().any(|r| r.len() == n && fits(r, rest))
|
||||
});
|
||||
let n = match matched {
|
||||
Some(n) => n,
|
||||
// An invalid code: as many bytes as the best partly matching range's codes.
|
||||
None => self.partial(rest),
|
||||
};
|
||||
let n = n.clamp(1, rest.len());
|
||||
let c = code(rest.get(..n).unwrap_or_default());
|
||||
let find = |maps: &[(usize, u32, u32, u32, usize)], work: &mut u64| {
|
||||
let at = maps.iter().position(|m| m.0 == n && (m.1..=m.2).contains(&c));
|
||||
*work += at.map_or(maps.len(), |k| k + 1) as u64;
|
||||
at.and_then(|k| maps.get(k)).map(|m| (m.4, m.3.saturating_add(c - m.1), m.3))
|
||||
};
|
||||
let notdef = find(&self.notdefs, &mut work).map(|(_, _, base)| base);
|
||||
let at = self.bases.iter().position(|m| m.0 == n && (m.1..=m.2).contains(&c));
|
||||
work += at.map_or(self.bases.len(), |k| k + 1) as u64;
|
||||
let base = at.and_then(|k| self.bases.get(k)).and_then(|m| Some((m.4, base_of(&m.3, c - m.1)?)));
|
||||
out.push(match (matched.is_some(), find(&self.cids, &mut work), base) {
|
||||
(true, Some((font, cid, _)), _) => CidChar { font, cid, notdef, base: None },
|
||||
(true, None, Some((font, base))) => CidChar { font, cid: 0, notdef: None, base: Some(base) },
|
||||
// No character mapping: the notdef mapping's CID, else font 0's CID 0.
|
||||
_ => match find(&self.notdefs, &mut work) {
|
||||
Some((font, _, base)) if matched.is_some() => CidChar { font, cid: base, notdef: None, base: None },
|
||||
_ => CidChar { font: 0, cid: 0, notdef: None, base: None },
|
||||
},
|
||||
});
|
||||
i += n;
|
||||
}
|
||||
(out, work)
|
||||
}
|
||||
|
||||
/// How many bytes an invalid code at the start of `rest` takes: the length of the codes of
|
||||
/// the range matching the most of its first bytes (the shortest of those), or of the
|
||||
/// shortest range when none matches its first byte.
|
||||
fn partial(&self, rest: &[u8]) -> usize {
|
||||
let prefix = |r: &[(u8, u8)]| r.iter().zip(rest).take_while(|((lo, hi), b)| (lo..=hi).contains(b)).count();
|
||||
let best = self.space.iter().map(|r| prefix(r)).max().unwrap_or(0);
|
||||
let pick = self.space.iter().filter(|r| best == 0 || prefix(r) == best).map(Vec::len).min();
|
||||
pick.unwrap_or(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// What the code `k` codes after the first of a base-font mapping to `dst` maps to: a string's
|
||||
/// code with its last byte that much higher, the `k`th of an array of codes and names, or a
|
||||
/// name (TN #5099, `beginbfrange`).
|
||||
fn base_of(dst: &Obj, k: u32) -> Option<Base> {
|
||||
match dst {
|
||||
// An array's elements are read as codes and names only: an array may hold itself, so
|
||||
// reading its arrays in turn could recurse without end.
|
||||
Obj::Array { items, .. } => base_of_leaf(items.borrow().get(usize::try_from(k).ok()?)?, 0),
|
||||
_ => base_of_leaf(dst, k),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`base_of`] for a string or a name.
|
||||
fn base_of_leaf(dst: &Obj, k: u32) -> Option<Base> {
|
||||
match dst {
|
||||
Obj::Str(s) => {
|
||||
let last = u32::from(s.len().checked_sub(1).and_then(|i| s.get(i))?).checked_add(k)?;
|
||||
Some(Base::Code(u8::try_from(last).ok()?))
|
||||
}
|
||||
Obj::Name(n) if k == 0 => Some(Base::Name(n.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does code `bytes` (as long as `range`, at least) start in codespace range `range`?
|
||||
fn fits(range: &[(u8, u8)], bytes: &[u8]) -> bool {
|
||||
range.len() <= bytes.len() && range.iter().zip(bytes).all(|((lo, hi), b)| (lo..=hi).contains(&b))
|
||||
}
|
||||
|
||||
/// The number a code's bytes make, the first byte highest.
|
||||
fn code(bytes: &[u8]) -> u32 {
|
||||
bytes.iter().take(MAX_CODE).fold(0u32, |c, b| c << 8 | u32::from(*b))
|
||||
}
|
||||
|
||||
/// The number `n` bytes at `at` of `data` make, the first byte highest.
|
||||
fn number(data: &[u8], at: usize, n: usize) -> Option<u64> {
|
||||
let bytes = data.get(at..at.checked_add(n)?)?;
|
||||
(n <= 8).then(|| bytes.iter().fold(0u64, |v, b| v << 8 | u64::from(*b)))
|
||||
}
|
||||
|
||||
/// How a predefined Unicode CMap's codes encode their characters.
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub(super) enum Unicode {
|
||||
/// Two bytes a character (`UCS2`) or a UTF-16 surrogate pair, the high byte first.
|
||||
Utf16,
|
||||
Utf8,
|
||||
/// Four bytes, the high byte first.
|
||||
Utf32,
|
||||
}
|
||||
|
||||
/// The encoding of predefined Unicode CMap `name` and whether it is vertical: one of Adobe's
|
||||
/// public CMap resources whose name says its codes are Unicode: `Uni` and a character
|
||||
/// collection, then `-UCS2`, `-UTF8`, `-UTF16` or `-UTF32` (and `-HW` for half-width forms),
|
||||
/// then `-H` or `-V` (`UniJIS-UCS2-H`, `UniGB-UTF16-V`:
|
||||
/// <https://github.com/adobe-type-tools/cmap-resources>).
|
||||
pub(super) fn unicode_cmap(name: &str) -> Option<(Unicode, bool)> {
|
||||
let (rest, dir) = name.rsplit_once('-')?;
|
||||
let vertical = match dir {
|
||||
"H" => false,
|
||||
"V" => true,
|
||||
_ => return None,
|
||||
};
|
||||
let rest = rest.strip_suffix("-HW").unwrap_or(rest);
|
||||
let (collection, form) = rest.rsplit_once('-')?;
|
||||
if !(collection.len() > 3 && collection.starts_with("Uni") && !collection.contains('-')) {
|
||||
return None;
|
||||
}
|
||||
let form = match form {
|
||||
"UCS2" | "UTF16" => Unicode::Utf16,
|
||||
"UTF8" => Unicode::Utf8,
|
||||
"UTF32" => Unicode::Utf32,
|
||||
_ => return None,
|
||||
};
|
||||
Some((form, vertical))
|
||||
}
|
||||
|
||||
/// The characters show string `s` encodes in Unicode form `form` (invalid codes left out).
|
||||
pub(super) fn unicode_text(s: &[u8], form: Unicode) -> String {
|
||||
match form {
|
||||
Unicode::Utf16 => {
|
||||
let units = s.as_chunks::<2>().0.iter().map(|b| u16::from_be_bytes(*b));
|
||||
char::decode_utf16(units).filter_map(Result::ok).collect()
|
||||
}
|
||||
Unicode::Utf8 => String::from_utf8_lossy(s).chars().filter(|c| *c != char::REPLACEMENT_CHARACTER).collect(),
|
||||
Unicode::Utf32 => s.as_chunks::<4>().0.iter().filter_map(|b| char::from_u32(u32::from_be_bytes(*b))).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A predefined Identity CMap: two-byte codes, each the CID of its own value; vertical for
|
||||
/// `Identity-V`.
|
||||
pub(super) fn identity(name: &str, vertical: bool) -> Obj {
|
||||
let full = || Obj::string(vec![0xff, 0xff]);
|
||||
let zero = || Obj::string(vec![0, 0]);
|
||||
let mut d = Dict::new();
|
||||
d.insert(Key::name("CMapName"), Obj::name(name));
|
||||
d.insert(Key::name("CMapType"), Obj::Int(1));
|
||||
d.insert(Key::name("WMode"), Obj::Int(i64::from(vertical)));
|
||||
d.insert(Key::name(CODESPACE), Obj::array(vec![zero(), full()]));
|
||||
d.insert(Key::name(CIDS), Obj::array(vec![zero(), full(), Obj::Int(0), Obj::Int(0)]));
|
||||
Obj::dict(d)
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
/// `composefont` and the operators of the `CIDInit` and `FontSetInit` procedure sets.
|
||||
pub fn cid_op(&mut self, op: Op) -> Res {
|
||||
use Op::*;
|
||||
match op {
|
||||
// A CMap's mappings take font 0 until its own `usefont` (Adobe Technical Note #5014).
|
||||
BeginCMap => self.cmap_font = 0,
|
||||
EndCMap | EndRearrangedFont => {}
|
||||
BeginCodeSpaceRange | BeginBfChar | BeginBfRange | BeginCidChar | BeginCidRange | BeginNotDefChar | BeginNotDefRange => {
|
||||
self.pop_int()?;
|
||||
self.push(Obj::Mark)?;
|
||||
}
|
||||
EndCodeSpaceRange => {
|
||||
let items = self.pop_mark_items()?;
|
||||
if items.len() % 2 != 0 || !items.iter().all(|o| matches!(o, Obj::Str(_))) {
|
||||
return ps_err("typecheck", "endcodespacerange");
|
||||
}
|
||||
self.cmap_append(CODESPACE, items)?;
|
||||
}
|
||||
EndCidChar | EndCidRange | EndNotDefChar | EndNotDefRange => {
|
||||
let items = self.pop_mark_items()?;
|
||||
let per = if matches!(op, EndCidChar | EndNotDefChar) { 2 } else { 3 };
|
||||
if items.len() % per != 0 {
|
||||
return ps_err("rangecheck", op.name());
|
||||
}
|
||||
let mut out = Vec::with_capacity(items.len() / per * 4);
|
||||
for m in items.chunks_exact(per) {
|
||||
let (lo, hi, cid) = match m {
|
||||
[lo, cid] => (lo, lo, cid),
|
||||
[lo, hi, cid] => (lo, hi, cid),
|
||||
_ => return ps_err("rangecheck", op.name()),
|
||||
};
|
||||
if !(matches!(lo, Obj::Str(_)) && matches!(hi, Obj::Str(_)) && cid.as_num().is_some()) {
|
||||
return ps_err("typecheck", op.name());
|
||||
}
|
||||
out.extend([lo.clone(), hi.clone(), cid.clone(), Obj::Int(self.cmap_font)]);
|
||||
}
|
||||
self.cmap_append(if matches!(op, EndCidChar | EndCidRange) { CIDS } else { NOTDEFS }, out)?;
|
||||
}
|
||||
// Mappings to character codes or names in base fonts (TN #5099, §§3.3 and 4).
|
||||
EndBfChar | EndBfRange => {
|
||||
let items = self.pop_mark_items()?;
|
||||
let per = if op == EndBfChar { 2 } else { 3 };
|
||||
if items.len() % per != 0 {
|
||||
return ps_err("rangecheck", op.name());
|
||||
}
|
||||
let mut out = Vec::with_capacity(items.len() / per * 4);
|
||||
for m in items.chunks_exact(per) {
|
||||
let (lo, hi, dst) = match m {
|
||||
[lo, dst] => (lo, lo, dst),
|
||||
[lo, hi, dst] => (lo, hi, dst),
|
||||
_ => return ps_err("rangecheck", op.name()),
|
||||
};
|
||||
if !(matches!(lo, Obj::Str(_)) && matches!(hi, Obj::Str(_)) && matches!(dst, Obj::Str(_) | Obj::Name(_) | Obj::Array { .. })) {
|
||||
return ps_err("typecheck", op.name());
|
||||
}
|
||||
out.extend([lo.clone(), hi.clone(), dst.clone(), Obj::Int(self.cmap_font)]);
|
||||
}
|
||||
self.cmap_append(BASES, out)?;
|
||||
}
|
||||
UseFont => {
|
||||
let n = self.pop_int()?;
|
||||
self.cmap_font = n.clamp(0, 255);
|
||||
}
|
||||
UseCMap => {
|
||||
let name = self.pop()?;
|
||||
let parent = self.find_cmap(&name)?;
|
||||
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", "usecmap".into()))?;
|
||||
for k in [CODESPACE, CIDS, NOTDEFS, BASES] {
|
||||
// Both arrays are copied into a new one: charged by its size first, as
|
||||
// `cmap_append` charges its copy, so `usecmap` of a large CMap in a loop runs
|
||||
// out of the budgets instead of the memory.
|
||||
let len = |c: &DictRef| c.borrow().get(&Key::name(k)).and_then(|a| a.items().map(|i| i.len())).unwrap_or(0);
|
||||
let n = len(&parent).saturating_add(len(&d));
|
||||
if n > MAX_RANGES * 4 {
|
||||
return Err(PsError::Limit("a CMap has too many mappings"));
|
||||
}
|
||||
self.alloc(n.saturating_mul(std::mem::size_of::<Obj>()))?;
|
||||
self.spend_bytes(n.saturating_mul(std::mem::size_of::<Obj>()))?;
|
||||
let theirs = parent.borrow().get(&Key::name(k)).and_then(|a| a.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
let ours = d.borrow().get(&Key::name(k)).and_then(|a| a.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
// The parent's mappings first: the CMap's own come later and win.
|
||||
let all: Vec<Obj> = theirs.into_iter().chain(ours).collect();
|
||||
if all.len() > MAX_RANGES * 4 {
|
||||
return Err(PsError::Limit("a CMap has too many mappings"));
|
||||
}
|
||||
self.insert(&d, Key::name(k), Some(Obj::array(all)))?;
|
||||
}
|
||||
let w = parent.borrow().get(&Key::name("WMode")).cloned();
|
||||
if let Some(w) = w
|
||||
&& !d.borrow().contains_key(&Key::name("WMode"))
|
||||
{
|
||||
self.insert(&d, Key::name("WMode"), Some(w))?;
|
||||
}
|
||||
}
|
||||
BeginRearrangedFont => {
|
||||
self.pop()?;
|
||||
self.pop()?;
|
||||
}
|
||||
BeginUseMatrix => {
|
||||
self.pop_int()?;
|
||||
}
|
||||
EndUseMatrix => {
|
||||
self.pop()?;
|
||||
}
|
||||
StartData => self.start_data()?,
|
||||
ComposeFont => self.compose_font()?,
|
||||
_ => return ps_err("undefined", op.name()),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The operands down to the mark that a `begin…` operator pushed.
|
||||
fn pop_mark_items(&mut self) -> Res<Vec<Obj>> {
|
||||
let at = self.stack.iter().rposition(|o| matches!(o, Obj::Mark)).ok_or(PsError::Ps("unmatchedmark", String::new()))?;
|
||||
self.spend((self.stack.len() - at) as u64)?;
|
||||
let items = self.stack.split_off(at + 1);
|
||||
self.stack.pop();
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// `items` added to the CMap being defined (the current dictionary) under `key`.
|
||||
fn cmap_append(&mut self, key: &str, items: Vec<Obj>) -> Res {
|
||||
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", key.into()))?;
|
||||
let mut all = d.borrow().get(&Key::name(key)).and_then(|a| a.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
if all.len().saturating_add(items.len()) > MAX_RANGES * 4 {
|
||||
return Err(PsError::Limit("a CMap has too many mappings"));
|
||||
}
|
||||
self.alloc(items.len() * std::mem::size_of::<Obj>())?;
|
||||
// The mappings so far are copied too: a CMap of many small blocks pays for each copy.
|
||||
self.spend_bytes(all.len().saturating_mul(std::mem::size_of::<Obj>()))?;
|
||||
all.extend(items);
|
||||
self.insert(&d, Key::name(key), Some(Obj::array(all)))
|
||||
}
|
||||
|
||||
/// CMap `name` (a name, or a CMap dictionary itself).
|
||||
pub(super) fn find_cmap(&mut self, name: &Obj) -> Res<DictRef> {
|
||||
if let Obj::Dict(d) = name {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let key = name.key().ok_or(PsError::Ps("typecheck", "CMap".into()))?;
|
||||
if let Some(Obj::Dict(d)) = self.instances(&Obj::name("CMap"))?.borrow().get(&key) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let text = name.text().map(|t| t.to_string()).unwrap_or_default();
|
||||
match unicode_cmap(&text) {
|
||||
Some((_, vertical)) => {
|
||||
let mut d = Dict::new();
|
||||
d.insert(Key::name("CMapName"), Obj::name(&text));
|
||||
d.insert(Key::name("CMapType"), Obj::Int(1));
|
||||
d.insert(Key::name("WMode"), Obj::Int(i64::from(vertical)));
|
||||
Ok(DictRef::new(d))
|
||||
}
|
||||
None => Err(PsError::Ps("undefinedresource", text)),
|
||||
}
|
||||
}
|
||||
|
||||
/// CIDFont `name` (a name, or a dictionary itself): one the program defined, in the CIDFont
|
||||
/// category or as a font, else a CIDFont of that name without glyphs (its glyphs show
|
||||
/// nothing).
|
||||
pub(super) fn find_cid_font(&mut self, name: &Obj) -> Res<DictRef> {
|
||||
if let Obj::Dict(d) = name {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let key = name.key().ok_or(PsError::Ps("typecheck", "composefont".into()))?;
|
||||
if let Some(Obj::Dict(d)) = self.instances(&Obj::name("CIDFont"))?.borrow().get(&key) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
d.insert(Key::name("CIDFontName"), key.obj());
|
||||
d.insert(Key::name("CIDFontType"), Obj::Int(0));
|
||||
d.insert(Key::name("FontType"), Obj::Int(9));
|
||||
d.insert(Key::name("FontMatrix"), matrix_obj(vectorcraft_geom::Affine::scale(0.001)));
|
||||
Ok(DictRef::new(d))
|
||||
}
|
||||
|
||||
/// `key cmap [fonts] composefont font`: a Type 0 font with `FMapType` 9 over `cmap` and the
|
||||
/// CIDFonts (or fonts) named, defined as a font (PLRM 3rd ed., `composefont` in chapter 8).
|
||||
fn compose_font(&mut self) -> Res {
|
||||
let fonts = self.pop()?;
|
||||
let cmap = self.pop()?;
|
||||
let key = self.pop()?.key().ok_or(PsError::Ps("typecheck", "composefont".into()))?;
|
||||
let names = fonts.items().map(|i| i.to_vec()).ok_or(PsError::Ps("typecheck", "composefont".into()))?;
|
||||
let cmap = self.find_cmap(&cmap)?;
|
||||
let mut deps = Vec::with_capacity(names.len());
|
||||
for n in &names {
|
||||
deps.push(Obj::Dict(self.find_cid_font(n)?));
|
||||
}
|
||||
let font = self.composed(key, cmap, deps);
|
||||
self.push(Obj::Dict(font))
|
||||
}
|
||||
|
||||
/// The Type 0 font `key` with `FMapType` 9 over CMap `cmap` and descendants `deps`, defined
|
||||
/// as a font.
|
||||
pub(super) fn composed(&mut self, key: Key, cmap: DictRef, deps: Vec<Obj>) -> DictRef {
|
||||
let mut d = Dict::new();
|
||||
d.insert(Key::name("FontName"), key.obj());
|
||||
d.insert(Key::name("FontType"), Obj::Int(0));
|
||||
d.insert(Key::name("FMapType"), Obj::Int(9));
|
||||
d.insert(Key::name("FontMatrix"), matrix_obj(vectorcraft_geom::Affine::IDENTITY));
|
||||
d.insert(Key::name("Encoding"), Obj::array((0..deps.len() as i64).map(Obj::Int).collect()));
|
||||
d.insert(Key::name("FDepVector"), Obj::array(deps));
|
||||
if let Some(w) = cmap.borrow().get(&Key::name("WMode")).cloned() {
|
||||
d.insert(Key::name("WMode"), w);
|
||||
}
|
||||
d.insert(Key::name("CMap"), Obj::Dict(cmap));
|
||||
let font = DictRef::new(d);
|
||||
self.fonts.borrow_mut().insert(key, Obj::Dict(font.clone()));
|
||||
font
|
||||
}
|
||||
|
||||
/// `StartData`: `(Binary) n StartData` in a Type 0 CIDFont file reads its binary data section
|
||||
/// (`n` bytes after the space that ends the name, or `n` bytes in hexadecimal for `(Hex)`)
|
||||
/// into the CIDFont's `GlyphData`, defines the CIDFont (the current dictionary) as a resource
|
||||
/// and ends it and the CIDInit dictionary under it (Adobe Technical Note #5014, §4.3). A
|
||||
/// FontSet's `/name n StartData` (Compact Font Format data: PLRM 3rd ed., §5.8.1) defines its
|
||||
/// CID-keyed fonts as CIDFonts (see [`Self::font_set`]); its other fonts are set as type by
|
||||
/// name.
|
||||
fn start_data(&mut self) -> Res {
|
||||
// Glyph data is often larger than the most other operators take (`pop_count`).
|
||||
let n = self.pop_int()?;
|
||||
let kind = self.pop()?;
|
||||
let f = self.running.last().cloned().unwrap_or_else(|| Rc::new(std::cell::RefCell::new(super::data::Stream::Current)));
|
||||
if n < 0 {
|
||||
return ps_err("rangecheck", "StartData");
|
||||
}
|
||||
let n = usize::try_from(n).unwrap_or(usize::MAX);
|
||||
if n > MAX_GLYPH_DATA {
|
||||
return Err(PsError::Limit("a font's data is too large"));
|
||||
}
|
||||
self.alloc(n)?;
|
||||
self.spend((n / 256) as u64)?;
|
||||
let hex = match &kind {
|
||||
Obj::Str(s) => s.to_vec() == b"Hex",
|
||||
_ => false,
|
||||
};
|
||||
let data = if hex {
|
||||
let d = self.read_hex(&f, n)?;
|
||||
// The data can end with the `>` of a hexadecimal string.
|
||||
self.skip_hex_end(&f)?;
|
||||
d
|
||||
} else {
|
||||
self.read(&f, n)?
|
||||
};
|
||||
if !matches!(kind, Obj::Str(_)) {
|
||||
return self.font_set(data);
|
||||
}
|
||||
let d = self.dicts.last().cloned().ok_or(PsError::Ps("dictstackunderflow", "StartData".into()))?;
|
||||
self.insert(&d, Key::name("GlyphData"), Some(Obj::string(data)))?;
|
||||
// A Type 0 CIDFont without a matrix of its own: the 1000-unit glyph space of Type 1
|
||||
// fonts, and each FDArray matrix scaled to it (PLRM 3rd ed., §5.11.3).
|
||||
// The changes go through the journal, so `restore` undoes them as it does the program's.
|
||||
if !d.borrow().contains_key(&Key::name("FontMatrix")) {
|
||||
self.insert(&d, Key::name("FontMatrix"), Some(matrix_obj(vectorcraft_geom::Affine::scale(0.001))))?;
|
||||
let fds = d.borrow().get(&Key::name("FDArray")).and_then(|a| a.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
for fd in fds {
|
||||
if let Obj::Dict(fd) = fd {
|
||||
let m = fd.borrow().get(&Key::name("FontMatrix")).and_then(|o| o.items().and_then(|i| super::interp::matrix_of(&i.borrow())));
|
||||
if let Some(m) = m {
|
||||
self.insert(&fd, Key::name("FontMatrix"), Some(matrix_obj(vectorcraft_geom::Affine::scale(1000.0) * m)))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let name = d.borrow().get(&Key::name("CIDFontName")).and_then(Obj::key);
|
||||
if let Some(k) = name {
|
||||
self.instances(&Obj::name("CIDFont"))?.borrow_mut().insert(k, Obj::Dict(d.clone()));
|
||||
}
|
||||
if self.dicts.len() > 3 && self.dicts.last().is_some_and(|t| t.same(&d)) {
|
||||
self.dicts.pop();
|
||||
let cid_init = self.instances(&Obj::name("ProcSet"))?.borrow().get(&Key::name("CIDInit")).cloned();
|
||||
if let (Some(Obj::Dict(p)), Some(top)) = (cid_init, self.dicts.last())
|
||||
&& top.same(&p)
|
||||
&& self.dicts.len() > 3
|
||||
{
|
||||
self.dicts.pop();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The CID-keyed fonts of FontSet data `data` (Compact Font Format: Adobe Technical Note
|
||||
/// #5176, a top DICT with `ROS`), each defined as a Type 0 CIDFont of its name in the CFF's
|
||||
/// Name INDEX, in glyph space of its units an em, whose glyphs are its Type 2 charstrings
|
||||
/// picked by the CIDs its charset gives them.
|
||||
fn font_set(&mut self, data: Vec<u8>) -> Res {
|
||||
let data: Rc<[u8]> = data.into();
|
||||
for index in 0..MAX_FONT_SET {
|
||||
let Ok(font) = CffFontRef::new_cff(&data, index, None) else { break };
|
||||
let Some(name) = font.metadata().and_then(|m| m.name().map(str::to_string)) else { continue };
|
||||
if !font.is_cid() || self.cff.len() >= MAX_CFF {
|
||||
continue;
|
||||
}
|
||||
let upem = f64::from(font.upem());
|
||||
let upem = if upem.is_finite() && upem >= 1.0 { upem } else { 1000.0 };
|
||||
let mut d = Dict::new();
|
||||
d.insert(Key::name("CIDFontName"), Obj::name(&name));
|
||||
d.insert(Key::name("CIDFontType"), Obj::Int(0));
|
||||
d.insert(Key::name("FontType"), Obj::Int(9));
|
||||
d.insert(Key::name("FontMatrix"), matrix_obj(vectorcraft_geom::Affine::scale(1.0 / upem)));
|
||||
let d = DictRef::new(d);
|
||||
self.instances(&Obj::name("CIDFont"))?.borrow_mut().insert(Key::name(&name), Obj::Dict(d.clone()));
|
||||
self.cff.push((d, Rc::new(Cff::new(data.clone(), index))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The mappings of CMap dictionary `d` (read once while it stays the same).
|
||||
pub(super) fn cmap(&mut self, d: &DictRef) -> Res<Rc<CMap>> {
|
||||
let array = |k: &str| d.borrow().get(&Key::name(k)).and_then(|a| a.items().cloned());
|
||||
let key = [array(CIDS), array(CODESPACE), array(NOTDEFS), array(BASES)];
|
||||
let same = |a: &Option<Shared<Obj>>, b: &Option<Shared<Obj>>| match (a, b) {
|
||||
(Some(a), Some(b)) => a.same(b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if let Some((_, m)) = self.cmaps.iter().find(|(k, _)| k.iter().zip(&key).all(|(a, b)| same(a, b))) {
|
||||
return Ok(m.clone());
|
||||
}
|
||||
let m = Rc::new(CMap::read(&d.borrow()));
|
||||
self.spend((m.cids.len() + m.space.len() + m.notdefs.len() + m.bases.len()) as u64)?;
|
||||
if self.cmaps.len() >= MAX_CMAPS {
|
||||
self.cmaps.remove(0);
|
||||
}
|
||||
self.cmaps.push((key, m.clone()));
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Glyph `cid` of CIDFont `font`: its outline in the CIDFont's glyph space and its width
|
||||
/// there, or `None` when the font has no glyph for it (or isn't a CIDFont with glyphs).
|
||||
pub(super) fn cid_glyph(&mut self, font: &DictRef, cid: u32) -> Res<Option<(BezPath, f64)>> {
|
||||
let (kind, count) = {
|
||||
let f = font.borrow();
|
||||
let num = |k: &str| f.get(&Key::name(k)).and_then(Obj::as_num);
|
||||
(num("CIDFontType"), num("CIDCount"))
|
||||
};
|
||||
if count.is_some_and(|c| f64::from(cid) >= c) {
|
||||
return Ok(None);
|
||||
}
|
||||
let glyph = match kind {
|
||||
Some(2.0) => self.cid_type2(font, cid)?,
|
||||
Some(0.0) => self.cid_type0(font, cid)?,
|
||||
_ => None,
|
||||
};
|
||||
// Widths from the font's `Metrics` override the glyph's own (PLRM 3rd ed., §5.9.2).
|
||||
Ok(glyph.map(|(outline, w)| {
|
||||
let m = font.borrow().get(&Key::name("Metrics")).cloned();
|
||||
let w = match m {
|
||||
Some(Obj::Dict(m)) => match m.borrow().get(&Key::Int(i64::from(cid))) {
|
||||
Some(o) => match o.as_num() {
|
||||
Some(v) => v,
|
||||
None => o
|
||||
.items()
|
||||
.and_then(|i| match i.len() {
|
||||
2 => i.get(1).and_then(|v| v.as_num()),
|
||||
4 => i.get(2).and_then(|v| v.as_num()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(w),
|
||||
},
|
||||
None => w,
|
||||
},
|
||||
_ => w,
|
||||
};
|
||||
(outline, if w.is_finite() { w } else { 0.0 })
|
||||
}))
|
||||
}
|
||||
|
||||
/// Glyph `cid` of a Type 2 CIDFont: the TrueType glyph its `CIDMap` gives it (a string, or
|
||||
/// strings, of `GDBytes`-byte glyph indices by CID; a dictionary; or an integer added to the
|
||||
/// CID: PLRM 3rd ed., §5.11.3), its width in ems.
|
||||
fn cid_type2(&mut self, font: &DictRef, cid: u32) -> Res<Option<(BezPath, f64)>> {
|
||||
let Some(data) = self.type42_data(font)? else { return Ok(None) };
|
||||
let gid = {
|
||||
let f = font.borrow();
|
||||
let gd = f.get(&Key::name("GDBytes")).and_then(Obj::as_num).filter(|n| (1.0..=4.0).contains(n)).map_or(2, |n| n as usize);
|
||||
match f.get(&Key::name("CIDMap")) {
|
||||
None => Some(u64::from(cid)),
|
||||
Some(Obj::Int(off)) => u64::try_from(i64::from(cid).saturating_add(*off)).ok(),
|
||||
Some(Obj::Dict(d)) => d.borrow().get(&Key::Int(i64::from(cid))).and_then(Obj::as_num).filter(|g| *g >= 0.0).map(|g| g as u64),
|
||||
Some(Obj::Str(s)) => (cid as usize).checked_mul(gd).and_then(|at| number(&s.borrow(), at, gd)),
|
||||
Some(Obj::Array { items, .. }) => {
|
||||
// The strings one after another, each a whole number of entries.
|
||||
let Some(mut at) = (cid as usize).checked_mul(gd) else { return Ok(None) };
|
||||
let mut found = None;
|
||||
for s in items.borrow().iter() {
|
||||
let Obj::Str(s) = s else { continue };
|
||||
let s = s.borrow();
|
||||
let len = s.len() - s.len() % gd;
|
||||
if at < len {
|
||||
found = number(&s, at, gd);
|
||||
break;
|
||||
}
|
||||
at -= len;
|
||||
}
|
||||
found
|
||||
}
|
||||
Some(_) => None,
|
||||
}
|
||||
};
|
||||
let Some(gid) = gid.and_then(|g| u32::try_from(g).ok()).filter(|g| *g <= u32::from(u16::MAX)) else { return Ok(None) };
|
||||
if gid == 0 && cid != 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(super::type42::glyph(&data.data, gid))
|
||||
}
|
||||
|
||||
/// Glyph `cid` of a Type 0 CIDFont: the Type 1 charstring its `GlyphDirectory` entry or
|
||||
/// its binary data section's offset table gives it, run with the subroutines of its
|
||||
/// FDArray dictionary's `Private` dictionary, in the FDArray dictionary's glyph space under
|
||||
/// the CIDFont's; its width in the CIDFont's glyph space.
|
||||
fn cid_type0(&mut self, font: &DictRef, cid: u32) -> Res<Option<(BezPath, f64)>> {
|
||||
if let Some(cff) = self.cff.iter().find(|(k, _)| k.same(font)).map(|(_, c)| c.clone()) {
|
||||
self.spend(64)?;
|
||||
let read = cff.gids_read().is_some();
|
||||
let glyph = cff.glyph(cid);
|
||||
self.spend(cff.take_work())?;
|
||||
// Reading the charset visits each of its glyphs.
|
||||
if !read {
|
||||
self.spend(cff.gids_read().unwrap_or(0) as u64)?;
|
||||
}
|
||||
return Ok(glyph);
|
||||
}
|
||||
let f = font.borrow();
|
||||
let num = |k: &str, default: f64| f.get(&Key::name(k)).and_then(Obj::as_num).unwrap_or(default);
|
||||
let fd_bytes = num("FDBytes", 0.0);
|
||||
let gd_bytes = num("GDBytes", 0.0);
|
||||
if !(0.0..=4.0).contains(&fd_bytes) || !(0.0..=4.0).contains(&gd_bytes) {
|
||||
return Ok(None);
|
||||
}
|
||||
let (fd_bytes, gd_bytes) = (fd_bytes as usize, gd_bytes as usize);
|
||||
let data: Option<Shared<u8>> = match f.get(&Key::name("GlyphData")) {
|
||||
Some(Obj::Str(s)) => Some(s.clone()),
|
||||
_ => None,
|
||||
};
|
||||
// The FDArray index and the charstring.
|
||||
let found: Option<(usize, Vec<u8>)> = match f.get(&Key::name("GlyphDirectory")) {
|
||||
Some(dir @ (Obj::Array { .. } | Obj::Dict(_))) => {
|
||||
let entry = match dir {
|
||||
Obj::Dict(d) => d.borrow().get(&Key::Int(i64::from(cid))).cloned(),
|
||||
_ => dir.items().and_then(|i| i.get(cid as usize)),
|
||||
};
|
||||
match entry {
|
||||
Some(Obj::Str(s)) => {
|
||||
let s = s.to_vec();
|
||||
let fd = number(&s, 0, fd_bytes).unwrap_or(0) as usize;
|
||||
Some((fd, s.get(fd_bytes..).unwrap_or_default().to_vec()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => data.as_ref().and_then(|data| {
|
||||
let data = data.borrow();
|
||||
let map = num("CIDMapOffset", 0.0);
|
||||
if !(0.0..=data.len() as f64).contains(&map) {
|
||||
return None;
|
||||
}
|
||||
let at = (map as usize).checked_add((cid as usize).checked_mul(fd_bytes + gd_bytes)?)?;
|
||||
let fd = if fd_bytes == 0 { 0 } else { number(&data, at, fd_bytes)? as usize };
|
||||
let start = number(&data, at + fd_bytes, gd_bytes)? as usize;
|
||||
let end = number(&data, at + 2 * fd_bytes + gd_bytes, gd_bytes)? as usize;
|
||||
Some((fd, data.get(start..end)?.to_vec()))
|
||||
}),
|
||||
};
|
||||
let Some((fd, charstring)) = found.filter(|(_, c)| !c.is_empty()) else { return Ok(None) };
|
||||
let Some(Obj::Dict(fd)) = f.get(&Key::name("FDArray")).and_then(|a| a.items().and_then(|i| i.get(fd))) else { return Ok(None) };
|
||||
drop(f);
|
||||
let fd = fd.borrow();
|
||||
let fdm = fd
|
||||
.get(&Key::name("FontMatrix"))
|
||||
.and_then(|o| o.items().and_then(|i| super::interp::matrix_of(&i.borrow())))
|
||||
.unwrap_or(vectorcraft_geom::Affine::IDENTITY);
|
||||
let Some(Obj::Dict(private)) = fd.get(&Key::name("Private")).cloned() else { return Ok(None) };
|
||||
drop(fd);
|
||||
let len_iv = private.borrow().get(&Key::name("lenIV")).and_then(Obj::as_num).unwrap_or(4.0);
|
||||
let subrs = self.cid_subrs(&private, data.as_ref(), len_iv)?;
|
||||
self.spend((charstring.len() as u64).saturating_add(64))?;
|
||||
let charstring = decrypt(&charstring, len_iv);
|
||||
let (glyph, work) = type1_glyph(&charstring, &subrs);
|
||||
// The subroutines it ran, which the evaluator limits for each glyph, not for the file.
|
||||
self.spend(work)?;
|
||||
let Some((outline, width)) = glyph else { return Ok(None) };
|
||||
let mut outline = outline;
|
||||
outline.apply_affine(fdm);
|
||||
let w = fdm * vectorcraft_geom::Point::new(width, 0.0) - fdm * vectorcraft_geom::Point::ZERO;
|
||||
Ok(Some((outline, w.x)))
|
||||
}
|
||||
|
||||
/// The subroutines of a Type 0 CIDFont's FDArray `Private` dictionary `private`, decrypted
|
||||
/// with `len_iv`: its `Subrs` array, else the subroutine table of the CIDFont's binary data
|
||||
/// `data` (Adobe Technical Note #5014). Read once while they stay the same, and counted
|
||||
/// against the budgets before they are copied.
|
||||
fn cid_subrs(&mut self, private: &DictRef, data: Option<&Shared<u8>>, len_iv: f64) -> Res<Rc<Vec<Vec<u8>>>> {
|
||||
let same_data = |d: &Option<Shared<u8>>| match (d, data) {
|
||||
(Some(a), Some(b)) => a.same(b),
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
};
|
||||
if let Some(i) = self.cid_subrs.iter().position(|(p, d, _)| p.same(private) && same_data(d)) {
|
||||
let hit = self.cid_subrs.remove(i);
|
||||
let subrs = hit.2.clone();
|
||||
self.cid_subrs.push(hit);
|
||||
return Ok(subrs);
|
||||
}
|
||||
let p = private.borrow();
|
||||
// Each subroutine's bytes: a string of `Subrs`, or a range of the binary data.
|
||||
let strings: Option<Vec<Option<Shared<u8>>>> = match p.get(&Key::name("Subrs")) {
|
||||
Some(Obj::Array { items, .. }) => Some(
|
||||
items
|
||||
.borrow()
|
||||
.iter()
|
||||
.map(|s| match s {
|
||||
Obj::Str(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
let mut ranges: Vec<Option<(usize, usize)>> = Vec::new();
|
||||
if strings.is_none() {
|
||||
let num = |k: &str| p.get(&Key::name(k)).and_then(Obj::as_num).filter(|n| *n >= 0.0);
|
||||
if let (Some(map), Some(sd), Some(count), Some(data)) = (num("SubrMapOffset"), num("SDBytes"), num("SubrCount"), data) {
|
||||
let data = data.borrow();
|
||||
let (map, sd, count) = (map as usize, (sd as usize).min(4), (count as usize).min(MAX_RANGES));
|
||||
for i in 0..count {
|
||||
let at = map.saturating_add(i.saturating_mul(sd));
|
||||
let range = number(&data, at, sd).zip(number(&data, at.saturating_add(sd), sd));
|
||||
ranges.push(range.map(|(a, b)| (a as usize, b as usize)).filter(|(a, b)| a <= b && *b <= data.len()));
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(p);
|
||||
let total = match &strings {
|
||||
Some(strings) => strings.iter().flatten().fold(0usize, |t, s| t.saturating_add(s.len())),
|
||||
None => ranges.iter().flatten().fold(0usize, |t, (a, b)| t.saturating_add(b - a)),
|
||||
};
|
||||
if total > MAX_GLYPH_DATA {
|
||||
return Err(PsError::Limit("a font's subroutines are too large"));
|
||||
}
|
||||
self.alloc(total)?;
|
||||
self.spend_bytes(total)?;
|
||||
self.spend((strings.as_ref().map_or(ranges.len(), Vec::len) / 16) as u64)?;
|
||||
let subrs: Vec<Vec<u8>> = match (&strings, data) {
|
||||
(Some(strings), _) => strings.iter().map(|s| s.as_ref().map(|s| decrypt(&s.borrow(), len_iv)).unwrap_or_default()).collect(),
|
||||
(None, Some(data)) => {
|
||||
let data = data.borrow();
|
||||
ranges.iter().map(|r| r.and_then(|(a, b)| data.get(a..b)).map(|s| decrypt(s, len_iv)).unwrap_or_default()).collect()
|
||||
}
|
||||
(None, None) => vec![],
|
||||
};
|
||||
let subrs = Rc::new(subrs);
|
||||
if self.cid_subrs.len() >= MAX_SUBR_SETS {
|
||||
self.cid_subrs.remove(0);
|
||||
}
|
||||
self.cid_subrs.push((private.clone(), data.cloned(), subrs.clone()));
|
||||
Ok(subrs)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most fonts read from one FontSet.
|
||||
const MAX_FONT_SET: u32 = 64;
|
||||
/// Most CIDFonts read from FontSets.
|
||||
const MAX_CFF: usize = 256;
|
||||
|
||||
/// A CID-keyed font in Compact Font Format data: the data and the font's index in it, and the
|
||||
/// glyph its charset gives each CID (read when first asked).
|
||||
pub(super) struct Cff {
|
||||
data: Rc<[u8]>,
|
||||
index: u32,
|
||||
gids: std::cell::OnceCell<std::collections::HashMap<u16, GlyphId>>,
|
||||
/// The charstring bytes glyphs entered, not yet charged.
|
||||
work: std::cell::Cell<u64>,
|
||||
}
|
||||
|
||||
impl Cff {
|
||||
pub(super) fn new(data: Rc<[u8]>, index: u32) -> Self {
|
||||
Self { data, index, gids: std::cell::OnceCell::new(), work: std::cell::Cell::new(0) }
|
||||
}
|
||||
|
||||
/// Whether the CID-to-glyph map has been read, and the glyphs it holds.
|
||||
fn gids_read(&self) -> Option<usize> {
|
||||
self.gids.get().map(std::collections::HashMap::len)
|
||||
}
|
||||
|
||||
/// Glyph `cid`: the glyph the charset gives that CID, drawn from its Type 2 charstring with
|
||||
/// the subroutines and widths of its FDSelect subfont, in font units; `None` when the font
|
||||
/// has no glyph for it.
|
||||
pub(super) fn glyph(&self, cid: u32) -> Option<(BezPath, f64)> {
|
||||
let font = CffFontRef::new_cff(&self.data, self.index, None).ok()?;
|
||||
// The charset read once into a map, instead of searched for each glyph.
|
||||
let gids = self.gids.get_or_init(|| font.charset().map(|c| c.iter().map(|(g, sid)| (sid.to_u16(), g)).collect()).unwrap_or_default());
|
||||
let gid = *gids.get(&u16::try_from(cid).ok()?)?;
|
||||
if gid.to_u32() == 0 && cid != 0 {
|
||||
return None;
|
||||
}
|
||||
let sub = font.subfont(font.subfont_index(gid)?, &[]).ok()?;
|
||||
// Run as `CffFontRef::draw` does, through the counting context, so the subroutines a
|
||||
// glyph enters (up to a million operations each time) are paid for.
|
||||
let subrs = match sub.subrs_offset() {
|
||||
0 => Index::Empty,
|
||||
at => Index::new(self.data.get(at as usize..)?, false).ok()?,
|
||||
};
|
||||
let charstrings = font.charstrings().clone();
|
||||
let data = charstrings.get(gid.to_u32() as usize)?;
|
||||
let ctx = (&self.data[..], &charstrings, font.global_subrs(), &subrs);
|
||||
let mut pen = super::type42::Pen(BezPath::new(), 1.0);
|
||||
let transform = font.transform(&sub, None);
|
||||
let mut filter = NopFilterSink::new(&mut pen);
|
||||
let mut sink = TransformSink::new(&mut filter, transform);
|
||||
let (w, work) = super::type1::evaluate(&ctx, data, &mut sink);
|
||||
self.work.set(self.work.get().saturating_add(work));
|
||||
let w = w.ok()?.map(|w| w + sub.nominal_width()).or(sub.default_width());
|
||||
let w = w.map_or(0.0, |w| f64::from(transform.transform_h_metric(w).to_f32().max(0.0)));
|
||||
Some((pen.0, if w.is_finite() { w } else { 0.0 }))
|
||||
}
|
||||
|
||||
/// The charstring bytes entered since the last call (see [`super::type1::evaluate`]).
|
||||
pub(super) fn take_work(&self) -> u64 {
|
||||
self.work.take()
|
||||
}
|
||||
}
|
||||
|
||||
/// Most FDArray dictionaries whose subroutines are kept decrypted between glyphs.
|
||||
const MAX_SUBR_SETS: usize = 32;
|
||||
|
||||
/// The decrypted subroutines of FDArray `Private` dictionaries, by the dictionary and the
|
||||
/// CIDFont's binary data (see [`Interp::cid_subrs`]), the most recently used last.
|
||||
pub(super) type CidSubrs = Vec<(DictRef, Option<Shared<u8>>, Rc<Vec<Vec<u8>>>)>;
|
||||
|
||||
/// Type 1 charstring `data` decrypted (the charstring key 4330, the first `len_iv` bytes
|
||||
/// dropped: Adobe Type 1 Font Format, §7.2); as it is when `len_iv` is negative.
|
||||
pub(super) fn decrypt(data: &[u8], len_iv: f64) -> Vec<u8> {
|
||||
if len_iv < 0.0 {
|
||||
return data.to_vec();
|
||||
}
|
||||
let mut r: u16 = 4330;
|
||||
let plain = data.iter().map(|&c| {
|
||||
let p = c ^ (r >> 8) as u8;
|
||||
r = (u16::from(c).wrapping_add(r)).wrapping_mul(52845).wrapping_add(22719);
|
||||
p
|
||||
});
|
||||
plain.skip(len_iv.min(64.0) as usize).collect()
|
||||
}
|
||||
|
||||
/// The outline (glyph space) and width of decrypted Type 1 charstring `data`, with
|
||||
/// subroutines `subrs`, or `None` when it can't be run; and the bytes of the charstrings it
|
||||
/// entered (see [`super::type1::evaluate`]).
|
||||
pub(super) fn type1_glyph(data: &[u8], subrs: &[Vec<u8>]) -> (Option<(BezPath, f64)>, u64) {
|
||||
let ctx = Type1 { subrs };
|
||||
let mut sink = Sink(BezPath::new());
|
||||
let (w, work) = super::type1::evaluate(&ctx, data, &mut sink);
|
||||
(w.ok().map(|w| (sink.0, w.map_or(0.0, |w| f64::from(w.to_f32())))), work)
|
||||
}
|
||||
|
||||
/// What a Type 1 charstring runs with: its font's subroutines (no `seac` accents: a CIDFont's
|
||||
/// glyphs have no names to build them from).
|
||||
struct Type1<'a> {
|
||||
subrs: &'a [Vec<u8>],
|
||||
}
|
||||
|
||||
impl CharstringContext for Type1<'_> {
|
||||
fn kind(&self) -> CharstringKind {
|
||||
CharstringKind::Type1
|
||||
}
|
||||
fn seac_components(&self, base: i32, _accent: i32) -> Result<[&[u8]; 2], CsError> {
|
||||
Err(CsError::InvalidSeacCode(base))
|
||||
}
|
||||
fn global_subr(&self, _index: i32) -> Result<&[u8], CsError> {
|
||||
Err(CsError::MissingSubroutines)
|
||||
}
|
||||
fn subr(&self, index: i32) -> Result<&[u8], CsError> {
|
||||
usize::try_from(index).ok().and_then(|i| self.subrs.get(i)).map(Vec::as_slice).ok_or(CsError::MissingSubroutines)
|
||||
}
|
||||
}
|
||||
|
||||
/// A charstring's path, in glyph space.
|
||||
struct Sink(BezPath);
|
||||
|
||||
impl CommandSink for Sink {
|
||||
fn move_to(&mut self, x: Fixed, y: Fixed) {
|
||||
self.0.move_to((f64::from(x.to_f32()), f64::from(y.to_f32())));
|
||||
}
|
||||
fn line_to(&mut self, x: Fixed, y: Fixed) {
|
||||
self.0.line_to((f64::from(x.to_f32()), f64::from(y.to_f32())));
|
||||
}
|
||||
fn curve_to(&mut self, cx0: Fixed, cy0: Fixed, cx1: Fixed, cy1: Fixed, x: Fixed, y: Fixed) {
|
||||
let p = |a: Fixed, b: Fixed| (f64::from(a.to_f32()), f64::from(b.to_f32()));
|
||||
self.0.curve_to(p(cx0, cy0), p(cx1, cy1), p(x, y));
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ impl Interp<'_> {
|
||||
}
|
||||
|
||||
/// Up to `n` bytes of file `f`.
|
||||
fn read(&mut self, f: &Rc<RefCell<Stream>>, n: usize) -> Res<Vec<u8>> {
|
||||
pub(in super::super) fn read(&mut self, f: &Rc<RefCell<Stream>>, n: usize) -> Res<Vec<u8>> {
|
||||
self.materialize(f)?;
|
||||
Ok(match &mut *f.borrow_mut() {
|
||||
Stream::Current => {
|
||||
@@ -236,7 +236,7 @@ impl Interp<'_> {
|
||||
}
|
||||
|
||||
/// Up to `n` bytes read as hexadecimal digits from `f` (other characters skipped).
|
||||
fn read_hex(&mut self, f: &Rc<RefCell<Stream>>, n: usize) -> Res<Vec<u8>> {
|
||||
pub(in super::super) fn read_hex(&mut self, f: &Rc<RefCell<Stream>>, n: usize) -> Res<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut high: Option<u8> = None;
|
||||
while out.len() < n {
|
||||
@@ -250,6 +250,31 @@ impl Interp<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Skip the whitespace and the `>` that end hexadecimal data in `f`, if a `>` is next: the
|
||||
/// rest is left unread.
|
||||
pub(in super::super) fn skip_hex_end(&mut self, f: &Rc<RefCell<Stream>>) -> Res {
|
||||
self.materialize(f)?;
|
||||
let blank = |b: &u8| b.is_ascii_whitespace() || *b == 0;
|
||||
match &mut *f.borrow_mut() {
|
||||
Stream::Current => {
|
||||
let rest = self.lex.data();
|
||||
let n = rest.iter().take_while(|b| blank(b)).count();
|
||||
if rest.get(n) == Some(&b'>') {
|
||||
self.lex.advance(n + 1);
|
||||
}
|
||||
}
|
||||
Stream::Buf { data, pos, .. } => {
|
||||
let rest = data.get(*pos..).unwrap_or_default();
|
||||
let n = rest.iter().take_while(|b| blank(b)).count();
|
||||
if rest.get(n) == Some(&b'>') {
|
||||
*pos += n + 1;
|
||||
}
|
||||
}
|
||||
Stream::Pending { .. } => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read bytes `got` into string `s` and push the part of `s` they fill.
|
||||
fn fill(&mut self, s: Shared<u8>, got: &[u8]) -> Res {
|
||||
if !s.write(0, got) {
|
||||
@@ -479,16 +504,18 @@ impl Interp<'_> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `file eexec`: an encrypted font program follows in `file`. It is skipped there, up to the
|
||||
/// `cleartomark` that ends it (type is drawn in the app's fonts, by name): the font
|
||||
/// dictionary it would complete is defined as it is.
|
||||
/// `file eexec`: an encrypted font program follows in `file`. It isn't run: it is skipped
|
||||
/// there, up to the `cleartomark` that ends it, and the font dictionary it would complete is
|
||||
/// defined as it is, with the glyphs the Type 1 font parser reads from it (see `type1`).
|
||||
fn eexec(&mut self) -> Res {
|
||||
let src = self.pop()?;
|
||||
let mut font = None;
|
||||
if let Some(Obj::Dict(d)) = self.stack.last().cloned() {
|
||||
let name = d.borrow().get(&Key::name("FontName")).cloned();
|
||||
if let Some(k) = name.and_then(|n| n.key()) {
|
||||
self.pop()?;
|
||||
self.fonts.borrow_mut().insert(k, Obj::Dict(d));
|
||||
self.fonts.borrow_mut().insert(k, Obj::Dict(d.clone()));
|
||||
font = Some(d);
|
||||
}
|
||||
}
|
||||
// `currentfile eexec`: the program's own file or the file being run. A string operand
|
||||
@@ -499,15 +526,31 @@ impl Interp<'_> {
|
||||
};
|
||||
self.materialize(&f)?;
|
||||
let missing = || PsError::Ps("ioerror", "eexec".into());
|
||||
match &mut *f.borrow_mut() {
|
||||
// The encrypted part, for the font's glyphs (a font's, of a size that can be read).
|
||||
// Copied only when the font can still be kept (it is dropped otherwise).
|
||||
let room = font.is_some() && self.type1_room();
|
||||
let keep = |part: &[u8]| (room && part.len() <= super::super::type1::MAX_PROGRAM).then(|| part.to_vec());
|
||||
let (encrypted, scanned) = match &mut *f.borrow_mut() {
|
||||
Stream::Buf { data, pos, .. } => {
|
||||
let at = find(data.get(*pos..).unwrap_or_default(), b"cleartomark").ok_or_else(missing)?;
|
||||
let rest = data.get(*pos..).unwrap_or_default();
|
||||
let at = find(rest, b"cleartomark").ok_or_else(missing)?;
|
||||
let part = keep(rest.get(..at).unwrap_or_default());
|
||||
*pos += at;
|
||||
(part, at)
|
||||
}
|
||||
_ => {
|
||||
let at = find(self.lex.data(), b"cleartomark").ok_or_else(missing)?;
|
||||
let rest = self.lex.data();
|
||||
let at = find(rest, b"cleartomark").ok_or_else(missing)?;
|
||||
let part = keep(rest.get(..at).unwrap_or_default());
|
||||
self.lex.advance(at);
|
||||
(part, at)
|
||||
}
|
||||
};
|
||||
// The search for `cleartomark` read the part: charged by its length (as `search` is), so `eexec` on a
|
||||
// large reusable file in a loop runs out of the budget.
|
||||
self.spend((scanned / 64) as u64)?;
|
||||
if let (Some(d), Some(part)) = (font, encrypted) {
|
||||
self.attach_type1(&d, &part)?;
|
||||
}
|
||||
self.push(Obj::Mark)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,19 @@ pub(crate) struct Interp<'a> {
|
||||
/// The `sfnts` arrays whose TrueType data has been counted against [`MAX_MEMORY`]: a font
|
||||
/// read again after it left `type42` doesn't count again.
|
||||
pub type42_counted: HashSet<Shared<Obj>>,
|
||||
/// The mappings of the CMaps shown with so far, by their range arrays (see `cid`), the most
|
||||
/// recent last.
|
||||
pub cmaps: super::cid::CMaps,
|
||||
/// The Type 1 fonts read from the encrypted part of their programs, by the `CharStrings`
|
||||
/// dictionary given to their font dictionaries (see `type1`).
|
||||
pub type1: Vec<(DictRef, Rc<super::type1::Font>)>,
|
||||
/// The CIDFonts a FontSet's Compact Font Format data defined, by their dictionaries: the
|
||||
/// data and the font's index in it (see `cid`).
|
||||
pub cff: Vec<(DictRef, Rc<super::cid::Cff>)>,
|
||||
/// The decrypted subroutines of Type 0 CIDFonts (see `cid`).
|
||||
pub cid_subrs: super::cid::CidSubrs,
|
||||
/// The font number `usefont` gave the CMap mappings that follow.
|
||||
pub cmap_font: i64,
|
||||
pub g: GState,
|
||||
pub saved: Vec<GState>,
|
||||
pub out: Out,
|
||||
@@ -101,7 +114,7 @@ pub(crate) struct Fault {
|
||||
}
|
||||
|
||||
/// The TrueType data of Type 42 fonts by their `sfnts` array (see [`Interp::type42`]).
|
||||
pub(crate) type Type42Cache = Vec<(Shared<Obj>, Option<Rc<Vec<u8>>>)>;
|
||||
pub(crate) type Type42Cache = Vec<(Shared<Obj>, Option<Rc<super::type42::Font>>)>;
|
||||
|
||||
/// Most procedure names a [`Fault`] keeps.
|
||||
const FAULT_PROCS: usize = 3;
|
||||
@@ -137,7 +150,7 @@ impl<'a> Interp<'a> {
|
||||
let encodings = new_dict();
|
||||
{
|
||||
let mut d = system.borrow_mut();
|
||||
for (op, name) in Op::ALL {
|
||||
for (op, name) in Op::ALL.iter().filter(|(op, _)| !Op::PROCSET.contains(op)) {
|
||||
d.insert(Key::name(name), Obj::Op(*op));
|
||||
}
|
||||
for name in ["StandardEncoding", "ISOLatin1Encoding"] {
|
||||
@@ -155,6 +168,21 @@ impl<'a> Interp<'a> {
|
||||
let resources = new_dict();
|
||||
resources.borrow_mut().insert(Key::name("Category"), Obj::dict(categories));
|
||||
resources.borrow_mut().insert(Key::name("Encoding"), Obj::Dict(encodings));
|
||||
// The procedure sets that build CMaps and CIDFonts (CIDInit) and font sets (FontSetInit),
|
||||
// and the predefined Identity CMaps.
|
||||
let cid_init: Dict = Op::PROCSET.iter().map(|op| (Key::name(op.name()), Obj::Op(*op))).collect();
|
||||
let font_set_init = Dict::from([(Key::name("StartData"), Obj::Op(Op::StartData))]);
|
||||
resources.borrow_mut().insert(
|
||||
Key::name("ProcSet"),
|
||||
Obj::dict(Dict::from([(Key::name("CIDInit"), Obj::dict(cid_init)), (Key::name("FontSetInit"), Obj::dict(font_set_init))])),
|
||||
);
|
||||
resources.borrow_mut().insert(
|
||||
Key::name("CMap"),
|
||||
Obj::dict(Dict::from([
|
||||
(Key::name("Identity-H"), super::cid::identity("Identity-H", false)),
|
||||
(Key::name("Identity-V"), super::cid::identity("Identity-V", true)),
|
||||
])),
|
||||
);
|
||||
let fonts = new_dict();
|
||||
system.borrow_mut().insert(Key::name("FontDirectory"), Obj::Dict(fonts.clone()));
|
||||
for name in ["GlobalFontDirectory", "SharedFontDirectory"] {
|
||||
@@ -199,6 +227,11 @@ impl<'a> Interp<'a> {
|
||||
fault: None,
|
||||
glyph_width: None,
|
||||
type42: vec![],
|
||||
cmaps: vec![],
|
||||
type1: vec![],
|
||||
cff: vec![],
|
||||
cid_subrs: vec![],
|
||||
cmap_font: 0,
|
||||
type42_counted: HashSet::new(),
|
||||
g,
|
||||
saved: vec![],
|
||||
@@ -383,6 +416,11 @@ impl<'a> Interp<'a> {
|
||||
self.spend((n / 256) as u64)
|
||||
}
|
||||
|
||||
/// The operations left in the budget (`max_ops`).
|
||||
pub fn ops_left(&self) -> u64 {
|
||||
self.max_ops.saturating_sub(self.ops)
|
||||
}
|
||||
|
||||
/// Count `ops` operations' worth of work against the budget (`max_ops`): what an operator that costs
|
||||
/// far more than one operation (laying out type) does in a loop has to end too.
|
||||
pub fn spend(&mut self, ops: u64) -> Res {
|
||||
@@ -1401,7 +1439,7 @@ impl<'a> Interp<'a> {
|
||||
|
||||
/// Put `v` under `k` in `d` (`None`: remove it), within [`MAX_ALLOC`] entries; while a `save`
|
||||
/// is open the old value is kept for `restore`.
|
||||
fn insert(&mut self, d: &DictRef, k: Key, v: Option<Obj>) -> Res {
|
||||
pub fn insert(&mut self, d: &DictRef, k: Key, v: Option<Obj>) -> Res {
|
||||
let old = {
|
||||
let mut dm = d.borrow_mut();
|
||||
if v.is_some() && dm.len() >= MAX_ALLOC && !dm.contains_key(&k) {
|
||||
@@ -1431,6 +1469,21 @@ impl<'a> Interp<'a> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `o` the standard encoding (`Some(false)`) or the ISO Latin-1 encoding (`Some(true)`)
|
||||
/// of systemdict, whose names here are all `.notdef`?
|
||||
pub fn standard_encoding(&self, o: &Obj) -> Option<bool> {
|
||||
let items = o.items()?;
|
||||
let s = self.system.borrow();
|
||||
let is = |k: &str| s.get(&Key::name(k)).and_then(Obj::items).is_some_and(|e| e.same(items));
|
||||
if is("StandardEncoding") {
|
||||
Some(false)
|
||||
} else if is("ISOLatin1Encoding") {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The instances of `category`: `FontDirectory` for fonts.
|
||||
pub fn instances(&mut self, category: &Obj) -> Res<DictRef> {
|
||||
if category.text().as_deref() == Some("Font") { Ok(self.fonts.clone()) } else { self.category(category) }
|
||||
|
||||
@@ -21,6 +21,11 @@ impl DictRef {
|
||||
note(Made::Dict(Rc::downgrade(&d)));
|
||||
Self(d)
|
||||
}
|
||||
|
||||
/// Is `o` this very dictionary?
|
||||
pub fn same(&self, o: &Self) -> bool {
|
||||
Rc::ptr_eq(&self.0, &o.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// An array or a dictionary a program made.
|
||||
@@ -443,6 +448,16 @@ macro_rules! ops {
|
||||
/// Every operator with its name (`systemdict`).
|
||||
pub const ALL: &'static [(Op, &'static str)] = &[$((Op::$v, $n)),*];
|
||||
|
||||
/// The operators of the CIDInit procedure set (PLRM 3rd ed., §5.11.4), found through
|
||||
/// it rather than systemdict.
|
||||
pub const PROCSET: &'static [Op] = &[
|
||||
Op::BeginCMap, Op::EndCMap, Op::UseCMap, Op::BeginCodeSpaceRange, Op::EndCodeSpaceRange, Op::UseFont,
|
||||
Op::BeginBfChar, Op::EndBfChar, Op::BeginBfRange, Op::EndBfRange, Op::BeginCidChar, Op::EndCidChar,
|
||||
Op::BeginCidRange, Op::EndCidRange, Op::BeginNotDefChar, Op::EndNotDefChar, Op::BeginNotDefRange,
|
||||
Op::EndNotDefRange, Op::BeginRearrangedFont, Op::EndRearrangedFont, Op::BeginUseMatrix, Op::EndUseMatrix,
|
||||
Op::StartData,
|
||||
];
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self { $(Op::$v => $n),* }
|
||||
}
|
||||
@@ -542,6 +557,15 @@ ops! {
|
||||
SetVmThreshold = "setvmthreshold", VmReclaim = "vmreclaim", StartJob = "startjob", CurrentObjectFormat = "currentobjectformat",
|
||||
Revision = "revision", SerialNumber = "serialnumber", Read = "read", FilePosition = "fileposition",
|
||||
SetFilePosition = "setfileposition", DeleteFile = "deletefile", RenameFile = "renamefile", Run = "run",
|
||||
// CID-keyed fonts: `composefont`, and the operators of the CIDInit procedure set (CMaps and
|
||||
// `StartData`, which FontSetInit has too), which aren't in systemdict (see `Op::PROCSET`).
|
||||
ComposeFont = "composefont", BeginCMap = "begincmap", EndCMap = "endcmap", UseCMap = "usecmap",
|
||||
BeginCodeSpaceRange = "begincodespacerange", EndCodeSpaceRange = "endcodespacerange", UseFont = "usefont",
|
||||
BeginBfChar = "beginbfchar", EndBfChar = "endbfchar", BeginBfRange = "beginbfrange", EndBfRange = "endbfrange",
|
||||
BeginCidChar = "begincidchar", EndCidChar = "endcidchar", BeginCidRange = "begincidrange", EndCidRange = "endcidrange",
|
||||
BeginNotDefChar = "beginnotdefchar", EndNotDefChar = "endnotdefchar", BeginNotDefRange = "beginnotdefrange",
|
||||
EndNotDefRange = "endnotdefrange", BeginRearrangedFont = "beginrearrangedfont", EndRearrangedFont = "endrearrangedfont",
|
||||
BeginUseMatrix = "beginusematrix", EndUseMatrix = "endusematrix", StartData = "StartData",
|
||||
// User paths.
|
||||
UFill = "ufill", UEoFill = "ueofill", UStroke = "ustroke", UAppend = "uappend", UPath = "upath", SetBBox = "setbbox",
|
||||
}
|
||||
|
||||
@@ -983,8 +983,8 @@ fn intervals_share_their_arrays_and_strings() {
|
||||
fn resourceforall_enumerates_matching_names() {
|
||||
// #234.
|
||||
blue("(*) { pop } 128 string /Category resourceforall 0 0 1 setrgbcolor");
|
||||
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /ProcSet resourceforall n");
|
||||
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /ProcSet defineresource pop } forall";
|
||||
let count = |template: &str| format!("/n 0 def ({template}) {{ pop /n n 1 add def }} 128 string /Form resourceforall n");
|
||||
let define = "[/Foo /Fop /Bar /F*x] { 1 dict /Form defineresource pop } forall";
|
||||
check(&format!("{define} {} 4 eq", count("*")));
|
||||
check(&format!("{define} {} 2 eq", count("Fo?")));
|
||||
check(&format!("{define} {} 3 eq", count("F*")));
|
||||
@@ -992,10 +992,10 @@ fn resourceforall_enumerates_matching_names() {
|
||||
check(&format!("{define} {} 0 eq", count("Q*")));
|
||||
check(&format!("{define} {} 1 eq", count("*a*")));
|
||||
// Each name is copied into the scratch string; exit stops.
|
||||
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /ProcSet resourceforall last (Bar) eq"));
|
||||
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /ProcSet resourceforall n 1 eq"));
|
||||
check(&format!("{define} /last () def (B*) {{ dup length string copy /last exch def }} 8 string /Form resourceforall last (Bar) eq"));
|
||||
check(&format!("{define} /n 0 def (*) {{ pop /n n 1 add def exit }} 8 string /Form resourceforall n 1 eq"));
|
||||
// A scratch string too small for the names, as the prolog probes it.
|
||||
check(&format!("{define} {{ (*) {{ pop }} 2 string /ProcSet resourceforall }} stopped"));
|
||||
check(&format!("{define} {{ (*) {{ pop }} 2 string /Form resourceforall }} stopped"));
|
||||
check("/n 0 def (Gen*) { pop /n n 1 add def } 16 string /Category resourceforall n 1 eq");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
//! CID-keyed fonts (PLRM 3rd ed., §5.11): CMaps built with the CIDInit procedure set's
|
||||
//! operators, `composefont`, `FMapType` 9 fonts mapping codes to CIDs, Type 0 CIDFonts (Type 1
|
||||
//! charstrings in the binary data `StartData` reads: Adobe Technical Note #5014) and Type 2
|
||||
//! CIDFonts (TrueType glyphs through `CIDMap`). The CIDFonts are written from scratch: Type 0
|
||||
//! glyphs are hand-made charstrings (Adobe Type 1 Font Format, chapter 6), Type 2 glyphs come
|
||||
//! from the bundled Source Sans 3.
|
||||
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::tests::{all, bounds, check};
|
||||
use super::tests_composite::{glyph, sfnts};
|
||||
use crate::import::import;
|
||||
|
||||
/// A Type 1 charstring number (Adobe Type 1 Font Format, §6.2).
|
||||
fn num(v: i32) -> Vec<u8> {
|
||||
match v {
|
||||
-107..=107 => vec![(v + 139) as u8],
|
||||
108..=1131 => vec![((v - 108) / 256 + 247) as u8, ((v - 108) % 256) as u8],
|
||||
-1131..=-108 => vec![((-v - 108) / 256 + 251) as u8, ((-v - 108) % 256) as u8],
|
||||
_ => [vec![255], v.to_be_bytes().to_vec()].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A charstring from numbers and commands (`hsbw` 13, `rmoveto` 21, `rlineto` 5, `closepath`
|
||||
/// 9, `callsubr` 10, `return` 11, `endchar` 14).
|
||||
fn cs(parts: &[(&[i32], u8)]) -> Vec<u8> {
|
||||
parts.iter().flat_map(|(args, op)| args.iter().flat_map(|a| num(*a)).chain([*op])).collect()
|
||||
}
|
||||
|
||||
/// `plain` encrypted with the charstring key and 4 leading bytes (Adobe Type 1 Font Format, §7).
|
||||
fn encrypt(plain: &[u8]) -> Vec<u8> {
|
||||
let mut r: u16 = 4330;
|
||||
[0u8; 4]
|
||||
.iter()
|
||||
.chain(plain)
|
||||
.map(|&p| {
|
||||
let c = p ^ (r >> 8) as u8;
|
||||
r = (u16::from(c).wrapping_add(r)).wrapping_mul(52845).wrapping_add(22719);
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hex(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
/// A Type 0 CIDFont `/TestCID` with three CIDs: 0 an empty glyph 500 wide; 1 a square (100 to
|
||||
/// 500 across, 500 high) 600 wide, its charstring plain (FDArray dictionary 0, `lenIV` -1); 2 a
|
||||
/// triangle (500 across, 400 high) 500 wide drawn by a subroutine, encrypted (dictionary 1,
|
||||
/// `lenIV` 4). The binary data section, written in hexadecimal when `hex_data`.
|
||||
fn cid_font(hex_data: bool) -> Vec<u8> {
|
||||
let empty = cs(&[(&[0, 500], 13), (&[], 14)]);
|
||||
let square = cs(&[(&[0, 600], 13), (&[100, 0], 21), (&[400, 0], 5), (&[0, 500], 5), (&[-400, 0], 5), (&[], 9), (&[], 14)]);
|
||||
let triangle = encrypt(&cs(&[(&[0, 500], 13), (&[0], 10), (&[], 14)]));
|
||||
let subr = encrypt(&cs(&[(&[0, 0], 21), (&[500, 0], 5), (&[-250, 400], 5), (&[], 9), (&[], 11)]));
|
||||
// The offset table: FDArray index (1 byte) and offset (2 bytes) of CIDs 0 to 2 and the end.
|
||||
let table = 4 * 3;
|
||||
let offsets = [table, table + empty.len(), table + empty.len() + square.len(), table + empty.len() + square.len() + triangle.len()];
|
||||
let mut data = vec![];
|
||||
for (fd, at) in [0u8, 0, 1, 0].iter().zip(offsets) {
|
||||
data.push(*fd);
|
||||
data.extend_from_slice(&(at as u16).to_be_bytes());
|
||||
}
|
||||
data.extend(empty);
|
||||
data.extend(square);
|
||||
data.extend(triangle);
|
||||
let mut out = format!(
|
||||
"/CIDInit /ProcSet findresource begin\n20 dict begin\n/CIDFontName /TestCID def /CIDFontType 0 def\n\
|
||||
/CIDSystemInfo 3 dict dup begin /Registry (Test) def /Ordering (Shapes) def /Supplement 0 def end def\n\
|
||||
/FontBBox [0 0 1000 1000] def\n/FDArray [\n\
|
||||
4 dict begin /FontMatrix [0.001 0 0 0.001 0 0] def /Private 2 dict dup begin /lenIV -1 def end def currentdict end\n\
|
||||
4 dict begin /FontMatrix [0.001 0 0 0.001 0 0] def /Private 2 dict dup begin /lenIV 4 def /Subrs [<{}>] def end def currentdict end\n\
|
||||
] def\n/CIDMapOffset 0 def /FDBytes 1 def /GDBytes 2 def /CIDCount 3 def\n",
|
||||
hex(&subr)
|
||||
)
|
||||
.into_bytes();
|
||||
if hex_data {
|
||||
out.extend(format!("(Hex) {} StartData\n{}>\n", data.len(), hex(&data)).into_bytes());
|
||||
} else {
|
||||
out.extend(format!("(Binary) {} StartData ", data.len()).into_bytes());
|
||||
out.extend(data);
|
||||
out.push(b'\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// CMap `/Test-H`: one-byte codes 00–7F and two-byte codes 8000–FFFF; A and B are CIDs 1 and 2,
|
||||
/// <8001> CID 2, and codes 00–1F without a character take CID 1 as their notdef.
|
||||
const CMAP: &str = "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
|
||||
/CMapName /Test-H def /CMapType 1 def /WMode 0 def\n\
|
||||
2 begincodespacerange <00> <7f> <8000> <ffff> endcodespacerange\n\
|
||||
1 begincidrange <41> <42> 1 endcidrange\n1 begincidchar <8001> 2 endcidchar\n\
|
||||
1 beginnotdefrange <00> <1f> 1 endnotdefrange\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/TestFont /Test-H [/TestCID] composefont pop\n";
|
||||
|
||||
/// `body` after the CIDFont and the CMap, as an EPS file.
|
||||
fn program(hex_data: bool, body: &str) -> Vec<u8> {
|
||||
let head = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n".to_vec();
|
||||
[head, cid_font(hex_data), CMAP.as_bytes().to_vec(), format!("{body}\nshowpage\n%%EOF\n").into_bytes()].concat()
|
||||
}
|
||||
|
||||
fn paths(r: &crate::Imported) -> Vec<vectorcraft_geom::Rect> {
|
||||
all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| bounds(n)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type0_cidfonts_draw_their_charstrings_through_the_cmap() {
|
||||
for hex_data in [false, true] {
|
||||
let r = import(&program(hex_data, "/TestFont 50 selectfont 10 20 moveto (AB) show")).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert!(!all(&r.document).iter().any(|n| matches!(n.kind, NodeKind::Text(_))));
|
||||
let p = paths(&r);
|
||||
assert_eq!(p.len(), 2, "{p:?}");
|
||||
// The square, 100 to 500 units across at 50 pt an em; the triangle 600 units on.
|
||||
let (sq, tri) = (p[0], p[1]);
|
||||
assert!((sq.x0 - 15.0).abs() < 0.01 && (sq.width() - 20.0).abs() < 0.01 && (sq.height() - 25.0).abs() < 0.01, "{sq:?}");
|
||||
assert!((sq.y1 - 80.0).abs() < 0.01, "{sq:?}");
|
||||
assert!((tri.x0 - 40.0).abs() < 0.01 && (tri.width() - 25.0).abs() < 0.01 && (tri.height() - 20.0).abs() < 0.01, "{tri:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Codes take as many bytes as their codespace range says; codes without a character take their
|
||||
/// notdef mapping's CID, else CID 0 (PLRM 3rd ed., §5.11.5).
|
||||
#[test]
|
||||
fn codes_map_by_their_codespace_with_notdef_fallbacks() {
|
||||
let fonts = String::from_utf8(cid_font(true)).unwrap() + CMAP;
|
||||
let width = |s: &str, w: f64| check(&format!("{fonts} /TestFont 50 selectfont {s} stringwidth pop {w} sub abs 0.01 lt"));
|
||||
width("(AB)", 55.0);
|
||||
// A two-byte code mapped by a character mapping.
|
||||
width("<8001>", 25.0);
|
||||
// A two-byte code (one character) without a mapping: CID 0.
|
||||
width("<9041>", 25.0);
|
||||
// A one-byte code with a notdef mapping: CID 1.
|
||||
width("<01>", 30.0);
|
||||
}
|
||||
|
||||
/// `usecmap` brings in another CMap's ranges and mappings; the CMap's own come after them.
|
||||
#[test]
|
||||
fn usecmap_takes_the_parents_mappings() {
|
||||
let r = import(&program(
|
||||
true,
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /Test-H usecmap /CMapName /Child def\n\
|
||||
1 begincidchar <43> 2 endcidchar endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/Child2 /Child [/TestCID] composefont 50 scalefont setfont 10 20 moveto (AC) show",
|
||||
))
|
||||
.unwrap();
|
||||
let p = paths(&r);
|
||||
assert_eq!(p.len(), 2, "{p:?}");
|
||||
assert!((p[1].x0 - 40.0).abs() < 0.01 && (p[1].height() - 20.0).abs() < 0.01, "{p:?}");
|
||||
}
|
||||
|
||||
/// A Type 2 CIDFont with Source Sans 3's glyphs: CIDs 1 and 2 are H and I.
|
||||
fn cid2_font(cid_map: &str) -> String {
|
||||
format!(
|
||||
"20 dict begin /CIDFontName /TestCID2 def /CIDFontType 2 def /FontType 11 def /FontMatrix [1 0 0 1 0 0] def\n\
|
||||
/CIDSystemInfo 3 dict dup begin /Registry (Test) def /Ordering (Latin) def /Supplement 0 def end def\n\
|
||||
/FontBBox [0 0 1 1] def /CIDCount 3 def /GDBytes 2 def /CIDMap {cid_map} def\n\
|
||||
/sfnts [\n{}] def CIDFontName currentdict end /CIDFont defineresource pop\n",
|
||||
sfnts()
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type2_cidfonts_draw_their_truetype_glyphs_through_cidmap() {
|
||||
let (h, wh) = glyph('H');
|
||||
let (i, wi) = glyph('I');
|
||||
for cid_map in [format!("<0000{h:04x}{i:04x}>"), format!("[<0000{h:04x}> <{i:04x}>]")] {
|
||||
let body = format!("{}/T2 /Identity-H [/TestCID2] composefont 50 scalefont setfont 10 20 moveto <00010002> show", cid2_font(&cid_map));
|
||||
let r = super::tests::read(&body);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let p = paths(&r);
|
||||
assert_eq!(p.len(), 2, "{cid_map}: {p:?}");
|
||||
let (bh, bi) = (p[0], p[1]);
|
||||
assert!(bh.x0 > 10.0 && bh.x0 < 18.0 && bh.height() > 25.0 && bh.height() < 40.0, "{bh:?}");
|
||||
assert!(bi.x0 > 10.0 + 50.0 * wh - 1.0 && bi.x1 < 10.0 + 50.0 * (wh + wi), "{bi:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `Identity-V`: each glyph an em below the one before (writing mode 1).
|
||||
#[test]
|
||||
fn identity_v_sets_glyphs_downwards() {
|
||||
let (h, _) = glyph('H');
|
||||
let (i, _) = glyph('I');
|
||||
let body = format!(
|
||||
"{}/T2V /Identity-V [/TestCID2] composefont 40 scalefont setfont 50 90 moveto <00010002> show",
|
||||
cid2_font(&format!("<0000{h:04x}{i:04x}>"))
|
||||
);
|
||||
let p = paths(&super::tests::read(&body));
|
||||
assert_eq!(p.len(), 2, "{p:?}");
|
||||
// Document y runs down: the I is 40 pt (an em) below the H.
|
||||
assert!((p[1].y1 - p[0].y1 - 40.0).abs() < 0.5, "{p:?}");
|
||||
check(&format!(
|
||||
"{}/T2V /Identity-V [/TestCID2] composefont 40 scalefont setfont (\\000\\001\\000\\002) stringwidth -80 sub abs 0.01 lt exch abs 0.01 lt and",
|
||||
cid2_font("0")
|
||||
));
|
||||
}
|
||||
|
||||
/// A CIDFont the file doesn't carry draws nothing, an em a character, and says so.
|
||||
#[test]
|
||||
fn cidfonts_the_file_lacks_draw_nothing_with_a_warning() {
|
||||
let r = super::tests::read(
|
||||
"/X /Identity-H [/NotInFile] composefont 50 scalefont setfont 10 20 moveto <00410042> show 0 0 1 setrgbcolor currentpoint 5 5 rectfill",
|
||||
);
|
||||
let p = paths(&r);
|
||||
assert_eq!(p.len(), 1, "{p:?}");
|
||||
// The point moved two ems along.
|
||||
assert!((p[0].x0 - 110.0).abs() < 0.01, "{p:?}");
|
||||
assert!(r.warnings.iter().any(|w| w.contains("CID-keyed")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// The CIDInit operators are found through the procedure set, not systemdict.
|
||||
#[test]
|
||||
fn cidinit_operators_are_not_in_systemdict() {
|
||||
check("systemdict /begincmap known not /CIDInit /ProcSet findresource /begincmap known and systemdict /composefont known and");
|
||||
}
|
||||
|
||||
/// A CFF INDEX (Adobe Technical Note #5176, §5) of `items`, with 2-byte offsets.
|
||||
fn cff_index(items: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut out = (items.len() as u16).to_be_bytes().to_vec();
|
||||
if items.is_empty() {
|
||||
return out;
|
||||
}
|
||||
out.push(2);
|
||||
let mut at = 1u16;
|
||||
out.extend(at.to_be_bytes());
|
||||
for i in items {
|
||||
at += i.len() as u16;
|
||||
out.extend(at.to_be_bytes());
|
||||
}
|
||||
out.extend(items.iter().flatten());
|
||||
out
|
||||
}
|
||||
|
||||
/// A CFF DICT operand as a 5-byte integer (TN #5176, Table 3), so offsets don't change its size.
|
||||
fn cff_int(v: i32) -> Vec<u8> {
|
||||
[vec![29], v.to_be_bytes().to_vec()].concat()
|
||||
}
|
||||
|
||||
/// A Type 2 charstring (Adobe Technical Note #5177) from numbers, each a 3-byte integer
|
||||
/// (`28`), and operators (`rmoveto` 21, `rlineto` 5, `endchar` 14).
|
||||
fn cs2(parts: &[(&[i16], u8)]) -> Vec<u8> {
|
||||
parts.iter().flat_map(|(args, op)| args.iter().flat_map(|a| [vec![28], a.to_be_bytes().to_vec()].concat()).chain([*op])).collect()
|
||||
}
|
||||
|
||||
/// A CID-keyed CFF font `/CffCID` (TN #5176, §18): glyph 0 `.notdef`, glyph 1 CID 1 a square
|
||||
/// (100 to 500 across, 500 high) 600 wide, glyph 2 CID 7 a triangle (500 across, 400 high) 500
|
||||
/// wide (the first number of a charstring its width, `nominalWidthX` 0).
|
||||
fn cid_cff() -> Vec<u8> {
|
||||
let glyphs = vec![
|
||||
cs2(&[(&[0], 14)]),
|
||||
cs2(&[(&[600, 100, 0], 21), (&[400, 0, 0, 500, -400, 0], 5), (&[], 14)]),
|
||||
cs2(&[(&[500, 0, 0], 21), (&[500, 0, -250, 400], 5), (&[], 14)]),
|
||||
];
|
||||
let header = vec![1, 0, 4, 2];
|
||||
let names = cff_index(&[b"CffCID".to_vec()]);
|
||||
let strings = cff_index(&[b"Test".to_vec(), b"Shapes".to_vec()]);
|
||||
let global = cff_index(&[]);
|
||||
// The top DICT: ROS (12 30), CIDCount (12 34), charset (15), CharStrings (17), FDArray
|
||||
// (12 36), FDSelect (12 37), each offset a 5-byte integer.
|
||||
let top = |charset: i32, strings_at: i32, fd_array: i32, fd_select: i32| -> Vec<u8> {
|
||||
[
|
||||
cff_int(391),
|
||||
cff_int(392),
|
||||
cff_int(0),
|
||||
vec![12, 30],
|
||||
cff_int(8),
|
||||
vec![12, 34],
|
||||
cff_int(charset),
|
||||
vec![15],
|
||||
cff_int(strings_at),
|
||||
vec![17],
|
||||
cff_int(fd_array),
|
||||
vec![12, 36],
|
||||
cff_int(fd_select),
|
||||
vec![12, 37],
|
||||
]
|
||||
.concat()
|
||||
};
|
||||
let top_len = cff_index(&[top(0, 0, 0, 0)]).len();
|
||||
let charset_at = header.len() + names.len() + top_len + strings.len() + global.len();
|
||||
// Format 0: the CIDs of glyphs 1 and 2.
|
||||
let charset = vec![0, 0, 1, 0, 7];
|
||||
let fd_select_at = charset_at + charset.len();
|
||||
let fd_select = vec![0, 0, 0, 0];
|
||||
let strings_at = fd_select_at + fd_select.len();
|
||||
let char_strings = cff_index(&glyphs);
|
||||
let private_at = strings_at + char_strings.len();
|
||||
// defaultWidthX (20) and nominalWidthX (21) 0.
|
||||
let private = [cff_int(0), vec![20], cff_int(0), vec![21]].concat();
|
||||
let fd_array_at = private_at + private.len();
|
||||
let fd = [cff_int(private.len() as i32), cff_int(private_at as i32), vec![18]].concat();
|
||||
let fd_array = cff_index(&[fd]);
|
||||
let top = cff_index(&[top(charset_at as i32, strings_at as i32, fd_array_at as i32, fd_select_at as i32)]);
|
||||
[header, names, top, strings, global, charset, fd_select, char_strings, private, fd_array].concat()
|
||||
}
|
||||
|
||||
/// A FontSet holding CID-keyed Compact Font Format data (PLRM 3rd ed., §5.8.1, `StartData`;
|
||||
/// TN #5176) defines its CIDFont, whose glyphs are drawn by their CIDs.
|
||||
#[test]
|
||||
fn cff_fontsets_draw_their_cidfonts_glyphs() {
|
||||
let data = cid_cff();
|
||||
let head = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n".to_vec();
|
||||
let set =
|
||||
[format!("/FontSetInit /ProcSet findresource begin\n/CffCID {} StartData ", data.len()).into_bytes(), data, b"\nend\n".to_vec()].concat();
|
||||
let body = "/CffFont /Identity-H [/CffCID] composefont 50 scalefont setfont 10 20 moveto <00010007> show\n\
|
||||
(\\000\\001\\000\\007) stringwidth pop 55 sub abs 0.01 lt { 0 0 5 5 rectfill } if\nshowpage\n%%EOF\n";
|
||||
let r = import(&[head, set, body.as_bytes().to_vec()].concat()).unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let p = paths(&r);
|
||||
assert_eq!(p.len(), 3, "{p:?}");
|
||||
let (sq, tri) = (p[0], p[1]);
|
||||
assert!((sq.x0 - 15.0).abs() < 0.01 && (sq.width() - 20.0).abs() < 0.01 && (sq.height() - 25.0).abs() < 0.01, "{sq:?}");
|
||||
assert!((tri.x0 - 40.0).abs() < 0.01 && (tri.width() - 25.0).abs() < 0.01 && (tri.height() - 20.0).abs() < 0.01, "{tri:?}");
|
||||
}
|
||||
|
||||
/// Damaged FontSet data (each byte of the CFF data changed, in turn) is read as fonts or as
|
||||
/// nothing, never a crash.
|
||||
#[test]
|
||||
fn damaged_cff_fontsets_never_panic() {
|
||||
let data = cid_cff();
|
||||
let head = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n".to_vec();
|
||||
let body = b"/CffFont /Identity-H [/CffCID] composefont 50 scalefont setfont 10 20 moveto <000100070002> show showpage\n%%EOF\n".to_vec();
|
||||
for at in 0..data.len() {
|
||||
for v in [0u8, 0x7f, 0xff, data[at].wrapping_add(1)] {
|
||||
let mut d = data.clone();
|
||||
d[at] = v;
|
||||
let set =
|
||||
[format!("/FontSetInit /ProcSet findresource begin\n/CffCID {} StartData ", d.len()).into_bytes(), d, b"\nend\n".to_vec()].concat();
|
||||
let _ = import(&[head.clone(), set, body.clone()].concat());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CMap mapping two-byte codes to base fonts' codes and glyph names (`beginbfchar`,
|
||||
/// `beginbfrange`, `usefont`: Adobe Technical Note #5099, "Building CMap Files for CID-Keyed
|
||||
/// Fonts"): font 0 a Type 42 font that carries Source Sans 3, font 1 a font found by name.
|
||||
const BF_CMAP: &str = "/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
|
||||
/CMapName /TestBF-H def /CMapType 1 def /WMode 0 def\n\
|
||||
1 begincodespacerange <0000> <ffff> endcodespacerange\n\
|
||||
0 usefont 1 beginbfchar <0001> <01> endbfchar 1 beginbfrange <0002> <0003> [/g2 /g1] endbfrange\n\
|
||||
1 usefont 1 beginbfrange <0010> <0012> <41> endbfrange\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n";
|
||||
|
||||
#[test]
|
||||
fn cmaps_map_codes_to_base_fonts_codes_and_glyph_names() {
|
||||
let (h, wh) = glyph('H');
|
||||
let (i, wi) = glyph('I');
|
||||
// `/Plain42`: codes 1 and 2 are glyphs g1 (H) and g2 (I); g2 is also named outside the encoding.
|
||||
let plain = format!(
|
||||
"10 dict begin /FontName /Plain42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for Encoding 1 /g1 put\n\
|
||||
/CharStrings 3 dict def CharStrings begin /.notdef 0 def /g1 {h} def /g2 {i} def end\n\
|
||||
/sfnts [\n{}] def FontName currentdict end definefont pop\n",
|
||||
sfnts()
|
||||
);
|
||||
let body = format!("{plain}{BF_CMAP}/BF /TestBF-H [/Plain42 /Helvetica] composefont 50 scalefont setfont 10 20 moveto <000100020003> show");
|
||||
let r = super::tests::read(&body);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let p = paths(&r);
|
||||
assert_eq!(p.len(), 3, "{p:?}");
|
||||
// H, I, H, each its own width along.
|
||||
assert!(p[0].x0 > 10.0 && p[0].x0 < 18.0, "{p:?}");
|
||||
assert!(p[1].x0 > 10.0 + 50.0 * wh - 1.0 && p[1].x1 < 10.0 + 50.0 * (wh + wi), "{p:?}");
|
||||
assert!(p[2].x0 > 10.0 + 50.0 * (wh + wi) && (p[2].width() - p[0].width()).abs() < 0.01, "{p:?}");
|
||||
// Codes to a font found by name set its characters as type.
|
||||
let body = format!("{plain}{BF_CMAP}/BF /TestBF-H [/Plain42 /Helvetica] composefont 20 scalefont setfont 10 20 moveto <001000110012> show");
|
||||
let r = super::tests::read(&body);
|
||||
let text: String = all(&r.document)
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.plain_text()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(text, "ABC");
|
||||
}
|
||||
|
||||
/// A CID-keyed font over a predefined Unicode CMap the file doesn't define, its CIDFont only
|
||||
/// named: the codes say their characters, set as type in the font of the CIDFont's name,
|
||||
/// whether found as `CIDFont-CMap` or composed.
|
||||
#[test]
|
||||
fn named_cidfonts_over_unicode_cmaps_set_their_characters_as_type() {
|
||||
let texts = |r: &crate::Imported| -> Vec<vectorcraft_doc::TextObject> {
|
||||
all(&r.document)
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some((**t).clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let r = super::tests::read("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont 10 20 moveto <4e2d0041> show");
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let t = texts(&r);
|
||||
assert_eq!(t.len(), 1, "{:?}", all(&r.document));
|
||||
assert_eq!(t[0].plain_text(), "\u{4e2d}A");
|
||||
assert_eq!((t[0].runs[0].style.font_family.as_str(), t[0].runs[0].style.size), ("Ryumin", 20.0));
|
||||
let r = super::tests::read("/X /UniGB-UTF8-H [/NotInFile] composefont 20 scalefont setfont 10 20 moveto (\\344\\270\\255A) show");
|
||||
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "\u{4e2d}A");
|
||||
// The point moves past the type.
|
||||
check("/Ryumin-Light-UniJIS-UCS2-H findfont 20 scalefont setfont <00410042> stringwidth pop dup 5 gt exch 40 lt and");
|
||||
}
|
||||
|
||||
/// A Type 2 CIDFont the file carries (its glyph space one unit an em), over a Unicode CMap the
|
||||
/// file doesn't define, sets its characters as type at the font's size, not a thousand times it.
|
||||
#[test]
|
||||
fn type2_cidfonts_over_unicode_cmaps_set_type_at_their_size() {
|
||||
let r =
|
||||
super::tests::read(&format!("{}/X /UniGB-UCS2-H [/TestCID2] composefont 20 scalefont setfont 10 20 moveto <4e2d0041> show", cid2_font("0")));
|
||||
let sizes: Vec<f64> = all(&r.document)
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some(t.runs[0].style.size * t.xf.determinant().abs().sqrt()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!sizes.is_empty() && sizes.iter().all(|s| (s - 20.0).abs() < 0.5), "{sizes:?} {:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A code a base-font CMap doesn't map, in a base font, is that font's code 0 (`.notdef`): it
|
||||
/// moves the point by that glyph's width, not by a thousand ems, and the font isn't missing.
|
||||
#[test]
|
||||
fn unmapped_codes_in_base_fonts_take_their_notdef() {
|
||||
let plain = format!(
|
||||
"10 dict begin /FontName /Plain42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for\n\
|
||||
/CharStrings 1 dict def CharStrings begin /.notdef 0 def end\n\
|
||||
/sfnts [\n{}] def FontName currentdict end definefont pop\n",
|
||||
sfnts()
|
||||
);
|
||||
check(&format!(
|
||||
"{plain}{BF_CMAP}/BF /TestBF-H [/Plain42 /Helvetica] composefont 50 scalefont setfont <0009> stringwidth pop dup 0 gt exch 100 lt and"
|
||||
));
|
||||
let r = super::tests::read(&format!(
|
||||
"{plain}{BF_CMAP}/BF /TestBF-H [/Plain42 /Helvetica] composefont 50 scalefont setfont 10 20 moveto <0009> show 0 0 10 10 rectfill"
|
||||
));
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A CMap's mappings take font 0 until its own `usefont`, whatever font number an earlier CMap
|
||||
/// ended with (Adobe Technical Note #5014).
|
||||
#[test]
|
||||
fn each_cmap_starts_at_font_0() {
|
||||
let r = import(&program(
|
||||
true,
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Two-H def\n\
|
||||
1 begincodespacerange <00> <ff> endcodespacerange 1 usefont 1 begincidchar <41> 1 endcidchar\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /One-H def\n\
|
||||
1 begincodespacerange <00> <ff> endcodespacerange 1 begincidrange <41> <42> 1 endcidrange\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/One /One-H [/TestCID] composefont 50 scalefont setfont 10 20 moveto (AB) show",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(paths(&r).len(), 2, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// A Type 0 CIDFont with a matrix of its own (its FDArray matrices map 1000 units to an em):
|
||||
/// vertical type moves an em a character, not a thousand.
|
||||
#[test]
|
||||
fn cidfonts_with_their_own_matrix_move_an_em_a_character() {
|
||||
let font = String::from_utf8(cid_font(true)).unwrap().replace("/CIDFontType 0 def", "/CIDFontType 0 def /FontMatrix [1 0 0 1 0 0] def");
|
||||
check(&format!(
|
||||
"{font}{CMAP} /V /Identity-V [/TestCID] composefont 40 scalefont setfont (\\000\\001\\000\\002) stringwidth -80 sub abs 0.01 lt exch abs 0.01 lt and"
|
||||
));
|
||||
}
|
||||
|
||||
/// `restore` undoes the matrices `StartData` gives a CIDFont and its FDArray dictionaries, as
|
||||
/// it undoes the program's own changes.
|
||||
#[test]
|
||||
fn restore_undoes_the_matrices_start_data_sets() {
|
||||
let font = String::from_utf8(cid_font(true)).unwrap().replacen(
|
||||
"4 dict begin /FontMatrix [0.001 0 0 0.001 0 0] def /Private 2 dict dup begin /lenIV -1 def end def currentdict end",
|
||||
"fd0",
|
||||
1,
|
||||
);
|
||||
check(&format!(
|
||||
"/fd0 4 dict begin /FontMatrix [0.001 0 0 0.001 0 0] def /Private 2 dict dup begin /lenIV -1 def end def currentdict end def\n\
|
||||
/sv save def\n{font}\nsv restore fd0 /FontMatrix get 0 get 0.001 sub abs 1e-9 lt"
|
||||
));
|
||||
}
|
||||
|
||||
/// Notdef mappings added to a CMap that was already shown with take effect (its mappings are
|
||||
/// read again).
|
||||
#[test]
|
||||
fn notdef_mappings_added_later_are_read() {
|
||||
let fonts = String::from_utf8(cid_font(true)).unwrap() + CMAP;
|
||||
check(&format!(
|
||||
"{fonts} /TestFont 50 selectfont <21> stringwidth pop pop\n\
|
||||
/CIDInit /ProcSet findresource begin /Test-H /CMap findresource begin 1 beginnotdefrange <20> <2f> 1 endnotdefrange end end\n\
|
||||
<21> stringwidth pop 30 sub abs 0.01 lt"
|
||||
));
|
||||
}
|
||||
|
||||
/// A CMap built of many small blocks pays for copying its mappings at each block.
|
||||
#[test]
|
||||
fn many_small_cmap_blocks_run_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget("/CIDInit /ProcSet findresource begin 12 dict begin begincmap { 1 begincidchar <41> 1 endcidchar } loop");
|
||||
}
|
||||
|
||||
/// A CIDFont's subroutines are decrypted once, not again for every glyph: measuring a glyph of
|
||||
/// a font with 10 MB of them in a loop ends within the budget.
|
||||
#[test]
|
||||
fn cidfont_subroutines_are_read_once() {
|
||||
let font = String::from_utf8(cid_font(true)).unwrap();
|
||||
let at = font.find("/Subrs [<").unwrap() + "/Subrs [".len();
|
||||
let font = format!(
|
||||
"{}{} 0 1 9998 {{ pop 1000 string }} for{}",
|
||||
&font[..at],
|
||||
&font[at..font[at..].find(']').unwrap() + at],
|
||||
&font[font[at..].find(']').unwrap() + at..]
|
||||
);
|
||||
super::tests::runs_out_of_budget(&format!("{font}{CMAP} /TestFont 50 selectfont {{ (B) stringwidth pop pop }} loop"));
|
||||
}
|
||||
|
||||
/// Binary data larger than most operators' counts (4 MB) is read: CJK CIDFonts carry more.
|
||||
#[test]
|
||||
fn start_data_reads_more_than_4_mb() {
|
||||
let mut font = cid_font(false);
|
||||
let marker = b"(Binary) ";
|
||||
let at = font.windows(marker.len()).position(|w| w == marker).unwrap();
|
||||
let rest = font.split_off(at);
|
||||
let len_end = rest.iter().skip(marker.len()).position(|b| *b == b' ').unwrap() + marker.len();
|
||||
let n: usize = std::str::from_utf8(&rest[marker.len()..len_end]).unwrap().parse().unwrap();
|
||||
let data_at = len_end + " StartData ".len();
|
||||
let mut data = rest[data_at..data_at + n].to_vec();
|
||||
data.resize(5_000_000, 0);
|
||||
font.extend(format!("(Binary) {} StartData ", data.len()).into_bytes());
|
||||
font.extend(data);
|
||||
font.push(b'\n');
|
||||
let head = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n".to_vec();
|
||||
let r = import(&[head, font, CMAP.as_bytes().to_vec(), b"/TestFont 50 selectfont 10 20 moveto (AB) show\nshowpage\n%%EOF\n".to_vec()].concat())
|
||||
.unwrap();
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
assert_eq!(paths(&r).len(), 2);
|
||||
}
|
||||
|
||||
/// Decoding a string through a CMap with many mappings counts against the operation budget.
|
||||
#[test]
|
||||
fn decoding_through_a_large_cmap_runs_out_of_the_budget() {
|
||||
// A CIDFont the file doesn't carry: its glyphs cost nothing, so the decoding is what counts.
|
||||
super::tests::runs_out_of_budget(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Big-H def\n\
|
||||
2 begincodespacerange <00> <7f> <8000> <ffff> endcodespacerange\n\
|
||||
16000 begincidrange 0 1 15999 { /i exch def 2 string dup 0 i 256 idiv 128 add put dup 1 i 256 mod put dup 1 } for endcidrange\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/Big /Big-H [/NotInFile] composefont 50 scalefont setfont /s 1000 string def { s stringwidth pop pop } loop",
|
||||
);
|
||||
}
|
||||
|
||||
/// A CID-keyed font's glyphs are grouped under the font's name: a CID stands for no character.
|
||||
#[test]
|
||||
fn cid_glyph_groups_take_the_fonts_name() {
|
||||
let r = import(&program(true, "/TestFont 50 selectfont 10 20 moveto (AB) show")).unwrap();
|
||||
let names: Vec<_> = all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Group { .. })).map(|n| n.name.clone()).collect();
|
||||
assert_eq!(names, [Some("TestFont".to_string())]);
|
||||
}
|
||||
|
||||
/// One show of a long string through a CMap with many mappings stops at the operation budget,
|
||||
/// not after decoding the whole string.
|
||||
#[test]
|
||||
fn one_long_show_through_a_large_cmap_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(
|
||||
"/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Big-H def\n\
|
||||
2 begincodespacerange <00> <7f> <8000> <ffff> endcodespacerange\n\
|
||||
/mk { 0 1 15999 { /i exch def 2 string dup 0 i 256 idiv 128 add put dup 1 i 256 mod put dup 1 } for } def\n\
|
||||
16000 begincidrange mk endcidrange 16000 begincidrange mk endcidrange 16000 begincidrange mk endcidrange 16000 begincidrange mk endcidrange\n\
|
||||
16000 beginnotdefrange mk endnotdefrange 16000 beginnotdefrange mk endnotdefrange 16000 beginnotdefrange mk endnotdefrange 16000 beginnotdefrange mk endnotdefrange\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/Big /Big-H [/NotInFile] composefont 50 scalefont setfont 4000000 string stringwidth pop pop",
|
||||
);
|
||||
}
|
||||
|
||||
/// The evaluator limits each glyph, not the file: measuring a glyph whose subroutines (1 to 8
|
||||
/// each calling the next four times) run a quarter of a million operations in a loop ends within
|
||||
/// the operation budget.
|
||||
#[test]
|
||||
fn cid_glyphs_measured_in_a_loop_run_out_of_the_budget() {
|
||||
let font = String::from_utf8(cid_font(true)).unwrap();
|
||||
let at = font.find("/Subrs [<").unwrap() + "/Subrs [".len();
|
||||
let end = font[at..].find(']').unwrap() + at;
|
||||
let mut subrs = vec![encrypt(&cs(&[(&[1], 10), (&[0, 0], 21), (&[500, 0], 5), (&[-250, 400], 5), (&[], 9), (&[], 11)]))];
|
||||
for k in 1..=8 {
|
||||
subrs.push(encrypt(&cs(&[(&[k + 1], 10), (&[k + 1], 10), (&[k + 1], 10), (&[k + 1], 10), (&[], 11)])));
|
||||
}
|
||||
subrs.push(encrypt(&cs(&[(&[], 11)])));
|
||||
let list: String = subrs.iter().map(|s| format!("<{}> ", hex(s))).collect();
|
||||
let font = format!("{}{list}{}", &font[..at], &font[end..]);
|
||||
check(&format!("{font}{CMAP} /TestFont 50 selectfont (B) stringwidth pop 25 sub abs 0.01 lt"));
|
||||
super::tests::runs_out_of_budget(&format!("{font}{CMAP} /TestFont 50 selectfont {{ (B) stringwidth pop pop }} loop"));
|
||||
}
|
||||
|
||||
/// Setting type over a Unicode CMap copies the composite font for every string: the copy is
|
||||
/// charged by the font's size, so a font with a huge number of keys shown in a loop runs out of
|
||||
/// the operation budget.
|
||||
#[test]
|
||||
fn unicode_type_in_a_huge_font_in_a_loop_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(
|
||||
"/Ryumin-Light-UniJIS-UCS2-H findfont dup length 200000 add dict copy dup begin 0 1 199999 { dup def } for end\n\
|
||||
/Huge exch definefont 20 scalefont setfont { <0041> stringwidth pop pop } loop",
|
||||
);
|
||||
}
|
||||
|
||||
/// A `beginbfrange` destination array that holds itself is read without recursing into it (an
|
||||
/// array element that is an array maps nothing), so showing its code is no crash.
|
||||
#[test]
|
||||
fn a_bfrange_array_holding_itself_is_no_crash() {
|
||||
let body = "/CIDInit /ProcSet findresource begin 12 dict begin begincmap /CMapName /Self-H def\n\
|
||||
1 begincodespacerange <00> <ff> endcodespacerange\n\
|
||||
/a 1 array def a 0 a put 1 beginbfrange <00> <00> a endbfrange\n\
|
||||
endcmap CMapName currentdict /CMap defineresource pop end end\n\
|
||||
/S /Self-H [/Helvetica] composefont 20 scalefont setfont 10 20 moveto <00> show 0 0 10 10 rectfill";
|
||||
let r = super::tests::read(body);
|
||||
assert_eq!(paths(&r).len(), 1, "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Drawing a glyph of a FontSet's CIDFont counts the charstring bytes it enters, as Type 1
|
||||
/// glyphs do, so glyphs that run long (nested subroutines) are paid for by the `show` that
|
||||
/// draws them, not a flat amount.
|
||||
#[test]
|
||||
fn cff_glyphs_count_the_charstrings_they_run() {
|
||||
let cff = super::cid::Cff::new(std::rc::Rc::from(cid_cff()), 0);
|
||||
assert!(cff.glyph(1).is_some());
|
||||
assert!(cff.take_work() > 0);
|
||||
assert_eq!(cff.take_work(), 0);
|
||||
}
|
||||
|
||||
/// `usecmap` copies the parent's mappings into new arrays: the copies count against the memory
|
||||
/// budget, so keeping the CMaps made from a large one in a loop stops with an error instead of
|
||||
/// growing without bound.
|
||||
#[test]
|
||||
fn usecmap_copies_count_against_the_memory_budget() {
|
||||
let r = import(&super::tests::eps(
|
||||
"0 0 10 10 rectfill /CIDInit /ProcSet findresource begin 12 dict begin begincmap\n\
|
||||
1 begincodespacerange <0000> <ffff> endcodespacerange\n\
|
||||
0 1 599 { pop 100 begincidrange 0 1 99 { pop <0000> <0001> 0 } for endcidrange } for\n\
|
||||
endcmap /Big currentdict /CMap defineresource pop end end\n\
|
||||
/CIDInit /ProcSet findresource begin { 12 dict begin /Big usecmap currentdict end } loop",
|
||||
))
|
||||
.unwrap();
|
||||
assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings);
|
||||
}
|
||||
@@ -11,10 +11,10 @@ use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::tests::{all, bounds, fill, read};
|
||||
|
||||
const FONT: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
pub(super) const FONT: &[u8] = include_bytes!("../../../../assets/fonts/SourceSans3-Regular.ttf");
|
||||
|
||||
/// The glyph index of `c` in [`FONT`] and its advance width in ems.
|
||||
fn glyph(c: char) -> (u32, f64) {
|
||||
pub(super) fn glyph(c: char) -> (u32, f64) {
|
||||
let font = FontRef::new(FONT).unwrap();
|
||||
let gid = font.charmap().map(c).unwrap();
|
||||
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
@@ -24,7 +24,7 @@ fn glyph(c: char) -> (u32, f64) {
|
||||
|
||||
/// `sfnts` strings of [`FONT`]: the first of odd length, ending in a padding byte that isn't part
|
||||
/// of the font, the rest split at even lengths.
|
||||
fn sfnts() -> String {
|
||||
pub(super) fn sfnts() -> String {
|
||||
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
|
||||
let mut out = format!("<{}00>\n", hex(&FONT[..20000]));
|
||||
for chunk in FONT[20000..].chunks(40000) {
|
||||
@@ -251,3 +251,16 @@ fn unreadable_type42_data_read_again_runs_out_of_the_budget() {
|
||||
/Comp findfont 50 scalefont setfont {{ ({s}) stringwidth pop pop }} loop"
|
||||
));
|
||||
}
|
||||
|
||||
/// Characters that map to no glyph (an empty `FDepVector`) cost nothing to draw, but finding that
|
||||
/// out reads the whole string: a `loop` showing, measuring or outlining a long string of them runs
|
||||
/// out of the operation budget instead of stalling the import.
|
||||
#[test]
|
||||
fn unmapped_composite_characters_in_a_loop_run_out_of_the_budget() {
|
||||
let font = "16 dict begin /FontName /Comp def /FontType 0 def /FontMatrix [1 0 0 1 0 0] def /FMapType 2 def\n\
|
||||
/Encoding [0] def /FDepVector [] def FontName currentdict end definefont pop\n\
|
||||
/Comp findfont 50 scalefont setfont /s 100000 string def";
|
||||
for body in ["{ 0 0 moveto s show } loop", "{ s stringwidth pop pop } loop", "{ newpath 0 0 moveto s false charpath } loop"] {
|
||||
super::tests::runs_out_of_budget(&format!("{font} {body}"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Type 1 fonts embedded in the file: their encrypted part (`eexec`) is read for their glyphs
|
||||
//! (Adobe Type 1 Font Format, chapters 6 and 7), so their type keeps the font's own widths, and
|
||||
//! glyphs that stand for no character are drawn from their outlines. The font here is written
|
||||
//! from scratch: two square and triangle glyphs in hand-made charstrings.
|
||||
|
||||
use vectorcraft_doc::{NodeKind, TextObject};
|
||||
|
||||
use super::tests::{all, bounds, check, read};
|
||||
use crate::import::import;
|
||||
|
||||
/// A Type 1 charstring number (§6.2).
|
||||
fn num(v: i32) -> Vec<u8> {
|
||||
match v {
|
||||
-107..=107 => vec![(v + 139) as u8],
|
||||
108..=1131 => vec![((v - 108) / 256 + 247) as u8, ((v - 108) % 256) as u8],
|
||||
-1131..=-108 => vec![((-v - 108) / 256 + 251) as u8, ((-v - 108) % 256) as u8],
|
||||
_ => [vec![255], v.to_be_bytes().to_vec()].concat(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A charstring (`hsbw` 13, `rmoveto` 21, `rlineto` 5, `closepath` 9, `endchar` 14).
|
||||
fn cs(parts: &[(&[i32], u8)]) -> Vec<u8> {
|
||||
parts.iter().flat_map(|(args, op)| args.iter().flat_map(|a| num(*a)).chain([*op])).collect()
|
||||
}
|
||||
|
||||
/// `plain` encrypted with key `r` after 4 leading bytes (§7.1: 55665 for the encrypted part,
|
||||
/// 4330 for charstrings).
|
||||
fn encrypt(plain: &[u8], mut r: u16) -> Vec<u8> {
|
||||
[0u8; 4]
|
||||
.iter()
|
||||
.chain(plain)
|
||||
.map(|&p| {
|
||||
let c = p ^ (r >> 8) as u8;
|
||||
r = (u16::from(c).wrapping_add(r)).wrapping_mul(52845).wrapping_add(22719);
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Font `/TestOne`: A a square (100 to 500 across, 500 high) 600 wide, B a triangle (500
|
||||
/// across, 400 high) 500 wide, and the triangle again under the name `g1`, which says nothing,
|
||||
/// at code 67 (C). Its encoding is `encoding` (a PostScript expression); its encrypted part
|
||||
/// written in hexadecimal when `hex`.
|
||||
fn font(encoding: &str, hex: bool) -> Vec<u8> {
|
||||
font_with(encoding, hex, &[], None)
|
||||
}
|
||||
|
||||
/// [`font`] with subroutines `subrs` and, when given, charstring `a` for A.
|
||||
fn font_with(encoding: &str, hex: bool, subrs: &[Vec<u8>], a: Option<Vec<u8>>) -> Vec<u8> {
|
||||
let square = a.unwrap_or_else(|| cs(&[(&[0, 600], 13), (&[100, 0], 21), (&[400, 0], 5), (&[0, 500], 5), (&[-400, 0], 5), (&[], 9), (&[], 14)]));
|
||||
let triangle = cs(&[(&[0, 500], 13), (&[0, 0], 21), (&[500, 0], 5), (&[-250, 400], 5), (&[], 9), (&[], 14)]);
|
||||
let notdef = cs(&[(&[0, 250], 13), (&[], 14)]);
|
||||
let mut private = b"dup /Private 8 dict dup begin\n/RD {string currentfile exch readstring pop} executeonly def\n\
|
||||
/ND {noaccess def} executeonly def\n/NP {noaccess put} executeonly def\n/lenIV 4 def\n/password 5839 def\n\
|
||||
/MinFeature {16 16} def\n"
|
||||
.to_vec();
|
||||
private.extend(format!("/Subrs {} array\n", subrs.len()).into_bytes());
|
||||
for (i, subr) in subrs.iter().enumerate() {
|
||||
let e = encrypt(subr, 4330);
|
||||
private.extend(format!("dup {i} {} RD ", e.len()).into_bytes());
|
||||
private.extend(e);
|
||||
private.extend(b" NP\n");
|
||||
}
|
||||
private.extend(b"readonly def\n2 index /CharStrings 4 dict dup begin\n");
|
||||
for (name, glyph) in [(".notdef", ¬def), ("A", &square), ("B", &triangle), ("g1", &triangle)] {
|
||||
let e = encrypt(glyph, 4330);
|
||||
private.extend(format!("/{name} {} RD ", e.len()).into_bytes());
|
||||
private.extend(e);
|
||||
private.extend(b" ND\n");
|
||||
}
|
||||
private.extend(b"end\nend\nreadonly put\nnoaccess put\ndup /FontName get exch definefont pop\nmark currentfile closefile\n");
|
||||
let encrypted = encrypt(&private, 55665);
|
||||
let mut out = format!(
|
||||
"%!FontType1-1.0: TestOne 001\n12 dict begin\n/FontName /TestOne def /FontType 1 def /PaintType 0 def\n\
|
||||
/FontMatrix [0.001 0 0 0.001 0 0] readonly def /FontBBox {{0 0 1000 1000}} readonly def\n\
|
||||
/Encoding {encoding} def\ncurrentdict end\ncurrentfile eexec\n"
|
||||
)
|
||||
.into_bytes();
|
||||
if hex {
|
||||
for line in encrypted.chunks(32) {
|
||||
out.extend(line.iter().map(|b| format!("{b:02x}")).collect::<String>().into_bytes());
|
||||
out.push(b'\n');
|
||||
}
|
||||
} else {
|
||||
out.extend(encrypted);
|
||||
out.push(b'\n');
|
||||
}
|
||||
for _ in 0..8 {
|
||||
out.extend(b"0".repeat(64));
|
||||
out.push(b'\n');
|
||||
}
|
||||
out.extend(b"cleartomark\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// The font's own encoding: A, B and `g1` at codes 65 to 67.
|
||||
const OWN: &str = "256 array 0 1 255 {1 index exch /.notdef put} for dup 65 /A put dup 66 /B put dup 67 /g1 put readonly";
|
||||
|
||||
fn texts(r: &crate::Imported) -> Vec<TextObject> {
|
||||
all(&r.document)
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some((**t).clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn program(encoding: &str, hex: bool, body: &str) -> crate::Imported {
|
||||
let head = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 100 100\n%%EndComments\n".to_vec();
|
||||
import(&[head, font(encoding, hex), format!("{body}\nshowpage\n%%EOF\n").into_bytes()].concat()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_type1_type_keeps_the_fonts_widths() {
|
||||
for hex in [true, false] {
|
||||
let r = program(OWN, hex, "/TestOne findfont 50 scalefont setfont 10 20 moveto (AB) show");
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let t = texts(&r);
|
||||
assert_eq!(t.len(), 1, "{:?}", all(&r.document));
|
||||
assert_eq!(t[0].plain_text(), "AB");
|
||||
assert_eq!(t[0].runs[0].style.font_family, "Test One");
|
||||
// B starts where A's 600 units end, whatever the font laid out.
|
||||
let o: Vec<f64> = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t[0]).glyphs.iter().map(|g| g.origin.x).collect();
|
||||
assert!((o[1] - o[0] - 30.0).abs() < 0.05, "{o:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_type1_fonts_measure_and_outline_their_glyphs() {
|
||||
let fonts = String::from_utf8(font(OWN, true)).unwrap();
|
||||
check(&format!("{fonts} /TestOne findfont 50 scalefont setfont (AB) stringwidth pop 55 sub abs 0.01 lt"));
|
||||
check(&format!(
|
||||
"{fonts} /TestOne findfont 50 scalefont setfont newpath 10 20 moveto (A) true charpath pathbbox 45 sub abs 0.01 lt \
|
||||
exch 35 sub abs 0.01 lt and exch 20 sub abs 0.01 lt and exch 15 sub abs 0.01 lt and"
|
||||
));
|
||||
check(&format!("{fonts} /TestOne findfont /CharStrings get /A known"));
|
||||
}
|
||||
|
||||
/// A glyph whose name says no character (`g1`) is drawn from the font.
|
||||
#[test]
|
||||
fn embedded_type1_glyphs_without_a_character_are_drawn() {
|
||||
let r = program(OWN, true, "/TestOne findfont 50 scalefont setfont 10 20 moveto (C) show");
|
||||
assert!(texts(&r).is_empty());
|
||||
let paths: Vec<_> = all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).map(|n| bounds(n)).collect();
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert!((paths[0].x0 - 10.0).abs() < 0.01 && (paths[0].width() - 25.0).abs() < 0.01 && (paths[0].height() - 20.0).abs() < 0.01, "{paths:?}");
|
||||
}
|
||||
|
||||
/// A font in the standard encoding takes the standard names of its codes (PLRM 3rd ed.,
|
||||
/// Appendix E.5), so its glyphs and widths are found.
|
||||
#[test]
|
||||
fn standard_encoding_fonts_find_their_glyphs() {
|
||||
let fonts = String::from_utf8(font("StandardEncoding", true)).unwrap();
|
||||
check(&format!("{fonts} /TestOne findfont 50 scalefont setfont (AB) stringwidth pop 55 sub abs 0.01 lt"));
|
||||
let r = read(&format!("{fonts} /TestOne findfont 50 scalefont setfont 10 20 moveto (AB) show"));
|
||||
assert_eq!(texts(&r)[0].plain_text(), "AB");
|
||||
}
|
||||
|
||||
/// A font found by name (its program not in the file), re-encoded with glyph names at other
|
||||
/// codes, sets the characters its names say, not its codes read as Latin-1.
|
||||
#[test]
|
||||
fn named_fonts_set_the_characters_their_glyph_names_say() {
|
||||
let r = read(
|
||||
"/Helvetica findfont dup length dict begin { 1 index /FID ne { def } { pop pop } ifelse } forall\n\
|
||||
/Encoding 256 array 0 1 255 { 1 index exch /.notdef put } for dup 1 /H put dup 2 /i put dup 3 /eacute put def\n\
|
||||
currentdict end /Helv-Re exch definefont pop /Helv-Re 20 selectfont 10 20 moveto (\\001\\002\\003) show",
|
||||
);
|
||||
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "Hié");
|
||||
}
|
||||
|
||||
/// A font whose encoding is a copy of `StandardEncoding` (a different array, so not known as the
|
||||
/// standard encoding) still finds each code's glyph by its character's name, not `.notdef`.
|
||||
#[test]
|
||||
fn copied_standard_encodings_find_their_glyphs() {
|
||||
let fonts = String::from_utf8(font("StandardEncoding 256 array copy", true)).unwrap();
|
||||
check(&format!("{fonts} /TestOne findfont 50 scalefont setfont (AB) stringwidth pop 55 sub abs 0.01 lt"));
|
||||
let r = read(&format!("{fonts} /TestOne findfont 50 scalefont setfont 10 20 moveto (AB) show"));
|
||||
assert_eq!(texts(&r)[0].plain_text(), "AB");
|
||||
}
|
||||
|
||||
/// Subroutines 1 to 8 each calling the one below four times (`callsubr` 10, `return` 11), and
|
||||
/// A a square drawn after calling subroutine 8: a few bytes that run some 260,000 operations.
|
||||
fn heavy_font() -> String {
|
||||
let mut subrs = vec![cs(&[(&[], 11)])];
|
||||
for k in 1..=8 {
|
||||
subrs.push(cs(&[(&[k - 1], 10), (&[k - 1], 10), (&[k - 1], 10), (&[k - 1], 10), (&[], 11)]));
|
||||
}
|
||||
let a = cs(&[(&[0, 600], 13), (&[8], 10), (&[100, 0], 21), (&[400, 0], 5), (&[0, 500], 5), (&[-400, 0], 5), (&[], 9), (&[], 14)]);
|
||||
String::from_utf8(font_with(OWN, true, &subrs, Some(a))).unwrap()
|
||||
}
|
||||
|
||||
/// Running a glyph's charstrings counts the bytes of every subroutine call, for the operation
|
||||
/// budget; and the glyph still draws.
|
||||
#[test]
|
||||
fn running_a_type1_glyph_counts_its_subroutine_calls() {
|
||||
let program = heavy_font();
|
||||
let at = program.find("eexec\n").unwrap() + "eexec\n".len();
|
||||
let font = super::type1::parse(&program.as_bytes()[at..]).unwrap();
|
||||
let gid = font.font.glyph_names().find(|(_, n)| *n == "A").unwrap().0.to_u32();
|
||||
let font = super::type1::Embedded::Type1(std::rc::Rc::new(font));
|
||||
let (outline, w) = font.glyph(gid).unwrap();
|
||||
assert!((w - 0.6).abs() < 1e-9 && !outline.elements().is_empty(), "{w}");
|
||||
let work = font.take_work();
|
||||
assert!(work > 4u64.pow(8), "{work}");
|
||||
assert_eq!(font.take_work(), 0);
|
||||
check(&format!("{program} /TestOne findfont 50 scalefont setfont (A) stringwidth pop 30 sub abs 0.01 lt"));
|
||||
}
|
||||
|
||||
/// The evaluator limits each glyph, not the file: measuring a glyph whose subroutines run a
|
||||
/// quarter of a million operations in a loop ends within the operation budget.
|
||||
#[test]
|
||||
fn type1_glyphs_measured_in_a_loop_run_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(&format!("{} /TestOne findfont 50 scalefont setfont {{ (A) stringwidth pop pop }} loop", heavy_font()));
|
||||
}
|
||||
|
||||
/// One long string of a costly glyph pays for each glyph as it is measured: a million copies of
|
||||
/// the glyph above stop at the operation budget after a few hundred, instead of all running first.
|
||||
#[test]
|
||||
fn a_long_string_of_type1_glyphs_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(&format!(
|
||||
"{} /TestOne findfont 50 scalefont setfont /s 1000000 string def 0 1 999999 {{ s exch 65 put }} for s stringwidth pop pop",
|
||||
heavy_font()
|
||||
));
|
||||
}
|
||||
|
||||
/// `eexec`'s search for `cleartomark` reads the encrypted part: on a large reusable file in a
|
||||
/// loop it runs out of the operation budget instead of reading megabytes a pass for free.
|
||||
#[test]
|
||||
fn eexec_on_a_large_file_in_a_loop_runs_out_of_the_budget() {
|
||||
super::tests::runs_out_of_budget(
|
||||
"/s 4000000 string def s 3999980 (cleartomark) putinterval /f s /ReusableStreamDecode filter def\n\
|
||||
{ f resetfile f eexec cleartomark } loop",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
//! Plain Type 42 fonts (TrueType fonts in PostScript, Adobe Technical Note #5012, "The Type 42
|
||||
//! Font Format Specification") that carry their TrueType font: each code's character from its
|
||||
//! glyph name, or from the glyph its `CharStrings` name gives when the name says nothing
|
||||
//! (`/g12`); its width from the font's own metrics; glyphs that stand for no character drawn
|
||||
//! from the font's outlines. Spacing from the show operators (PLRM 3rd ed., §5.1.4 and the
|
||||
//! `ashow`, `widthshow` and `xshow` entries of chapter 8) is kept on live type as tracking. The
|
||||
//! fonts are written from scratch around the bundled Source Sans 3.
|
||||
|
||||
use skrifa::{FontRef, MetadataProvider};
|
||||
use vectorcraft_doc::{NodeKind, TextObject};
|
||||
|
||||
use super::tests::{all, read};
|
||||
use super::tests_composite::{FONT, glyph, sfnts};
|
||||
|
||||
/// A program defining Type 42 font `/Plain42` whose `Encoding` gives codes 1 to `names.len()`
|
||||
/// glyph names `/g1`, `/g2`… mapped through `CharStrings` to the glyph of each of `glyphs`.
|
||||
fn font(glyphs: &[u32]) -> String {
|
||||
let mut enc = String::new();
|
||||
let mut strings = String::new();
|
||||
for (i, g) in glyphs.iter().enumerate() {
|
||||
enc.push_str(&format!("Encoding {} /g{} put ", i + 1, i + 1));
|
||||
strings.push_str(&format!("/g{} {g} def ", i + 1));
|
||||
}
|
||||
format!(
|
||||
"10 dict begin /FontName /Plain42 def /FontType 42 def /FontMatrix [1 0 0 1 0 0] def /FontBBox [0 0 1 1] def /PaintType 0 def\n\
|
||||
/Encoding 256 array def 0 1 255 {{ Encoding exch /.notdef put }} for {enc}\n\
|
||||
/CharStrings {} dict def CharStrings begin /.notdef 0 def {strings} end\n\
|
||||
/sfnts [\n{}] def FontName currentdict end definefont pop\n",
|
||||
glyphs.len() + 1,
|
||||
sfnts()
|
||||
)
|
||||
}
|
||||
|
||||
fn texts(r: &crate::Imported) -> Vec<TextObject> {
|
||||
all(&r.document)
|
||||
.iter()
|
||||
.filter_map(|n| match &n.kind {
|
||||
NodeKind::Text(t) => Some((**t).clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Where each glyph of `t` starts along its baseline (text space).
|
||||
fn origins(t: &TextObject) -> Vec<f64> {
|
||||
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).glyphs.iter().map(|g| g.origin.x).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_names_that_say_nothing_read_the_fonts_character_map() {
|
||||
let (h, _) = glyph('H');
|
||||
let (i, _) = glyph('I');
|
||||
let r = read(&format!("{}/Plain42 findfont 40 scalefont setfont 10 20 moveto (\\001\\002) show", font(&[h, i])));
|
||||
let t = texts(&r);
|
||||
assert_eq!(t.len(), 1, "{:?}", all(&r.document));
|
||||
assert_eq!(t[0].plain_text(), "HI");
|
||||
assert_eq!(t[0].runs[0].style.font_family, "Plain42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glyph_name_says_its_character_whatever_its_code() {
|
||||
// The Euro by its name at code 128, where Latin-1 has a control code.
|
||||
// A standard encoding name says its character too (`hyphen`), not one the font's character
|
||||
// map also maps to its glyph (the soft hyphen).
|
||||
let (e, _) = glyph('€');
|
||||
let (h, _) = glyph('-');
|
||||
let program = font(&[e, h])
|
||||
.replace("Encoding 1 /g1 put", "Encoding 128 /Euro put")
|
||||
.replace("/g1 ", "/Euro ")
|
||||
.replace("Encoding 2 /g2 put", "Encoding 45 /hyphen put")
|
||||
.replace("/g2 ", "/hyphen ");
|
||||
let r = read(&format!("{program}/Plain42 findfont 40 scalefont setfont 10 20 moveto (\\200-) show"));
|
||||
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<String>(), "€-");
|
||||
}
|
||||
|
||||
/// A glyph of the bundled font with an outline that the character map doesn't reach (an
|
||||
/// alternate form) and whose name says no character.
|
||||
fn alternate_glyph() -> u32 {
|
||||
let f = FontRef::new(FONT).unwrap();
|
||||
let mapped: std::collections::HashSet<u32> = f.charmap().mappings().map(|(_, g)| g.to_u32()).collect();
|
||||
let names = f.glyph_names();
|
||||
(1..f.glyph_names().num_glyphs())
|
||||
.find(|g| !mapped.contains(g) && super::text::glyph_char(names.get(skrifa::GlyphId::new(*g)).unwrap().as_str()) == '\u{2022}')
|
||||
.and_then(|g| {
|
||||
// One with an outline.
|
||||
let (o, _) = super::type42::glyph(FONT, g)?;
|
||||
(!o.elements().is_empty()).then_some(g)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyphs_that_stand_for_no_character_are_drawn_from_the_font() {
|
||||
let alt = alternate_glyph();
|
||||
let (h, _) = glyph('H');
|
||||
let r = read(&format!("{}/Plain42 findfont 40 scalefont setfont 10 20 moveto (\\001\\002) show", font(&[h, alt])));
|
||||
assert!(texts(&r).is_empty());
|
||||
let paths = all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).count();
|
||||
assert_eq!(paths, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fonts_own_widths_space_its_type_and_move_the_point() {
|
||||
let (h, wh) = glyph('H');
|
||||
let (i, wi) = glyph('I');
|
||||
let f = font(&[h, i]);
|
||||
let check = |cond: String| super::tests::check(&format!("{f} /Plain42 findfont 50 scalefont setfont {cond}"));
|
||||
check(format!("(\\001\\002) stringwidth pop {} sub abs 0.01 lt", 50.0 * (wh + wi)));
|
||||
check(format!("gsave nulldevice 10 20 moveto (\\001\\002) show currentpoint grestore pop {} sub abs 0.01 lt", 10.0 + 50.0 * (wh + wi)));
|
||||
check("gsave nulldevice 10 20 moveto (\\001\\002) [30 7] xshow currentpoint grestore pop 47 sub abs 0.01 lt".into());
|
||||
check("newpath 10 20 moveto (\\001) true charpath pathbbox exch pop sub abs 25 gt exch 10 sub abs 8 lt and".into());
|
||||
}
|
||||
|
||||
/// `xshow` and `ashow` put each character where they say: the type's tracking makes up the
|
||||
/// difference from its font's advances.
|
||||
#[test]
|
||||
fn show_operators_space_live_type_with_tracking() {
|
||||
let (h, wh) = glyph('H');
|
||||
let (i, _) = glyph('I');
|
||||
let f = font(&[h, i, h]);
|
||||
let r = read(&format!("{f}/Plain42 findfont 50 scalefont setfont 10 20 moveto (\\001\\002\\003) [40 25 10] xshow"));
|
||||
let t = texts(&r);
|
||||
assert_eq!(t.len(), 1);
|
||||
assert_eq!(t[0].plain_text(), "HIH");
|
||||
let o = origins(&t[0]);
|
||||
assert!((o[1] - o[0] - 40.0).abs() < 0.05 && (o[2] - o[1] - 25.0).abs() < 0.05, "{o:?}");
|
||||
let r = read(&format!("{f}/Plain42 findfont 50 scalefont setfont 10 20 moveto 6 0 (\\001\\002) ashow"));
|
||||
let o = origins(&texts(&r)[0]);
|
||||
assert!((o[1] - o[0] - (50.0 * wh + 6.0)).abs() < 0.05, "{o:?}");
|
||||
}
|
||||
|
||||
/// A font found by name (its program isn't in the file) keeps the installed font's spacing,
|
||||
/// plus what the show operator adds.
|
||||
#[test]
|
||||
fn named_fonts_take_the_show_operators_spacing() {
|
||||
let r = read("/Helvetica findfont 20 scalefont setfont 10 20 moveto (AB) [30 0] xshow");
|
||||
let t = texts(&r);
|
||||
assert_eq!(t[0].plain_text(), "AB");
|
||||
let o = origins(&t[0]);
|
||||
assert!((o[1] - o[0] - 30.0).abs() < 0.05, "{o:?}");
|
||||
// Plain `show` keeps a single run with no tracking.
|
||||
let r = read("/Helvetica findfont 20 scalefont setfont 10 20 moveto (AB) show");
|
||||
let t = texts(&r);
|
||||
assert!(t[0].runs.len() == 1 && t[0].runs[0].style.tracking == 0.0, "{:?}", t[0].runs);
|
||||
}
|
||||
|
||||
/// `kshow` runs its procedure between each two characters with their codes (PLRM 3rd ed.,
|
||||
/// `kshow`): where it moves the current point, the next character follows (kept on live type as
|
||||
/// tracking), and what it draws is drawn over the type.
|
||||
#[test]
|
||||
fn kshow_procedures_kern_live_type() {
|
||||
let (h, wh) = glyph('H');
|
||||
let (i, wi) = glyph('I');
|
||||
let f = font(&[h, i, h]);
|
||||
// Only the pair of codes 1 then 2 is kerned, 20 units.
|
||||
let kern = "{ exch 1 eq exch 2 eq and { 20 0 rmoveto } if }";
|
||||
let r = read(&format!("{f}/Plain42 findfont 50 scalefont setfont 10 20 moveto {kern} (\\001\\002\\003) kshow"));
|
||||
let t = texts(&r);
|
||||
assert_eq!(t.len(), 1, "{:?}", all(&r.document));
|
||||
assert_eq!(t[0].plain_text(), "HIH");
|
||||
let o = origins(&t[0]);
|
||||
assert!((o[1] - o[0] - (50.0 * wh + 20.0)).abs() < 0.05, "{o:?}");
|
||||
assert!((o[2] - o[1] - 50.0 * wi).abs() < 0.05, "{o:?}");
|
||||
let check = |cond: String| super::tests::check(&format!("{f} /Plain42 findfont 50 scalefont setfont {cond}"));
|
||||
check(format!(
|
||||
"gsave nulldevice 10 20 moveto {kern} (\\001\\002\\003) kshow currentpoint grestore pop {} sub abs 0.01 lt",
|
||||
10.0 + 50.0 * (2.0 * wh + wi) + 20.0
|
||||
));
|
||||
// A font found by name kerns the same way.
|
||||
let r = read("/Helvetica findfont 20 scalefont setfont 10 20 moveto { pop pop -3 0 rmoveto } (HIH) kshow");
|
||||
let t = texts(&r);
|
||||
let plain = read("/Helvetica findfont 20 scalefont setfont 10 20 moveto (HIH) show");
|
||||
let (o, p) = (origins(&t[0]), origins(&texts(&plain)[0]));
|
||||
assert!((o[1] - o[0] - (p[1] - p[0] - 3.0)).abs() < 0.05 && (o[2] - o[1] - (p[2] - p[1] - 3.0)).abs() < 0.05, "{o:?} {p:?}");
|
||||
// A procedure that draws: a square after the type, over it.
|
||||
let r = read("/Helvetica findfont 20 scalefont setfont 10 20 moveto { pop pop currentpoint 2 2 rectfill } (AB) kshow");
|
||||
let kinds: Vec<bool> = all(&r.document)
|
||||
.iter()
|
||||
.filter(|n| matches!(n.kind, NodeKind::Text(_) | NodeKind::Path { .. }))
|
||||
.map(|n| matches!(n.kind, NodeKind::Text(_)))
|
||||
.collect();
|
||||
assert_eq!(kinds, [true, false]);
|
||||
}
|
||||
|
||||
/// Characters the installed font would join in a ligature (`fi`) keep the places the file gives
|
||||
/// each of them: the type is set without ligatures and each character's tracking is its own.
|
||||
#[test]
|
||||
fn characters_a_ligature_would_join_keep_their_places() {
|
||||
// Each character at its own width in the bundled Source Sans 3, as a PDF converter writes them.
|
||||
let (_, wf) = glyph('f');
|
||||
let (_, wi) = glyph('i');
|
||||
let (f, i) = (50.0 * wf, 50.0 * wi);
|
||||
let r = read(&format!("/SourceSans3-Regular findfont 50 scalefont setfont 10 20 moveto (fit) [{f} {i} 0] xshow"));
|
||||
let t = texts(&r);
|
||||
assert_eq!(t[0].plain_text(), "fit");
|
||||
let o = origins(&t[0]);
|
||||
assert_eq!(o.len(), 3, "{o:?}");
|
||||
assert!((o[1] - o[0] - f).abs() < 0.05 && (o[2] - o[1] - i).abs() < 0.05, "{o:?} {f} {i}");
|
||||
}
|
||||
|
||||
/// A move that is finite in user space but not along the type's baseline (a font matrix
|
||||
/// squashed a thousandfold) is an `undefinedresult`, not a tracking that isn't a number.
|
||||
#[test]
|
||||
fn moves_too_large_for_the_baseline_are_refused() {
|
||||
let r = read("0 0 10 10 rectfill /Helvetica findfont [0.001 0 0 50 0 0] makefont setfont 10 20 moveto (A\\001) [1e307 -1e307] xshow");
|
||||
assert!(texts(&r).iter().all(|t| t.runs.iter().all(|r| r.style.tracking.is_finite())), "{:?}", texts(&r));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("undefinedresult")), "{:?}", r.warnings);
|
||||
}
|
||||
|
||||
/// Moves that overflow (two `1e308` numbers in `xshow`) in type drawn from the font's outlines
|
||||
/// are an `undefinedresult` before anything is painted, as for live type: no glyph lands at
|
||||
/// infinity.
|
||||
#[test]
|
||||
fn outline_moves_that_overflow_are_refused() {
|
||||
let alt = alternate_glyph();
|
||||
let (h, _) = glyph('H');
|
||||
let r = read(&format!(
|
||||
"0 0 10 10 rectfill {}/Plain42 findfont 40 scalefont setfont 10 20 moveto (\\001\\002\\001) [1e308 1e308 0] xshow",
|
||||
font(&[h, alt])
|
||||
));
|
||||
assert!(r.warnings.iter().any(|w| w.contains("undefinedresult")), "{:?}", r.warnings);
|
||||
assert_eq!(all(&r.document).iter().filter(|n| matches!(n.kind, NodeKind::Path { .. })).count(), 1, "{:?}", all(&r.document));
|
||||
}
|
||||
|
||||
/// Reading a font's character map counts the mappings it visits, once, for the operation
|
||||
/// budget.
|
||||
#[test]
|
||||
fn reading_the_character_map_counts_its_mappings() {
|
||||
let f = super::type42::Font::new(FONT.to_vec());
|
||||
let (h, _) = glyph('H');
|
||||
assert_eq!(f.char_of(h), Some('H'));
|
||||
let n = f.take_char_map_work();
|
||||
assert!(n > 100, "{n}");
|
||||
f.char_of(h);
|
||||
assert_eq!(f.take_char_map_work(), 0);
|
||||
}
|
||||
|
||||
/// A glyph named for a symbol font's private-use code (`uniF041`) stands for no character, as
|
||||
/// when the character map maps only that code to it, so it's drawn from the font's outline;
|
||||
/// other names still say their character.
|
||||
#[test]
|
||||
fn private_use_glyph_names_stand_for_no_character() {
|
||||
use super::type42::post_name_char;
|
||||
assert_eq!(post_name_char("uniF041"), None);
|
||||
assert_eq!(post_name_char("uF0FF"), None);
|
||||
assert_eq!(post_name_char("uni00AD"), None);
|
||||
assert_eq!(post_name_char("uniF100"), Some('\u{F100}'));
|
||||
assert_eq!(post_name_char("uni20AC"), Some('€'));
|
||||
assert_eq!(post_name_char("A"), Some('A'));
|
||||
assert_eq!(post_name_char("bullet"), Some('\u{2022}'));
|
||||
assert_eq!(post_name_char("g12"), None);
|
||||
}
|
||||
|
||||
/// A glyph the character map doesn't reach is looked up by its glyph name once: the first lookup
|
||||
/// counts for the operation budget, a second one is remembered (a miss too).
|
||||
#[test]
|
||||
fn glyph_name_lookups_are_counted_once() {
|
||||
let f = super::type42::Font::new(FONT.to_vec());
|
||||
let alt = alternate_glyph();
|
||||
let first = f.char_of(alt);
|
||||
f.take_char_map_work();
|
||||
// The character map was read by the first call; the name lookup is remembered.
|
||||
let g = FontRef::new(FONT).unwrap().glyph_names().num_glyphs() + 5;
|
||||
assert_eq!(f.char_of(g), None);
|
||||
assert!(f.take_char_map_work() > 0);
|
||||
assert_eq!(f.char_of(g), None);
|
||||
assert_eq!(f.char_of(alt), first);
|
||||
assert_eq!(f.take_char_map_work(), 0);
|
||||
}
|
||||
|
||||
/// Control codes (a new string's zeros) set no type but are still read: showing or measuring a
|
||||
/// long string of them in a loop runs out of the operation budget.
|
||||
#[test]
|
||||
fn control_codes_in_a_loop_run_out_of_the_budget() {
|
||||
for body in ["{ s stringwidth pop pop } loop", "{ 0 0 moveto s show } loop"] {
|
||||
super::tests::runs_out_of_budget(&format!("/Helvetica findfont 10 scalefont setfont /s 1000000 string def {body}"));
|
||||
}
|
||||
}
|
||||
+691
-101
@@ -11,9 +11,11 @@ use vectorcraft_doc::{CharStyle, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_text::TextLayout;
|
||||
|
||||
use super::cid::Base;
|
||||
use super::graphics::MAX_APART;
|
||||
use super::interp::{Interp, matrix_obj, matrix_of};
|
||||
use super::obj::{Dict, DictRef, Key, Obj, Op, PsError, Res, ps_err};
|
||||
use super::type1::Embedded;
|
||||
|
||||
/// The font a program uses before it sets one.
|
||||
const DEFAULT_FONT: &str = "Helvetica";
|
||||
@@ -168,12 +170,14 @@ impl Interp<'_> {
|
||||
ResourceForAll => self.resource_for_all()?,
|
||||
Show | AShow | WidthShow | AWidthShow | XShow | YShow | XYShow | KShow => {
|
||||
let (s, spacing) = self.show_operands(op)?;
|
||||
if let Some(glyphs) = self.composite_glyphs(&s) {
|
||||
if let Some((text, font)) = self.unicode_type(&s)? {
|
||||
self.show_unicode(&text, &font)?;
|
||||
} else if let Some(glyphs) = self.composite_glyphs(&s)? {
|
||||
self.show_composite(&glyphs, &spacing)?;
|
||||
} else {
|
||||
match self.type3_font() {
|
||||
Some(font) => self.show_type3(&font, &s, &spacing)?,
|
||||
None => self.show(&s)?,
|
||||
None => self.show_plain(&s, &spacing)?,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,6 +233,7 @@ impl Interp<'_> {
|
||||
};
|
||||
self.push(Obj::Dict(font))?;
|
||||
}
|
||||
op if op == ComposeFont || Op::PROCSET.contains(&op) => self.cid_op(op)?,
|
||||
_ => return ps_err("undefined", op.name()),
|
||||
}
|
||||
Ok(())
|
||||
@@ -277,14 +282,26 @@ impl Interp<'_> {
|
||||
|
||||
/// How far `s` moves the current point (user space).
|
||||
fn width_of(&mut self, s: &[u8]) -> Res<Vec2> {
|
||||
if let Some(glyphs) = self.composite_glyphs(s) {
|
||||
if let Some((text, font)) = self.unicode_type(s)? {
|
||||
let saved = self.g.font.replace(font);
|
||||
let r = self.set_text(&text, Point::ZERO).map(|t| t.2);
|
||||
self.g.font = saved;
|
||||
return r;
|
||||
}
|
||||
if let Some(glyphs) = self.composite_glyphs(s)? {
|
||||
let mut w = Vec2::ZERO;
|
||||
for g in &glyphs {
|
||||
w += self.composite_glyph(g, Point::new(w.x, w.y), Draw::No)?;
|
||||
}
|
||||
return Ok(w);
|
||||
}
|
||||
let Some(font) = self.type3_font() else { return Ok(self.set_type(s, Point::ZERO)?.2) };
|
||||
let Some(font) = self.type3_font() else {
|
||||
if let Some((_, glyphs)) = self.type42_plain(s)? {
|
||||
let (_, f1) = self.font()?;
|
||||
return Ok(glyphs.iter().map(|g| f1 * Point::new(g.width, 0.0) - f1 * Point::ZERO).sum());
|
||||
}
|
||||
return Ok(self.set_type(s, Point::ZERO)?.2);
|
||||
};
|
||||
let mut w = Vec2::ZERO;
|
||||
for &code in s {
|
||||
w += self.type3_glyph(&font, Glyph::Code(code), Point::new(w.x, w.y), false)?;
|
||||
@@ -300,11 +317,7 @@ impl Interp<'_> {
|
||||
let w = self.type3_glyph(font, Glyph::Code(code), at, true)?;
|
||||
let d = spacing.advance(i, i64::from(code), w);
|
||||
self.g.cur = Some(self.xf() * (at + d));
|
||||
if let (Spacing::Kern(p), Some(next)) = (spacing, s.get(i + 1)) {
|
||||
self.push(Obj::Int(i64::from(code)))?;
|
||||
self.push(Obj::Int(i64::from(*next)))?;
|
||||
self.call(p.clone())?;
|
||||
}
|
||||
self.kern(spacing, s, i, at + d)?;
|
||||
}
|
||||
let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect();
|
||||
self.group_glyphs(start, &text);
|
||||
@@ -381,12 +394,23 @@ impl Interp<'_> {
|
||||
self.out.push(group, &clips);
|
||||
}
|
||||
|
||||
/// The font named `k`: one the program defined, else a font of that name.
|
||||
/// The font named `k`: one the program defined; else, for a name `CIDFont-CMap` whose CMap
|
||||
/// is a predefined Unicode CMap, the CID-keyed font of those two (the usual name of a
|
||||
/// CID-keyed font, as `composefont` makes them: PLRM 3rd ed., chapter 8); else a font of
|
||||
/// that name.
|
||||
fn find_font(&mut self, k: &Obj) -> Res<DictRef> {
|
||||
let key = k.key().ok_or(PsError::Ps("typecheck", "findfont".into()))?;
|
||||
if let Some(Obj::Dict(d)) = self.fonts.borrow().get(&key) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
let text = k.text().map(|t| t.to_string()).unwrap_or_default();
|
||||
let split =
|
||||
text.match_indices('-').map(|(i, _)| i).find(|&i| i > 0 && super::cid::unicode_cmap(text.get(i + 1..).unwrap_or_default()).is_some());
|
||||
if let Some((cid_font, cmap)) = split.and_then(|i| Some((text.get(..i)?, text.get(i + 1..)?))) {
|
||||
let cmap = self.find_cmap(&Obj::name(cmap))?;
|
||||
let dep = self.find_cid_font(&Obj::name(cid_font))?;
|
||||
return Ok(self.composed(key, cmap, vec![Obj::Dict(dep)]));
|
||||
}
|
||||
let mut d = Dict::new();
|
||||
d.insert(Key::name("FontName"), key.obj());
|
||||
d.insert(Key::name("FontType"), Obj::Int(1));
|
||||
@@ -421,10 +445,41 @@ impl Interp<'_> {
|
||||
/// `s` (read as Latin-1) set in the current font at user point `at`: point type placed there,
|
||||
/// its layout in text space, and how far it moves the current point (user space).
|
||||
fn set_type(&mut self, s: &[u8], at: Point) -> Res<(TextObject, TextLayout, Vec2)> {
|
||||
// Every byte is read, also the control codes left out of the type (a new string's zeros).
|
||||
self.spend(s.len() as u64)?;
|
||||
let text: String = self.named_chars(s).into_iter().filter(|c| !c.is_control()).collect();
|
||||
self.set_text(&text, at)
|
||||
}
|
||||
|
||||
/// The character each code of `s` stands for in the current font (one found by name, whose
|
||||
/// program isn't in the file): the one its glyph name says (the standard encodings by their
|
||||
/// codes' names), else the code read as Latin-1.
|
||||
fn named_chars(&self, s: &[u8]) -> Vec<char> {
|
||||
let Some(font) = self.g.font.clone() else { return s.iter().map(|b| char::from(*b)).collect() };
|
||||
let f = font.borrow();
|
||||
let standard = f.get(&Key::name("Encoding")).and_then(|e| self.standard_encoding(e));
|
||||
s.iter()
|
||||
.map(|&code| {
|
||||
let mut name = glyph_name(&f, code);
|
||||
if &*name == ".notdef"
|
||||
&& let Some(iso) = standard
|
||||
{
|
||||
name = Rc::from(standard_name(code, iso));
|
||||
}
|
||||
match &*name {
|
||||
".notdef" => char::from(code),
|
||||
n => named_char(code, n).unwrap_or(char::from(code)),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`Self::set_type`] for the characters `text`.
|
||||
fn set_text(&mut self, text: &str, at: Point) -> Res<(TextObject, TextLayout, Vec2)> {
|
||||
// Laying out type costs about as much as 300 operations, and 64 more a character
|
||||
// (measured), so `{ (a) stringwidth pop pop } loop` or `cshow` on a long string ends
|
||||
// within the budget like any other loop instead of running for minutes.
|
||||
self.spend((s.len() as u64).saturating_mul(64).saturating_add(300))?;
|
||||
self.spend((text.len() as u64).saturating_mul(64).saturating_add(300))?;
|
||||
let (name, f1) = self.font()?;
|
||||
// Text space (y down, `size` points an em) onto the document.
|
||||
let m = self.xf() * Affine::translate(at.to_vec2()) * f1;
|
||||
@@ -435,10 +490,9 @@ impl Interp<'_> {
|
||||
let (family, font_style) = family_style(&name);
|
||||
// The available family's own spelling: `MicrosoftYaHei` is Microsoft YaHei, not "Microsoft Ya Hei".
|
||||
let font_family = vectorcraft_text::FontDb::global().find_family(&family).unwrap_or(family);
|
||||
let text: String = s.iter().map(|b| char::from(*b)).filter(|c| !c.is_control()).collect();
|
||||
let fill = if self.g.paint.is_none() { Paint::solid(vectorcraft_color::Color::BLACK) } else { self.g.paint.clone() };
|
||||
let style = CharStyle { font_family, font_style, size, fill, stroke: Paint::None, ..CharStyle::default() };
|
||||
let mut t = TextObject::point(Point::ZERO, &text, style);
|
||||
let mut t = TextObject::point(Point::ZERO, text, style);
|
||||
let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
t.xf = m * Affine::scale_non_uniform(1.0 / size, -1.0 / size);
|
||||
t.cached_bounds = Some(layout.bounds);
|
||||
@@ -448,19 +502,223 @@ impl Interp<'_> {
|
||||
|
||||
/// `show`: point type at the current point, which moves past it.
|
||||
fn show(&mut self, s: &[u8]) -> Res {
|
||||
self.show_plain(s, &Spacing::Plain)
|
||||
}
|
||||
|
||||
/// `s` shown in a font that isn't a Type 3 or composite font, spaced as `spacing` says: point
|
||||
/// type at the current point, which moves past it as far as the file says, each character
|
||||
/// where the file puts it. A Type 42 font that carries its TrueType font gives each code its
|
||||
/// character (by its glyph name, else the character its glyph stands for in the font's own
|
||||
/// character map) and its glyph's width; type in a font found by name is spaced by the
|
||||
/// installed font's advances. Where the file's spacing differs from the advances of the
|
||||
/// installed (or fallback) font, each character's tracking makes up the difference, so type
|
||||
/// in a font that isn't installed keeps the file's line lengths and justified lines don't
|
||||
/// drift, as PDF import does. Glyphs that stand for no character are drawn from the font's
|
||||
/// outlines (the whole string, as one group).
|
||||
fn show_plain(&mut self, s: &[u8], spacing: &Spacing) -> Res {
|
||||
// Every code gets a move, also the control codes left out of the type.
|
||||
self.spend(s.len() as u64)?;
|
||||
let at = self.user_point()?;
|
||||
let (t, _, adv) = self.set_type(s, at)?;
|
||||
if !self.g.paint.is_none() && !t.plain_text().trim().is_empty() {
|
||||
let clips = self.g.clips.clone();
|
||||
self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips);
|
||||
let (_, f1) = self.font()?;
|
||||
let plain = self.type42_plain(s)?;
|
||||
if let Some((data, glyphs)) = &plain
|
||||
&& glyphs.iter().any(|g| g.ch.is_none() && g.gid != 0)
|
||||
{
|
||||
return self.show_type42_outlines(data, glyphs, s, spacing, at, f1);
|
||||
}
|
||||
self.g.cur = Some(self.xf() * (at + adv));
|
||||
// Each code's character, if it has one that shows (control codes don't).
|
||||
let chars: Vec<Option<char>> = match &plain {
|
||||
Some((_, glyphs)) => glyphs.iter().map(|g| g.ch).collect::<Vec<_>>(),
|
||||
None => self.named_chars(s).into_iter().map(Some).collect(),
|
||||
}
|
||||
.into_iter()
|
||||
.map(|c| c.filter(|c| !c.is_control()))
|
||||
.collect();
|
||||
let text: String = chars.iter().flatten().collect();
|
||||
let (mut t, mut layout, _) = self.set_text(&text, at)?;
|
||||
let size = t.runs.first().map_or(1.0, |r| r.style.size);
|
||||
let starts: Vec<usize> = text.char_indices().map(|(b, _)| b).collect();
|
||||
// The file places each character's glyph: where the layout joins characters in a
|
||||
// ligature, the type is set without ligatures, so each character has its own advance.
|
||||
let glyph_starts: std::collections::HashSet<usize> = layout.glyphs.iter().map(|g| g.byte).collect();
|
||||
if starts.iter().any(|b| !glyph_starts.contains(b)) {
|
||||
for run in &mut t.runs {
|
||||
run.style.features.push("-liga".into());
|
||||
}
|
||||
layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
t.cached_bounds = Some(layout.bounds);
|
||||
}
|
||||
// Each character's advance in the layout (text space).
|
||||
let mut natural = vec![0.0; starts.len()];
|
||||
for g in &layout.glyphs {
|
||||
if let Some(n) = starts.binary_search(&g.byte).ok().and_then(|k| natural.get_mut(k)) {
|
||||
*n += g.advance;
|
||||
}
|
||||
}
|
||||
// Each code's move (user space) and that move in text space, along the baseline and across.
|
||||
let em = |x: f64| f1 * Point::new(x, 0.0) - f1 * Point::ZERO;
|
||||
let inv = f1.inverse();
|
||||
let mut moves = Vec::with_capacity(s.len());
|
||||
let mut k = 0;
|
||||
// What `kshow`'s procedure draws goes after the type.
|
||||
let start = self.out.drawn.len();
|
||||
let mut pen = at;
|
||||
for (i, (&code, c)) in s.iter().zip(&chars).enumerate() {
|
||||
let own = match &plain {
|
||||
Some((_, glyphs)) => glyphs.get(i).map_or(0.0, |g| g.width),
|
||||
None if c.is_some() => natural.get(k).copied().unwrap_or(0.0) / size,
|
||||
None => 0.0,
|
||||
};
|
||||
k += usize::from(c.is_some());
|
||||
let mut d = spacing.advance(i, i64::from(code), em(own));
|
||||
d += self.kern(spacing, s, i, pen + d)?;
|
||||
pen += d;
|
||||
let e = inv * Point::new(d.x, d.y) - inv * Point::ZERO;
|
||||
if ![d.x, d.y, e.x * size, e.y * size].iter().all(|v| v.is_finite()) {
|
||||
return ps_err("undefinedresult", "a show");
|
||||
}
|
||||
moves.push((d, e.x * size, e.y * size));
|
||||
}
|
||||
let total: Vec2 = moves.iter().map(|m| m.0).sum();
|
||||
if !(total.x.is_finite() && total.y.is_finite()) {
|
||||
return ps_err("undefinedresult", "a show");
|
||||
}
|
||||
let across = moves.iter().any(|m| m.2.abs() > size * 1e-3);
|
||||
// Nothing shows on the null device (`nulldevice`, a Type 3 glyph only measured).
|
||||
let painted = !self.g.paint.is_none() && !self.g.null;
|
||||
if across {
|
||||
// Moves across the baseline (`yshow`, `xyshow`): each character where the file puts it.
|
||||
let mut pen = at;
|
||||
for ((d, ..), c) in moves.iter().zip(&chars) {
|
||||
if let Some(c) = c
|
||||
&& painted
|
||||
&& !c.is_whitespace()
|
||||
{
|
||||
let (t, ..) = self.set_text(c.encode_utf8(&mut [0; 4]), pen)?;
|
||||
let clips = self.g.clips.clone();
|
||||
self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips);
|
||||
}
|
||||
pen += *d;
|
||||
}
|
||||
} else if painted && !text.trim().is_empty() {
|
||||
// The moves of codes before the first character move the type; those of codes after
|
||||
// a character, before the next, are that character's.
|
||||
let mut lead = Vec2::ZERO;
|
||||
let mut target: Vec<f64> = Vec::with_capacity(natural.len());
|
||||
for ((d, along, _), c) in moves.iter().zip(&chars) {
|
||||
match (c, target.last_mut()) {
|
||||
(Some(_), _) => target.push(*along),
|
||||
(None, Some(last)) => *last += along,
|
||||
(None, None) => lead += *d,
|
||||
}
|
||||
}
|
||||
let tracking: Vec<f64> = target
|
||||
.iter()
|
||||
.zip(&natural)
|
||||
// Hundredths of a thousandth of an em: no drift along a line, no noise in the panels.
|
||||
.map(|(want, has)| (((want - has) / size * 1000.0).clamp(-1000.0, 10000.0) * 100.0).round() / 100.0)
|
||||
.collect();
|
||||
if tracking.iter().any(|t| *t != 0.0)
|
||||
&& let Some(run) = t.runs.first().cloned()
|
||||
{
|
||||
let mut runs: Vec<vectorcraft_doc::TextRun> = Vec::new();
|
||||
for (c, tr) in text.chars().zip(&tracking) {
|
||||
match runs.last_mut() {
|
||||
Some(last) if last.style.tracking == *tr => last.text.push(c),
|
||||
_ => runs.push(vectorcraft_doc::TextRun {
|
||||
text: c.to_string(),
|
||||
style: CharStyle { tracking: *tr, ..run.style.clone() },
|
||||
inline: run.inline.clone(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
t.runs = runs;
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
|
||||
}
|
||||
if lead != Vec2::ZERO {
|
||||
t.xf = Affine::translate(self.xf() * Point::new(lead.x, lead.y) - self.xf() * Point::ZERO) * t.xf;
|
||||
}
|
||||
let clips = self.g.clips.clone();
|
||||
if self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips).is_some()
|
||||
&& let Some(after) = self.out.drawn.get_mut(start..)
|
||||
{
|
||||
after.rotate_right(1);
|
||||
}
|
||||
}
|
||||
self.g.cur = Some(self.xf() * (at + total));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `kshow` between character `i` of `s` and the next: its procedure run with the two codes
|
||||
/// and the current point at user point `pen` (PLRM 3rd ed., `kshow`: the procedure may
|
||||
/// change the current point, as kerning does) → how far it moved the point (user space).
|
||||
/// Nothing for other show operators or after the last character.
|
||||
fn kern(&mut self, spacing: &Spacing, s: &[u8], i: usize, pen: Point) -> Res<Vec2> {
|
||||
let (Spacing::Kern(p), Some(&code), Some(&next)) = (spacing, s.get(i), s.get(i + 1)) else { return Ok(Vec2::ZERO) };
|
||||
self.g.cur = Some(self.xf() * pen);
|
||||
self.push(Obj::Int(i64::from(code)))?;
|
||||
self.push(Obj::Int(i64::from(next)))?;
|
||||
self.call(p.clone())?;
|
||||
Ok(self.user_point()? - pen)
|
||||
}
|
||||
|
||||
/// `s` in a Type 42 font, some of whose glyphs stand for no character, drawn from the font's
|
||||
/// outlines at the current point (one group), spaced as `spacing` says.
|
||||
fn show_type42_outlines(&mut self, data: &Embedded, glyphs: &[Plain], s: &[u8], spacing: &Spacing, at: Point, f1: Affine) -> Res {
|
||||
// The moves, checked before anything is painted: moves that overflow (two `1e308`
|
||||
// numbers in `xshow`) are an error, as in `show_plain`, not glyphs at infinity.
|
||||
let mut pen = at;
|
||||
for (i, (g, &code)) in glyphs.iter().zip(s).enumerate() {
|
||||
pen += spacing.advance(i, i64::from(code), f1 * Point::new(g.width, 0.0) - f1 * Point::ZERO);
|
||||
if !(pen.x.is_finite() && pen.y.is_finite()) {
|
||||
return ps_err("undefinedresult", "a show");
|
||||
}
|
||||
}
|
||||
let start = self.out.drawn.len();
|
||||
let mut text = String::new();
|
||||
let mut pen = at;
|
||||
for (i, (g, &code)) in glyphs.iter().zip(s).enumerate() {
|
||||
let glyph = data.glyph(g.gid);
|
||||
self.spend(data.take_work())?;
|
||||
if let Some(outline) = glyph.map(|o| o.0) {
|
||||
self.type42_paint(outline, pen, f1, Draw::Fill)?;
|
||||
}
|
||||
let d = spacing.advance(i, i64::from(code), f1 * Point::new(g.width, 0.0) - f1 * Point::ZERO);
|
||||
// `kshow`'s procedure moves the point as it runs, so its moves are checked as they come.
|
||||
let d = d + self.kern(spacing, s, i, pen + d)?;
|
||||
pen += d;
|
||||
if !(pen.x.is_finite() && pen.y.is_finite()) {
|
||||
return ps_err("undefinedresult", "a show");
|
||||
}
|
||||
text.push(g.ch.unwrap_or('\u{2022}'));
|
||||
}
|
||||
self.g.cur = Some(self.xf() * pen);
|
||||
self.group_glyphs(start, &text);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Glyph outline `outline` (glyph space) under font matrix `fm` at user point `at`: filled, or
|
||||
/// added to the current path (`charpath`).
|
||||
fn type42_paint(&mut self, mut outline: vectorcraft_geom::BezPath, at: Point, fm: Affine, paint: Draw) -> Res {
|
||||
// Measuring a glyph reads its outline too, so `stringwidth` in a loop pays for it.
|
||||
self.spend((outline.elements().len() as u64).saturating_add(16))?;
|
||||
if paint == Draw::No {
|
||||
return Ok(());
|
||||
}
|
||||
outline.apply_affine(self.xf() * Affine::translate(at.to_vec2()) * fm);
|
||||
if paint == Draw::Fill {
|
||||
return self.fill_path(outline, vectorcraft_geom::FillRule::NonZero);
|
||||
}
|
||||
if self.g.path.elements().len().saturating_add(outline.elements().len()) > super::graphics::MAX_PATH {
|
||||
return Err(PsError::Limit("a path has too many points"));
|
||||
}
|
||||
self.g.path.extend(outline);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `charpath`: the glyph outlines of `s` added to the path.
|
||||
fn char_path(&mut self, s: &[u8]) -> Res {
|
||||
if let Some(glyphs) = self.composite_glyphs(s) {
|
||||
if let Some(glyphs) = self.composite_glyphs(s)? {
|
||||
for g in &glyphs {
|
||||
let at = self.user_point()?;
|
||||
let w = self.composite_glyph(g, at, Draw::Path)?;
|
||||
@@ -468,6 +726,20 @@ impl Interp<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((data, glyphs)) = self.type42_plain(s)? {
|
||||
let (_, f1) = self.font()?;
|
||||
let mut pen = self.user_point()?;
|
||||
for g in &glyphs {
|
||||
let glyph = data.glyph(g.gid);
|
||||
self.spend(data.take_work())?;
|
||||
if let Some((outline, _)) = glyph {
|
||||
self.type42_paint(outline, pen, f1, Draw::Path)?;
|
||||
}
|
||||
pen += f1 * Point::new(g.width, 0.0) - f1 * Point::ZERO;
|
||||
}
|
||||
self.g.cur = Some(self.xf() * pen);
|
||||
return Ok(());
|
||||
}
|
||||
let at = self.user_point()?;
|
||||
let (t, layout, adv) = self.set_type(s, at)?;
|
||||
for g in &layout.glyphs {
|
||||
@@ -520,14 +792,19 @@ impl Spacing {
|
||||
}
|
||||
}
|
||||
|
||||
/// A character of a composite font: the descendant font that draws it, its code there, and the
|
||||
/// composite font's matrix (over the descendant's own).
|
||||
/// A character of a composite font: the descendant font that draws it, its code there (its last
|
||||
/// byte for a CID), its CID and the notdef mapping's (`FMapType` 9), the composite font's matrix
|
||||
/// (over the descendant's own) and whether it is set vertically (`WMode` 1).
|
||||
struct Mapped {
|
||||
font: DictRef,
|
||||
/// The byte that picked the descendant (the font number).
|
||||
pick: u8,
|
||||
code: u8,
|
||||
/// The glyph name a CMap maps the character to, when the font's encoding has no code for it.
|
||||
name: Option<Rc<str>>,
|
||||
cid: Option<(u32, Option<u32>)>,
|
||||
outer: Affine,
|
||||
vertical: bool,
|
||||
}
|
||||
|
||||
/// What drawing a glyph does with it.
|
||||
@@ -553,23 +830,51 @@ fn font_matrix(f: &Dict, default: f64) -> Affine {
|
||||
|
||||
/// Font `f`'s glyph units in its matrix's space: Type 1 glyphs are in 1000-unit space; a Type 42
|
||||
/// font's glyph space is one unit an em (its FontMatrix, usually the identity, is already the
|
||||
/// em's).
|
||||
/// em's). A CIDFont's em is the glyph units its matrix makes one unit of text space: one for a
|
||||
/// Type 2 CIDFont, its units an em for one from a FontSet's Compact Font Format data.
|
||||
fn units(f: &Dict) -> f64 {
|
||||
if font_type(f) == Some(42.0) { 1.0 } else { 1000.0 }
|
||||
if font_type(f) == Some(42.0) {
|
||||
return 1.0;
|
||||
}
|
||||
if let Some(kind) = f.get(&Key::name("CIDFontType")).and_then(Obj::as_num) {
|
||||
let default = if kind == 2.0 { 1.0 } else { 0.001 };
|
||||
let scale = font_matrix(f, default).determinant().abs().sqrt();
|
||||
return if scale.is_finite() && scale > 1e-9 { 1.0 / scale } else { 1.0 / default };
|
||||
}
|
||||
1000.0
|
||||
}
|
||||
|
||||
impl Interp<'_> {
|
||||
/// The characters of `s` in the current font when it is a composite font with the 8/8 mapping
|
||||
/// (`FMapType` 2: a byte picking the descendant through `Encoding` and `FDepVector`, then the
|
||||
/// code in it: PLRM 3rd ed., §5.10.1 "Type 0 Font Dictionaries"); `None` for other fonts and
|
||||
/// mappings, which are set as type in the font of the composite font's name.
|
||||
fn composite_glyphs(&self, s: &[u8]) -> Option<Vec<Mapped>> {
|
||||
let font = self.g.font.clone()?;
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(2.0) {
|
||||
return None;
|
||||
/// code in it: PLRM 3rd ed., §5.10.1 "Type 0 Font Dictionaries") or a CMap (`FMapType` 9:
|
||||
/// codes mapped to a descendant and a CID, §5.11.5); `None` for other fonts and mappings,
|
||||
/// which are set as type in the font of the composite font's name.
|
||||
fn composite_glyphs(&mut self, s: &[u8]) -> Res<Option<Vec<Mapped>>> {
|
||||
let Some(font) = self.g.font.clone() else { return Ok(None) };
|
||||
let map = {
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(0.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
f.get(&Key::name("FMapType")).and_then(Obj::as_num)
|
||||
};
|
||||
if map == Some(9.0) {
|
||||
return self.cid_chars(&font, s).map(Some);
|
||||
}
|
||||
let (encoding, fonts) = (f.get(&Key::name("Encoding"))?.items()?.clone(), f.get(&Key::name("FDepVector"))?.items()?.clone());
|
||||
if map != Some(2.0) {
|
||||
return Ok(None);
|
||||
}
|
||||
// The scan visits every byte pair, also those that map to no glyph and so cost nothing
|
||||
// further: charged by the string's length first, so a long string of unmapped characters
|
||||
// shown in a loop runs out of the budget.
|
||||
self.spend(s.len() as u64)?;
|
||||
let f = font.borrow();
|
||||
let (Some(encoding), Some(fonts)) =
|
||||
(f.get(&Key::name("Encoding")).and_then(|e| e.items().cloned()), f.get(&Key::name("FDepVector")).and_then(|e| e.items().cloned()))
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let (encoding, fonts) = (encoding.borrow(), fonts.borrow());
|
||||
let outer = font_matrix(&f, 1.0);
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
@@ -579,10 +884,104 @@ impl Interp<'_> {
|
||||
if let Some(Obj::Dict(d)) = index.and_then(|i| fonts.get(i as usize))
|
||||
&& font_type(&d.borrow()) != Some(0.0)
|
||||
{
|
||||
out.push(Mapped { font: d.clone(), pick, code, outer });
|
||||
out.push(Mapped { font: d.clone(), pick, code, name: None, cid: None, outer, vertical: false });
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// The characters of `s` in `FMapType` 9 font `font`: its CMap's codes, each a CID in the
|
||||
/// descendant `FDepVector` gives its font number (PLRM 3rd ed., §5.11.5).
|
||||
fn cid_chars(&mut self, font: &DictRef, s: &[u8]) -> Res<Vec<Mapped>> {
|
||||
let (cmap, fonts, outer, wmode) = {
|
||||
let f = font.borrow();
|
||||
let fonts = f.get(&Key::name("FDepVector")).and_then(|e| e.items().map(|i| i.to_vec())).unwrap_or_default();
|
||||
let wmode = f.get(&Key::name("WMode")).and_then(Obj::as_num);
|
||||
(f.get(&Key::name("CMap")).cloned(), fonts, font_matrix(&f, 1.0), wmode)
|
||||
};
|
||||
let Some(Obj::Dict(cmap)) = cmap else { return Ok(vec![]) };
|
||||
let vertical = wmode.or_else(|| cmap.borrow().get(&Key::name("WMode")).and_then(Obj::as_num)) == Some(1.0);
|
||||
let map = self.cmap(&cmap)?;
|
||||
// Decoding stops when it would pass the budget left, which `spend` then reports.
|
||||
let (chars, work) = map.decode(s, self.ops_left().saturating_mul(64));
|
||||
// Each code is compared with the CMap's ranges and mappings until one matches.
|
||||
self.spend((s.len() as u64).saturating_add(work / 64))?;
|
||||
Ok(chars
|
||||
.into_iter()
|
||||
.filter_map(|c| match (fonts.get(c.font), c.base) {
|
||||
// A base font's code, or its glyph name (at the code its encoding gives it, if any).
|
||||
(Some(Obj::Dict(d)), Some(Base::Code(code))) => {
|
||||
Some(Mapped { font: d.clone(), pick: 0, code, name: None, cid: None, outer, vertical })
|
||||
}
|
||||
(Some(Obj::Dict(d)), Some(Base::Name(name))) => {
|
||||
let code = encoded(&d.borrow(), &name);
|
||||
let name = code.is_none().then_some(name);
|
||||
Some(Mapped { font: d.clone(), pick: 0, code: code.unwrap_or(0), name, cid: None, outer, vertical })
|
||||
}
|
||||
// A code without a mapping in a base font (not a CIDFont): its code 0, the
|
||||
// `.notdef` glyph CID 0 stands for in a CIDFont.
|
||||
(Some(Obj::Dict(d)), None) if !d.borrow().contains_key(&Key::name("CIDFontType")) => {
|
||||
Some(Mapped { font: d.clone(), pick: 0, code: 0, name: None, cid: None, outer, vertical })
|
||||
}
|
||||
(Some(Obj::Dict(d)), None) => {
|
||||
Some(Mapped { font: d.clone(), pick: 0, code: (c.cid & 0xff) as u8, name: None, cid: Some((c.cid, c.notdef)), outer, vertical })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The characters of `s` and the font to set them in when the current font is a CID-keyed
|
||||
/// font over a predefined Unicode CMap the file doesn't define (`UniJIS-UCS2-H`), whose
|
||||
/// mappings to CIDs aren't known: its codes say the characters, set as type in the font of
|
||||
/// its CIDFont's name.
|
||||
fn unicode_type(&mut self, s: &[u8]) -> Res<Option<(String, DictRef)>> {
|
||||
let Some((text, font, name)) = self.unicode_parts(s) else { return Ok(None) };
|
||||
// The composite font is copied under the CIDFont's name for every string: charged by
|
||||
// its size, so a huge font dictionary shown in a loop runs out of the budget.
|
||||
let entries = font.borrow().len();
|
||||
self.spend(entries as u64)?;
|
||||
let mut named = font.borrow().clone();
|
||||
named.insert(Key::name("FontName"), name);
|
||||
Ok(Some((text, DictRef::new(named))))
|
||||
}
|
||||
|
||||
/// The text, the composite font and the CIDFont's name of [`Self::unicode_type`].
|
||||
fn unicode_parts(&self, s: &[u8]) -> Option<(String, DictRef, Obj)> {
|
||||
let font = self.g.font.clone()?;
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(0.0) || f.get(&Key::name("FMapType")).and_then(Obj::as_num) != Some(9.0) {
|
||||
return None;
|
||||
}
|
||||
let Some(Obj::Dict(cmap)) = f.get(&Key::name("CMap")) else { return None };
|
||||
let cmap = cmap.borrow();
|
||||
// A CMap the file built has mappings of its own.
|
||||
if cmap.contains_key(&Key::name("CodeSpaceRange")) {
|
||||
return None;
|
||||
}
|
||||
let (form, _) = super::cid::unicode_cmap(&cmap.get(&Key::name("CMapName")).and_then(Obj::text)?)?;
|
||||
let Some(Obj::Dict(dep)) = f.get(&Key::name("FDepVector")).and_then(|v| v.items()?.get(0)) else { return None };
|
||||
let name = {
|
||||
let d = dep.borrow();
|
||||
d.get(&Key::name("CIDFontName")).or_else(|| d.get(&Key::name("FontName"))).cloned()?
|
||||
};
|
||||
Some((super::cid::unicode_text(s, form), font.clone(), name))
|
||||
}
|
||||
|
||||
/// `text` set as point type at the current point in font `font` (see
|
||||
/// [`Self::unicode_type`]), which moves past it.
|
||||
fn show_unicode(&mut self, text: &str, font: &DictRef) -> Res {
|
||||
let at = self.user_point()?;
|
||||
let saved = self.g.font.replace(font.clone());
|
||||
let r = self.set_text(text, at);
|
||||
self.g.font = saved;
|
||||
let (t, _, adv) = r?;
|
||||
if !self.g.paint.is_none() && !self.g.null && !text.trim().is_empty() {
|
||||
let clips = self.g.clips.clone();
|
||||
self.out.push(Node::new(NodeId(0), NodeKind::Text(Box::new(t))), &clips);
|
||||
}
|
||||
self.g.cur = Some(self.xf() * (at + adv));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The characters of a composite font shown at the current point, spaced as `spacing` says.
|
||||
@@ -596,7 +995,8 @@ impl Interp<'_> {
|
||||
// times 256 plus its code (PLRM 3rd ed., `widthshow` in chapter 8).
|
||||
let d = spacing.advance(i, i64::from(g.pick) * 256 + i64::from(g.code), w);
|
||||
self.g.cur = Some(self.xf() * (at + d));
|
||||
let c = code_char(&g.font.borrow(), g.code);
|
||||
// A CID stands for no character (its code is only the CID's low byte).
|
||||
let c = if g.cid.is_some() { '\u{2022}' } else { g.name.as_deref().map_or_else(|| code_char(&g.font.borrow(), g.code), glyph_char) };
|
||||
if c != '\u{2022}' {
|
||||
text.push(c);
|
||||
}
|
||||
@@ -618,29 +1018,25 @@ impl Interp<'_> {
|
||||
let f = g.font.borrow();
|
||||
font_type(&f) == Some(3.0) && (f.contains_key(&Key::name("BuildGlyph")) || f.contains_key(&Key::name("BuildChar")))
|
||||
};
|
||||
if builds {
|
||||
return self.type3_glyph_in(&g.font, Glyph::Code(g.code), at, paint == Draw::Fill, g.outer);
|
||||
if let Some((cid, notdef)) = g.cid {
|
||||
return self.cid_glyph_at(g, cid, notdef, at, paint);
|
||||
}
|
||||
let fm = g.outer * font_matrix(&g.font.borrow(), 1.0);
|
||||
if let Some((outline, advance)) = self.type42_glyph(&g.font, g.code)? {
|
||||
// Measuring a glyph reads its outline too, so `stringwidth` in a loop pays for it.
|
||||
self.spend((outline.elements().len() as u64).saturating_add(16))?;
|
||||
if paint != Draw::No {
|
||||
let mut p = outline;
|
||||
p.apply_affine(self.xf() * Affine::translate(at.to_vec2()) * fm);
|
||||
if paint == Draw::Fill {
|
||||
self.fill_path(p, vectorcraft_geom::FillRule::NonZero)?;
|
||||
} else {
|
||||
if self.g.path.elements().len().saturating_add(p.elements().len()) > super::graphics::MAX_PATH {
|
||||
return Err(PsError::Limit("a path has too many points"));
|
||||
}
|
||||
self.g.path.extend(p);
|
||||
}
|
||||
}
|
||||
if builds {
|
||||
let glyph = g.name.clone().map_or(Glyph::Code(g.code), Glyph::Name);
|
||||
return self.type3_glyph_in(&g.font, glyph, at, paint == Draw::Fill, g.outer);
|
||||
}
|
||||
// An em of the descendant's glyphs: a Type 42 font's matrix maps ems, a Type 1 font's
|
||||
// its 1000-unit glyph space.
|
||||
let fm = {
|
||||
let f = g.font.borrow();
|
||||
if units(&f) == 1.0 { g.outer * font_matrix(&f, 1.0) } else { g.outer * font_matrix(&f, 0.001) * Affine::scale(units(&f)) }
|
||||
};
|
||||
if let Some((outline, advance)) = self.type42_glyph(&g.font, g.code, g.name.as_deref())? {
|
||||
self.type42_paint(outline, at, fm, paint)?;
|
||||
return Ok(fm * Point::new(advance, 0.0) - fm * Point::ZERO);
|
||||
}
|
||||
// Set as type in the descendant's font, under the composite font's matrix.
|
||||
let c = code_char(&g.font.borrow(), g.code);
|
||||
let c = g.name.as_deref().map_or_else(|| code_char(&g.font.borrow(), g.code), |n| named_char(0, n).unwrap_or('\u{2022}'));
|
||||
let Ok(byte) = u8::try_from(u32::from(c)) else { return Ok(Vec2::ZERO) };
|
||||
// Scaling the descendant copies its dictionary for every character: charged by its size,
|
||||
// so a huge font dictionary shown in a loop runs out of the budget.
|
||||
@@ -662,62 +1058,230 @@ impl Interp<'_> {
|
||||
r
|
||||
}
|
||||
|
||||
/// Glyph `code` of Type 42 font `font` from the TrueType font it carries: its outline and
|
||||
/// width in its glyph space (ems), or `None` when it isn't a Type 42 font with one.
|
||||
fn type42_glyph(&mut self, font: &DictRef, code: u8) -> Res<Option<(vectorcraft_geom::BezPath, f64)>> {
|
||||
let (sfnts, gid) = {
|
||||
/// Glyph `cid` of CIDFont `g.font` at user point `at` (else the notdef mapping's glyph, else
|
||||
/// CID 0's) → its width (user space), or its vertical displacement in writing mode 1 (the
|
||||
/// glyph's origin then half its width left of the point and 0.88 em above it, and the next
|
||||
/// one an em below it, the defaults ISO 32000-1 §9.7.4.3 gives CIDFonts without vertical
|
||||
/// metrics). A CIDFont the file doesn't carry draws nothing, an em wide.
|
||||
fn cid_glyph_at(&mut self, g: &Mapped, cid: u32, notdef: Option<u32>, at: Point, paint: Draw) -> Res<Vec2> {
|
||||
let (em, fm) = {
|
||||
let f = g.font.borrow();
|
||||
// Without a matrix, Type 2 CIDFonts, like Type 42 fonts, are in ems; Type 0 in
|
||||
// 1000-unit space. With one, an em is the glyph units it makes one unit of text space.
|
||||
let default = if f.get(&Key::name("CIDFontType")).and_then(Obj::as_num) == Some(2.0) { 1.0 } else { 0.001 };
|
||||
let m = font_matrix(&f, default);
|
||||
let scale = m.determinant().abs().sqrt();
|
||||
let em = if scale.is_finite() && scale > 1e-9 { 1.0 / scale } else { 1.0 / default };
|
||||
(em, g.outer * m)
|
||||
};
|
||||
let mut glyph = self.cid_glyph(&g.font, cid)?;
|
||||
for other in [notdef, Some(0)].into_iter().flatten() {
|
||||
if glyph.is_none() {
|
||||
glyph = self.cid_glyph(&g.font, other)?;
|
||||
}
|
||||
}
|
||||
let (outline, w) = match glyph {
|
||||
Some(g) => g,
|
||||
None => {
|
||||
self.out.warn(MISSING_CID_FONT);
|
||||
(vectorcraft_geom::BezPath::new(), em)
|
||||
}
|
||||
};
|
||||
if g.vertical {
|
||||
self.type42_paint(outline, at, fm * Affine::translate((-w / 2.0, -0.88 * em)), paint)?;
|
||||
return Ok(fm * Point::new(0.0, -em) - fm * Point::ZERO);
|
||||
}
|
||||
self.type42_paint(outline, at, fm, paint)?;
|
||||
Ok(fm * Point::new(w, 0.0) - fm * Point::ZERO)
|
||||
}
|
||||
|
||||
/// Glyph `code` (or the glyph named `name`) of font `font` from the font program it carries
|
||||
/// (a Type 42 or Type 1 font): its outline and width in ems, or `None` when it carries none.
|
||||
fn type42_glyph(&mut self, font: &DictRef, code: u8, name: Option<&str>) -> Res<Option<(vectorcraft_geom::BezPath, f64)>> {
|
||||
let Some(data) = self.embedded(font)? else { return Ok(None) };
|
||||
let standard = font.borrow().get(&Key::name("Encoding")).and_then(|e| self.standard_encoding(e));
|
||||
let gid = match name {
|
||||
Some(n) => {
|
||||
let f = font.borrow();
|
||||
char_string(&f, n).or_else(|| named_char(0, n).and_then(|c| data.gid_of(c))).or_else(|| char_string(&f, ".notdef")).unwrap_or(0)
|
||||
}
|
||||
None => type42_code(&font.borrow(), &data, code, standard).1,
|
||||
};
|
||||
// Reading the font's character map (once) visits each of its mappings; running a Type 1
|
||||
// glyph's charstrings is paid for by their bytes.
|
||||
self.spend(data.take_work())?;
|
||||
self.spend(64)?;
|
||||
let glyph = data.glyph(gid);
|
||||
self.spend(data.take_work())?;
|
||||
Ok(glyph)
|
||||
}
|
||||
|
||||
/// The codes of `s` in the current font when it is a font that carries its font program (a
|
||||
/// Type 42 font's TrueType font, a Type 1 font's encrypted part), not a descendant of a
|
||||
/// composite font: the font program, and each code's glyph.
|
||||
fn type42_plain(&mut self, s: &[u8]) -> Res<Option<(Embedded, Vec<Plain>)>> {
|
||||
let Some(font) = self.g.font.clone() else { return Ok(None) };
|
||||
let Some(data) = self.embedded(&font)? else { return Ok(None) };
|
||||
self.spend((s.len() as u64).saturating_mul(16))?;
|
||||
let f = font.borrow();
|
||||
let standard = f.get(&Key::name("Encoding")).and_then(|e| self.standard_encoding(e));
|
||||
let mut glyphs = Vec::with_capacity(s.len());
|
||||
for &code in s {
|
||||
let (ch, gid) = type42_code(&f, &data, code, standard);
|
||||
let width = data.advance(gid);
|
||||
// Reading the font's character map (once) visits each of its mappings; measuring a
|
||||
// Type 1 glyph runs its charstrings. Paid for code by code, so that a long string of
|
||||
// a costly glyph stops at the budget instead of running every glyph first.
|
||||
self.spend(data.take_work())?;
|
||||
glyphs.push(Plain { ch, gid, width });
|
||||
}
|
||||
drop(f);
|
||||
Ok(Some((data, glyphs)))
|
||||
}
|
||||
|
||||
/// The font program font `font` carries: a Type 42 font's TrueType font, or the Type 1 font
|
||||
/// read from its encrypted part (see `type1`).
|
||||
fn embedded(&mut self, font: &DictRef) -> Res<Option<Embedded>> {
|
||||
if let Some(d) = self.type42_data(font)? {
|
||||
return Ok(Some(Embedded::TrueType(d)));
|
||||
}
|
||||
let strings = match font.borrow().get(&Key::name("CharStrings")) {
|
||||
Some(Obj::Dict(d)) if font_type(&font.borrow()) == Some(1.0) => d.clone(),
|
||||
_ => return Ok(None),
|
||||
};
|
||||
Ok(self.type1.iter().find(|(k, _)| k.same(&strings)).map(|(_, f)| Embedded::Type1(f.clone())))
|
||||
}
|
||||
|
||||
/// Whether another embedded Type 1 font can still be kept (see [`Self::attach_type1`]).
|
||||
pub(super) fn type1_room(&self) -> bool {
|
||||
self.type1.len() < MAX_TYPE1
|
||||
}
|
||||
|
||||
/// Give font dictionary `d` the glyphs of the Type 1 font whose encrypted part is
|
||||
/// `encrypted`: a `CharStrings` dictionary naming each glyph's index, by which it is drawn.
|
||||
pub(super) fn attach_type1(&mut self, d: &DictRef, encrypted: &[u8]) -> Res {
|
||||
if self.type1.len() >= MAX_TYPE1 {
|
||||
return Ok(());
|
||||
}
|
||||
self.alloc(encrypted.len())?;
|
||||
self.spend((encrypted.len() / 256) as u64)?;
|
||||
let Some(font) = super::type1::parse(encrypted) else { return Ok(()) };
|
||||
// Its charstrings are kept a second time, to be run here.
|
||||
self.alloc(font.size())?;
|
||||
let strings: Dict = font.font.glyph_names().map(|(g, n)| (Key::name(n), Obj::Int(i64::from(g.to_u32())))).collect();
|
||||
let strings = DictRef::new(strings);
|
||||
d.borrow_mut().insert(Key::name("CharStrings"), Obj::Dict(strings.clone()));
|
||||
self.type1.push((strings, Rc::new(font)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The TrueType font Type 42 font `font` carries (read once for each `sfnts` array), or
|
||||
/// `None` when it isn't a Type 42 font with one.
|
||||
pub(super) fn type42_data(&mut self, font: &DictRef) -> Res<Option<Rc<super::type42::Font>>> {
|
||||
let sfnts = {
|
||||
let f = font.borrow();
|
||||
if font_type(&f) != Some(42.0) {
|
||||
// Type 42 fonts and Type 2 CIDFonts (FontType 11) carry TrueType fonts.
|
||||
let cid2 = f.get(&Key::name("CIDFontType")).and_then(Obj::as_num) == Some(2.0);
|
||||
if font_type(&f) != Some(42.0) && !cid2 {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(Obj::Array { items: sfnts, .. }) = f.get(&Key::name("sfnts")).cloned() else { return Ok(None) };
|
||||
let name = glyph_name(&f, code);
|
||||
let strings = f.get(&Key::name("CharStrings")).and_then(|c| match c {
|
||||
Obj::Dict(d) => Some(d.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let gid = strings
|
||||
.and_then(|d| {
|
||||
let d = d.borrow();
|
||||
d.get(&Key::name(&name)).or_else(|| d.get(&Key::name(".notdef"))).and_then(Obj::as_num)
|
||||
})
|
||||
.filter(|g| (0.0..=f64::from(u16::MAX)).contains(g))
|
||||
.map_or(0, |g| g as u32);
|
||||
(sfnts, gid)
|
||||
sfnts
|
||||
};
|
||||
// The least recently used font goes first: a hit moves to the end.
|
||||
let data = match self.type42.iter().position(|(k, _)| k.same(&sfnts)) {
|
||||
Some(i) => {
|
||||
let hit = self.type42.remove(i);
|
||||
let data = hit.1.clone();
|
||||
self.type42.push(hit);
|
||||
data
|
||||
}
|
||||
None => {
|
||||
// Joining the strings visits each element and copies its bytes, whether or not
|
||||
// the font can then be read: charged first, as other bulk copies are.
|
||||
let (elements, bytes) = super::type42::font_size(&sfnts.borrow());
|
||||
self.spend_bytes(bytes)?;
|
||||
self.spend((elements / 16) as u64)?;
|
||||
// Fonts whose data can't be read are kept too, so they are copied only once.
|
||||
let data = super::type42::font_data(&sfnts.borrow()).map(Rc::new);
|
||||
if let Some(d) = &data
|
||||
&& self.type42_counted.insert(sfnts.clone())
|
||||
{
|
||||
self.alloc(d.len())?;
|
||||
}
|
||||
if self.type42.len() >= MAX_TYPE42 {
|
||||
self.type42.remove(0);
|
||||
}
|
||||
self.type42.push((sfnts, data.clone()));
|
||||
data
|
||||
}
|
||||
};
|
||||
let Some(data) = data else { return Ok(None) };
|
||||
Ok(super::type42::glyph(&data, gid))
|
||||
if let Some(i) = self.type42.iter().position(|(k, _)| k.same(&sfnts)) {
|
||||
let hit = self.type42.remove(i);
|
||||
let data = hit.1.clone();
|
||||
self.type42.push(hit);
|
||||
return Ok(data);
|
||||
}
|
||||
// Joining the strings visits each element and copies its bytes, whether or not the font
|
||||
// can then be read: charged first, as other bulk copies are.
|
||||
let (elements, bytes) = super::type42::font_size(&sfnts.borrow());
|
||||
self.spend_bytes(bytes)?;
|
||||
self.spend((elements / 16) as u64)?;
|
||||
// Fonts whose data can't be read are kept too, so they are copied only once.
|
||||
let data = super::type42::font_data(&sfnts.borrow()).map(|d| Rc::new(super::type42::Font::new(d)));
|
||||
if let Some(d) = &data
|
||||
&& self.type42_counted.insert(sfnts.clone())
|
||||
{
|
||||
self.alloc(d.data.len())?;
|
||||
}
|
||||
if self.type42.len() >= MAX_TYPE42 {
|
||||
self.type42.remove(0);
|
||||
}
|
||||
self.type42.push((sfnts, data.clone()));
|
||||
Ok(data)
|
||||
}
|
||||
}
|
||||
|
||||
/// A character code of a font carrying its program: the character it stands for (`None` when
|
||||
/// neither its glyph name nor the font says), its glyph and the glyph's width (ems).
|
||||
struct Plain {
|
||||
ch: Option<char>,
|
||||
gid: u32,
|
||||
width: f64,
|
||||
}
|
||||
|
||||
/// The glyph index `CharStrings` gives glyph name `name` in Type 42 font `f`.
|
||||
fn char_string(f: &Dict, name: &str) -> Option<u32> {
|
||||
let Some(Obj::Dict(d)) = f.get(&Key::name("CharStrings")) else { return None };
|
||||
d.borrow().get(&Key::name(name)).and_then(Obj::as_num).filter(|g| (0.0..=f64::from(u16::MAX)).contains(g)).map(|g| g as u32)
|
||||
}
|
||||
|
||||
/// The character and glyph of code `code` in font `f`, carrying font program `data`: by the
|
||||
/// glyph name its `Encoding` gives it (a name such as `A`, `uni20AC` or `Euro` says the
|
||||
/// character; else the glyph `CharStrings` gives it stands for one in the font's character map
|
||||
/// or `post` names). The standard encodings (`standard`: whether ISO Latin-1's) have no names
|
||||
/// here: the code's name in them is used (PLRM 3rd ed., Appendix E.5 and E.6). A code the
|
||||
/// encoding leaves `.notdef` is read as Latin-1, drawn by that character's glyph.
|
||||
fn type42_code(f: &Dict, data: &Embedded, code: u8, standard: Option<bool>) -> (Option<char>, u32) {
|
||||
let mut name = glyph_name(f, code);
|
||||
if &*name == ".notdef"
|
||||
&& let Some(iso) = standard
|
||||
{
|
||||
name = Rc::from(standard_name(code, iso));
|
||||
}
|
||||
if &*name == ".notdef" {
|
||||
let c = char::from(code);
|
||||
// A font without a character map (an embedded Type 1 font) has that character's glyph
|
||||
// by its name. A copy of a standard encoding (`StandardEncoding 256 array copy`) comes
|
||||
// here too: its names are the empty systemdict array's.
|
||||
let latin = match code {
|
||||
0x27 => "quotesingle",
|
||||
0x2d => "hyphen",
|
||||
0x60 => "grave",
|
||||
_ => standard_name(code, true),
|
||||
};
|
||||
return (Some(c), data.gid_of(c).or_else(|| char_string(f, latin)).or_else(|| char_string(f, ".notdef")).unwrap_or(0));
|
||||
}
|
||||
let named = named_char(code, &name);
|
||||
let gid = char_string(f, &name).or_else(|| named.and_then(|c| data.gid_of(c))).or_else(|| char_string(f, ".notdef")).unwrap_or(0);
|
||||
(named.or_else(|| data.char_of(gid)), gid)
|
||||
}
|
||||
|
||||
/// The name the standard encoding (or, when `iso`, the ISO Latin-1 encoding) gives code `code`:
|
||||
/// the same names in the printable ASCII range but for ISO Latin-1's `minus`; ISO Latin-1's
|
||||
/// upper half named as Windows-1252 names it, with its soft `hyphen`.
|
||||
fn standard_name(code: u8, iso: bool) -> &'static str {
|
||||
use skrifa::raw::ps::encoding::PredefinedEncoding;
|
||||
match (iso, code) {
|
||||
(false, _) => PredefinedEncoding::Standard.name(code),
|
||||
(true, 0x2d) => "minus",
|
||||
(true, 0x20..=0x7e) => PredefinedEncoding::Standard.name(code),
|
||||
(true, 0xad) => "hyphen",
|
||||
(true, 0xa0) => "space",
|
||||
(true, 0xa1..=0xff) => crate::latin::high_name(code).unwrap_or(".notdef"),
|
||||
_ => ".notdef",
|
||||
}
|
||||
}
|
||||
|
||||
/// Most Type 1 fonts read from their encrypted parts.
|
||||
const MAX_TYPE1: usize = 256;
|
||||
|
||||
/// The warning for CID-keyed type in a CIDFont the file doesn't carry.
|
||||
const MISSING_CID_FONT: &str = "CID-keyed type in a font the file doesn't carry was left out";
|
||||
|
||||
/// Most Type 42 fonts whose TrueType data is kept between glyphs.
|
||||
const MAX_TYPE42: usize = 16;
|
||||
|
||||
@@ -730,11 +1294,37 @@ fn code_char(f: &Dict, code: u8) -> char {
|
||||
}
|
||||
}
|
||||
|
||||
/// The code font `f`'s `Encoding` gives glyph name `name`, if any.
|
||||
fn encoded(f: &Dict, name: &str) -> Option<u8> {
|
||||
let items = f.get(&Key::name("Encoding"))?.items()?.to_vec();
|
||||
items.iter().position(|o| o.text().as_deref() == Some(name)).and_then(|i| u8::try_from(i).ok())
|
||||
}
|
||||
|
||||
/// The glyph name font `f`'s `Encoding` gives code `code` (`.notdef` without one).
|
||||
fn glyph_name(f: &Dict, code: u8) -> Rc<str> {
|
||||
f.get(&Key::name("Encoding")).and_then(|e| e.items()?.get(usize::from(code))).and_then(|o| o.text()).unwrap_or_else(|| Rc::from(".notdef"))
|
||||
}
|
||||
|
||||
/// The character glyph name `name` (at code `code`) says: a single character or `uniXXXX`; a
|
||||
/// name of the standard encoding's printable ASCII range (`hyphen`, `quotedbl`: PLRM 3rd ed.,
|
||||
/// Appendix E.6); one of the Windows-1252 names; else `None`.
|
||||
pub(super) fn named_char(code: u8, name: &str) -> Option<char> {
|
||||
match glyph_char(name) {
|
||||
'\u{2022}' if name != "bullet" => {}
|
||||
c => return Some(c),
|
||||
}
|
||||
use skrifa::raw::ps::encoding::PredefinedEncoding;
|
||||
if let Some(c) = (0x20u8..0x7F).find(|c| PredefinedEncoding::Standard.name(*c) == name) {
|
||||
// The standard encoding has curly quotes where ASCII has the straight quote and the grave.
|
||||
return Some(match c {
|
||||
0x27 => '\u{2019}',
|
||||
0x60 => '\u{2018}',
|
||||
c => char::from(c),
|
||||
});
|
||||
}
|
||||
crate::latin::glyph(code, name)
|
||||
}
|
||||
|
||||
/// A font scaled or transformed by `m` (`scalefont`, `makefont`).
|
||||
fn transformed(font: &DictRef, m: Affine) -> DictRef {
|
||||
let mut d = font.borrow().clone();
|
||||
@@ -744,7 +1334,7 @@ fn transformed(font: &DictRef, m: Affine) -> DictRef {
|
||||
}
|
||||
|
||||
/// The character a glyph name stands for (`A`, `space`, `uni20AC`), else a bullet.
|
||||
fn glyph_char(name: &str) -> char {
|
||||
pub(super) fn glyph_char(name: &str) -> char {
|
||||
let mut chars = name.chars();
|
||||
if let (Some(c), None) = (chars.next(), chars.next()) {
|
||||
return c;
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Embedded fonts whose glyphs are drawn from the font program the file carries: Type 1 fonts,
|
||||
//! whose encrypted part (`eexec`: Adobe Type 1 Font Format, chapter 7) holds their charstrings,
|
||||
//! and Type 42 fonts (see `type42`). The interpreter doesn't run the encrypted part; it is read
|
||||
//! by the Type 1 font parser, and the font dictionary gets the `CharStrings` dictionary the part
|
||||
//! would have given it, each glyph name standing for its glyph's index, by which the glyph is
|
||||
//! drawn. Its charstrings are run here, counting the bytes of the charstrings and subroutines
|
||||
//! they enter, so that the work of drawing glyphs is paid for from the operation budget.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use skrifa::GlyphId;
|
||||
use skrifa::outline::OutlinePen;
|
||||
use skrifa::raw::ps::cs::{self, CharstringContext, CharstringKind, CommandSink, NopFilterSink, TransformSink};
|
||||
use skrifa::raw::ps::error::Error as CsError;
|
||||
use skrifa::raw::ps::type1::Type1Font;
|
||||
use skrifa::raw::types::Fixed;
|
||||
use vectorcraft_geom::BezPath;
|
||||
|
||||
use super::type42;
|
||||
|
||||
/// Most bytes of an encrypted font program read.
|
||||
pub(super) const MAX_PROGRAM: usize = 16 << 20;
|
||||
|
||||
/// The font program a plain or descendant font carries.
|
||||
#[derive(Clone)]
|
||||
pub(super) enum Embedded {
|
||||
TrueType(Rc<type42::Font>),
|
||||
Type1(Rc<Font>),
|
||||
}
|
||||
|
||||
impl Embedded {
|
||||
/// Glyph `gid`: its outline and advance width, in ems (y up), or `None` when it can't be read.
|
||||
pub fn glyph(&self, gid: u32) -> Option<(BezPath, f64)> {
|
||||
match self {
|
||||
Self::TrueType(f) => type42::glyph(&f.data, gid),
|
||||
Self::Type1(f) => {
|
||||
let mut pen = type42::Pen(BezPath::new(), 1.0 / UNITS);
|
||||
// A glyph that can't be drawn draws nothing, and has no width.
|
||||
let w = f.draw(gid, &mut pen).map_or(0.0, |w| w / UNITS);
|
||||
Some((pen.0, if w.is_finite() { w } else { 0.0 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Glyph `gid`'s advance width, in ems.
|
||||
pub fn advance(&self, gid: u32) -> f64 {
|
||||
match self {
|
||||
Self::TrueType(f) => type42::advance(&f.data, gid),
|
||||
Self::Type1(_) => self.glyph(gid).map_or(0.0, |g| g.1),
|
||||
}
|
||||
}
|
||||
|
||||
/// The character glyph `gid` stands for, when the font says (a TrueType font's character
|
||||
/// map or `post` names; a Type 1 glyph's name).
|
||||
pub fn char_of(&self, gid: u32) -> Option<char> {
|
||||
match self {
|
||||
Self::TrueType(f) => f.char_of(gid),
|
||||
Self::Type1(f) => f.font.glyph_name(GlyphId::new(gid)).and_then(|n| super::text::named_char(0, n)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The work done since last asked, for the operation budget: a TrueType font's reading its
|
||||
/// character map (see [`type42::Font::take_char_map_work`]), a Type 1 font's running its
|
||||
/// charstrings (see [`Font::draw`]).
|
||||
pub fn take_work(&self) -> u64 {
|
||||
match self {
|
||||
Self::TrueType(f) => f.take_char_map_work(),
|
||||
Self::Type1(f) => f.work.take(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The glyph a TrueType font's character map gives `c` (Type 1 fonts have none).
|
||||
pub fn gid_of(&self, c: char) -> Option<u32> {
|
||||
match self {
|
||||
Self::TrueType(f) => f.gid_of(c),
|
||||
Self::Type1(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type 1 glyph space: 1000 units an em, as the font matrix of the program made for parsing says.
|
||||
const UNITS: f64 = 1000.0;
|
||||
|
||||
/// A Type 1 font read from its encrypted part: the font, and its charstrings as the font parser
|
||||
/// reads them, run here so their work is counted.
|
||||
pub(super) struct Font {
|
||||
pub font: Type1Font,
|
||||
/// Each glyph's name and decrypted charstring, by glyph index.
|
||||
charstrings: Vec<(Vec<u8>, Vec<u8>)>,
|
||||
/// The bytes of charstrings and subroutines run since last asked (see [`Embedded::take_work`]).
|
||||
work: Cell<u64>,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
/// The bytes the font keeps.
|
||||
pub fn size(&self) -> usize {
|
||||
self.charstrings.iter().fold(0usize, |n, (a, b)| n.saturating_add(a.len()).saturating_add(b.len()))
|
||||
}
|
||||
|
||||
/// Draw glyph `gid` with `pen` (in 1000-unit glyph space, as the font parser draws it): its
|
||||
/// advance width, or `None` when it can't be run. The bytes of each charstring it enters
|
||||
/// (its own, its subroutines', `seac` accents') are added to the work done.
|
||||
fn draw(&self, gid: u32, pen: &mut impl OutlinePen) -> Option<f64> {
|
||||
let (name, charstring) = self.charstrings.get(gid as usize)?;
|
||||
if self.font.glyph_name(GlyphId::new(gid)).map(str::as_bytes) != Some(name.as_slice()) {
|
||||
return None;
|
||||
}
|
||||
let transform = self.font.transform(None);
|
||||
let mut filter = NopFilterSink::new(pen);
|
||||
let mut sink = TransformSink::new(&mut filter, transform);
|
||||
let (w, work) = evaluate(&self.font, charstring, &mut sink);
|
||||
self.work.set(self.work.get().saturating_add(work));
|
||||
let w = w.ok()??;
|
||||
Some(f64::from(transform.transform_h_metric(w).to_f32().max(0.0)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Type 1 charstring `data` run in context `ctx` into `sink`: its width (when it gives one) and
|
||||
/// the bytes of the charstrings it entered, at least one for each, which bound the operations it
|
||||
/// ran. The evaluator stops a charstring after a million operations but starts again for every
|
||||
/// glyph, so drawing many glyphs is paid for with these.
|
||||
pub(super) fn evaluate(ctx: &impl CharstringContext, data: &[u8], sink: &mut impl CommandSink) -> (Result<Option<Fixed>, CsError>, u64) {
|
||||
let counted = Counted { ctx, work: Cell::new(data.len() as u64 + 1) };
|
||||
let w = cs::evaluate(&counted, None, data, sink);
|
||||
(w, counted.work.get())
|
||||
}
|
||||
|
||||
/// A charstring context counting the bytes of the charstrings it gives the evaluator.
|
||||
struct Counted<'a, C> {
|
||||
ctx: &'a C,
|
||||
work: Cell<u64>,
|
||||
}
|
||||
|
||||
impl<C> Counted<'_, C> {
|
||||
fn add<'b>(&self, s: &'b [u8]) -> &'b [u8] {
|
||||
self.work.set(self.work.get().saturating_add(s.len() as u64 + 1));
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl<C: CharstringContext> CharstringContext for Counted<'_, C> {
|
||||
fn kind(&self) -> CharstringKind {
|
||||
self.ctx.kind()
|
||||
}
|
||||
fn seac_components(&self, base: i32, accent: i32) -> Result<[&[u8]; 2], CsError> {
|
||||
let [b, a] = self.ctx.seac_components(base, accent)?;
|
||||
Ok([self.add(b), self.add(a)])
|
||||
}
|
||||
fn global_subr(&self, index: i32) -> Result<&[u8], CsError> {
|
||||
self.ctx.global_subr(index).map(|s| self.add(s))
|
||||
}
|
||||
fn subr(&self, index: i32) -> Result<&[u8], CsError> {
|
||||
self.ctx.subr(index).map(|s| self.add(s))
|
||||
}
|
||||
fn weight_vector(&self) -> &[Fixed] {
|
||||
self.ctx.weight_vector()
|
||||
}
|
||||
}
|
||||
|
||||
/// The Type 1 font whose encrypted part is `encrypted` (from just after `eexec`, binary or
|
||||
/// hexadecimal), or `None` when it can't be read. The part is read on its own, after a clear
|
||||
/// part that gives only the font matrix of 1000-unit glyph space: the font's own clear part, run
|
||||
/// by the interpreter, gives its matrix and encoding.
|
||||
pub(super) fn parse(encrypted: &[u8]) -> Option<Font> {
|
||||
if encrypted.len() > MAX_PROGRAM {
|
||||
return None;
|
||||
}
|
||||
let mut program = b"%!FontType1-1.0: Embedded\n/FontMatrix [0.001 0 0 0.001 0 0] readonly def\ncurrentfile eexec\n".to_vec();
|
||||
program.extend_from_slice(encrypted);
|
||||
let font = Type1Font::new(&program).ok()?;
|
||||
Some(Font { font, charstrings: charstrings(encrypted), work: Cell::new(0) })
|
||||
}
|
||||
|
||||
/// The `.notdef` glyph of a font that has none: `0 333 hsbw endchar` (Adobe Type 1 Font Format,
|
||||
/// §6), as the font parser gives it.
|
||||
const NOTDEF: &[u8] = &[139, 247, 225, 13, 14];
|
||||
|
||||
/// The charstrings of encrypted part `encrypted`, with their names, in the order the font parser
|
||||
/// numbers them: the first `CharStrings` dictionary's entries (`/name len RD bytes ND`) in the
|
||||
/// order written, decrypted with the charstring key unless `lenIV` is negative (Adobe Type 1 Font
|
||||
/// Format, §2.4 and §7), `.notdef` (made when missing) swapped to the front. Empty when the part
|
||||
/// can't be read.
|
||||
fn charstrings(encrypted: &[u8]) -> Vec<(Vec<u8>, Vec<u8>)> {
|
||||
let skip = encrypted.iter().take_while(|c| matches!(c, b' ' | b'\t' | b'\r' | b'\n')).count();
|
||||
let encrypted = encrypted.get(skip..).unwrap_or_default();
|
||||
let hex = encrypted.len() > 3 && encrypted.iter().take(4).all(u8::is_ascii_hexdigit);
|
||||
let bytes = if hex { unhex(encrypted) } else { encrypted.to_vec() };
|
||||
let plain: Vec<u8> = decrypt(&bytes, 55665).into_iter().skip(4).collect();
|
||||
let mut lex = Lexer { d: &plain, pos: 0 };
|
||||
let mut len_iv = 4;
|
||||
let mut out = Vec::new();
|
||||
while let Some(t) = lex.next() {
|
||||
match t {
|
||||
Tok::Name(b"lenIV") => match lex.next() {
|
||||
Some(Tok::Int(n)) => len_iv = n,
|
||||
_ => return vec![],
|
||||
},
|
||||
Tok::Name(b"CharStrings") => {
|
||||
let Some(Tok::Int(_)) = lex.next() else { return vec![] };
|
||||
while let Some(t) = lex.next() {
|
||||
let ends = lex.peek().is_some_and(special_or_space);
|
||||
match t {
|
||||
Tok::Other(b"end") if ends => break,
|
||||
Tok::Other(b"def") if ends && !out.is_empty() => break,
|
||||
Tok::Name(name) => {
|
||||
let Some(Tok::Blob(data)) = lex.next() else { return vec![] };
|
||||
let data = if len_iv >= 0 { decrypt(data, 4330).into_iter().skip(len_iv as usize).collect() } else { data.to_vec() };
|
||||
out.push((name.to_vec(), data));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let notdef = match out.iter().position(|(n, _)| n == b".notdef") {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
out.push((b".notdef".to_vec(), NOTDEF.to_vec()));
|
||||
out.len() - 1
|
||||
}
|
||||
};
|
||||
out.swap(0, notdef);
|
||||
out
|
||||
}
|
||||
|
||||
/// `data` decrypted with key `r` (Adobe Type 1 Font Format, §7.1).
|
||||
fn decrypt(data: &[u8], mut r: u16) -> Vec<u8> {
|
||||
data.iter()
|
||||
.map(|&c| {
|
||||
let p = c ^ (r >> 8) as u8;
|
||||
r = (u16::from(c).wrapping_add(r)).wrapping_mul(52845).wrapping_add(22719);
|
||||
p
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Hexadecimal `data` as bytes, white space skipped, up to the first other character (an odd
|
||||
/// last digit is its byte's high half).
|
||||
fn unhex(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len() / 2);
|
||||
let mut high: Option<u8> = None;
|
||||
for &c in data {
|
||||
if space(c) {
|
||||
continue;
|
||||
}
|
||||
let Some(v) = char::from(c).to_digit(16) else { break };
|
||||
let v = v as u8;
|
||||
match high.take() {
|
||||
Some(h) => out.push(h << 4 | v),
|
||||
None => high = Some(v),
|
||||
}
|
||||
}
|
||||
out.extend(high.map(|h| h << 4));
|
||||
out
|
||||
}
|
||||
|
||||
fn space(c: u8) -> bool {
|
||||
matches!(c, b' ' | b'\n' | b'\r' | b'\t' | b'\0' | 0x0C)
|
||||
}
|
||||
|
||||
fn special_or_space(c: u8) -> bool {
|
||||
space(c) || matches!(c, b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%')
|
||||
}
|
||||
|
||||
/// A token of a font's decrypted encrypted part, as the font parser splits them.
|
||||
enum Tok<'a> {
|
||||
Int(i64),
|
||||
Name(&'a [u8]),
|
||||
/// The bytes after `len RD ` (or `-|`).
|
||||
Blob(&'a [u8]),
|
||||
Other(&'a [u8]),
|
||||
}
|
||||
|
||||
struct Lexer<'a> {
|
||||
d: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Lexer<'a> {
|
||||
fn peek(&self) -> Option<u8> {
|
||||
self.d.get(self.pos).copied()
|
||||
}
|
||||
|
||||
fn byte(&mut self) -> Option<u8> {
|
||||
let c = self.peek()?;
|
||||
self.pos += 1;
|
||||
Some(c)
|
||||
}
|
||||
|
||||
fn skip_while(&mut self, f: impl Fn(u8) -> bool) {
|
||||
while self.peek().is_some_and(&f) {
|
||||
self.pos += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_line(&mut self) {
|
||||
while let Some(c) = self.byte() {
|
||||
if c == b'\n' || c == b'\r' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a string after its `(`, nested parentheses and escapes included.
|
||||
fn string(&mut self) -> Option<()> {
|
||||
let mut depth = 1u32;
|
||||
loop {
|
||||
match self.byte()? {
|
||||
b'(' => depth = depth.saturating_add(1),
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
b'\\' => {
|
||||
self.byte()?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The rest of a procedure after its `{`, nested procedures, comments and strings included.
|
||||
fn procedure(&mut self) -> Option<()> {
|
||||
let mut depth = 1u32;
|
||||
loop {
|
||||
match self.byte()? {
|
||||
b'{' => depth = depth.saturating_add(1),
|
||||
b'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
b'%' => self.skip_line(),
|
||||
b'(' => self.string()?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn next(&mut self) -> Option<Tok<'a>> {
|
||||
loop {
|
||||
self.skip_while(space);
|
||||
let start = self.pos;
|
||||
match self.byte()? {
|
||||
b'%' => self.skip_line(),
|
||||
b'{' => return self.procedure().map(|()| Tok::Other(b"{")),
|
||||
b'(' => return self.string().map(|()| Tok::Other(b"(")),
|
||||
b'<' if self.peek() == Some(b'<') => self.pos += 1,
|
||||
b'<' => {
|
||||
// Hexadecimal digits and white space, then `>`.
|
||||
let end = loop {
|
||||
let c = self.byte()?;
|
||||
if !space(c) && !c.is_ascii_hexdigit() {
|
||||
break c;
|
||||
}
|
||||
};
|
||||
return (end == b'>').then_some(Tok::Other(b"<"));
|
||||
}
|
||||
b'>' => {
|
||||
if self.byte()? != b'>' {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
b'/' => {
|
||||
let c = self.peek()?;
|
||||
if special_or_space(c) {
|
||||
if space(c) {
|
||||
self.pos += 1;
|
||||
}
|
||||
return Some(Tok::Name(&[]));
|
||||
}
|
||||
self.skip_while(|c| !special_or_space(c));
|
||||
return self.d.get(start + 1..self.pos).map(Tok::Name);
|
||||
}
|
||||
b'[' | b']' => return self.d.get(start..self.pos).map(Tok::Other),
|
||||
c => {
|
||||
self.skip_while(|c| !special_or_space(c));
|
||||
let text = self.d.get(start..self.pos)?;
|
||||
if (c.is_ascii_digit() || c == b'-')
|
||||
&& !text.contains(&b'.')
|
||||
&& let Some(n) = int(text)
|
||||
{
|
||||
return Some(self.blob(n).unwrap_or(Tok::Int(n)));
|
||||
}
|
||||
return Some(Tok::Other(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The `n` bytes after `RD ` or `-| `, when those come next.
|
||||
fn blob(&mut self, n: i64) -> Option<Tok<'a>> {
|
||||
let mut at = self.pos;
|
||||
while self.d.get(at).copied().is_some_and(space) {
|
||||
at += 1;
|
||||
}
|
||||
let start = at;
|
||||
while self.d.get(at).copied().is_some_and(|c| !special_or_space(c)) {
|
||||
at += 1;
|
||||
}
|
||||
if !matches!(self.d.get(start..at), Some(b"RD" | b"-|")) {
|
||||
return None;
|
||||
}
|
||||
// One space, then the bytes; a count past the end ends the part.
|
||||
let from = at + 1;
|
||||
let to = usize::try_from(n).ok().and_then(|n| from.checked_add(n));
|
||||
self.pos = to.filter(|to| *to <= self.d.len()).unwrap_or(self.d.len());
|
||||
Some(Tok::Blob(to.and_then(|to| self.d.get(from..to)).unwrap_or_default()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A PostScript integer (`12`, `-3`, `8#17`), or `None`.
|
||||
fn int(text: &[u8]) -> Option<i64> {
|
||||
let s = std::str::from_utf8(text).ok()?;
|
||||
match s.find('#') {
|
||||
Some(i @ (1 | 2)) => {
|
||||
let radix = s.get(..i)?.parse::<u32>().ok().filter(|r| (2..=36).contains(r))?;
|
||||
i64::from_str_radix(s.get(i + 1..)?, radix).ok()
|
||||
}
|
||||
_ => s.parse().ok(),
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
|
||||
use skrifa::instance::{LocationRef, Size};
|
||||
use skrifa::outline::{DrawSettings, OutlinePen};
|
||||
use std::cell::{Cell, OnceCell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use skrifa::{FontRef, GlyphId, MetadataProvider};
|
||||
use vectorcraft_geom::BezPath;
|
||||
|
||||
@@ -45,6 +48,86 @@ pub(super) fn font_size(sfnts: &[Obj]) -> (usize, usize) {
|
||||
(sfnts.len(), bytes)
|
||||
}
|
||||
|
||||
/// The TrueType font a Type 42 font carries, with the character each glyph stands for (read
|
||||
/// when first asked).
|
||||
pub(super) struct Font {
|
||||
pub data: Vec<u8>,
|
||||
chars: OnceCell<HashMap<u32, char>>,
|
||||
/// The characters glyph names give glyphs the character map doesn't reach, misses too:
|
||||
/// a custom `post` name is found by scanning the names before it, so each glyph is looked
|
||||
/// up once.
|
||||
names: RefCell<HashMap<u32, Option<char>>>,
|
||||
/// The character-map mappings visited reading `chars`, not yet counted against the
|
||||
/// operation budget (see [`Self::take_char_map_work`]).
|
||||
work: Cell<u64>,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
pub fn new(data: Vec<u8>) -> Self {
|
||||
Self { data, chars: OnceCell::new(), names: RefCell::new(HashMap::new()), work: Cell::new(0) }
|
||||
}
|
||||
|
||||
/// The work reading the character map did since last asked (mappings visited).
|
||||
pub fn take_char_map_work(&self) -> u64 {
|
||||
self.work.take()
|
||||
}
|
||||
|
||||
/// The character glyph `gid` stands for: the one the font's character map maps to it
|
||||
/// (the lowest, when it maps several), else the one its `post` table's glyph name says
|
||||
/// (`uni20AC`, a single character). `None` for a glyph neither names, and for one a symbol
|
||||
/// font maps only from, or names only as, its private-use codes (U+F000–U+F0FF), which
|
||||
/// aren't characters.
|
||||
pub fn char_of(&self, gid: u32) -> Option<char> {
|
||||
let chars = self.chars.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
if let Ok(font) = FontRef::new(&self.data) {
|
||||
for (c, g) in font.charmap().mappings() {
|
||||
self.work.set(self.work.get().saturating_add(1));
|
||||
let Some(c) = char::from_u32(c).filter(|c| is_char(*c)) else {
|
||||
continue;
|
||||
};
|
||||
m.entry(g.to_u32()).and_modify(|e: &mut char| *e = (*e).min(c)).or_insert(c);
|
||||
}
|
||||
}
|
||||
m
|
||||
});
|
||||
if let Some(c) = chars.get(&gid) {
|
||||
return Some(*c);
|
||||
}
|
||||
if let Some(c) = self.names.borrow().get(&gid) {
|
||||
return *c;
|
||||
}
|
||||
// Finding a custom name reads the names before it: about one operation for every 16.
|
||||
self.work.set(self.work.get().saturating_add(u64::from(gid / 16) + 1));
|
||||
let c = FontRef::new(&self.data).ok().and_then(|font| {
|
||||
let name = font.glyph_names().get(GlyphId::new(gid))?;
|
||||
post_name_char(name.as_str())
|
||||
});
|
||||
self.names.borrow_mut().insert(gid, c);
|
||||
c
|
||||
}
|
||||
|
||||
/// The glyph the font's character map gives `c`.
|
||||
pub fn gid_of(&self, c: char) -> Option<u32> {
|
||||
let font = FontRef::new(&self.data).ok()?;
|
||||
font.charmap().map(c).map(|g| g.to_u32()).filter(|g| *g != 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether character `c`, read from a font's character map or glyph names, is one the glyph
|
||||
/// stands for: not a symbol font's private-use codes, controls or the soft hyphen, which doesn't
|
||||
/// show (a hyphen glyph mapped from it is the hyphen's).
|
||||
fn is_char(c: char) -> bool {
|
||||
!('\u{F000}'..='\u{F0FF}').contains(&c) && !c.is_control() && c != '\u{AD}'
|
||||
}
|
||||
|
||||
/// The character `post` table glyph name `name` says (`uni20AC`, a single character), when it
|
||||
/// says one [`is_char`] accepts.
|
||||
pub(super) fn post_name_char(name: &str) -> Option<char> {
|
||||
let c = super::text::glyph_char(name);
|
||||
(c != '\u{2022}' || name == "bullet").then_some(c).filter(|c| is_char(*c))
|
||||
}
|
||||
|
||||
/// Glyph `gid` of TrueType font `data`: its outline and advance width, in ems (y up), or `None`
|
||||
/// when the font can't be read.
|
||||
pub(super) fn glyph(data: &[u8], gid: u32) -> Option<(BezPath, f64)> {
|
||||
@@ -65,8 +148,16 @@ pub(super) fn glyph(data: &[u8], gid: u32) -> Option<(BezPath, f64)> {
|
||||
Some((pen.0, if advance.is_finite() { advance } else { 0.0 }))
|
||||
}
|
||||
|
||||
/// The advance width of glyph `gid` of TrueType font `data`, in ems (0 when it can't be read).
|
||||
pub(super) fn advance(data: &[u8], gid: u32) -> f64 {
|
||||
let Ok(font) = FontRef::new(data) else { return 0.0 };
|
||||
let upem = f64::from(font.metrics(Size::unscaled(), LocationRef::default()).units_per_em);
|
||||
let w = font.glyph_metrics(Size::unscaled(), LocationRef::default()).advance_width(GlyphId::new(gid)).map_or(0.0, f64::from) / upem;
|
||||
if w.is_finite() { w } else { 0.0 }
|
||||
}
|
||||
|
||||
/// A pen drawing an outline scaled from font units to ems.
|
||||
struct Pen(BezPath, f64);
|
||||
pub(super) struct Pen(pub BezPath, pub f64);
|
||||
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
|
||||
@@ -172,6 +172,11 @@ const OTHER_NAMES: &[(&str, char)] = &[
|
||||
("grave", '`'),
|
||||
];
|
||||
|
||||
/// The Windows-1252 glyph name of code `code` (128–255), if it has one.
|
||||
pub(crate) fn high_name(code: u8) -> Option<&'static str> {
|
||||
code.checked_sub(0x80).and_then(|i| HIGH.get(usize::from(i))).copied().filter(|n| *n != ".notdef")
|
||||
}
|
||||
|
||||
/// Character `b` of Windows-1252.
|
||||
pub(crate) fn decode(b: u8) -> char {
|
||||
match b {
|
||||
|
||||
@@ -718,6 +718,11 @@ fn errors_are_tool_results_not_crashes() {
|
||||
|
||||
/// A fake control server: answers `document.inspect`, echoes `engine.execute`, errors otherwise.
|
||||
fn fake_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
|
||||
fake_app_refusing_confirm(None)
|
||||
}
|
||||
|
||||
/// [`fake_app`], answering `ui.dialog.confirm` with the error `refusal` when one is given.
|
||||
fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thread::JoinHandle<Vec<Value>>) {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let h = std::thread::spawn(move || {
|
||||
@@ -733,6 +738,7 @@ fn fake_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
|
||||
"engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}),
|
||||
"ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}),
|
||||
"ui.dialog.set" if !dialog_open => json!({"ok": false, "error": "no dialog open"}),
|
||||
"ui.dialog.confirm" if refusal.is_some() => json!({"ok": false, "error": refusal}),
|
||||
m if m.starts_with("ui.dialog.") => {
|
||||
dialog_open &= m != "ui.dialog.cancel";
|
||||
json!({"ok": true, "result": {"echo": msg["params"]}})
|
||||
@@ -823,6 +829,23 @@ fn answer_dialog_does_not_confirm_when_the_app_refuses_the_set() {
|
||||
assert_eq!(seen[1]["params"], json!({"fields": set}));
|
||||
}
|
||||
|
||||
/// The app refuses to confirm a plug-in's install approval for automation: the call is an error
|
||||
/// with the app's reason, and nothing else is sent that could install the module.
|
||||
#[test]
|
||||
fn answer_dialog_reports_that_the_app_refuses_to_approve_a_plugin_install() {
|
||||
let refusal = "Only the person at the keyboard can approve installing a plug-in";
|
||||
let (addr, h) = fake_app_refusing_confirm(Some(refusal));
|
||||
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
|
||||
let set = json!([{"field": "__params", "value": {"dataBase64": "AAAA"}}]);
|
||||
let r = call(&mut s, 1, "answer_dialog", json!({"set": set, "action": "confirm"}));
|
||||
assert_eq!(r["isError"], true, "{r}");
|
||||
assert!(text_of(&r).contains(refusal), "{r}");
|
||||
drop(s);
|
||||
let seen = h.join().unwrap();
|
||||
let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect();
|
||||
assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answer_dialog_needs_the_desktop_app() {
|
||||
let mut s = server();
|
||||
|
||||
@@ -168,6 +168,7 @@ impl VectorcraftApp {
|
||||
scrub_scope: None,
|
||||
automation_roots: None,
|
||||
synthetic_frame: false,
|
||||
pending_plugin_install: None,
|
||||
pen_end: Default::default(),
|
||||
host_iteration: 0,
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -24,12 +24,18 @@ impl VectorcraftApp {
|
||||
/// control or MCP request, or a frame of synthetic input): the automation roots don't confine
|
||||
/// what that app does.
|
||||
pub(crate) fn refuse_automation(&self, id: &str) -> Result<(), String> {
|
||||
if self.ui.answering_control || self.synthetic_frame {
|
||||
if self.automation_is_acting() {
|
||||
return Err(format!("{id} opens another app: only the person at the keyboard can run it"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Did automation send what is being handled now: a control or MCP request, or a frame of
|
||||
/// synthetic input?
|
||||
pub(crate) fn automation_is_acting(&self) -> bool {
|
||||
self.ui.answering_control || self.synthetic_frame
|
||||
}
|
||||
|
||||
/// Run a Help link command and open the URL it returns.
|
||||
pub fn open_link(&mut self, id: &str) {
|
||||
if let Ok(v) = self.run(id, serde_json::json!({}))
|
||||
|
||||
@@ -402,6 +402,7 @@ registry! {
|
||||
Plugin: [plugin::KIND] => plugin::SPEC,
|
||||
PluginExport: [plugin::EXPORT_KIND] => plugin::EXPORT_SPEC,
|
||||
PluginImport: [plugin::IMPORT_KIND] => plugin::IMPORT_SPEC,
|
||||
PluginInstall: [plugin::INSTALL_KIND] => plugin::INSTALL_SPEC,
|
||||
ScriptCheck: [script_check::KIND] => script_check::SPEC,
|
||||
VectorHalftone: [halftone::KIND] => halftone::SPEC,
|
||||
SimplifySlider: [simplify_slider::KIND] => simplify_slider::SPEC,
|
||||
@@ -563,6 +564,9 @@ pub fn cancel(app: &mut VectorcraftApp) {
|
||||
if d.kind == swatch_conflict::KIND {
|
||||
crate::placing::cancel_swatch_paste(app);
|
||||
}
|
||||
if d.kind == plugin::INSTALL_KIND {
|
||||
app.pending_plugin_install = None;
|
||||
}
|
||||
if d.kind == safe_mode::STOPPED {
|
||||
safe_mode::cancel_stopped(app);
|
||||
}
|
||||
|
||||
@@ -13,16 +13,22 @@
|
||||
//! Export button writes the file. Importers open the files File › Open finds no built-in format
|
||||
//! for, and File › Open With lists them, to open a file with one of them by name; one with
|
||||
//! parameters opens dialog `pluginImport` once the file is picked, and its Open button opens it.
|
||||
//!
|
||||
//! Dialog `pluginInstall` asks a person to approve a plug-in's permissions or heavy budget. What it
|
||||
//! shows and installs is held by the app, not in its fields; a control request or synthetic input
|
||||
//! can't confirm it. Enter confirms it only when OK has the keyboard focus.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_engine::cmd::plugin::InstallApproval;
|
||||
use vectorcraft_plugins::{Budget, Kind, Manifest, Plugin, permissions, registry};
|
||||
|
||||
use super::{DialogSpec, form};
|
||||
use super::{DialogAction, DialogSpec, form};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::menus::Item;
|
||||
use crate::state::Dialog;
|
||||
use crate::widgets;
|
||||
|
||||
/// The dialog kind of plug-in filters.
|
||||
pub const KIND: &str = "plugin";
|
||||
@@ -310,3 +316,71 @@ fn import_confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
|
||||
let picked = p["picked"] == true;
|
||||
crate::io::open_with_on(app, &p, picked && crate::opening::available())
|
||||
}
|
||||
|
||||
/// The dialog kind of the approval of a plug-in install.
|
||||
pub const INSTALL_KIND: &str = "pluginInstall";
|
||||
|
||||
/// A plug-in install waiting for a person's answer.
|
||||
pub(crate) struct PendingInstall {
|
||||
/// The question and the lines under it, in the UI language.
|
||||
pub message: String,
|
||||
pub detail: String,
|
||||
/// `plugin.install`'s params for OK.
|
||||
pub params: Value,
|
||||
/// What OK approves: the module the question is about.
|
||||
pub approval: InstallApproval,
|
||||
}
|
||||
|
||||
/// No OK in the shared footer: the OK in its body is a `Sense::click` button, which Enter clicks
|
||||
/// only when it has the keyboard focus.
|
||||
pub(super) const INSTALL_SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("Install Plug-in…").trim_end_matches('…').to_string(),
|
||||
body_with_actions: Some(install_body),
|
||||
confirm: install_confirm,
|
||||
ok: None,
|
||||
max_width: Some(420.0),
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Asks a person whether to install what `pending` describes. → `{pending}`.
|
||||
pub(crate) fn ask_install(app: &mut VectorcraftApp, pending: PendingInstall) -> Value {
|
||||
app.pending_plugin_install = Some(pending);
|
||||
app.ui.dialog = Some(Dialog::new(INSTALL_KIND, json!({})));
|
||||
json!({ "pending": INSTALL_KIND })
|
||||
}
|
||||
|
||||
fn install_body(app: &mut VectorcraftApp, ui: &mut egui::Ui, _: &mut Dialog) -> DialogAction {
|
||||
let Some(pending) = app.pending_plugin_install.as_ref() else { return DialogAction::Cancel };
|
||||
ui.label(egui::RichText::new(&pending.message).strong());
|
||||
ui.add_space(8.0);
|
||||
ui.label(egui::RichText::new(&pending.detail).color(crate::theme::Tokens::get(ui.ctx()).text_dim));
|
||||
ui.add_space(16.0);
|
||||
let mut action = DialogAction::None;
|
||||
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
||||
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if widgets::primary_button_enabled(ui, "OK", true).clicked() {
|
||||
action = DialogAction::Confirm;
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if widgets::secondary_button(ui, "Cancel").clicked() {
|
||||
action = DialogAction::Cancel;
|
||||
}
|
||||
});
|
||||
action
|
||||
}
|
||||
|
||||
/// OK: installs the module the dialog showed, if a person pressed it; automation is refused and the
|
||||
/// dialog stays open.
|
||||
fn install_confirm(app: &mut VectorcraftApp, _: &Dialog) -> Result<Value, String> {
|
||||
if app.automation_is_acting() {
|
||||
return Err("Only the person at the keyboard can approve installing a plug-in".into());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
let Some(pending) = app.pending_plugin_install.take() else { return Ok(Value::Null) };
|
||||
app.session.approve_plugin_install(pending.approval);
|
||||
let r = app.run("plugin.install", pending.params);
|
||||
app.session.clear_plugin_install_approval();
|
||||
let r = r?;
|
||||
crate::io::report_installed(app, &r);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,13 @@ use vectorcraft_plugins::wat as tpl;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The install approval has no OK in the shared footer, so Enter presses OK only when OK has the
|
||||
/// keyboard focus.
|
||||
#[test]
|
||||
fn enter_does_not_answer_the_install_approval() {
|
||||
assert!(plugin::INSTALL_SPEC.ok.is_none() && plugin::INSTALL_SPEC.body_with_actions.is_some());
|
||||
}
|
||||
|
||||
/// A filter whose parameters use every presentation key.
|
||||
fn manifest(id: &str) -> String {
|
||||
format!(
|
||||
|
||||
@@ -340,6 +340,14 @@ fn native(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Gives the main window the keyboard when a floating document's OS window has it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn focus_main_window(app: &VectorcraftApp, ctx: &egui::Context) {
|
||||
if native(app, ctx) && app.ui.floating.iter().any(|w| w.focused) {
|
||||
ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Focus);
|
||||
}
|
||||
}
|
||||
|
||||
/// The OS window of document window `w`.
|
||||
fn viewport_id(w: &DocWindow) -> egui::ViewportId {
|
||||
egui::ViewportId::from_hash_of(("vectorcraft-document-window", w.uid, w.number))
|
||||
|
||||
@@ -451,6 +451,41 @@ fn closing_an_os_window_keeps_it_while_its_unsaved_document_stays_open() {
|
||||
);
|
||||
}
|
||||
|
||||
/// The update prompt, opening on its own, takes the keyboard once from a document's OS window that
|
||||
/// has it, and leaves the keyboard alone when none of those windows has it.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn the_update_prompt_takes_the_keyboard_from_a_document_window() {
|
||||
let _host = native_host();
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_embed_viewports(false);
|
||||
let mut app = app_with(2);
|
||||
if !native(&app, &ctx) {
|
||||
// Wayland: the windows are inside the main window.
|
||||
return;
|
||||
}
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
app.run("window.arrange.floatInWindow", json!({})).unwrap();
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let focus_commands = |app: &mut VectorcraftApp| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::updates_ui::show(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
out.viewport_output.get(&egui::ViewportId::ROOT).map_or(0, |v| v.commands.iter().filter(|c| **c == egui::ViewportCommand::Focus).count())
|
||||
};
|
||||
|
||||
app.ui.floating[0].focused = true;
|
||||
assert_eq!(focus_commands(&mut app), 0, "no prompt is open");
|
||||
crate::updates_ui::open_prompt_for_test(&mut app);
|
||||
assert_eq!(focus_commands(&mut app), 1, "the main window gets the keyboard");
|
||||
assert_eq!(focus_commands(&mut app), 0, "once");
|
||||
|
||||
crate::updates_ui::close_dialogs_for_test(&mut app);
|
||||
assert_eq!(focus_commands(&mut app), 0, "the prompt closed");
|
||||
app.ui.floating[0].focused = false;
|
||||
crate::updates_ui::open_prompt_for_test(&mut app);
|
||||
assert_eq!(focus_commands(&mut app), 0, "no document window has the keyboard");
|
||||
}
|
||||
|
||||
/// Where the last frame drew the title of `title`'s tab in the tab strip.
|
||||
fn tab_at(shapes: &[Shape], title: &str) -> Pos2 {
|
||||
shapes
|
||||
|
||||
@@ -5792,7 +5792,7 @@ axis Y Axis Y osa
|
||||
@msg Running “{name}”… Pokretanje „{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` živi efekat radi dok se dokument iscrtava, pa ne može biti `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out izvoznik uvijek dobija budžet izvoza: izostavite `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): instalirajte ga s `allowHeavy: true` kada korisnik pristane
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” traži veliki budžet (svako pokretanje do {minutes} minuta i {times} puta više uobičajenih instrukcija): osoba ga mora odobriti u desktop aplikaciji, ili proslijedite `allowHeavy: true` u sesiji pokrenutoj s --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Instalirati „{name}”, koji traži ove dozvole?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5823,7 +5823,9 @@ axis Y Axis Y osa
|
||||
@msg permission `{shown}` is listed twice dozvola `{shown}` navedena je dvaput
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it dozvola `{shown}` rezervisana je za kasniji API dodataka; ovaj domaćin je ne dodjeljuje
|
||||
@msg unknown permission `{shown}` nepoznata dozvola `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” traži dozvole {asked}: instalirajte ga s `grant` koji ih navodi kada korisnik pristane
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}” traži dozvole {asked}: osoba ih mora odobriti u desktop aplikaciji, ili proslijedite `grant` u sesiji pokrenutoj s --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` važe samo u sesiji pokrenutoj s --allow-plugin-grants: odobrite dodatak u desktop aplikaciji
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Instalaciju dodatka može odobriti samo osoba za tastaturom
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nije dobio `{p}`, što je potrebno njegovom izlazu: ništa nije promijenjeno
|
||||
@msg the module doesn't have the SHA-256 `hash` gives: nothing was installed modul nema SHA-256 naveden u `hash`: ništa nije instalirano
|
||||
New Set… Novi skup…
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5927,7 +5927,7 @@ digits Malayalam Malaiàlam
|
||||
@msg {path}: {e} {path}: {e}
|
||||
@msg {status} ({e}) {status} ({e})
|
||||
@msg {tool} isn't in a toolbar group of several tools {tool} no forma part d’un grup de diverses eines a la barra d’eines
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): instal·la’l amb `allowHeavy: true` quan l’usuari hi estigui d’acord
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» demana un pressupost elevat (cada execució fins a {minutes} minuts i {times} vegades les instruccions habituals): una persona l’ha d’aprovar a l’aplicació d’escriptori, o passa `allowHeavy: true` en una sessió iniciada amb --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Instal·la «{name}», que demana aquests permisos?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5967,7 +5967,9 @@ digits Malayalam Malaiàlam
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) pressupost d’objectes (més de {MAX_OBJECTS} objectes de només lectura)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permís `{shown}` està reservat per a una API de complements posterior; aquest amfitrió no el concedeix
|
||||
@msg unknown permission `{shown}` permís desconegut `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» demana els permisos {asked}: instal·la’l amb un `grant` que els enumeri quan l’usuari hi estigui d’acord
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» demana els permisos {asked}: una persona els ha d’aprovar a l’aplicació d’escriptori, o passa `grant` en una sessió iniciada amb --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` només tenen efecte en una sessió iniciada amb --allow-plugin-grants: aprova el complement a l’aplicació d’escriptori
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Només la persona al teclat pot aprovar la instal·lació d’un complement
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a «{name}» no se li ha concedit `{p}`, que la seva sortida necessita: no s’ha canviat res
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` un filtre pesat s’executa en segon pla, de manera que no pot declarar `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` com a màxim {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5919,7 +5919,7 @@ axis Y Axis osa Y
|
||||
@msg Running “{name}”… Spouští se „{name}“…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` živý efekt běží během vykreslování dokumentu, takže nemůže být `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out exportní zásuvný modul dostává vždy rozpočet exportu: klíč `budget` vynechte
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): nainstalujte ho s `allowHeavy: true`, jakmile uživatel souhlasí
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ žádá o velký výpočetní rozpočet (každé spuštění až {minutes} minut a {times}krát víc instrukcí než obvykle): musí ho schválit člověk v desktopové aplikaci, nebo předejte `allowHeavy: true` v relaci spuštěné s --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Nainstalovat „{name}“, který žádá o tato oprávnění?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5959,7 +5959,9 @@ axis Y Axis osa Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) rozpočet objektů (více než {MAX_OBJECTS} objektů jen pro čtení)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it oprávnění `{shown}` je vyhrazeno pro pozdější API zásuvných modulů; tento hostitel ho neuděluje
|
||||
@msg unknown permission `{shown}` neznámé oprávnění `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ žádá o oprávnění {asked}: nainstalujte ho s `grant`, který je uvádí, jakmile uživatel souhlasí
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ žádá o oprávnění {asked}: musí je schválit člověk v desktopové aplikaci, nebo předejte `grant` v relaci spuštěné s --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` a `allowHeavy` platí jen v relaci spuštěné s --allow-plugin-grants: schvalte zásuvný modul v desktopové aplikaci
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Instalaci zásuvného modulu může schválit jen člověk u klávesnice
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ nedostal `{p}`, které jeho výstup potřebuje: nic se nezměnilo
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` těžký filtr běží na pozadí, takže nemůže deklarovat `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` nejvýše {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5502,7 +5502,7 @@ axis Y Axis Y-Achse
|
||||
@msg Running “{name}”… „{name}“ wird ausgeführt…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` ein Live-Effekt läuft, während das Dokument gezeichnet wird, daher kann er nicht `heavy` sein
|
||||
@msg an exporter always gets an export's budget: leave `budget` out ein Export-Plug-in erhält immer das Budget eines Exports: Lassen Sie `budget` weg
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Installieren Sie es mit `allowHeavy: true`, sobald der Benutzer zustimmt
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ verlangt ein großes Rechenbudget (jede Ausführung bis zu {minutes} Minuten und {times}-mal so viele Anweisungen wie üblich): Eine Person muss es in der Desktop-App genehmigen, oder übergeben Sie `allowHeavy: true` in einer mit --allow-plugin-grants gestarteten Sitzung
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? „{name}“ installieren, das diese Berechtigungen verlangt?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5542,7 +5542,9 @@ axis Y Axis Y-Achse
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) Objektbudget (mehr als {MAX_OBJECTS} schreibgeschützte Objekte)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it die Berechtigung `{shown}` ist für eine spätere Plug-in-API reserviert; dieser Host gewährt sie nicht
|
||||
@msg unknown permission `{shown}` unbekannte Berechtigung `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ verlangt die Berechtigungen {asked}: Installieren Sie es mit `grant`, das sie aufführt, sobald der Benutzer zustimmt
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ verlangt die Berechtigungen {asked}: Eine Person muss sie in der Desktop-App genehmigen, oder übergeben Sie `grant` in einer mit --allow-plugin-grants gestarteten Sitzung
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` und `allowHeavy` wirken nur in einer mit --allow-plugin-grants gestarteten Sitzung: Genehmigen Sie das Plug-in in der Desktop-App
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Nur die Person an der Tastatur kann die Installation eines Plug-ins genehmigen
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“ wurde `{p}` nicht gewährt, das seine Ausgabe braucht: Es wurde nichts geändert
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` ein schwerer Filter läuft im Hintergrund und kann daher keine `commands` deklarieren
|
||||
@msg at most {MAX_COMMANDS} `commands` höchstens {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5870,7 +5870,7 @@ axis Y Axis Άξονας Y
|
||||
@msg Running “{name}”… Εκτέλεση του «{name}»…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` ένα δυναμικό εφέ εκτελείται κατά τη σχεδίαση του εγγράφου, οπότε δεν μπορεί να είναι `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out ένα πρόσθετο εξαγωγής παίρνει πάντα τον προϋπολογισμό μιας εξαγωγής: παραλείψτε το `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): εγκαταστήστε το με `allowHeavy: true` μόλις συμφωνήσει ο χρήστης
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants το «{_1}» ζητά μεγάλο υπολογιστικό προϋπολογισμό (κάθε εκτέλεση έως {minutes} λεπτά και {times} φορές τις συνήθεις εντολές): πρέπει να το εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `allowHeavy: true` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Να εγκατασταθεί το «{name}», που ζητά αυτά τα δικαιώματα;
|
||||
ID: {id} Αναγνωριστικό: {id}
|
||||
@@ -5910,7 +5910,9 @@ axis Y Axis Άξονας Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) προϋπολογισμός αντικειμένων (περισσότερα από {MAX_OBJECTS} αντικείμενα μόνο για ανάγνωση)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it το δικαίωμα `{shown}` είναι δεσμευμένο για μεταγενέστερο API προσθέτων· αυτός ο κεντρικός υπολογιστής δεν το παραχωρεί
|
||||
@msg unknown permission `{shown}` άγνωστο δικαίωμα `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees το «{name}» ζητά τα δικαιώματα {asked}: εγκαταστήστε το με ένα `grant` που τα απαριθμεί μόλις συμφωνήσει ο χρήστης
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants το «{name}» ζητά τα δικαιώματα {asked}: πρέπει να τα εγκρίνει ένα άτομο στην εφαρμογή επιφάνειας εργασίας ή περάστε `grant` σε μια συνεδρία που ξεκίνησε με --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app τα `grant` και `allowHeavy` ισχύουν μόνο σε συνεδρία που ξεκίνησε με --allow-plugin-grants: εγκρίνετε το πρόσθετο στην εφαρμογή επιφάνειας εργασίας
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Μόνο το άτομο στο πληκτρολόγιο μπορεί να εγκρίνει την εγκατάσταση ενός πρόσθετου
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed στο «{name}» δεν παραχωρήθηκε το `{p}`, που χρειάζεται η έξοδός του: δεν άλλαξε τίποτα
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` ένα βαρύ φίλτρο εκτελείται στο παρασκήνιο, οπότε δεν μπορεί να δηλώνει `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` το πολύ {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5861,7 +5861,7 @@ axis Y Axis Eje Y
|
||||
@msg Running “{name}”… Ejecutando “{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` un efecto interactivo se ejecuta mientras se dibuja el documento, así que no puede ser `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out un plugin de exportación siempre recibe el presupuesto de una exportación: omite `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): instálalo con `allowHeavy: true` cuando el usuario lo acepte
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pide un presupuesto de cálculo grande (cada ejecución hasta {minutes} minutos y {times} veces las instrucciones habituales): una persona debe aprobarlo en la aplicación de escritorio, o pasa `allowHeavy: true` en una sesión iniciada con --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? ¿Instalar “{name}”, que pide estos permisos?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5901,7 +5901,9 @@ axis Y Axis Eje Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) presupuesto de objetos (más de {MAX_OBJECTS} objetos de solo lectura)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it el permiso `{shown}` está reservado para una API de plugins posterior; este host no lo concede
|
||||
@msg unknown permission `{shown}` permiso desconocido `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pide los permisos {asked}: instálalo con un `grant` que los incluya cuando el usuario lo acepte
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pide los permisos {asked}: una persona debe aprobarlos en la aplicación de escritorio, o pasa `grant` en una sesión iniciada con --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` y `allowHeavy` solo tienen efecto en una sesión iniciada con --allow-plugin-grants: aprueba el complemento en la aplicación de escritorio
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Solo la persona que está en el teclado puede aprobar la instalación de un complemento
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” no se le concedió `{p}`, que su resultado necesita: no se cambió nada
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesado se ejecuta en segundo plano, así que no puede declarar `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` como máximo {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5949,7 +5949,7 @@ axis Y Axis Y-akseli
|
||||
@msg Running “{name}”… Suoritetaan ”{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` live-tehoste suoritetaan asiakirjaa piirrettäessä, joten se ei voi olla `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out vientilaajennus saa aina viennin budjetin: jätä `budget` pois
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): asenna se asetuksella `allowHeavy: true`, kun käyttäjä hyväksyy
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” pyytää suurta laskentabudjettia (jokainen ajo enintään {minutes} minuuttia ja {times} kertaa tavallinen määrä käskyjä): ihmisen on hyväksyttävä se työpöytäsovelluksessa, tai anna `allowHeavy: true` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Asennetaanko ”{name}”, joka pyytää näitä käyttöoikeuksia?
|
||||
ID: {id} Tunnus: {id}
|
||||
@@ -5989,7 +5989,9 @@ axis Y Axis Y-akseli
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) objektibudjetti (yli {MAX_OBJECTS} vain luku -objektia)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it käyttöoikeus `{shown}` on varattu myöhemmälle laajennus-API:lle; tämä isäntä ei myönnä sitä
|
||||
@msg unknown permission `{shown}` tuntematon käyttöoikeus `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” pyytää käyttöoikeuksia {asked}: asenna se `grant`-luettelolla, jossa ne ovat, kun käyttäjä suostuu
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” pyytää käyttöoikeuksia {asked}: ihmisen on hyväksyttävä ne työpöytäsovelluksessa, tai anna `grant` istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` ja `allowHeavy` toimivat vain istunnossa, joka on käynnistetty valitsimella --allow-plugin-grants: hyväksy laajennus työpöytäsovelluksessa
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Vain näppäimistön ääressä oleva ihminen voi hyväksyä laajennuksen asennuksen
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” ei saanut käyttöoikeutta `{p}`, jota sen tulos tarvitsee: mitään ei muutettu
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` raskas suodin suoritetaan taustalla, joten se ei voi määritellä `commands`-kohtaa
|
||||
@msg at most {MAX_COMMANDS} `commands` enintään {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5914,7 +5914,7 @@ axis Y Axis Axe Y
|
||||
@msg Running “{name}”… Exécution de « {name} »…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` un effet dynamique s’exécute pendant le tracé du document, il ne peut donc pas être `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out un module externe d’exportation reçoit toujours le budget d’une exportation : omettez `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : installez-le avec `allowHeavy: true` une fois que l’utilisateur l’accepte
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants « {_1} » demande un budget de calcul important (chaque exécution jusqu’à {minutes} minutes et {times} fois les instructions habituelles) : une personne doit l’approuver dans l’application de bureau, ou passez `allowHeavy: true` dans une session démarrée avec --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Installer « {name} », qui demande ces autorisations ?
|
||||
ID: {id} ID : {id}
|
||||
@@ -5954,7 +5954,9 @@ axis Y Axis Axe Y
|
||||
@msg memory budget (object {_1} needs {size} bytes of input, {left} left) budget mémoire (l’objet {_1} nécessite {size} octets d’entrée, il en reste {left})
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) budget d’objets (plus de {MAX_OBJECTS} objets en lecture seule)
|
||||
@msg unknown permission `{shown}` autorisation inconnue `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees « {name} » demande les autorisations {asked} : installez-le avec `grant` qui les liste une fois que l’utilisateur l’accepte
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants « {name} » demande les autorisations {asked} : une personne doit les approuver dans l’application de bureau, ou passez `grant` dans une session démarrée avec --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` et `allowHeavy` ne prennent effet que dans une session démarrée avec --allow-plugin-grants : approuvez le module externe dans l’application de bureau
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Seule la personne au clavier peut approuver l’installation d’un module externe
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed « {name} » n’a pas reçu `{p}`, dont sa sortie a besoin : rien n’a été modifié
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` un filtre lourd s’exécute en arrière-plan, il ne peut donc pas déclarer `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` au plus {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5254,7 +5254,7 @@ antiAlias Sharp शार्प
|
||||
Zoom with Mouse Wheel माउस व्हील के साथ ज़ूम
|
||||
@id file.close बंद करें
|
||||
@id pathfinder.add मिलाएँ
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): उपयोगकर्ता की सहमति मिलने पर इसे `allowHeavy: true` से इंस्टॉल करें
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” अधिक संसाधन माँगता है (हर रन में {minutes} मिनट और सामान्य से {times} गुना निर्देश): इसे डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `allowHeavy: true` दें
|
||||
@msg "{0}" is not a colour: write it as #rrggbb "{0}" एक रंग नहीं है: इसे #rrggbb के रूप में लिखें
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? "{name}" स्थापित करें, जो ये अनुमतियां मांगता है?
|
||||
@@ -5295,7 +5295,9 @@ antiAlias Sharp शार्प
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) ऑब्जेक्ट बजट ({MAX_OBJECTS} से अधिक केवल-पठन ऑब्जेक्ट)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it अनुमति `{shown}` बाद के प्लग-इन API के लिए आरक्षित है; यह होस्ट इसे नहीं देता
|
||||
@msg unknown permission `{shown}` अज्ञात अनुमति `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” अनुमतियां {asked} मांगता है: उपयोगकर्ता की सहमति मिलने पर इसे उन्हें सूचीबद्ध करने वाले `grant` के साथ इंस्टॉल करें
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” अनुमतियां {asked} मांगता है: इन्हें डेस्कटॉप ऐप में किसी व्यक्ति को स्वीकृत करना होगा, या --allow-plugin-grants से शुरू किए गए सत्र में `grant` दें
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` और `allowHeavy` केवल --allow-plugin-grants से शुरू किए गए सत्र में प्रभावी होते हैं: डेस्कटॉप ऐप में प्लग-इन स्वीकृत करें
|
||||
@msg Only the person at the keyboard can approve installing a plug-in प्लग-इन इंस्टॉल करने की स्वीकृति केवल कीबोर्ड पर मौजूद व्यक्ति दे सकता है
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” को `{p}` नहीं दी गई, जो उसके आउटपुट को चाहिए: कुछ नहीं बदला
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` भारी फ़िल्टर पृष्ठभूमि में चलता है, इसलिए वह `commands` घोषित नहीं कर सकता
|
||||
@msg at most {MAX_COMMANDS} `commands` अधिकतम {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5902,7 +5902,7 @@ axis Y Axis Y tengely
|
||||
@msg Running “{name}”… Futtatás: „{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` az élő effektus a dokumentum rajzolása közben fut, ezért nem lehet `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out az exportáló bővítmény mindig az exportálás keretét kapja: hagyd ki a `budget` kulcsot
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): telepítsd az `allowHeavy: true` megadásával, miután a felhasználó beleegyezett
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants a(z) „{_1}” nagy számítási keretet kér (futásonként legfeljebb {minutes} perc és a szokásos utasításszám {times}-szorosa): egy személynek jóvá kell hagynia az asztali alkalmazásban, vagy add meg az `allowHeavy: true` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Telepíted a(z) „{name}” bővítményt, amely ezeket az engedélyeket kéri?
|
||||
ID: {id} Azonosító: {id}
|
||||
@@ -5942,7 +5942,9 @@ axis Y Axis Y tengely
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) objektumkeret (több mint {MAX_OBJECTS} csak olvasható objektum)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a(z) `{shown}` engedély egy későbbi bővítmény-API számára van fenntartva; ez a gazdaprogram nem adja meg
|
||||
@msg unknown permission `{shown}` ismeretlen engedély: `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees a(z) „{name}” a következő engedélyeket kéri: {asked}; telepítsd egy őket felsoroló `grant` értékkel, ha a felhasználó beleegyezik
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants a(z) „{name}” a következő engedélyeket kéri: {asked}; egy személynek jóvá kell hagynia őket az asztali alkalmazásban, vagy add meg a `grant` értéket egy --allow-plugin-grants kapcsolóval indított munkamenetben
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app a `grant` és az `allowHeavy` csak --allow-plugin-grants kapcsolóval indított munkamenetben érvényes: hagyd jóvá a bővítményt az asztali alkalmazásban
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Bővítmény telepítését csak a billentyűzetnél ülő személy hagyhatja jóvá
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a(z) „{name}” nem kapta meg a(z) `{p}` engedélyt, amelyre a kimenetének szüksége van: semmi sem változott
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` a nehéz szűrő a háttérben fut, ezért nem deklarálhat `commands` értéket
|
||||
@msg at most {MAX_COMMANDS} `commands` legfeljebb {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5536,7 +5536,7 @@ axis Y Axis Asse Y
|
||||
@msg Running “{name}”… Esecuzione di “{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` un effetto dinamico viene eseguito mentre il documento viene disegnato, quindi non può essere `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out un plug-in di esportazione riceve sempre il budget di un’esportazione: ometti `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): installalo con `allowHeavy: true` quando l’utente acconsente
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” chiede un budget di calcolo elevato (ogni esecuzione fino a {minutes} minuti e {times} volte le istruzioni consuete): una persona deve approvarlo nell’app desktop, oppure passa `allowHeavy: true` in una sessione avviata con --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Installare “{name}”, che chiede queste autorizzazioni?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5576,7 +5576,9 @@ axis Y Axis Asse Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) budget di oggetti (più di {MAX_OBJECTS} oggetti di sola lettura)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it l’autorizzazione `{shown}` è riservata a un’API dei plug-in successiva; questo host non la concede
|
||||
@msg unknown permission `{shown}` autorizzazione sconosciuta `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” chiede le autorizzazioni {asked}: installalo con un `grant` che le elenca quando l’utente acconsente
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” chiede le autorizzazioni {asked}: una persona deve approvarle nell’app desktop, oppure passa `grant` in una sessione avviata con --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` e `allowHeavy` hanno effetto solo in una sessione avviata con --allow-plugin-grants: approva il plug-in nell’app desktop
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Solo la persona alla tastiera può approvare l’installazione di un plug-in
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed a “{name}” non è stata concessa `{p}`, che serve al suo risultato: non è stato modificato nulla
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` un filtro pesante viene eseguito in background, quindi non può dichiarare `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` al massimo {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5601,7 +5601,7 @@ axis Y Axis Y 軸
|
||||
@msg Running “{name}”… 「{name}」を実行中…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` ライブ効果はドキュメントの描画中に実行されるため、`heavy` にはできません
|
||||
@msg an exporter always gets an export's budget: leave `budget` out 書き出しプラグインには常に書き出しの予算が割り当てられます。`budget` は省略してください
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。ユーザーが同意したら `allowHeavy: true` を指定してインストールしてください
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」は大きな計算予算を求めています (1 回の実行につき最大 {minutes} 分、通常の {times} 倍の命令)。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `allowHeavy: true` を指定してください
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? これらの権限を求める「{name}」をインストールしますか?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5641,7 +5641,9 @@ axis Y Axis Y 軸
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) オブジェクト予算 ({MAX_OBJECTS} 個を超える読み取り専用オブジェクト)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 権限 `{shown}` は今後のプラグイン API 用に予約されています。このホストはこの権限を付与しません
|
||||
@msg unknown permission `{shown}` 不明な権限 `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」は権限 {asked} を求めています。ユーザーが同意したら、それらを列挙した `grant` を指定してインストールしてください
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」は権限 {asked} を求めています。デスクトップ アプリで人が承認するか、--allow-plugin-grants を付けて開始したセッションで `grant` を指定してください
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` と `allowHeavy` は --allow-plugin-grants を付けて開始したセッションでのみ有効です。デスクトップ アプリでプラグインを承認してください
|
||||
@msg Only the person at the keyboard can approve installing a plug-in プラグインのインストールを承認できるのはキーボードの前にいる人だけです
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」には出力に必要な `{p}` が付与されていません。何も変更されませんでした
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` 重いフィルターはバックグラウンドで実行されるため、`commands` を宣言できません
|
||||
@msg at most {MAX_COMMANDS} `commands` `commands` は最大 {MAX_COMMANDS} 個です
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5842,7 +5842,7 @@ axis Y Axis Y ღერძი
|
||||
@msg Running “{name}”… მიმდინარეობს „{name}“-ის გაშვება…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` ცოცხალი ეფექტი დოკუმენტის დახატვისას ეშვება, ამიტომ ის ვერ იქნება `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out ექსპორტის პლაგინი ყოველთვის ექსპორტის ბიუჯეტს იღებს: გამოტოვეთ `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `allowHeavy: true` პარამეტრით
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}“ დიდ გამოთვლით ბიუჯეტს ითხოვს (თითო გაშვება {minutes} წუთამდე და ჩვეულებრივზე {times}-ჯერ მეტი ინსტრუქცია): ის დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `allowHeavy: true` --allow-plugin-grants-ით გაშვებულ სესიაში
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? დავაყენოთ „{name}“, რომელიც ამ ნებართვებს ითხოვს?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5882,7 +5882,9 @@ axis Y Axis Y ღერძი
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) ობიექტების ბიუჯეტი ({MAX_OBJECTS}-ზე მეტი მხოლოდ წასაკითხი ობიექტი)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it ნებართვა `{shown}` დაჯავშნილია პლაგინების მომავალი API-სთვის; ეს ჰოსტი მას არ გასცემს
|
||||
@msg unknown permission `{shown}` უცნობი ნებართვა `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}“ ითხოვს ნებართვებს {asked}: მომხმარებლის თანხმობის შემდეგ დააყენეთ ის `grant` პარამეტრით, რომელშიც ისინი ჩამოთვლილია
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}“ ითხოვს ნებართვებს {asked}: ისინი დესკტოპის აპში ადამიანმა უნდა დაამტკიცოს, ან გადაეცით `grant` --allow-plugin-grants-ით გაშვებულ სესიაში
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` და `allowHeavy` მოქმედებს მხოლოდ --allow-plugin-grants-ით გაშვებულ სესიაში: დაამტკიცეთ დამატება დესკტოპის აპში
|
||||
@msg Only the person at the keyboard can approve installing a plug-in დამატების დაყენებას მხოლოდ კლავიატურასთან მყოფი ადამიანი ამტკიცებს
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}“-ს არ მიეცა `{p}`, რომელიც მის შედეგს სჭირდება: არაფერი შეცვლილა
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` მძიმე ფილტრი ფონურ რეჟიმში მუშაობს, ამიტომ მას არ შეუძლია `commands`-ის გამოცხადება
|
||||
@msg at most {MAX_COMMANDS} `commands` მაქსიმუმ {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -5497,7 +5497,7 @@ axis Y Axis Y축
|
||||
@msg Running “{name}”… “{name}” 실행 중…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` 라이브 효과는 문서를 그리는 동안 실행되므로 `heavy`일 수 없습니다
|
||||
@msg an exporter always gets an export's budget: leave `budget` out 내보내기 플러그인은 항상 내보내기 예산을 받습니다. `budget`은 생략하세요
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 사용자가 동의하면 `allowHeavy: true`로 설치하세요
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”은(는) 큰 계산 예산을 요청합니다(실행당 최대 {minutes}분, 평소의 {times}배 명령어). 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `allowHeavy: true`를 전달하세요
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? 이 권한을 요청하는 “{name}”을(를) 설치하시겠습니까?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5537,7 +5537,9 @@ axis Y Axis Y축
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) 개체 예산(읽기 전용 개체 {MAX_OBJECTS}개 초과)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` 권한은 이후 플러그인 API용으로 예약되어 있습니다. 이 호스트는 이 권한을 부여하지 않습니다
|
||||
@msg unknown permission `{shown}` 알 수 없는 권한 `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”은(는) {asked} 권한을 요청합니다. 사용자가 동의하면 이를 나열한 `grant`로 설치하세요
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”은(는) {asked} 권한을 요청합니다. 데스크톱 앱에서 사람이 승인하거나 --allow-plugin-grants로 시작한 세션에서 `grant`를 전달하세요
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant`와 `allowHeavy`는 --allow-plugin-grants로 시작한 세션에서만 적용됩니다. 데스크톱 앱에서 플러그인을 승인하세요
|
||||
@msg Only the person at the keyboard can approve installing a plug-in 플러그인 설치는 키보드 앞에 있는 사람만 승인할 수 있습니다
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”에 출력에 필요한 `{p}` 권한이 부여되지 않았습니다. 아무것도 변경되지 않았습니다
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` 무거운 필터는 백그라운드에서 실행되므로 `commands`를 선언할 수 없습니다
|
||||
@msg at most {MAX_COMMANDS} `commands` `commands`는 최대 {MAX_COMMANDS}개입니다
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5773,7 +5773,7 @@ axis Y Axis Oś Y
|
||||
@msg Running “{name}”… Uruchamianie „{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` efekt na żywo działa podczas rysowania dokumentu, więc nie może być `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out wtyczka eksportu zawsze dostaje budżet eksportu: pomiń `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): zainstaluj wtyczkę z `allowHeavy: true`, gdy użytkownik się zgodzi
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants „{_1}” prosi o duży budżet obliczeń (każde uruchomienie do {minutes} minut i {times} razy więcej instrukcji niż zwykle): musi ją zatwierdzić osoba w aplikacji desktopowej albo przekaż `allowHeavy: true` w sesji uruchomionej z --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Zainstalować „{name}”, wtyczkę, która prosi o te uprawnienia?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5813,7 +5813,9 @@ axis Y Axis Oś Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) budżet obiektów (ponad {MAX_OBJECTS} obiektów tylko do odczytu)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it uprawnienie `{shown}` jest zarezerwowane dla późniejszego API wtyczek; ten host go nie przyznaje
|
||||
@msg unknown permission `{shown}` nieznane uprawnienie `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees „{name}” prosi o uprawnienia {asked}: zainstaluj ją z `grant`, który je wymienia, gdy użytkownik się zgodzi
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants „{name}” prosi o uprawnienia {asked}: musi je zatwierdzić osoba w aplikacji desktopowej albo przekaż `grant` w sesji uruchomionej z --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` i `allowHeavy` działają tylko w sesji uruchomionej z --allow-plugin-grants: zatwierdź wtyczkę w aplikacji desktopowej
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Instalację wtyczki może zatwierdzić tylko osoba przy klawiaturze
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed „{name}” nie otrzymała `{p}`, którego potrzebuje jej wynik: nic nie zmieniono
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` ciężki filtr działa w tle, więc nie może deklarować `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` najwyżej {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5834,7 +5834,7 @@ axis Y Axis Eixo Y
|
||||
@msg Running “{name}”… Executando “{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` um efeito dinâmico é executado enquanto o documento é desenhado, então não pode ser `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out um plug-in de exportação sempre recebe o orçamento de uma exportação: omita `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): instale-o com `allowHeavy: true` depois que o usuário concordar
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” pede um orçamento de processamento grande (cada execução até {minutes} minutos e {times} vezes as instruções habituais): uma pessoa precisa aprová-lo no aplicativo de desktop, ou passe `allowHeavy: true` em uma sessão iniciada com --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Instalar “{name}”, que pede estas permissões?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5874,7 +5874,9 @@ axis Y Axis Eixo Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) orçamento de objetos (mais de {MAX_OBJECTS} objetos somente leitura)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it a permissão `{shown}` está reservada para uma API de plug-in futura; este host não a concede
|
||||
@msg unknown permission `{shown}` permissão desconhecida `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” pede as permissões {asked}: instale-o com `grant` listando-as depois que o usuário concordar
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” pede as permissões {asked}: uma pessoa precisa aprová-las no aplicativo de desktop, ou passe `grant` em uma sessão iniciada com --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` e `allowHeavy` só têm efeito em uma sessão iniciada com --allow-plugin-grants: aprove o plug-in no aplicativo de desktop
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Somente a pessoa ao teclado pode aprovar a instalação de um plug-in
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” não recebeu `{p}`, de que sua saída precisa: nada foi alterado
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` um filtro pesado é executado em segundo plano, então não pode declarar `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` no máximo {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -5530,7 +5530,7 @@ axis Y Axis Ось Y
|
||||
@msg Running “{name}”… Выполняется «{name}»…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` динамический эффект выполняется во время отрисовки документа, поэтому он не может быть `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out модуль экспорта всегда получает бюджет экспорта: не указывайте `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): установите его с `allowHeavy: true`, когда пользователь согласится
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запрашивает большой вычислительный бюджет (каждый запуск до {minutes} минут и в {times} раз больше инструкций, чем обычно): его должен одобрить человек в настольном приложении, либо передайте `allowHeavy: true` в сеансе, запущенном с --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Установить «{name}», которому нужны эти разрешения?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5570,7 +5570,9 @@ axis Y Axis Ось Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет объектов (более {MAX_OBJECTS} объектов только для чтения)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it разрешение `{shown}` зарезервировано для будущего API модулей; этот хост его не выдаёт
|
||||
@msg unknown permission `{shown}` неизвестное разрешение `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запрашивает разрешения {asked}: установите его с `grant`, где они перечислены, когда пользователь согласится
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запрашивает разрешения {asked}: их должен одобрить человек в настольном приложении, либо передайте `grant` в сеансе, запущенном с --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` и `allowHeavy` действуют только в сеансе, запущенном с --allow-plugin-grants: одобрите плагин в настольном приложении
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Одобрить установку плагина может только человек за клавиатурой
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не получил разрешение `{p}`, нужное для его результата: ничего не изменено
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` тяжёлый фильтр выполняется в фоне, поэтому не может объявлять `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` не более {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5942,7 +5942,7 @@ axis Y Axis Y-axel
|
||||
@msg Running “{name}”… Kör ”{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` en live-effekt körs medan dokumentet ritas, så den kan inte vara `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out ett exportinsticksprogram får alltid en exports budget: utelämna `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): installera det med `allowHeavy: true` när användaren har godkänt
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants ”{_1}” begär en stor beräkningsbudget (varje körning upp till {minutes} minuter och {times} gånger de vanliga instruktionerna): en person måste godkänna det i skrivbordsappen, eller skicka `allowHeavy: true` i en session som startats med --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Installera ”{name}”, som begär dessa behörigheter?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5982,7 +5982,9 @@ axis Y Axis Y-axel
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) objektbudget (fler än {MAX_OBJECTS} skrivskyddade objekt)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it behörigheten `{shown}` är reserverad för ett senare plugin-API; den här värden beviljar den inte
|
||||
@msg unknown permission `{shown}` okänd behörighet `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees ”{name}” begär behörigheterna {asked}: installera det med en `grant` som listar dem när användaren har godkänt
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants ”{name}” begär behörigheterna {asked}: en person måste godkänna dem i skrivbordsappen, eller skicka `grant` i en session som startats med --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` och `allowHeavy` gäller bara i en session som startats med --allow-plugin-grants: godkänn insticksprogrammet i skrivbordsappen
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Bara personen vid tangentbordet kan godkänna installationen av ett insticksprogram
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed ”{name}” beviljades inte `{p}`, som dess utdata behöver: ingenting ändrades
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` ett tungt filter körs i bakgrunden, så det kan inte deklarera `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` högst {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5875,7 +5875,7 @@ axis Y Axis Y ekseni
|
||||
@msg Running “{name}”… “{name}” çalıştırılıyor…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` canlı efekt belge çizilirken çalışır, bu yüzden `heavy` olamaz
|
||||
@msg an exporter always gets an export's budget: leave `budget` out dışa aktarma eklentisi her zaman bir dışa aktarmanın bütçesini alır: `budget` anahtarını çıkarın
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): kullanıcı onayladıktan sonra `allowHeavy: true` ile yükleyin
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” büyük bir hesaplama bütçesi istiyor (her çalıştırma en fazla {minutes} dakika ve olağan komutların {times} katı): masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `allowHeavy: true` geçirin
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Bu izinleri isteyen “{name}” yüklensin mi?
|
||||
ID: {id} Kimlik: {id}
|
||||
@@ -5915,7 +5915,9 @@ axis Y Axis Y ekseni
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) nesne bütçesi ({MAX_OBJECTS} adetten fazla salt okunur nesne)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it `{shown}` izni sonraki bir eklenti API’si için ayrılmıştır; bu ana bilgisayar bu izni vermez
|
||||
@msg unknown permission `{shown}` bilinmeyen izin `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” {asked} izinlerini istiyor: kullanıcı onayladıktan sonra bunları listeleyen bir `grant` ile yükleyin
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” {asked} izinlerini istiyor: masaüstü uygulamasında bir kişinin onaylaması gerekir veya --allow-plugin-grants ile başlatılan bir oturumda `grant` geçirin
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` ve `allowHeavy` yalnızca --allow-plugin-grants ile başlatılan bir oturumda etkili olur: eklentiyi masaüstü uygulamasında onaylayın
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Bir eklentinin yüklenmesini yalnızca klavye başındaki kişi onaylayabilir
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” öğesine çıktısının ihtiyaç duyduğu `{p}` izni verilmedi: hiçbir şey değiştirilmedi
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` ağır bir filtre arka planda çalışır, bu yüzden `commands` bildiremez
|
||||
@msg at most {MAX_COMMANDS} `commands` en fazla {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -5516,7 +5516,7 @@ axis Y Axis Вісь Y
|
||||
@msg Running “{name}”… Виконується «{name}»…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` динамічний ефект виконується під час відмальовування документа, тому він не може бути `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out модуль експорту завжди отримує бюджет експорту: не вказуйте `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): встановіть його з `allowHeavy: true`, коли користувач погодиться
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants «{_1}» запитує великий обчислювальний бюджет (кожен запуск до {minutes} хвилин і в {times} разів більше інструкцій, ніж зазвичай): його має схвалити людина в настільному застосунку, або передайте `allowHeavy: true` у сеансі, запущеному з --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Встановити «{name}», якому потрібні ці дозволи?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5556,7 +5556,9 @@ axis Y Axis Вісь Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) бюджет об’єктів (понад {MAX_OBJECTS} об’єктів лише для читання)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it дозвіл `{shown}` зарезервовано для пізнішого API модулів; цей хост його не надає
|
||||
@msg unknown permission `{shown}` невідомий дозвіл `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees «{name}» запитує дозволи {asked}: установіть його з `grant`, де їх перелічено, коли користувач погодиться
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants «{name}» запитує дозволи {asked}: їх має схвалити людина в настільному застосунку, або передайте `grant` у сеансі, запущеному з --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` і `allowHeavy` діють лише в сеансі, запущеному з --allow-plugin-grants: схваліть плагін у настільному застосунку
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Схвалити встановлення плагіна може лише людина за клавіатурою
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed «{name}» не отримав дозволу `{p}`, потрібного для його результату: нічого не змінено
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` важкий фільтр виконується у фоні, тому не може оголошувати `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` щонайбільше {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 182 and column 5.
|
@@ -5517,7 +5517,7 @@ axis Y Axis Trục Y
|
||||
@msg Running “{name}”… Đang chạy “{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` hiệu ứng trực tiếp chạy trong lúc tài liệu được vẽ nên không thể là `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out plug-in xuất luôn nhận ngân sách của một lần xuất: hãy bỏ `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): hãy cài đặt với `allowHeavy: true` khi người dùng đồng ý
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}” yêu cầu ngân sách tính toán lớn (mỗi lần chạy tới {minutes} phút và gấp {times} lần số lệnh thông thường): cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `allowHeavy: true` trong phiên khởi động bằng --allow-plugin-grants
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? Cài đặt “{name}”, plug-in yêu cầu các quyền này?
|
||||
ID: {id} ID: {id}
|
||||
@@ -5557,7 +5557,9 @@ axis Y Axis Trục Y
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) ngân sách đối tượng (hơn {MAX_OBJECTS} đối tượng chỉ đọc)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it quyền `{shown}` được dành cho một API plug-in sau này; máy chủ này không cấp quyền đó
|
||||
@msg unknown permission `{shown}` quyền không xác định `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}” yêu cầu các quyền {asked}: hãy cài đặt với `grant` liệt kê chúng khi người dùng đồng ý
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}” yêu cầu các quyền {asked}: cần một người phê duyệt trong ứng dụng máy tính, hoặc truyền `grant` trong phiên khởi động bằng --allow-plugin-grants
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` và `allowHeavy` chỉ có hiệu lực trong phiên khởi động bằng --allow-plugin-grants: hãy phê duyệt trình cắm trong ứng dụng máy tính
|
||||
@msg Only the person at the keyboard can approve installing a plug-in Chỉ người ngồi tại bàn phím mới có thể phê duyệt việc cài đặt trình cắm
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}” chưa được cấp `{p}`, quyền mà đầu ra của nó cần: không có gì thay đổi
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` bộ lọc nặng chạy ở chế độ nền nên không thể khai báo `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` tối đa {MAX_COMMANDS} `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 204 and column 5.
|
@@ -5757,7 +5757,7 @@ axis Y Axis Y 轴
|
||||
@msg Running “{name}”… 正在运行“{name}”…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` 实时效果在绘制文档时运行,因此不能为 `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out 导出增效工具始终使用导出的预算:请省略 `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):请在用户同意后使用 `allowHeavy: true` 安装
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants “{_1}”需要大量计算预算(每次运行最多 {minutes} 分钟,指令数为通常的 {times} 倍):需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `allowHeavy: true`
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? 要安装需要这些权限的“{name}”吗?
|
||||
ID: {id} ID:{id}
|
||||
@@ -5797,7 +5797,9 @@ axis Y Axis Y 轴
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) 对象预算(超过 {MAX_OBJECTS} 个只读对象)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 权限 `{shown}` 保留给以后的增效工具 API;此主机不授予该权限
|
||||
@msg unknown permission `{shown}` 未知权限 `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees “{name}”需要权限 {asked}:请在用户同意后使用列出这些权限的 `grant` 安装
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants “{name}”需要权限 {asked}:需要有人在桌面应用中批准,或在以 --allow-plugin-grants 启动的会话中传入 `grant`
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` 和 `allowHeavy` 仅在以 --allow-plugin-grants 启动的会话中生效:请在桌面应用中批准此插件
|
||||
@msg Only the person at the keyboard can approve installing a plug-in 只有键盘前的人才能批准安装插件
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed “{name}”未获授予其输出所需的 `{p}`:未作任何更改
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` 重型滤镜在后台运行,因此不能声明 `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 个 `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5554,7 +5554,7 @@ axis Y Axis Y 軸
|
||||
@msg Running “{name}”… 正在執行「{name}」…
|
||||
@msg a live effect runs while the document is drawn, so it can't be `heavy` 即時效果會在繪製文件時執行,因此不能是 `heavy`
|
||||
@msg an exporter always gets an export's budget: leave `budget` out 匯出增效模組一律使用匯出的預算:請移除 `budget`
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): install it with `allowHeavy: true` once the user agrees 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):請在使用者同意後以 `allowHeavy: true` 安裝
|
||||
@msg “{_1}” asks for a heavy budget (each run up to {minutes} minutes and {times} times the usual instructions): a person has to approve it in the desktop app, or pass `allowHeavy: true` in a session started with --allow-plugin-grants 「{_1}」需要大量運算預算(每次執行最多 {minutes} 分鐘,指令數為一般的 {times} 倍):需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `allowHeavy: true`
|
||||
# Plug-in permissions (Install Plug-in…, plugin.install grant)
|
||||
Install “{name}”, which asks for these permissions? 要安裝需要這些權限的「{name}」嗎?
|
||||
ID: {id} ID:{id}
|
||||
@@ -5594,7 +5594,9 @@ axis Y Axis Y 軸
|
||||
@msg object budget (more than {MAX_OBJECTS} read-only objects) 物件預算(超過 {MAX_OBJECTS} 個唯讀物件)
|
||||
@msg permission `{shown}` is reserved for a later plug-in API; this host doesn't grant it 權限 `{shown}` 保留給日後的增效模組 API;此主機不授予該權限
|
||||
@msg unknown permission `{shown}` 未知權限 `{shown}`
|
||||
@msg “{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees 「{name}」需要權限 {asked}:請在使用者同意後以列出這些權限的 `grant` 安裝
|
||||
@msg “{name}” asks for the permissions {asked}: a person has to approve them in the desktop app, or pass `grant` in a session started with --allow-plugin-grants 「{name}」需要權限 {asked}:需要有人在桌面應用程式中核准,或在以 --allow-plugin-grants 啟動的工作階段中傳入 `grant`
|
||||
@msg `grant` and `allowHeavy` take effect only in a session started with --allow-plugin-grants: approve the plug-in in the desktop app `grant` 和 `allowHeavy` 僅在以 --allow-plugin-grants 啟動的工作階段中生效:請在桌面應用程式中核准此外掛
|
||||
@msg Only the person at the keyboard can approve installing a plug-in 只有鍵盤前的人才能核准安裝外掛
|
||||
@msg “{name}” wasn't granted `{p}`, which its output needs: nothing was changed 「{name}」未獲授予其輸出所需的 `{p}`:未做任何變更
|
||||
@msg a heavy filter runs in the background, so it can't declare `commands` 重型濾鏡在背景執行,因此不能宣告 `commands`
|
||||
@msg at most {MAX_COMMANDS} `commands` 最多 {MAX_COMMANDS} 個 `commands`
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -109,7 +109,7 @@ pub(crate) fn install_hooks(ctx: &Context) {
|
||||
}
|
||||
|
||||
/// The base eligibility every custom-descriptor consumer shares: a hidden viewport, the Home
|
||||
/// screen, a modal dialog, the palette, a file sheet, an unfocused window, another egui keyboard
|
||||
/// screen, a modal dialog, an update dialog, the palette, a file sheet, an unfocused window, another egui keyboard
|
||||
/// owner or no Type-tool text edit all mean this pass publishes a stock descriptor or none. One
|
||||
/// helper so the pre-UI route test, the post-UI finalizer and the end-of-pass observation cannot
|
||||
/// drift apart; Home in particular is an ineligible route even though the Type tool keeps its
|
||||
@@ -118,6 +118,7 @@ fn pass_eligible(app: &VectorcraftApp, ctx: &Context) -> bool {
|
||||
!app.viewport_hidden(ctx)
|
||||
&& !crate::menus::home_showing(app)
|
||||
&& app.ui.dialog.is_none()
|
||||
&& !crate::updates_ui::blocks_input(app)
|
||||
&& !app.ui.palette_open
|
||||
&& !app.file_sheet_open()
|
||||
&& ctx.input(|input| input.focused)
|
||||
@@ -801,6 +802,31 @@ pub(crate) fn write_output(ctx: &Context, update: Option<ImeContextUpdate>, rect
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// The update prompt ends the Type tool's IME route, as a dialog does.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn the_update_prompt_ends_the_type_tools_route() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 20, "y": 80, "text": "Text", "size": 12, "color": "#000000"})).unwrap()["id"].as_u64().unwrap();
|
||||
let view = app.view_info();
|
||||
app.session.select_tool("type", view).unwrap();
|
||||
app.session.set_tool_option("textSelection", &json!({"id": id, "start": 4, "end": 4}));
|
||||
assert!(app.session.tool_wants_text());
|
||||
let ctx = Context::default();
|
||||
let eligible = |app: &VectorcraftApp| {
|
||||
let mut eligible = false;
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| eligible = pass_eligible(app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
eligible
|
||||
};
|
||||
assert!(eligible(&app));
|
||||
crate::updates_ui::open_prompt_for_test(&mut app);
|
||||
assert!(!eligible(&app), "no canvas descriptor behind the prompt");
|
||||
}
|
||||
|
||||
/// A changed context generation or IME purpose for the same owner requires a replacement reset,
|
||||
/// while an unchanged descriptor keeps the existing request.
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use export::{EXPORT_CANCELLED, cancel_exports, export, export_for_screens, e
|
||||
pub(crate) use export::{
|
||||
export_files, exported_with_notes, file_url, open_in_app, open_original, reveal_path, run_to_file, save_command_output_named,
|
||||
};
|
||||
pub(crate) use open::report_installed;
|
||||
pub(crate) use open::{EXPORT_PRESETS, import_presets, open_with_on, opened};
|
||||
pub use open::{
|
||||
MAX_RECENT_FILES, OpensAs, ask_revert, install_module, install_plugin, new_from_template, note_recent, open_bytes, open_dialog, open_document,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use vectorcraft_engine::cmd::plugin::InstallApproval;
|
||||
use vectorcraft_plugins::permissions;
|
||||
|
||||
use super::save::suggested;
|
||||
@@ -190,79 +191,94 @@ fn permission_words(name: &str) -> String {
|
||||
}
|
||||
|
||||
/// Installs the plug-in module that `module` names (`plugin.install`'s `path`, or `dataBase64`
|
||||
/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions the user hasn't
|
||||
/// granted it yet (`grant`) asks them first, showing its name, id, version, file hash and what each
|
||||
/// permission lets it do, so does a filter that asks for a heavy budget the user hasn't allowed
|
||||
/// yet (`allowHeavy`), and so does a module that would take the place of another one with its id
|
||||
/// and get its settings and cache (`replace`); OK installs it if the module still has the hash the
|
||||
/// prompt showed, Cancel installs nothing → `{pending}` while it asks. Every
|
||||
/// text from the manifest is shown with its hidden characters escaped and its length capped
|
||||
/// with `name`) → `plugin.install`'s result. A plug-in that lists permissions no person has granted
|
||||
/// it yet asks first, showing its name, id, version, file hash and what each permission lets it
|
||||
/// do, so does a filter that asks for a heavy budget, and so does a module that would take the
|
||||
/// place of another one with its id and get its settings and cache (`replace`); OK installs it if
|
||||
/// the module still has the hash the prompt showed, Cancel installs nothing → `{pending}` while it
|
||||
/// asks. The `grant` and `allowHeavy` of a request are ignored: only the dialog's OK approves.
|
||||
/// Every text from the manifest is shown with its hidden characters escaped and its length capped
|
||||
/// ([`permissions::display_text`]).
|
||||
pub fn install_module(app: &mut VectorcraftApp, module: Value) -> Result<Value, String> {
|
||||
let heavy_allowed = module.get("allowHeavy").and_then(Value::as_bool) == Some(true);
|
||||
let granted = module.get("grant").is_some_and(|g| !g.is_null());
|
||||
let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone()));
|
||||
let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect());
|
||||
let mut install = Value::Object(["path", "dataBase64", "name", "replace", "hash"].into_iter().filter_map(given).collect());
|
||||
let info = app.run("plugin.inspect", source)?;
|
||||
let (needs_grant, heavy) = (info["needsApproval"] == true, info["budget"] == "heavy");
|
||||
// `replace: false` asks for no replacing, so only a module without `replace` asks.
|
||||
let replace_given = module.get("replace").is_some_and(|r| !r.is_null());
|
||||
if !heavy_allowed || !granted || !replace_given {
|
||||
let given = |k: &str| module.get(k).map(|v| (k.to_string(), v.clone()));
|
||||
let source = Value::Object(["path", "dataBase64", "name"].into_iter().filter_map(given).collect());
|
||||
// What OK installs: the same module, with what the user has agreed to so far, pinned to
|
||||
// the bytes the prompt shows (`hash`).
|
||||
let mut answered =
|
||||
Value::Object(["path", "dataBase64", "name", "replace", "grant", "allowHeavy", "hash"].into_iter().filter_map(given).collect());
|
||||
let info = app.run("plugin.inspect", source)?;
|
||||
if answered.get("hash").is_none() {
|
||||
answered["hash"] = info["hash"].clone();
|
||||
}
|
||||
let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max);
|
||||
let name = shown("name", permissions::MAX_SHOWN_NAME);
|
||||
if !granted && info["needsApproval"] == true {
|
||||
let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)]);
|
||||
let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])];
|
||||
let version = shown("version", permissions::MAX_SHOWN_FIELD);
|
||||
if !version.is_empty() {
|
||||
lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)]));
|
||||
}
|
||||
lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))]));
|
||||
lines.push(String::new());
|
||||
let asked = info["permissions"].as_array().cloned().unwrap_or_default();
|
||||
lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p))));
|
||||
let mut again = answered;
|
||||
again["grant"] = Value::Array(asked);
|
||||
dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", again);
|
||||
return Ok(json!({ "pending": dialogs::confirm::KIND }));
|
||||
}
|
||||
if !heavy_allowed && info["budget"] == "heavy" {
|
||||
let (minutes, times) = vectorcraft_plugins::heavy_terms();
|
||||
let message = crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)]);
|
||||
let detail = crate::i18n::fmt(
|
||||
tl!(
|
||||
"Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it."
|
||||
),
|
||||
&[("minutes", &minutes.to_string()), ("times", ×.to_string())],
|
||||
);
|
||||
let mut allowed = answered;
|
||||
allowed["allowHeavy"] = json!(true);
|
||||
dialogs::confirm::ask(app, &message, &detail, "ui.installPlugin", allowed);
|
||||
return Ok(json!({ "pending": dialogs::confirm::KIND }));
|
||||
}
|
||||
if !replace_given && info["needsReplace"] == true {
|
||||
let message = crate::i18n::fmt(tl!("Install “{name}” in place of the plug-in with the same ID?"), &[("name", &name)]);
|
||||
let lines = [
|
||||
crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))]),
|
||||
crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))]),
|
||||
String::new(),
|
||||
tl!("This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID.").to_string(),
|
||||
];
|
||||
let mut replaced = answered;
|
||||
replaced["replace"] = json!(true);
|
||||
dialogs::confirm::ask(app, &message, &lines.join("\n"), "ui.installPlugin", replaced);
|
||||
return Ok(json!({ "pending": dialogs::confirm::KIND }));
|
||||
}
|
||||
let asks_replace = module.get("replace").is_none_or(Value::is_null) && info["needsReplace"] == true;
|
||||
if !needs_grant && !heavy && !asks_replace {
|
||||
let r = app.run("plugin.install", install)?;
|
||||
report_installed(app, &r);
|
||||
return Ok(r);
|
||||
}
|
||||
let r = app.run("plugin.install", module)?;
|
||||
// Pinned to the bytes the prompt shows.
|
||||
if install.get("hash").is_none() {
|
||||
install["hash"] = info["hash"].clone();
|
||||
}
|
||||
let shown = |k: &str, max: usize| permissions::display_text(info[k].as_str().unwrap_or_default(), max);
|
||||
let name = shown("name", permissions::MAX_SHOWN_NAME);
|
||||
let identity = [
|
||||
crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))]),
|
||||
crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))]),
|
||||
]
|
||||
.join("\n");
|
||||
let takes_place = tl!("This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID.");
|
||||
if asks_replace {
|
||||
install["replace"] = json!(true);
|
||||
}
|
||||
// A client may pass `replace` itself, so taking another module's place alone needs no approval
|
||||
// that only a person can give.
|
||||
if !needs_grant && !heavy {
|
||||
let message = crate::i18n::fmt(tl!("Install “{name}” in place of the plug-in with the same ID?"), &[("name", &name)]);
|
||||
dialogs::confirm::ask(app, &message, &format!("{identity}\n\n{takes_place}"), "ui.installPlugin", install);
|
||||
return Ok(json!({ "pending": dialogs::confirm::KIND }));
|
||||
}
|
||||
let asked = info["permissions"].as_array().cloned().unwrap_or_default();
|
||||
let mut paragraphs = vec![];
|
||||
if needs_grant {
|
||||
let mut lines = vec![crate::i18n::fmt(tl!("ID: {id}"), &[("id", &shown("id", permissions::MAX_SHOWN_FIELD))])];
|
||||
let version = shown("version", permissions::MAX_SHOWN_FIELD);
|
||||
if !version.is_empty() {
|
||||
lines.push(crate::i18n::fmt(tl!("Version: {version}"), &[("version", &version)]));
|
||||
}
|
||||
lines.push(crate::i18n::fmt(tl!("File SHA-256: {hash}"), &[("hash", &shown("hash", 64))]));
|
||||
lines.push(String::new());
|
||||
lines.extend(asked.iter().filter_map(Value::as_str).map(|p| format!("• {}", permission_words(p))));
|
||||
paragraphs.push(lines.join("\n"));
|
||||
install["grant"] = Value::Array(asked.clone());
|
||||
}
|
||||
if heavy {
|
||||
let (minutes, times) = vectorcraft_plugins::heavy_terms();
|
||||
paragraphs.push(crate::i18n::fmt(
|
||||
tl!(
|
||||
"Each run may take up to {minutes} minutes and do {times} times the work other plug-ins may. It runs in the background, and you can cancel it."
|
||||
),
|
||||
&[("minutes", &minutes.to_string()), ("times", ×.to_string())],
|
||||
));
|
||||
install["allowHeavy"] = json!(true);
|
||||
}
|
||||
if asks_replace {
|
||||
paragraphs.push(if needs_grant { takes_place.to_string() } else { format!("{identity}\n\n{takes_place}") });
|
||||
}
|
||||
let message = if needs_grant {
|
||||
crate::i18n::fmt(tl!("Install “{name}”, which asks for these permissions?"), &[("name", &name)])
|
||||
} else {
|
||||
crate::i18n::fmt(tl!("Install “{name}”, which asks for a heavy budget?"), &[("name", &name)])
|
||||
};
|
||||
let approval = InstallApproval {
|
||||
id: info["id"].as_str().unwrap_or_default().to_string(),
|
||||
hash: info["hash"].as_str().unwrap_or_default().to_string(),
|
||||
permissions: asked.iter().filter_map(Value::as_str).map(str::to_string).collect(),
|
||||
heavy,
|
||||
};
|
||||
let pending = dialogs::plugin::PendingInstall { message, detail: paragraphs.join("\n\n"), params: install, approval };
|
||||
Ok(dialogs::plugin::ask_install(app, pending))
|
||||
}
|
||||
|
||||
/// Says in the status bar that `plugin.install`'s result `r` was installed.
|
||||
pub(crate) fn report_installed(app: &mut VectorcraftApp, r: &Value) {
|
||||
app.status(format!("Installed plug-in {}", permissions::display_text(r["name"].as_str().unwrap_or_default(), permissions::MAX_SHOWN_NAME)));
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Where File → Open… starts: the folder of the most recently opened file, else the active
|
||||
|
||||
@@ -309,6 +309,9 @@ pub struct VectorcraftApp {
|
||||
/// This frame carries input the control channel injected ([`Self::raw_input_hook`]): it runs
|
||||
/// confined to [`Self::automation_roots`].
|
||||
synthetic_frame: bool,
|
||||
/// The plug-in install the approval dialog asks about, kept out of the dialog's fields, which
|
||||
/// `ui.dialog.set` writes.
|
||||
pub(crate) pending_plugin_install: Option<dialogs::plugin::PendingInstall>,
|
||||
/// The pen end the canvas saw last and the tool its eraser end stands in for
|
||||
/// ([`stylus::sync_eraser_tool`]).
|
||||
pub(crate) pen_end: stylus::PenEnd,
|
||||
|
||||
@@ -1488,7 +1488,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"ui.installPlugin",
|
||||
"Install Plug-in…",
|
||||
"",
|
||||
"{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), allowHeavy?: bool (the user agreed to its heavy budget), grant?: [permission] (the permissions the user granted it), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing → plugin.install's result | {pending: \"confirm\"} while it asks",
|
||||
"{path?: a .wasm file | dataBase64?: the module, name?: the file name shown as its source (with dataBase64), replace?: bool (true: the user agreed it takes the place of the plug-in with its id, as plugin.install), hash?: the module's SHA-256 (as plugin.install)} install a WebAssembly plug-in (plugin.install); without a path pick one (the web opens the browser's file picker; File › Open of a .wasm installs it too). A plug-in that lists permissions the user hasn't granted it asks the user first, showing its name, id, version, file SHA-256 and what each permission lets it do, so does a filter that asks for a heavy budget, and so does one without replace that would take the place of another module with its id and get its settings and cache (needsReplace in plugin.inspect), showing its id and file SHA-256: OK installs it if the file still has the SHA-256 shown, Cancel installs nothing. Only a person can press OK when it asks about permissions or a heavy budget: ui.dialog.confirm, answer_dialog and synthetic input are refused → plugin.install's result | {pending: \"pluginInstall\"} while it asks about permissions or a heavy budget | {pending: \"confirm\"} while it asks only about replacing",
|
||||
),
|
||||
(
|
||||
"plugin.cancelRuns",
|
||||
|
||||
@@ -1204,8 +1204,8 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a modal is open: a dialog, About, the command palette, a native file dialog, or a
|
||||
/// window behind the modal backdrop last frame (export progress, plug-in runs). Floating panels
|
||||
/// Whether a modal is open: a dialog, About, the command palette, a native file dialog, an update
|
||||
/// dialog, or a window behind the modal backdrop last frame (export progress, plug-in runs). Floating panels
|
||||
/// in OS windows of their own take no input meanwhile, as the main window behind it takes none.
|
||||
/// The window level and visibility a palette window takes: hidden under a modal dialog (a click
|
||||
/// must not bring it back over the dialog), at the normal level under a menu or another popup drawn
|
||||
@@ -1260,6 +1260,7 @@ pub(crate) fn modal_open(app: &VectorcraftApp, ctx: &egui::Context) -> bool {
|
||||
|| app.ui.about
|
||||
|| app.ui.palette_open
|
||||
|| app.file_dialog_open()
|
||||
|| crate::updates_ui::blocks_input(app)
|
||||
|| ctx.memory(|m| m.areas().is_visible(&crate::dialogs::modal::backdrop()))
|
||||
}
|
||||
|
||||
|
||||
@@ -642,6 +642,33 @@ fn a_modal_blocks_native_windows_and_takes_the_keyboard_from_them() {
|
||||
assert_eq!(h.app.session.tool_id(), "pen", "the window takes keys again once the modal closes");
|
||||
}
|
||||
|
||||
/// The update prompt and the result window of a check block a floating panel's OS window as any
|
||||
/// modal does.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn the_update_dialogs_block_native_windows_and_take_the_keyboard_from_them() {
|
||||
type Open = fn(&mut Harness);
|
||||
let opens: [(&str, Open); 2] = [
|
||||
("the prompt", |h| crate::updates_ui::open_prompt_for_test(&mut h.app)),
|
||||
("the result window", |h| crate::updates_ui::open_result_window_for_test(&mut h.app, &h.ctx)),
|
||||
];
|
||||
for (name, open) in opens {
|
||||
let mut h = Harness::new();
|
||||
let viewport = h.float("swatches");
|
||||
open(&mut h);
|
||||
h.frames(6);
|
||||
let focus_commands = h.host.borrow().commands.iter().filter(|(id, c)| *id == ViewportId::ROOT && *c == ViewportCommand::Focus).count();
|
||||
assert_eq!(focus_commands, 1, "{name}: the main window gets the keyboard");
|
||||
h.key_in(viewport, Key::P);
|
||||
assert_eq!(h.app.session.tool_id(), "selection", "{name}: a key typed in the blocked window does nothing");
|
||||
|
||||
crate::updates_ui::close_dialogs_for_test(&mut h.app);
|
||||
h.frames(3);
|
||||
h.key_in(viewport, Key::P);
|
||||
assert_eq!(h.app.session.tool_id(), "pen", "{name}: the window takes keys again once the dialog closes");
|
||||
}
|
||||
}
|
||||
|
||||
/// The level and visibility a palette window takes under a dialog or a menu, and otherwise: a
|
||||
/// palette stays out of the way of anything drawn in the main window.
|
||||
#[test]
|
||||
|
||||
@@ -210,6 +210,11 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
crate::ime::controls_without_editor(app, ctx);
|
||||
return;
|
||||
}
|
||||
// The update prompt and the modal result window draw over everything and read Escape themselves.
|
||||
if crate::updates_ui::blocks_input(app) {
|
||||
crate::ime::controls_without_editor(app, ctx);
|
||||
return;
|
||||
}
|
||||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||||
crate::ime::controls_without_editor(app, ctx);
|
||||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) {
|
||||
|
||||
@@ -91,9 +91,9 @@ pub(super) fn follow(app: &mut VectorcraftApp, ctx: &egui::Context, focused: boo
|
||||
// Each pass looks at the key held last; one that goes back hands over to the key under it.
|
||||
loop {
|
||||
let gesture = app.session.tool_busy() || app.session.pointer_held();
|
||||
// A dialog, or a canvas drag that does not reach the tool (a pan, zoom or turn of the view),
|
||||
// defers the decision.
|
||||
let dialog = app.ui.dialog.is_some() || crate::canvas::dragging(ctx);
|
||||
// A dialog, an update dialog, or a canvas drag that does not reach the tool (a pan, zoom or
|
||||
// turn of the view), defers the decision.
|
||||
let dialog = app.ui.dialog.is_some() || crate::updates_ui::blocks_input(app) || crate::canvas::dragging(ctx);
|
||||
let active = app.session.tool_id();
|
||||
let on = app.session.prefs.hold_tool_keys;
|
||||
let Some(held) = app.held_tool.last_mut() else { return };
|
||||
|
||||
@@ -75,6 +75,21 @@ fn pen_app() -> VectorcraftApp {
|
||||
app
|
||||
}
|
||||
|
||||
/// S let go while the update prompt is open leaves Scale chosen until the prompt closes.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn a_tool_key_let_go_behind_the_update_prompt_returns_after_it() {
|
||||
let mut app = pen_app();
|
||||
let keys = Keyboard::new();
|
||||
keys.press_s(&mut app, 0.0);
|
||||
crate::updates_ui::open_prompt_for_test(&mut app);
|
||||
keys.release_s(&mut app, 1.0);
|
||||
assert_eq!(app.session.tool_id(), "scale", "Scale stays behind the prompt");
|
||||
crate::updates_ui::close_dialogs_for_test(&mut app);
|
||||
keys.frame(&mut app, 1.1, vec![], None);
|
||||
assert_eq!(app.session.tool_id(), "pen", "the Pen is back after the prompt");
|
||||
}
|
||||
|
||||
/// S held from 0 s, its repeats at `repeats`, let go at `release`.
|
||||
fn hold_s(app: &mut VectorcraftApp, keys: &Keyboard, repeats: &[f64], release: f64) {
|
||||
keys.press_s(app, 0.0);
|
||||
|
||||
@@ -2647,6 +2647,7 @@ fn a_filter_plugin_keeps_its_shortcut_when_replaced_or_reloaded() {
|
||||
fn a_heavy_filter_plugin_runs_from_its_shortcut_as_from_its_menu_item() {
|
||||
use vectorcraft_plugins::wat as tpl;
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.session.allow_plugin_grants_from_requests();
|
||||
app.session.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let (at_once, asks) = ("test.UI.heavy_filter-at-once", "test.UI.heavy_filter-asks");
|
||||
for (id, params, chord) in [(at_once, "{}", "Cmd+Alt+Shift+F7"), (asks, r#"{"wiggle":{"type":"bool"}}"#, "Cmd+Alt+Shift+F8")] {
|
||||
|
||||
@@ -372,6 +372,147 @@ fn an_unidentifiable_stale_injected_batch_fails_closed() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// A plug-in's permissions are approved only by a person. A control request (or MCP, which comes
|
||||
/// through it) can't pass `grant`, can't answer the approval dialog or rewrite what it installs,
|
||||
/// and a frame of synthetic input can't press its OK; the person at the keyboard can.
|
||||
#[test]
|
||||
fn automation_cannot_approve_a_plugin_install() {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let id = "test.ui.approval.automation";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#);
|
||||
let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&bytes);
|
||||
let refused = |r: &Value, says: &str| assert!(r["error"].as_str().is_some_and(|e| e.contains(says)), "{r}");
|
||||
|
||||
let r = request(&mut app, &tx, "engine.execute", json!({"command": "plugin.install", "params": {"dataBase64": data, "grant": ["doc.read"]}}));
|
||||
refused(&r, "--allow-plugin-grants");
|
||||
assert!(app.ui.dialog.is_none());
|
||||
|
||||
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}}));
|
||||
assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}");
|
||||
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
|
||||
refused(&r, "Only the person at the keyboard");
|
||||
assert!(app.ui.dialog.is_some(), "the refused answer leaves it open");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
|
||||
// What the dialog installs isn't in its fields.
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"field": "__params", "value": {"dataBase64": "AAAA"}}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
// What MCP's `answer_dialog` sends: `ui.dialog.set {fields}`, then `ui.dialog.confirm`.
|
||||
let r = request(&mut app, &tx, "ui.dialog.set", json!({"fields": [{"field": "__params", "value": {"dataBase64": "AAAA"}}]}));
|
||||
assert!(r.get("error").is_none(), "{r}");
|
||||
let r = request(&mut app, &tx, "ui.dialog.confirm", json!({}));
|
||||
refused(&r, "Only the person at the keyboard");
|
||||
assert!(app.ui.dialog.is_some() && vectorcraft_plugins::registry::get(id).is_none());
|
||||
app.synthetic_frame = true;
|
||||
assert!(crate::dialogs::confirm(&mut app).unwrap_err().contains("Only the person at the keyboard"));
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
app.synthetic_frame = false;
|
||||
|
||||
crate::dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes)));
|
||||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
type DialogHarness = egui_kittest::Harness<'static, VectorcraftApp>;
|
||||
|
||||
/// Runs one UI pass of `h` for each batch of synthetic input the control path queued, each taken
|
||||
/// through the host's raw-input hook, until none is left and the last batch is acknowledged.
|
||||
fn take_injected(h: &mut DialogHarness) {
|
||||
let ctx = h.ctx.clone();
|
||||
loop {
|
||||
let mut raw = egui::RawInput::default();
|
||||
h.state_mut().raw_input_hook(&ctx, &mut raw);
|
||||
if raw.events.is_empty() && h.state().synthetic.is_empty() {
|
||||
break;
|
||||
}
|
||||
h.input_mut().events.extend(raw.events);
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// The open install approval, with nothing installed or granted for `id`.
|
||||
fn still_waiting(h: &DialogHarness, id: &str) -> bool {
|
||||
h.state().ui.dialog.as_ref().is_some_and(|d| d.kind == crate::dialogs::plugin::INSTALL_KIND)
|
||||
&& vectorcraft_plugins::registry::get(id).is_none()
|
||||
&& vectorcraft_plugins::registry::granted(id).is_none()
|
||||
}
|
||||
|
||||
/// Keys and pointer input sent through the control path approve nothing at the open install
|
||||
/// approval: Enter and Space press nothing with no button focused, and with OK focused they and a
|
||||
/// `ui.click` on it press it and are refused. `ui.pointer` drives the canvas. A person's click on
|
||||
/// the same button approves.
|
||||
#[test]
|
||||
fn injected_keys_and_pointer_input_cannot_approve_a_plugin_install() {
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
let refusal = "Only the person at the keyboard";
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default()).with_control(rx);
|
||||
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
|
||||
let id = "test.ui.approval.injected";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Approval","version":"1","kind":"filter","permissions":["doc.read"]}}"#);
|
||||
let bytes = wat::parse_str(vectorcraft_plugins::wat::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
|
||||
let data = vectorcraft_format::base64_encode(&bytes);
|
||||
let r = request(&mut app, &tx, "ui.menu.invoke", json!({"command": "ui.installPlugin", "params": {"dataBase64": data}}));
|
||||
assert_eq!(r["result"]["pending"], crate::dialogs::plugin::INSTALL_KIND, "{r}");
|
||||
let mut fonts = false;
|
||||
let mut h: DialogHarness = egui_kittest::Harness::builder().with_size(egui::vec2(900.0, 800.0)).build_ui_state(
|
||||
move |ui, app: &mut VectorcraftApp| {
|
||||
if !std::mem::replace(&mut fonts, true) {
|
||||
crate::theme::install_fonts(ui.ctx());
|
||||
return;
|
||||
}
|
||||
crate::dialogs::show(app, ui.ctx());
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(4);
|
||||
|
||||
for key in ["Enter", "Space"] {
|
||||
assert!(!h.get_by_label("OK").accesskit_node().is_focused(), "no button has the focus yet");
|
||||
h.state_mut().ui.status.clear();
|
||||
let r = request(h.state_mut(), &tx, "ui.key", json!({"key": key}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
take_injected(&mut h);
|
||||
assert!(still_waiting(&h, id), "{key}");
|
||||
assert_eq!(h.state().ui.status, "", "{key} presses nothing");
|
||||
}
|
||||
|
||||
for key in ["Enter", "Space"] {
|
||||
h.get_by_label("OK").focus();
|
||||
h.run_steps(2);
|
||||
assert!(h.get_by_label("OK").accesskit_node().is_focused());
|
||||
h.state_mut().ui.status.clear();
|
||||
let r = request(h.state_mut(), &tx, "ui.key", json!({"key": key}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
take_injected(&mut h);
|
||||
assert!(still_waiting(&h, id), "{key} on a focused OK");
|
||||
assert!(h.state().ui.status.contains(refusal), "{key} pressed OK and was refused: {:?}", h.state().ui.status);
|
||||
}
|
||||
|
||||
let at = h.get_by_label("OK").rect().center();
|
||||
h.state_mut().ui.status.clear();
|
||||
let r = request(h.state_mut(), &tx, "ui.click", json!({"x": at.x, "y": at.y}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
take_injected(&mut h);
|
||||
assert!(still_waiting(&h, id), "ui.click");
|
||||
assert!(h.state().ui.status.contains(refusal), "ui.click pressed OK and was refused: {:?}", h.state().ui.status);
|
||||
|
||||
let events = json!([{"kind": "down", "x": at.x, "y": at.y}, {"kind": "up", "x": at.x, "y": at.y}]);
|
||||
let r = request(h.state_mut(), &tx, "ui.pointer", json!({"events": events}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
take_injected(&mut h);
|
||||
assert!(still_waiting(&h, id), "ui.pointer");
|
||||
|
||||
h.get_by_label("OK").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().ui.dialog.is_none(), "{:?}", h.state().ui.status);
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&bytes)));
|
||||
h.state_mut().run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
/// Edit Original and the Attributes panel's Browser button hand a file or URL to another app: a
|
||||
/// control request (or MCP, which comes through it) can't run them, roots or not.
|
||||
#[test]
|
||||
|
||||
@@ -13,6 +13,7 @@ use crate::{VectorcraftApp, dialogs, plugin_runs, theme};
|
||||
/// An app with a new 200 × 200 pt document.
|
||||
fn app() -> VectorcraftApp {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.allow_plugin_grants_from_requests();
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app
|
||||
}
|
||||
@@ -71,10 +72,10 @@ fn installing_a_heavy_filter_asks_first() {
|
||||
let id = "test.ui.heavy.install";
|
||||
let bytes = heavy(id, 1, "{}");
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes), "name": "route.wasm"})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND);
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.str("message"), format!("Install “Test {id}”, which asks for a heavy budget?"));
|
||||
assert!(d.str("detail").contains("up to 5 minutes and do 20 times the work"), "{}", d.str("detail"));
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND);
|
||||
let pending = app.pending_plugin_install.as_ref().unwrap();
|
||||
assert_eq!(pending.message, format!("Install “Test {id}”, which asks for a heavy budget?"));
|
||||
assert!(pending.detail.contains("up to 5 minutes and do 20 times the work"), "{}", pending.detail);
|
||||
assert!(registry::get(id).is_none(), "not before the user agrees");
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(registry::get(id).map(|p| p.manifest().name.clone()), Some(format!("Test {id}")));
|
||||
@@ -82,7 +83,7 @@ fn installing_a_heavy_filter_asks_first() {
|
||||
// File › Open of the .wasm asks too, and Cancel installs nothing.
|
||||
registry::remove(id);
|
||||
crate::io::open_bytes(&mut app, "route.wasm", &bytes, None).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND));
|
||||
dialogs::cancel(&mut app);
|
||||
assert!(registry::get(id).is_none());
|
||||
}
|
||||
|
||||
@@ -131,8 +131,8 @@ fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> {
|
||||
/// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash
|
||||
/// and each permission in plain words, with hidden characters from the manifest escaped. Cancel
|
||||
/// installs nothing, OK installs it; installing the same module again doesn't ask, one with other
|
||||
/// bytes asks again, even when it lists the same permissions, and then whether to take the
|
||||
/// installed one's place, as does one that asks for another permission.
|
||||
/// bytes asks again, even when it lists the same permissions, and in the same dialog whether to
|
||||
/// take the installed one's place, as does one that asks for another permission.
|
||||
#[test]
|
||||
fn installing_a_plugin_that_lists_permissions_asks_first() {
|
||||
let mut app = app();
|
||||
@@ -143,25 +143,26 @@ fn installing_a_plugin_that_lists_permissions_asks_first() {
|
||||
let bytes = asking(id, name, r#"["doc.read","doc.write"]"#);
|
||||
let data = vectorcraft_format::base64_encode(&bytes);
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": data, "name": "tidy.wasm"})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND);
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.str("message"), "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?");
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND);
|
||||
let pending = app.pending_plugin_install.as_ref().unwrap();
|
||||
assert_eq!(pending.message, "Install “Tidy\\u{202E}fdp.exe\\u{2066}”, which asks for these permissions?");
|
||||
let hash = vectorcraft_plugins::permissions::sha256_hex(&bytes);
|
||||
assert_eq!(d.fields.get("__params").map(|p| p["hash"].clone()), Some(json!(hash)), "OK installs the module shown");
|
||||
let detail = d.str("detail");
|
||||
assert_eq!(pending.params["hash"], json!(hash), "OK installs the module shown");
|
||||
let detail = pending.detail.clone();
|
||||
assert_eq!(
|
||||
detail,
|
||||
format!(
|
||||
"ID: {id}\nVersion: 3.0\\u{{202E}}\\u{{0009}}\\u{{0007}}\nFile SHA-256: {hash}\n\n• Read the objects it is given\n• Change, add and delete objects in the document"
|
||||
)
|
||||
);
|
||||
assert!(!detail.chars().chain(d.str("message").chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}')));
|
||||
assert!(!detail.chars().chain(pending.message.chars()).any(|c| matches!(c, '\u{202E}' | '\u{2066}' | '\t' | '\u{0007}')));
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "not before the user agrees");
|
||||
dialogs::cancel(&mut app);
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "Cancel installs nothing");
|
||||
assert!(app.pending_plugin_install.is_none(), "Cancel drops what it asked about");
|
||||
// File › Open asks too, and OK installs it with what it lists granted.
|
||||
io::open_bytes(&mut app, "tidy.wasm", &bytes, None).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_some());
|
||||
@@ -173,39 +174,37 @@ fn installing_a_plugin_that_lists_permissions_asks_first() {
|
||||
// installed.
|
||||
let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#);
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND, "other bytes ask again");
|
||||
assert_eq!(app.ui.dialog.as_ref().unwrap().str("message"), "Install “Tidy 2”, which asks for these permissions?");
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND, "other bytes ask again");
|
||||
assert_eq!(app.pending_plugin_install.as_ref().unwrap().message, "Install “Tidy 2”, which asks for these permissions?");
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed");
|
||||
io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "File › Open asks too");
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
// OK on the permissions goes on to ask before it takes the installed one's place, showing its
|
||||
// id and file hash; Cancel there keeps the one installed and records no grant.
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.str("message"), "Install “Tidy 2” in place of the plug-in with the same ID?");
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::plugin::INSTALL_KIND), "File › Open asks too");
|
||||
// The same dialog says it takes the installed one's place, and OK replaces it; Cancel keeps the
|
||||
// one installed and records no grant.
|
||||
let pending = app.pending_plugin_install.as_ref().unwrap();
|
||||
let again_hash = vectorcraft_plugins::permissions::sha256_hex(&again);
|
||||
assert_eq!(
|
||||
d.str("detail"),
|
||||
format!(
|
||||
"ID: {id}\nFile SHA-256: {again_hash}\n\nThis file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID."
|
||||
)
|
||||
assert!(pending.detail.contains(&format!("File SHA-256: {again_hash}")), "{}", pending.detail);
|
||||
assert!(
|
||||
pending.detail.ends_with("\n\nThis file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID."),
|
||||
"{}",
|
||||
pending.detail
|
||||
);
|
||||
assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed");
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed");
|
||||
assert_ne!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(again_hash), "Cancel records no grant");
|
||||
app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap();
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "both questions are answered");
|
||||
assert!(app.ui.dialog.is_none(), "one question");
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()));
|
||||
let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again);
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(new_hash), "OK records the new module's hash");
|
||||
// One that asks for another permission asks again.
|
||||
let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#);
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND);
|
||||
assert!(app.ui.dialog.as_ref().unwrap().str("detail").ends_with("• Keep data of its own with the document"));
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND);
|
||||
assert!(app.pending_plugin_install.as_ref().unwrap().detail.contains("• Keep data of its own with the document"));
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()), "Cancel keeps the one installed");
|
||||
// OK keeps the `replace` it was asked with: `false` leaves the installed one in place.
|
||||
@@ -215,6 +214,43 @@ fn installing_a_plugin_that_lists_permissions_asks_first() {
|
||||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
/// A module with other bytes than the one installed with its id asks before taking its place: in
|
||||
/// the ordinary confirm when it needs nothing that only a person can approve, and in the approval
|
||||
/// dialog, with its id and file hash, when it also asks for a heavy budget.
|
||||
#[test]
|
||||
fn installing_a_plugin_over_another_module_asks_first() {
|
||||
let mut app = app();
|
||||
let id = "test.ui.replaces";
|
||||
let plain = |output: &str| wat::parse_str(tpl::returning(&format!(r#"{{"id":"{id}","name":"Plain","kind":"filter"}}"#), output)).unwrap();
|
||||
let module = |wasm: &[u8]| json!({"dataBase64": vectorcraft_format::base64_encode(wasm)});
|
||||
let takes_place = "This file is not the plug-in installed before with this ID. It gets the settings and cache kept for that ID.";
|
||||
app.run("ui.installPlugin", module(&plain(r#"{"objects":[]}"#))).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "nothing to take the place of");
|
||||
|
||||
let other = plain(r#"{"objects":[],"error":null}"#);
|
||||
let other_hash = vectorcraft_plugins::permissions::sha256_hex(&other);
|
||||
let r = app.run("ui.installPlugin", module(&other)).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND);
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.str("message"), "Install “Plain” in place of the plug-in with the same ID?");
|
||||
assert_eq!(d.str("detail"), format!("ID: {id}\nFile SHA-256: {other_hash}\n\n{takes_place}"));
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(other_hash));
|
||||
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Heavy","kind":"filter","budget":"heavy"}}"#);
|
||||
let heavy = wat::parse_str(tpl::spinning(&manifest, 1, r#"{"objects":[]}"#)).unwrap();
|
||||
let heavy_hash = vectorcraft_plugins::permissions::sha256_hex(&heavy);
|
||||
let r = app.run("ui.installPlugin", module(&heavy)).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND);
|
||||
let pending = app.pending_plugin_install.as_ref().unwrap();
|
||||
assert!(pending.detail.ends_with(&format!("\n\nID: {id}\nFile SHA-256: {heavy_hash}\n\n{takes_place}")), "{}", pending.detail);
|
||||
assert_eq!(pending.params["replace"], json!(true), "OK replaces the one installed");
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()), Some(heavy_hash));
|
||||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
/// A plug-in with two commands: Grow, which adds a triangle (`_context.command` is `grow`), and
|
||||
/// Size, with a parameter, whose `menu` names another menu.
|
||||
fn commands_plugin(id: &str) -> Vec<u8> {
|
||||
@@ -300,6 +336,63 @@ fn a_shortcut_runs_a_plugin_command() {
|
||||
app.run("plugin.remove", json!({"id": id})).unwrap();
|
||||
}
|
||||
|
||||
/// The `grant` and `allowHeavy` that `ui.installPlugin` is given approve nothing: it asks all the
|
||||
/// same, and what OK would approve comes from the manifest. A module with nothing to approve
|
||||
/// installs without either.
|
||||
#[test]
|
||||
fn ui_install_plugin_takes_no_grant_or_heavy_budget_from_its_params() {
|
||||
let mut app = app();
|
||||
let id = "test.ui.approval.params";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Asks","kind":"filter","permissions":["doc.read"]}}"#);
|
||||
let asks = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
|
||||
let given = |wasm: &[u8]| json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": ["doc.read", "doc.write"], "allowHeavy": true});
|
||||
let r = app.run("ui.installPlugin", given(&asks)).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND);
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none());
|
||||
let pending = app.pending_plugin_install.as_ref().unwrap();
|
||||
assert_eq!(pending.approval.permissions, ["doc.read"], "the permissions the manifest lists");
|
||||
assert_eq!(pending.params["grant"], json!(["doc.read"]));
|
||||
assert!(!pending.approval.heavy && pending.params.get("allowHeavy").is_none(), "no heavy budget was asked about");
|
||||
dialogs::cancel(&mut app);
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||||
|
||||
let heavy_id = "test.ui.approval.params.heavy";
|
||||
let manifest = format!(r#"{{"id":"{heavy_id}","name":"Heavy","kind":"filter","budget":"heavy"}}"#);
|
||||
let heavy = wat::parse_str(tpl::spinning(&manifest, 1, r#"{"objects":[]}"#)).unwrap();
|
||||
let r = app.run("ui.installPlugin", given(&heavy)).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND, "allowHeavy skips no question");
|
||||
assert!(vectorcraft_plugins::registry::get(heavy_id).is_none());
|
||||
dialogs::cancel(&mut app);
|
||||
|
||||
let plain_id = "test.ui.approval.params.plain";
|
||||
let manifest = format!(r#"{{"id":"{plain_id}","name":"Plain","kind":"filter"}}"#);
|
||||
let plain = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
|
||||
let r = app.run("ui.installPlugin", given(&plain)).unwrap();
|
||||
assert!(r.get("pending").is_none() && app.ui.dialog.is_none(), "{r}");
|
||||
assert!(vectorcraft_plugins::registry::get(plain_id).is_some());
|
||||
assert!(vectorcraft_plugins::registry::granted(plain_id).is_none(), "the grant it was given isn't recorded");
|
||||
app.run("plugin.remove", json!({"id": plain_id})).unwrap();
|
||||
}
|
||||
|
||||
/// An action's step can't grant what only a person approves: a played `plugin.install` with `grant`
|
||||
/// is refused at Accelerated and at Step by Step, and nothing is installed.
|
||||
#[test]
|
||||
fn an_action_step_cannot_grant_a_plugin_its_permissions() {
|
||||
use crate::panels::actions::{Action, ActionSet, PlaybackSpeed, play};
|
||||
let mut app = app();
|
||||
let id = "test.ui.approval.action";
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Asks","kind":"filter","permissions":["doc.read"]}}"#);
|
||||
let wasm = wat::parse_str(tpl::returning(&manifest, r#"{"objects":[]}"#)).unwrap();
|
||||
let step = json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]});
|
||||
app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Install", vec![("plugin.install".into(), step)])])];
|
||||
for speed in [PlaybackSpeed::Accelerated, PlaybackSpeed::Step] {
|
||||
app.ui.action_playback.speed = speed;
|
||||
let e = play(&mut app, 0, 0).unwrap_err();
|
||||
assert!(e.contains("take effect only in a session started with --allow-plugin-grants"), "{e}");
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none() && vectorcraft_plugins::registry::granted(id).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
/// A plug-in of API level 2 that lists `progress` names it in plain words when it asks.
|
||||
#[test]
|
||||
fn installing_a_plugin_that_reports_progress_says_so() {
|
||||
@@ -308,8 +401,8 @@ fn installing_a_plugin_that_reports_progress_says_so() {
|
||||
let manifest = format!(r#"{{"id":"{id}","name":"Count","kind":"filter","api":2,"permissions":["doc.read","progress"]}}"#);
|
||||
let bytes = wat::parse_str(tpl::reporting(&manifest, "(call $vc_progress (i64.const 1) (i64.const 2))", r#"{"objects":[]}"#)).unwrap();
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&bytes)})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND);
|
||||
let detail = app.ui.dialog.as_ref().unwrap().str("detail");
|
||||
assert_eq!(r["pending"], dialogs::plugin::INSTALL_KIND);
|
||||
let detail = app.pending_plugin_install.as_ref().unwrap().detail.clone();
|
||||
assert!(detail.ends_with("• Read the objects it is given\n• Show its progress, see Cancel and write log lines"), "{detail}");
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.permissions.len()), Some(2));
|
||||
|
||||
@@ -20,6 +20,9 @@ pub(crate) struct Runtime {
|
||||
/// The context of the last frame, to wake the UI when a check is queued from a menu callback.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
repaint: Option<egui::Context>,
|
||||
/// Whether an update dialog held the input in the last frame.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
was_blocking: bool,
|
||||
/// The web build has no updater; its window only says so.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) manual_window_open: bool,
|
||||
@@ -38,6 +41,8 @@ impl Default for Runtime {
|
||||
check_requested: false,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
repaint: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
was_blocking: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
manual_window_open: false,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -103,12 +108,52 @@ fn result_window_shows(status: &Status, prompt_open: bool) -> bool {
|
||||
!prompt_open && !matches!(status, Status::Idle)
|
||||
}
|
||||
|
||||
/// Whether the update prompt or the modal result window is open: keys, tool keys and clipboard
|
||||
/// events then stay out of the documents behind it, and the app's other windows take no input.
|
||||
pub(crate) fn blocks_input(app: &VectorcraftApp) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let updater = &app.updates_rt.updater;
|
||||
holds_keyboard(updater.status(), updater.prompt_open())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = app;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// The prompt is modal, and so is the result window for every status but [`Status::Updated`], a
|
||||
/// notice that leaves the app usable behind it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn holds_keyboard(status: &Status, prompt_open: bool) -> bool {
|
||||
prompt_open || !matches!(status, Status::Idle | Status::Updated(_))
|
||||
}
|
||||
|
||||
/// Whether an update may install and close the app: no document holds unsaved work, no save,
|
||||
/// export, file opening, plug-in filter or Image Trace is running, no action is playing, no dialog
|
||||
/// or file dialog is open, and no tool is in the middle of a drag or an edit it computes on
|
||||
/// another thread.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn safe_to_close(app: &VectorcraftApp) -> bool {
|
||||
let session = &app.session;
|
||||
!crate::unsaved::any_dirty(app)
|
||||
&& app.background.jobs.is_empty()
|
||||
&& !crate::opening::long_operation_busy(app)
|
||||
&& app.ui.action_playing.is_none()
|
||||
&& app.ui.dialog.is_none()
|
||||
&& !app.file_dialog_open()
|
||||
&& !session.in_interaction()
|
||||
&& !session.tool_busy()
|
||||
&& !session.tool_editing()
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
// The close for a ready update waits until no document holds unsaved work.
|
||||
let clean = !crate::unsaved::any_dirty(app);
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
// An installation, and the close for a ready update, wait for unsaved and running work.
|
||||
let safe = safe_to_close(app);
|
||||
match app.updates_rt.updater.show_if(ctx, safe) {
|
||||
Action::OpenUrl(url) => app.open_url(&url),
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
@@ -124,6 +169,12 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
|
||||
}
|
||||
}
|
||||
// A check at startup can open the prompt while a floating document's window covers it.
|
||||
let blocking = blocks_input(app);
|
||||
if blocking && !app.updates_rt.was_blocking {
|
||||
crate::doc_windows::focus_main_window(app, ctx);
|
||||
}
|
||||
app.updates_rt.was_blocking = blocking;
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -150,10 +201,109 @@ pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the update prompt for a test in another module.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn open_prompt_for_test(app: &mut VectorcraftApp) {
|
||||
tests::offer(app);
|
||||
}
|
||||
|
||||
/// Opens the result window of a running check for a test in another module.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn open_result_window_for_test(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
app.updates_rt.updater = tests::isolated_updater(env!("CARGO_PKG_VERSION"), "result");
|
||||
app.updates_rt.updater.check_now(ctx);
|
||||
}
|
||||
|
||||
/// Closes the update prompt and the result window for a test in another module.
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
pub(crate) fn close_dialogs_for_test(app: &mut VectorcraftApp) {
|
||||
app.updates_rt.updater = tests::isolated_updater(env!("CARGO_PKG_VERSION"), "closed");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
/// The folder that holds `name`'s update records.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn update_root(name: &str) -> std::path::PathBuf {
|
||||
std::env::temp_dir().join(format!("vectorcraft-updates-{}-{name}", std::process::id()))
|
||||
}
|
||||
|
||||
/// An updater that reads its records and the launcher's pins only under [`update_root`].
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn isolated_updater(version: &str, name: &str) -> Updater {
|
||||
let root = update_root(name);
|
||||
Updater::new(&crate::UPDATE_APP, version)
|
||||
.with_source(std::sync::Arc::new(|| Ok(None)))
|
||||
.with_paths(craft_update::Paths::under(&root))
|
||||
.with_pins_path(root.join("pins.json"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn offer(app: &mut VectorcraftApp) {
|
||||
app.updates_rt.updater = isolated_updater(env!("CARGO_PKG_VERSION"), "prompt");
|
||||
app.updates_rt.updater.offer(craft_update::Release {
|
||||
tag: "v99.0.0".into(),
|
||||
version: craft_update::parse_version("99.0.0"),
|
||||
name: "VectorCraft 99.0.0".into(),
|
||||
html_url: "https://github.com/storytold/vectorcraft/releases/tag/v99.0.0".into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
|
||||
/// An app with one saved document holding a selected rectangle.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn app_with_saved_object() -> VectorcraftApp {
|
||||
let services = crate::Services { write: Some(Box::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
|
||||
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap();
|
||||
assert!(!crate::unsaved::any_dirty(&app));
|
||||
app
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn press(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
|
||||
}
|
||||
|
||||
/// One frame in the app's order: the shortcuts read the input, then the update dialogs draw.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) {
|
||||
let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
show(app, ui.ctx());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// A Copy, the Pen tool's key and Delete, in one frame each: none of them may act.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn assert_keys_do_nothing(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let tool = app.session.tool_id();
|
||||
let mut reached = Vec::new();
|
||||
frame(app, ctx, vec![egui::Event::Copy]);
|
||||
if !app.session.clipboard.is_empty() {
|
||||
reached.push("Copy");
|
||||
}
|
||||
let tool_key =
|
||||
egui::Event::Key { key: egui::Key::P, physical_key: Some(egui::Key::P), pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
|
||||
frame(app, ctx, vec![tool_key, egui::Event::Text("p".into())]);
|
||||
if app.session.tool_id() != tool {
|
||||
reached.push("the tool key");
|
||||
}
|
||||
frame(app, ctx, vec![press(egui::Key::Delete)]);
|
||||
if app.session.doc().unwrap().selection.is_empty() {
|
||||
reached.push("Delete");
|
||||
}
|
||||
assert_eq!(reached, Vec::<&str>::new(), "these reached the document");
|
||||
assert!(!crate::unsaved::any_dirty(app), "a key edited the document");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_runtime_is_idle() {
|
||||
@@ -208,4 +358,164 @@ mod tests {
|
||||
output.textures_delta.clear();
|
||||
assert!(app.updates_rt.updater.prompt_open());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn the_prompt_and_the_result_window_hold_the_keyboard_but_the_updated_notice_does_not() {
|
||||
for status in [
|
||||
Status::Checking,
|
||||
Status::UpToDate,
|
||||
Status::Failed("offline".into()),
|
||||
Status::Installing,
|
||||
Status::Ready,
|
||||
Status::Managed,
|
||||
Status::InstallFailed("locked".into()),
|
||||
] {
|
||||
assert!(holds_keyboard(&status, false), "{status:?}");
|
||||
}
|
||||
assert!(!holds_keyboard(&Status::Idle, false));
|
||||
assert!(!holds_keyboard(&Status::Updated("99.0.0".into()), false));
|
||||
assert!(holds_keyboard(&Status::Idle, true), "the prompt");
|
||||
}
|
||||
|
||||
/// The update prompt, drawn: keys stop at it, Escape closes it, and the next key acts again.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn keys_do_not_reach_the_document_behind_the_update_prompt() {
|
||||
let mut app = app_with_saved_object();
|
||||
let ctx = egui::Context::default();
|
||||
offer(&mut app);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_keys_do_nothing(&mut app, &ctx);
|
||||
assert!(app.updates_rt.updater.prompt_open());
|
||||
|
||||
frame(&mut app, &ctx, vec![press(egui::Key::Escape)]);
|
||||
assert!(!app.updates_rt.updater.prompt_open(), "Escape closes the prompt");
|
||||
assert!(!app.session.doc().unwrap().selection.is_empty(), "Escape stops at the prompt");
|
||||
frame(&mut app, &ctx, vec![press(egui::Key::Delete)]);
|
||||
assert!(crate::unsaved::any_dirty(&app), "Delete acts once the prompt has closed");
|
||||
}
|
||||
|
||||
/// The result window of a check that is still running, drawn: keys stop at it too.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn keys_do_not_reach_the_document_behind_the_check_result_window() {
|
||||
let mut app = app_with_saved_object();
|
||||
let ctx = egui::Context::default();
|
||||
app.updates_rt.updater = isolated_updater("0.0.1", "checking");
|
||||
app.updates_rt.updater.check_now(&ctx);
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::Checking);
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_keys_do_nothing(&mut app, &ctx);
|
||||
}
|
||||
|
||||
/// The notice of a finished update, drawn: the keys act behind it.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn keys_reach_the_document_behind_the_updated_notice() {
|
||||
let mut app = app_with_saved_object();
|
||||
let ctx = egui::Context::default();
|
||||
let root = update_root("updated");
|
||||
let records = craft_update::Paths::under(&root).data;
|
||||
std::fs::create_dir_all(&records).unwrap();
|
||||
std::fs::write(records.join("self-update-done.txt"), "v99.0.0").unwrap();
|
||||
app.updates_rt.updater = isolated_updater("99.0.0", "updated");
|
||||
app.updates_rt.updater.check_at_start(&ctx);
|
||||
assert_eq!(app.updates_rt.updater.status(), &Status::Updated("99.0.0".into()));
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
frame(&mut app, &ctx, vec![press(egui::Key::Delete)]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
assert!(crate::unsaved::any_dirty(&app), "Delete acts behind the notice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn unsaved_changes_dialogs_and_running_work_hold_an_update_back() {
|
||||
let mut app = app_with_saved_object();
|
||||
assert!(safe_to_close(&app));
|
||||
|
||||
app.run("shape.rectangle", json!({"x": 70, "y": 10, "width": 50, "height": 50})).unwrap();
|
||||
assert!(!safe_to_close(&app), "unsaved changes");
|
||||
app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap();
|
||||
assert!(safe_to_close(&app));
|
||||
|
||||
app.ui.dialog = Some(crate::state::Dialog::new("about", json!({})));
|
||||
assert!(!safe_to_close(&app), "a dialog");
|
||||
app.ui.dialog = None;
|
||||
|
||||
let (shown, _) = crate::tests_picks::off_the_ui_thread(&mut app.services);
|
||||
// The command asks again with the answer: until then it reports that it has none.
|
||||
assert!(app.run("file.open", json!({})).is_err());
|
||||
assert!(app.file_dialog_open());
|
||||
assert!(!safe_to_close(&app), "a file dialog");
|
||||
shown.answer(&[]);
|
||||
crate::picks::poll(&mut app, &egui::Context::default());
|
||||
assert!(!app.file_dialog_open());
|
||||
assert!(safe_to_close(&app));
|
||||
|
||||
app.services.write_shared = Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(())));
|
||||
let (release, held) = std::sync::mpsc::channel::<()>();
|
||||
let work = move |_: crate::background::Writer| {
|
||||
let _ = held.recv();
|
||||
Ok(Value::Null)
|
||||
};
|
||||
// No label: a job the status bar does not list holds the close back too.
|
||||
crate::background::run(&mut app, true, String::new(), None, work, |_, r| r).unwrap();
|
||||
assert!(!safe_to_close(&app), "a background save");
|
||||
let _ = release.send(());
|
||||
crate::background::wait_all(&mut app);
|
||||
assert!(safe_to_close(&app));
|
||||
|
||||
crate::opening::start(&mut app, "/art/Wait.vectorcraft", vectorcraft_format::save(&vectorcraft_doc::Document::new(10.0, 10.0), false))
|
||||
.unwrap();
|
||||
assert!(!safe_to_close(&app), "a file opening");
|
||||
app.opening.pending.clear();
|
||||
assert!(safe_to_close(&app));
|
||||
|
||||
use crate::panels::actions::{Action, ActionSet, PlaybackSpeed, play};
|
||||
let step = ("object.move".to_owned(), json!({"dx": 1, "dy": 0}));
|
||||
app.ui.action_sets = vec![ActionSet::new("Test", vec![Action::new("Move", vec![step.clone(), step])])];
|
||||
app.ui.action_playback.speed = PlaybackSpeed::Step;
|
||||
assert_eq!(play(&mut app, 0, 0).unwrap()["pending"], json!(1));
|
||||
app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap();
|
||||
crate::background::wait_all(&mut app);
|
||||
assert!(!safe_to_close(&app), "an action playing");
|
||||
app.ui.action_playing = None;
|
||||
assert!(safe_to_close(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn a_gesture_or_a_tools_edit_holds_an_update_back() {
|
||||
let mut app = app_with_saved_object();
|
||||
let view = app.view_info();
|
||||
app.session.begin_interaction("Move").unwrap();
|
||||
assert!(!safe_to_close(&app), "an interaction");
|
||||
app.session.cancel_interaction().unwrap();
|
||||
assert!(safe_to_close(&app));
|
||||
|
||||
app.select_tool("selection");
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 150.0, 150.0), view).unwrap();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Drag, 180.0, 180.0), view).unwrap();
|
||||
assert!(app.session.tool_busy() && !app.session.in_interaction());
|
||||
assert!(!safe_to_close(&app), "a drag with a tool");
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 180.0, 180.0), view).unwrap();
|
||||
assert!(!app.session.tool_busy());
|
||||
assert!(safe_to_close(&app));
|
||||
|
||||
// Enough segments for the merge to run on another thread.
|
||||
for k in 0..40 {
|
||||
app.run("shape.rectangle", json!({"x": 10 * k, "y": 100, "width": 20, "height": 20})).unwrap();
|
||||
}
|
||||
app.run("file.save", json!({"path": "/docs/saved.vectorcraft"})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.select_tool("shapeBuilder");
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Move, 15.0, 110.0), view).unwrap();
|
||||
app.session.tool_poll(view, true).unwrap();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Down, 15.0, 110.0), view).unwrap();
|
||||
app.session.pointer(&PointerEvent::new(PointerKind::Up, 15.0, 110.0), view).unwrap();
|
||||
assert!(app.session.tool_editing() && !crate::unsaved::any_dirty(&app), "the click starts a merge on another thread");
|
||||
assert!(!safe_to_close(&app), "a tool's edit");
|
||||
crate::unsaved::finish_pending(&mut app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,7 +216,8 @@ the `effect` dialog (`effect.dialog {effect: "plugin.<id>"}`). An exporter plug-
|
||||
export's own parameters); `ui.dialog.confirm` writes the file. An importer plug-in with parameters opens the
|
||||
`pluginImport` dialog when `file.openWith` is given no `params`: one field per parameter, `__plugin`, and `__open`
|
||||
(`path` and `importer`); `ui.dialog.confirm` opens the file. `ui.installPlugin {path?}` installs a `.wasm` (without
|
||||
a path it asks for one); see [plugins.md](plugins.md).
|
||||
a path it asks for one); one that needs approval opens the `pluginInstall` dialog, which only a person can confirm
|
||||
(`ui.dialog.confirm` and injected input are refused); see [plugins.md](plugins.md).
|
||||
|
||||
Overprint Black: the Edit → Edit Colors → Overprint Black… menu item opens a `command` parameter dialog for
|
||||
`edit.colors.overprintBlack` (fields `remove`, `percentage`, `fill`, `stroke`, `includeCmyBlacks`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: CIDFonts only named, over a Unicode CMap, set as live type; EPS import: CMap mappings to base-font codes and glyph names; EPS import: CID-keyed fonts in FontSets (CFF) draw their glyphs; EPS import: `kshow` procedures kern live type; EPS import: embedded Type 1 fonts' glyphs and widths; EPS import: CID-keyed fonts draw their glyphs; EPS import: Type 42 fonts' characters and widths from the font they carry, show operators' spacing kept as tracking; EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
|
||||
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
|
||||
@@ -281,7 +281,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
| Illustrator template `.ait` | open, save | yes | PDF-compatible only, from Save as Template (`.vctemplate` stays its default) | New from Template opens a saved `.ait` untitled with its layers and art (`tests_pdfedit.rs`) |
|
||||
| Illustrator `.ai` | open, save | yes (PDF-compatible + editing-data structure with template layers and ruler guides, File Info) | PDF-compatible, or legacy version 7 (layers and groups) | see above; no real-file corpus |
|
||||
| PDF | open, place, save | yes (layers, masks, editable text, security, overprint, File Info from the XMP packet and the document information dictionary; Crop To › Trim takes the bleed box as the document's bleed) | yes (presets, PDF/X-1a/3/4, layers, marks and bleed, ICC, fast web view, 1.3+; objects' URLs as link annotations, not in PDF/X) | fuzzed import; shared guarded writer and windowed reader ([pdf-backend.md](pdf-backend.md)), metadata, incomplete-content and dependency-source regressions; PDF text opens where the file draws it, glyph by glyph (#722: kerning or none, spacing per character, text rise as baseline shift, vertical writing, justified text boxes); alpha soft masks drawn as images import with the mask (#1551); a clip only counts as covering the page when its shape does (#1542); a PDF another app copied pastes as editable vector art (#1499); letter-spaced type on a path keeps its length and its glyph-by-glyph stroke comes in as one text object (#1586, #1585); a crafted name tree with shared nodes no longer takes exponential time on open (#1781); fonts named with hyphens (`Noto-Sans-Bold`) or with a composite font's CMap (`Name-Identity-H`) find the installed family; invisible text (rendering mode 3: a scanned page's recognized text) opens as type with no fill or stroke and saves back as invisible, searchable text (#2675); PDF/X-1a and X-3 flattening in a CMYK document keeps CMYK inks where it rasterizes (#2160) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CID-keyed CJK type not read; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and set other descendants' characters as type at the composite font's size (other mappings and CID-keyed fonts are still set as type in the font of their name); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| EPS | open, place, save | yes (own PostScript L3 interpreter, Illustrator EPS editing data structure) | yes (L2/L3, TIFF previews) | CIDFonts the file doesn't carry not drawn, but for those over a predefined Unicode CMap (`UniJIS-UCS2-H`), set as type in the font of the CIDFont's name; name-keyed fonts in a FontSet set as type by name; #505; images encoded with `CCITTFaxDecode` (ITU-T T.4 Group 3 and T.6 Group 4, with `K`, `Columns`, `Rows`, `EndOfBlock`, `EndOfLine`, `EncodedByteAlign` and `BlackIs1`) draw, and damaged data keeps the rows decoded before its first damaged row; Type 42 (TrueType) fonts set type at its size (#1553); an embedded Type 1 font's encrypted part is read for its glyphs, which space its type, outline it and draw glyphs without a character, in plain and composite fonts; a Type 42 font that carries its TrueType font gives each code its character by its glyph name or, for names that say nothing (`/g12`), by the glyph's entry in the font's own character map, spaces its type by its own widths and draws glyphs that stand for no character from its outlines; the show operators' spacing along the baseline (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`, and the moves of `kshow`'s procedure, run between each two characters) is kept on live type as tracking, so type in a font that isn't installed keeps the file's line lengths (spacing that moves characters off the baseline, as `yshow`, `xyshow` or a `kshow` procedure can, places each character as its own text object); composite (Type 0) fonts with the 8/8 mapping, as PDF-to-PostScript converters write two-byte fonts, draw a Type 42 descendant's glyphs from the TrueType outlines it carries and an embedded Type 1 descendant's from its charstrings, and set other descendants' characters as type at the composite font's size (other mappings are still set as type in the font of their name); CID-keyed fonts (`composefont`, `FMapType` 9 over a CMap built with the CIDInit operators or `Identity-H`/`Identity-V`; `bfchar` and `bfrange` mappings give a base-font descendant its codes or glyph names) draw the glyphs of the Type 0 CIDFonts (Type 1 charstrings in their `StartData` binary data or `GlyphDirectory`) and Type 2 CIDFonts (TrueType glyphs through `CIDMap`) they carry, and the CID-keyed fonts of a FontSet's Compact Font Format data (Type 2 charstrings); a clip only counts as covering the page when its shape does (#1542); an EPS another app copied pastes as editable vector art (`clipboard.importEps`, #1499); a stitched shading keeps its hard colour changes on import (#1654); sampled functions (type 0) interpolate between their samples, multilinearly or by cubics with `Order 3`, and a sampled axial or radial gradient has a stop at each sample; a gradient the page paints as a row of abutting one-colour strips (as Level 2 writers draw smooth shades) imports as one linear gradient, without the seams the strips leave when each is drawn alone; CMYK images, CMYK JPEGs included, keep their ink amounts on import, as from PDF, and the pixels a `MaskColor` or `ImageType 3` mask hides become a grayscale opacity mask; ruzstd 0.9.1 for compressed editing data (#1807, RUSTSEC-2026-0344) |
|
||||
| PostScript `.ps` | open, place | yes (the EPS reader reads its first page; `.epsf` files open and place as EPS) | via Print to PostScript | `tests_epsimport.rs` |
|
||||
| SVG / SVGZ | open, place, save | yes | yes (SVG Options, Preserve Editing, symbols, filters, rich text) | strongest format; tests in `crates/svg/tests`; text on a path follows basic shapes as their SVG 2 equivalent paths (#1527); CSS transforms and the `font` shorthand (#1565), percentage lengths in symbols (#1596), Inkscape flowed text as area type (#1568) and Inkscape pages as artboards (#1601) import; an unsupported filter is named in its warning (#1598); an artboard exported on its own (Export As with Use Artboards, Export for Screens, Asset Export) has the artboard's name as `<title>`, Save the document's (#3544); attributes in other applications' XML namespaces (`plotter:pen="2"`) kept on their objects from import to export, as `data-*` attributes are (`crates/svg/tests/it/foreign_attributes.rs`); simple shapes export as `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>` and `<polyline>` where they draw the same as the path (#4652, `crates/svg/tests/it/basic_shapes.rs`) |
|
||||
| DXF | open, export | yes | yes (R12–2018) | R13–2018 closed SPLINE flags and control-point closure on import verified (#1299); circular cubic paths export as native CIRCLE/ARC where stroke semantics permit; R12 weighted strokes remain POLYLINE while non-circular curves keep the existing fallback (`crates/cad/src/tests.rs`); [Autodesk group 70](https://help.autodesk.com/cloudhelp/2024/ENU/AutoCAD-DXF/files/GUID-E1F884F8-AA90-4864-A215-3182D47A9C74.htm). DXF 2000–2018 LAYOUT objects write the paper image origin as 148/149 (was a stray 158), the shade plot codes (2004+) and the model type flag, and the plot style placeholder carries no subclass marker (#3085; structure test `r2000_and_later_files_have_the_structure_cad_apps_require`, checked with ezdxf's audit; not yet confirmed in Autodesk Fusion). CNC preset and VCarve QA remain open |
|
||||
@@ -320,6 +320,13 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | minor | EPS import: a CID-keyed font over a predefined Unicode CMap the file doesn't define (Adobe's public CMap resources named `Uni…-UCS2`, `-UTF8`, `-UTF16` or `-UTF32`, `-H` or `-V`: [cmap-resources](https://github.com/adobe-type-tools/cmap-resources)), found by its `CIDFont-CMap` name (`/Ryumin-Light-UniJIS-UCS2-H findfont`, the usual name of a CID-keyed font: its CIDFont's and CMap's names joined by a hyphen) or made with `composefont`, sets its codes' characters as live type in the font of its CIDFont's name, and `stringwidth` measures them. Such type was read as Latin-1 codes, or stopped the program at `composefont`. Vertical CMaps are set horizontally, and the show operators' extra spacing isn't kept; CIDFonts over other CMaps the file doesn't carry still draw nothing, with a warning. |
|
||||
| 2026-11-07 | minor | EPS import: CMap mappings to base fonts (`beginbfchar` … `endbfchar`, `beginbfrange` … `endbfrange` after `usefont`: [Adobe Technical Note #5099](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5099.CMapResources.pdf); PLRM 3rd ed., §5.11.4) are read instead of dropped: a code mapped to a character code (a range's codes counting up from its last byte) or to a glyph name (one, or an array of them) draws that glyph of the base font it names, a Type 3, Type 42 or embedded Type 1 font's own glyph or, in a font found by name, its character as type. Such codes took font 0's CID 0 and drew nothing. |
|
||||
| 2026-11-07 | minor | EPS import: a FontSet's Compact Font Format data (`/name n StartData` in the FontSetInit procedure set: PLRM 3rd ed., §5.8.1; [Adobe Technical Note #5176](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5176.CFF.pdf)) is read instead of skipped: each CID-keyed font in it (a top DICT with `ROS`) is defined as a CIDFont of its name, and type in a CID-keyed font over it draws its glyphs, picked by CID through the charset and drawn from their Type 2 charstrings with their FDSelect subfont's subroutines and widths. Name-keyed fonts in a FontSet are still set as type by name. |
|
||||
| 2026-11-07 | minor | EPS import: `kshow` (PLRM 3rd ed., chapter 8) runs its procedure between each two characters of type in a base font (not a Type 3 or composite font), with the two character codes, instead of only between a Type 3 font's glyphs: where it moves the current point (kerning), the next character follows, kept on the live type as tracking, and what it draws is drawn over the type. |
|
||||
| 2026-11-07 | minor | EPS import: an embedded Type 1 font's encrypted part (`eexec`, [Adobe Type 1 Font Format](https://adobe-type-tools.github.io/font-tech-notes/pdfs/T1_SPEC.pdf), chapter 7) is no longer only skipped: its charstrings are read, and the font dictionary gets the `CharStrings` dictionary the part would have given it. Its type then takes its characters from its glyph names (the standard and ISO Latin-1 encodings by their codes' names, PLRM 3rd ed., Appendix E), its widths move the current point and space the live type as tracking (as Type 42 fonts do), `stringwidth` and `charpath` read its glyphs, glyphs whose names say no character are drawn from its outlines, and a composite font's Type 1 descendants draw their glyphs. A font found by name, re-encoded with glyph names at other codes (codes 1 to 12 for `T`, `r`, `a`… in pdftocairo's subsets), sets the characters its names say instead of its codes read as Latin-1 (such type was left out). |
|
||||
| 2026-11-07 | minor | EPS import: CID-keyed fonts (PLRM 3rd ed., §5.11; [Adobe Technical Note #5014](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5014.CIDFont_Spec.pdf)) draw from the font data the file carries instead of stopping the program at `composefont` or the CIDInit operators: CMaps built with `begincmap` … `endcmap` (codespace ranges, CID and notdef mappings, `usefont`, `usecmap`; mappings to base-font codes are not read) and the predefined `Identity-H` and `Identity-V`; `composefont` and `FMapType` 9 fonts, whose codes take as many bytes as their codespace range says, an invalid or unmapped code falling back as §5.11.5 says; Type 0 CIDFonts' Type 1 charstrings, read by `StartData` (binary or hexadecimal) or from a `GlyphDirectory`, with their FDArray dictionaries' matrices and subroutines (`Subrs` or the binary data's subroutine table); Type 2 CIDFonts' TrueType glyphs through `CIDMap` (strings, a dictionary or an offset); `Metrics` widths; vertical writing mode set downwards. A CIDFont the file doesn't carry draws nothing, with a warning; FontSet (Compact Font Format) data is skipped. |
|
||||
| 2026-11-07 | minor | EPS import: a plain Type 42 font that carries its TrueType font ([Adobe Technical Note #5012](https://adobe-type-tools.github.io/font-tech-notes/pdfs/5012.Type42_Spec.pdf)) gives each code its character by its glyph name (single characters, `uniXXXX`, the standard encoding's and Windows-1252's names) or, when the name says nothing (`/g12`), by the glyph `CharStrings` gives it in the font's own character map or `post` names, instead of reading the code as Latin-1 (a Euro at code 128 was dropped); the font's own widths move the current point (`stringwidth`, `show`), `charpath` outlines its glyphs, and a string with a glyph that stands for no character is drawn from the font's outlines. Live type keeps the spacing the file gives it: where the show operator's numbers (`ashow`, `widthshow`, `xshow`, `yshow`, `xyshow`: PLRM 3rd ed., chapter 8) or the embedded font's widths differ from the installed or fallback font's advances, each character's tracking makes up the difference, as PDF import does, so justified type in a font that isn't installed no longer drifts (pdftocairo and pdftops EPS of a justified PDF page set in a missing font: 10.2% and 9.0% of the page differing from Ghostscript's render, now 6.2% and 5.8%). Type under `nulldevice` no longer shows. |
|
||||
| 2026-11-06 | minor | EPS import: the buffers an image decoder makes for itself (its sample planes, its copies of the strings a procedure data source hands back, and the split buffers of an interleaved masked image) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept, and anything the program's own procedures allocate, still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | EPS import: a sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading (type 1) over one came in as flat blocks; it is now interpolated between its samples (multilinearly), and with `Order 3` by cubics through them (Catmull-Rom, kept in the function's range), as the PostScript Language Reference (3rd ed., §3.10.1, type 0 functions) defines. An axial or radial shading whose function is a linearly interpolated sampled function of one input has a stop at each sample, not 33 evenly spaced ones. Tests `sampled_functions_interpolate_linearly_or_by_cubics`, `mesh_and_function_shadings_become_gradient_meshes`; a 2 × 2 function-based shading is 1.2% from Ghostscript's render (it was 45.9%); scores unchanged |
|
||||
| 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. |
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; G9: EPS gradients drawn as abutting strips import as one gradient, without seams; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; opening a slow file shows a progress window, part of #5930; Hindi catalog and contributor loanword preferences; agent wording pass complete; wider native proofreading welcome; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); scores and estimates unchanged; a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window; scores and estimates unchanged; large images in CMYK documents are shrunk before they are separated into ink planes (#4155); scores and estimates unchanged; `.ai` paths with a Calligraphic brush open brushed, with the 6D Art Pen's channels, and Windows reads the pen's tilt and barrel rotation, #372; scores and estimates unchanged; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; a slow first frame is waited for instead of restarting without the graphics adapter, and the menu bar no longer waits for the installed fonts' scan; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -195,6 +195,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
|
||||
|
||||
## G9 Illustrator `.ai` and EPS files from real users
|
||||
|
||||
- **Done (2026-11-09):** the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. This narrows G9.
|
||||
- **Done (2026-11-06):** an EPS page that paints its gradients as rows of abutting one-colour strips imports them as linear gradients, so the seams between strips no longer show and an Illustrator EPS drawn that way keeps its layers instead of opening as its page. This narrows G9; scores and estimates unchanged.
|
||||
- **Done (2026-10-30):** from an audit of a user's `.ai`/EPS files: All Caps and Small Caps; placed files at their box's corner; pages cropped to the art; gradient mesh corners with handle weights of 0 and the transparency written after a mesh, a masked mesh and a blend; a layer's state block with the stroke style; type on a path spaced as the file lays it out (the distance from the path under Align to Path is measured from the top of each character's em box, half the height of the top of its H or the bottom of its font's bounding box, and the fit under Gravity does not reproduce the file's layout); EPS10 legacy-format type; a PDF of some artboards compared on the artboard its page shows. Comparison limits remain unchanged. This narrows G9; scores and estimates unchanged.
|
||||
|
||||
@@ -839,6 +840,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | minor | G4 type: the custom dictionary (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, kept in a file beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; the Check Spelling and Edit Custom Dictionary dialogs and Auto Spell Check remain; scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | G4 type: the spell check engine is done (`text.spelling.check`, `text.spelling.suggest` on craft-proof, #4936); the Check Spelling dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
|
||||
|
||||
+22
-15
@@ -4199,8 +4199,8 @@ selected) on top of a template. With nothing selected, `stroke.set` / `stroke.se
|
||||
arrowheads, profile…) and `graphicStyle.apply` (every fill, stroke and effect, opacity, blend mode and the link to the
|
||||
style; `add: true` stacks it) set that template instead of editing art. With the Appearance panel's New Art Has Basic
|
||||
Appearance on (the default), a `select.*` command or `layer.target` that changes the selection loads the proxies and
|
||||
the weight from it: only a paint or weight the selected objects share, and art without paint (a placed image) changes
|
||||
nothing. With the option off (`appearance.setNewArtBasic {on: false}`, the preference `newArtBasic`) new art takes the
|
||||
the weight from it: only a paint or weight the selected objects share, and art without paint (a placed image that isn't
|
||||
colorized) changes nothing. With the option off (`appearance.setNewArtBasic {on: false}`, the preference `newArtBasic`) new art takes the
|
||||
whole appearance of the last selected object. `appearance.newArt` reports what the next object gets; `paint.default`
|
||||
resets it.
|
||||
`appearance.setNewArtBasic` rejects an `on` value that is not a boolean. Omitting `on` toggles the current preference.
|
||||
@@ -5554,7 +5554,7 @@ dictionaries (the standard ones are values in `systemdict`; `dictstack`, `intern
|
||||
intervals share storage, resources (categories made from `Generic`, `resourceforall`), executable filters (`cvx exec`
|
||||
runs their data; `flushfile` skips it), axial and radial shadings and shading patterns (gradients), images and image
|
||||
masks (data in the file through ASCII85, hex, run-length, Flate, LZW or DCT filters, or from procedures), and type as
|
||||
point type in the font the file names (embedded font programs are skipped). In a file in Illustrator's own format
|
||||
point type in the font the file names (embedded Type 1 and Type 42 fonts' characters and widths come from the font program they carry, and the show operators' spacing along the baseline is kept as tracking, while spacing that moves characters off it (`yshow`, `xyshow`) makes a text object per character; CID-keyed fonts draw the glyphs of the Type 0 and Type 2 CIDFonts the file carries, CIDFonts in a FontSet's Compact Font Format data included). In a file in Illustrator's own format
|
||||
(Illustrator 3–8 `.ai` and their EPS, written with the prolog that defines its operators) the groups it writes (`u` …
|
||||
`U`) come in as groups, nested as they were; clips and groups nest at most 128 deep. The artboard is the `%%HiResBoundingBox`
|
||||
(else `%%BoundingBox`; a letter page without one). A program the interpreter can't run (an operator it doesn't know,
|
||||
@@ -6703,7 +6703,7 @@ them again at startup (`plugin.remove` deletes them there):
|
||||
schema, defaults). An object filter runs on the selected paths and compound paths with `plugin.run {id, params}` (one
|
||||
undo step; it may change, delete or add objects, and the result is selected). A filter whose manifest lists `inputs`
|
||||
(`type`, `image`, `symbolInstance`; `inputs` in its `plugin.list` entry) also lists `doc.read` in its `permissions`, so
|
||||
`plugin.install` needs `grant`. It is also given the selected objects of those kinds to read, in paint order from the
|
||||
installing it needs a person's approval. It is also given the selected objects of those kinds to read, in paint order from the
|
||||
bottom, with at most 16 MiB of pixels and 16 MiB of type runs over one run (an object that doesn't fit what the objects
|
||||
before it left comes with `pixelsOmitted` or `truncated`; an image that reaches the decoder counts toward the pixels
|
||||
even when decoding fails). Every object counts toward the input's half of the plug-in's memory cap, ids, bounds,
|
||||
@@ -6734,19 +6734,26 @@ on the desktop a picked file is read off the UI thread and the reply is `{backgr
|
||||
`pluginImport` dialog. `params` holds the importer's parameters (their defaults when
|
||||
omitted). A failing plug-in returns an error and leaves the document as it was (an export writes
|
||||
no file, and an import opens no document). A filter whose manifest asks for a heavy budget (minutes rather than a minute, `budget: "heavy"` in its
|
||||
`plugin.inspect {path | dataBase64}` entry) installs only with `allowHeavy: true`: ask the user first. `plugin.run`
|
||||
`plugin.inspect {path | dataBase64}` entry) needs a person's approval, and `plugin.install` refuses `allowHeavy: true`
|
||||
from a request. `plugin.run`
|
||||
runs it where it is called, so the call may take minutes; `notifications/cancelled` stops it.
|
||||
A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`)
|
||||
installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass
|
||||
the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns
|
||||
an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the
|
||||
same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new
|
||||
`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it
|
||||
trusts the client that sends it. `plugin.remove` forgets the grant, and so does installing a module that lists no
|
||||
permissions under that id: the earlier bytes then need a new `grant`. A refused install keeps the grant. A failed write
|
||||
of the module to the plug-in store leaves the grant in the store. A stored module that is disabled, or not read in Safe
|
||||
Mode, keeps the grant for its id. When `plugin.remove` can't remove the grant from
|
||||
the plug-in store, it returns an error and the plug-in stays installed. A
|
||||
needs a person's approval too, and `plugin.install` refuses `grant: [permission]` from a request. With a desktop
|
||||
app connected, `ui.installPlugin` opens the approval dialog and returns `{pending: "pluginInstall"}`; `answer_dialog`,
|
||||
`ui.dialog.confirm` and injected input can't confirm it. A `vectorcraft-cli mcp` started with `--allow-plugin-grants`
|
||||
accepts `grant` and `allowHeavy` from any request, in that session only (headless; it can't be combined with
|
||||
`--connect`, `--automation-read-root` or `--automation-write-root`). A server confined to folders has no plug-in
|
||||
store, so it reads no `grants.json`, and a module that lists permissions or asks for a heavy budget does not install
|
||||
there. Without `--allow-plugin-grants` and without a recorded grant for the module, `plugin.install` returns an error
|
||||
naming the permissions needed. With `VECTORCRAFT_NO_PREFS` set there is no store, and a `grant` accepted under the
|
||||
flag lasts for that session only. In other sessions the approval, or a `grant` accepted under the flag, is kept with the module's
|
||||
hash, counts as approved in later sessions and in the desktop app, and covers only that module: the
|
||||
same bytes install again without one, also from a headless session that reads the same `grants.json`, and a module
|
||||
with other bytes has `needsApproval: true` and needs a new one even when it lists the same permissions. `plugin.remove`
|
||||
forgets the approval, and so does installing a module that lists no permissions under that id: the earlier bytes then
|
||||
need a new approval. A refused install keeps the grant. A failed write of the module to the plug-in store leaves the
|
||||
grant in the store. A stored module that is disabled, or not read in Safe Mode, keeps the grant for its id. When
|
||||
`plugin.remove` can't remove the grant from the plug-in store, it returns an error and the plug-in stays installed. A
|
||||
filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged.
|
||||
A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write
|
||||
log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log
|
||||
|
||||
+25
-11
@@ -30,8 +30,8 @@ This page is the contract for plug-in authors and the reference for the host (`c
|
||||
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, commands?, size, source, api, paintOutput?, permissions, hash, cacheBytes}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `commands` lists its [commands](#commands); `paintOutput: true` marks a [painted live effect](#painted-live-effects); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256; `cacheBytes` is the size of its [cache](#settings-and-cache)) |
|
||||
| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). |
|
||||
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`; an `ids` that is not a list of object ids is an error). One undo step; the result is selected, except the objects it locked or made guides. |
|
||||
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed, unless the user already granted a module with the same SHA-256. With `hash`, a module whose SHA-256 differs is refused. |
|
||||
| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`) and `needsReplace` (true when `plugin.install` needs `replace: true`). |
|
||||
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace"?: bool`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A module with other bytes than the plug-in installed with its id, or, with none installed, one whose id has [settings and a cache](#settings-and-cache) in the `Plug-ins` folder, installs only with `replace: true`, once the user agreed; it takes that plug-in's place and gets its settings and cache. The same module installs again without `replace`, and `replace: false` refuses a module whose id is installed. A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, unless a grant is already recorded for a module with the same SHA-256. A request's `grant` and `allowHeavy` are refused except in a session started with `vectorcraft-cli --allow-plugin-grants`. With `hash`, a module whose SHA-256 differs is refused. |
|
||||
| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when installing it needs a person's approval) and `needsReplace` (true when `plugin.install` needs `replace: true`). |
|
||||
| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder with its [settings and cache](#settings-and-cache). |
|
||||
| `plugin.clearCache` | `{"id"?}` | Deletes every entry in the [cache](#settings-and-cache) of plug-in `id`, or of every installed plug-in; settings stay. Returns `{"cleared": [id], "bytesFreed"}`. |
|
||||
| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). |
|
||||
@@ -147,8 +147,9 @@ an importer an import's). Each of its runs gets 1 G instructions (20 times a lig
|
||||
much there is only once the run has ended.
|
||||
|
||||
- **Installing asks first.** Install Plug-in… and File › Open show what the filter asks for, and install it once the
|
||||
user clicks OK; `plugin.install` refuses it without `allowHeavy: true`, which an agent passes once the user agreed
|
||||
(`plugin.inspect` tells it beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again
|
||||
user clicks OK (see [Permissions](#permissions) for who can). `plugin.install` refuses it without `allowHeavy: true`,
|
||||
and refuses `allowHeavy` from a request except in a session started with `vectorcraft-cli --allow-plugin-grants`
|
||||
(`plugin.inspect` shows the budget beforehand). A filter kept in the `Plug-ins` folder was agreed to and is installed again
|
||||
at startup. The Additional Plug-ins Folder skips heavy filters with a note in its report, since loading a folder
|
||||
asks nobody.
|
||||
- **It runs in the background.** Its dialog has no Preview. OK (or the menu item, without parameters) runs it on a
|
||||
@@ -194,16 +195,29 @@ capability model proposed for PhotoCraft plug-ins
|
||||
the file and each permission in plain words, and install it once the user clicks OK, if the file still has the
|
||||
SHA-256 shown; Cancel installs nothing. The
|
||||
names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters.
|
||||
`plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent
|
||||
passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the
|
||||
`plugin.inspect` entry's `hash` so that the module installed is the one the user saw. `plugin.install` does not
|
||||
check that a person approved `grant`: it trusts the client that sends it.
|
||||
Only a person can press its OK: `ui.dialog.confirm`, MCP's `answer_dialog` and injected input (`ui.key`, `ui.click`)
|
||||
are refused and the dialog stays open. Enter confirms it only when OK has the keyboard focus. `ui.dialog.set` can't
|
||||
change what it installs.
|
||||
- `plugin.install` refuses the module, naming the permissions it needs, unless this module already has a recorded grant
|
||||
(`plugin.inspect`'s `needsApproval` tells it beforehand). A request's `grant` is refused too: `plugin.install` takes
|
||||
it only with the approval the dialog gives. A `vectorcraft-cli mcp` or `run` started with `--allow-plugin-grants`
|
||||
takes `grant` and `allowHeavy` from any request; only the person starting the process can set that flag, and it
|
||||
doesn't apply to a running app. `mcp` refuses the flag together with `--automation-read-root` or
|
||||
`--automation-write-root`: a server confined to folders has no plug-in store, so it reads no `grants.json`, and a
|
||||
module that lists permissions or asks for a heavy budget does not install there. Without the flag, MCP in headless
|
||||
mode and the CLI install a module that lists permissions only when its hash is recorded in the shared `grants.json`.
|
||||
A person's approval in the desktop app records it, and so does a `grant` accepted under `--allow-plugin-grants`. A
|
||||
recorded grant counts as approved in later sessions without the flag and in the desktop app. With
|
||||
`VECTORCRAFT_NO_PREFS` set there is no store, and a `grant` accepted under the flag lasts for that session only.
|
||||
With `--allow-plugin-grants`, pass `hash` set to the `plugin.inspect` entry's `hash` so that the module installed is
|
||||
the one the user saw.
|
||||
- **Replacing asks first.** When the file is another module than the plug-in installed with its id, or, with none
|
||||
installed, its id has settings and a cache in the `Plug-ins` folder, Install Plug-in… and File › Open show its id
|
||||
and SHA-256 and ask whether to install it in place of that plug-in, which gives it the settings and cache kept for
|
||||
the id. `plugin.install` refuses such a module, naming it, unless `replace` is `true`, which an agent passes once
|
||||
the user agreed (`plugin.inspect`'s `needsReplace` tells it beforehand). The Additional Plug-ins Folder and
|
||||
`plugin.reload` do not ask.
|
||||
the id. When the same module also needs its permissions or a heavy budget approved, the one approval dialog asks
|
||||
about all of them, and only a person can press its OK. `plugin.install` refuses such a module, naming it, unless
|
||||
`replace` is `true`, which an agent passes once the user agreed (`plugin.inspect`'s `needsReplace` tells it
|
||||
beforehand). The Additional Plug-ins Folder and `plugin.reload` do not ask.
|
||||
- The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the
|
||||
`Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with
|
||||
other bytes asks again, even when it lists the same permissions, and does not get the id's settings and cache until
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# Roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; M4/M7: `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; M4/M7: `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; numeric fields step on wheel hover without taking keyboard focus, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; Indian-language hyphenation review and license allowlist, #4947; Hindi catalog and contributor loanword preferences; full wording review in progress; Effect dialogs: a colour swatch beside a hex colour opens the Color Picker, #5365; shape tools move the selection from its centre point and turn it from the zone outside a box corner, #5477; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -191,6 +191,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | minor | M7: the custom dictionary commands and Ignore All (#4936); the dialogs and Auto Spell Check remain; scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | M4/G9: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | M7: spell check engine, `text.spelling.check` and `text.spelling.suggest` on craft-proof (#4936); the dialog, Auto Spell Check and the custom dictionary remain; scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
|
||||
| 2026-11-09 | minor | Numeric fields step on wheel hover without taking focus; keyboard focus and existing rounding rules remain (#5949). Scores and estimates unchanged. |
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
# Parity with Adobe Illustrator
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (custom dictionary and Ignore All: `text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary` and `ignoreAll`, #4936; previously minor: EPS import: embedded fonts (composite, Type 42, CID-keyed, Type 1, CFF FontSet) draw their glyphs; spell check engine: `text.spelling.check` and `text.spelling.suggest` on craft-proof, #4936; previously minor: floating tab groups collapse into the side icon rail and opened rail panels fold back into it, #5925 in part; `.ai`/EPS type keeps its tab stops, and right-aligned lines with tabs lay out as the file's page, #6147; `.ai`/EPS type from the text document lays its lines out closer to the file's page, within limits, #6137; `.ai` and EPS import: older Art brush objects read; numeric fields step on wheel hover without taking keyboard focus, and a panel wheel turn cannot seed a canvas zoom glide, #5949; Character Styles panel: a double-click on a style's row opens its Character Style Options; the dialog keeps a fixed width and scrolls its sections, so its buttons stay on screen; Tools stays beside the canvas with left-side panels; new gradients follow document color mode; Selection QoL: marquee modes and visible feedback, #5313; SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; #3380 deletion-only review follow-up: independent survivors keep gradient placement, owned subpaths avoid discarded copies, and contracts/status/evidence are clarified; endpoint highlighting remains open; scores and estimates unchanged; the canvas keeps selection outlines and anchors between frames; Indian-language hyphenation review and license allowlist, #4947; Stroke gradients: an arrowhead aligned to the tip is painted to the corners of its base, #5964; Drop Shadow and glow dialogs: spinner arrows, #5367, #5368; Stroke Weight Up and Down shortcuts, #5797; Hindi catalog and contributor loanword preferences; full wording review in progress; Properties in Edit Artboards: a Transform section with the reference point above the Artboards section, #5219; the Control bar's Stroke: and Opacity: links scrub their fields, #4088; an image zoomed in on is separated, colorized or proofed only where it shows (#4155); a document's first frame renders on the render worker, so opening a heavy file no longer freezes the window, #4155; Control bar Paragraph section with type selected, #5372; translating unchanged type reuses its glyph and character-stroke outlines; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026; Units › East Asian Type acts, with Q and Ha units; scores unchanged)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
EPS embedded fonts, 2026-11-09 (#6023–#6067): the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. Evidence: `crates/eps/src/import/tests_composite.rs`, `tests_type42.rs`, `tests_cid.rs`, `tests_type1.rs`; details in [file-format-parity.md](file-format-parity.md).
|
||||
|
||||
Numeric fields step on wheel hover without taking keyboard focus (#5949). Arrow keys still require focus; modifiers, units, limits, mixed values and existing rounding stay in the shared stepping path. Headless regressions in `tests/numfields.rs` cover hovered unfocused fields, focused fields, trackpad accumulation and panel scroll exclusion. A canvas-first/panel-second regression also checks that a field turn cannot start a canvas zoom glide when the pointer enters the canvas on the next frame. Scores and estimates unchanged.
|
||||
|
||||
Raster rendering fits and preflattens curved stroke outlines more tightly only below four times the raster tolerance, retaining thin-band coverage without changing stroke weight or vector export geometry. Current cached character/object outlines, cropped long strokes, gradient clips and effect rasters share the helper; cropped-region reuse and guarded flattening remain. The unchanged `fractional_curved_strokes_retain_coverage_between_notches` regression covers fractional weights, rotation, phase, opacity and f32-rounded endpoints. This is a quality/cost tradeoff, not a speed claim. Scores and estimates unchanged.
|
||||
@@ -1585,6 +1587,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **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.
|
||||
- **Plug-in approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged.
|
||||
- **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged.
|
||||
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
@@ -1899,6 +1902,7 @@ Edit › Copy and Cut put the selected ruler guides on the clipboard, with the s
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-09 | minor | Type advanced: the custom dictionary commands (`text.spelling.addWord`, `removeWord`, `changeWord`, `dictionary`, saved beside the preferences) and Ignore All for the active document (`text.spelling.ignoreAll`), #4936; evidence added; the dialogs and Auto Spell Check remain; scores and hours unchanged. |
|
||||
| 2026-11-09 | minor | File formats: EPS import: the fonts an EPS carries draw their glyphs instead of being set as type in an installed font: composite (Type 0) fonts with the 8/8 mapping, plain Type 42 fonts (characters from their glyph names or character map, widths from the font), CID-keyed fonts over CMaps (Type 0 and Type 2 CIDFonts, CIDFonts in a FontSet's Compact Font Format data, base-font CMaps, named CIDFonts over predefined Unicode CMaps set as type) and embedded Type 1 fonts; the show operators' spacing along the baseline is kept as tracking, `kshow` included. Each step is charged to the interpreter's budgets. Hand-written test files only (no corpus file uses these fonts); scores and estimates unchanged. |
|
||||
| 2026-11-08 | minor | Type advanced: spell check engine, `text.spelling.check` lists unknown words per run language at `text.editRange` byte offsets and `text.spelling.suggest` gives suggestions, from craft-proof (#4936); evidence added; the dialog, Auto Spell Check and the custom dictionary remain; scores and hours unchanged. |
|
||||
| 2026-11-08 | minor | Floating tab groups collapse into the side icon rail and opened rail panels fold back into it (#5925, in part); tab groups remain intact. Scores and estimates unchanged. |
|
||||
| 2026-11-07 | minor | File formats and Type core: `.ai`/EPS type keeps its paragraphs' tab stops, and right-aligned area type sets the text after a line's last tab against the right edge, the text before it at its stops (#6147); evidence added; scores unchanged. |
|
||||
|
||||
Reference in New Issue
Block a user