Merge main into fix/outline-stroke-keeps-effects-on-outline

This commit is contained in:
dotCooCoo
2026-10-23 22:11:14 -07:00
819 changed files with 89318 additions and 7828 deletions
+30
View File
@@ -0,0 +1,30 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
timeout-minutes: 90
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev
+9 -15
View File
@@ -1,25 +1,19 @@
# FreeBSD build and package check in a QEMU VM.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
push:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
branches: [release]
pull_request:
branches: [main]
paths:
- ".github/workflows/freebsd.yml"
- "packaging/freebsd/**"
- "packaging/env.sh"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
concurrency:
+43 -1
View File
@@ -13,14 +13,56 @@ on:
default: ""
permissions:
contents: write
contents: read
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
jobs:
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
release:
needs: git-sources
permissions:
contents: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+21
View File
@@ -0,0 +1,21 @@
name: SonarCloud
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
concurrency:
group: sonarcloud-${{ github.ref }}
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
# Tracked at @main so org-wide CI improvements propagate without PR churn.
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
secrets: inherit
+7 -13
View File
@@ -5,23 +5,17 @@
# on GitHub Actions' native windows-11-arm runner.
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "3 5 * * *"
push:
branches: [main]
paths:
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
branches: [release]
pull_request:
branches: [main]
paths:
- ".github/workflows/windows-arm64.yml"
- "packaging/windows/**"
- "Cargo.toml"
- "Cargo.lock"
- "crates/**"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
workflow_dispatch:
concurrency:
+1
View File
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
+1
View File
@@ -169,6 +169,7 @@ Generated-in-code art is original and has no file to list. This covers the defau
| `assets/icons/dc-new-stroke.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-objects-on-path.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three small squares along a curve | MIT OR Apache-2.0 | |
| `assets/icons/dc-options.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-bullets.svg` | VectorCraft contributors | Original work, drawn for VectorCraft: three dots, each before a line | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-all.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
| `assets/icons/dc-para-justify-center.svg` | VectorCraft contributors | Original work, drawn for VectorCraft | MIT OR Apache-2.0 | |
+1
View File
@@ -45,6 +45,7 @@ Formerly **DrawCraft** (renamed 2026-10-01): old `.drawcraft` files and `"format
- **Layering** is enforced by `cargo xtask layers`. Nothing below L6 depends on egui/eframe/winit/rfd.
- **The UI is thin**: panels read engine state and act through `app.run(id, params)`. Colours come from `theme::Tokens`.
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Shared test corpora.** Real-file test oracles (Photoshop-authored PSDs, etc.) live in [`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in [craftrules `standards/test-corpora.md`](https://github.com/storytold/craftrules/blob/main/standards/test-corpora.md). Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does with `cargo xtask corpus`.
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings including the no-panic lints, tests, layers, wasm). One task id per commit (`M2.1: pen tool`).
Generated
+288 -302
View File
File diff suppressed because it is too large Load Diff
+18 -4
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.14.0"
version = "0.16.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -15,6 +15,9 @@ craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "ca50d63b77
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d991bf6792f8ade99c51358bf3c1d902c8cd96b5" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
# internal
vectorcraft-geom = { path = "crates/geom" }
vectorcraft-color = { path = "crates/color" }
@@ -22,8 +25,9 @@ vectorcraft-doc = { path = "crates/doc" }
vectorcraft-pathops = { path = "crates/pathops" }
vectorcraft-brush = { path = "crates/brush" }
vectorcraft-trace = { path = "crates/trace" }
# Without its default `bundled-fonts`: the engine turns them on (its own default feature), so a
# library crate never forces them on an embedder that supplies its fonts at run time.
# Without its default `bundled-fonts`: a crate that wants the fonts asks for them (the apps and
# vectorcraft-testkit through the engine's `bundled-fonts`), so a library crate never forces them on
# an embedder that supplies its fonts at run time.
vectorcraft-text = { path = "crates/text", default-features = false }
vectorcraft-effects = { path = "crates/effects" }
vectorcraft-three-d = { path = "crates/three-d" }
@@ -32,7 +36,9 @@ vectorcraft-svg = { path = "crates/svg" }
vectorcraft-pdf = { path = "crates/pdf" }
vectorcraft-format = { path = "crates/format" }
vectorcraft-tools = { path = "crates/tools" }
vectorcraft-engine = { path = "crates/engine" }
# Without its default `bundled-fonts`, as vectorcraft-text: each crate that wants the fonts asks for
# them, so an embedder can build without them (docs/development.md › Leaving out the bundled fonts).
vectorcraft-engine = { path = "crates/engine", default-features = false }
vectorcraft-ui-egui = { path = "crates/ui-egui" }
vectorcraft-mcp = { path = "crates/mcp" }
vectorcraft-testkit = { path = "crates/testkit" }
@@ -50,6 +56,8 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
crc32fast = "1.5"
# tests: a TCP port bound without listening (connections to it are refused)
@@ -83,6 +91,8 @@ ruzstd = { version = "0.9.1", default-features = false, features = ["std"] }
# CCITT Group 3 and Group 4 decoder (ITU-T T.4 and T.6): EPS images read through
# `CCITTFaxDecode`. The PDF reader's hayro-syntax uses the same version.
hayro-ccitt = "0.3"
# Substring search for the EPS reader's PostScript `search`.
memchr = "2"
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# CMYK image samples (vectorcraft-doc's cmyk module): the image crate's own TIFF and JPEG
@@ -114,11 +124,15 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
# Workspace crates keep line tables, so panics and backtraces name file and line; dependencies
# carry no debug info. Full debug info would make the dev target dir several times larger.
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.release]
lto = "thin"
+12 -7
View File
@@ -8,6 +8,10 @@
</p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.vectorcraft.png" width="128" height="128" alt="VectorCraft icon: an engraved dragon on a red tile">
</p>
<h1 align="center">VectorCraft</h1>
<p align="center">
@@ -202,10 +206,10 @@ the app with `WAYLAND_DISPLAY= vectorcraft`: the window then opens through XWayl
(see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
On Linux with Wayland, under KDE Plasma 6.3 or later and under GNOME, VectorCraft doesn't respond to a drawing tablet's
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse: set the same
preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use XWayland only when a pen is
attached as VectorCraft starts, and restart; or start the app with `WAYLAND_DISPLAY= vectorcraft` (see
[`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
pen yet (#491, #2028). Open the window through XWayland instead, where the pen works as a mouse and the app reads its
pressure: set the same preference to **X11 (XWayland)**, or to **X11 (XWayland) When a Pen Is Attached** to use
XWayland only when a pen is attached as VectorCraft starts, and restart; or start the app with
`WAYLAND_DISPLAY= vectorcraft` (see [`docs/development.md`](docs/development.md#linux-wayland-and-x11)).
### Use it from Claude Code and other agents
@@ -238,8 +242,9 @@ Pathfinder and Shape Builder, paint, gradients, appearance and transparency, typ
files (SVG, PDF and PDF-compatible `.ai` with PDF/X, legacy version 7 `.ai` keeping layers, groups and type, EPS, DXF, EMF/WMF, raster formats, PSD out and PSD/PSB in as their merged image, Print, Package).
Affinity documents (`.af` from Affinity 3, `.afdesign`, `.afpub` and, by their content, `.afphoto` from Affinity 1 and 2) open
and place natively: layers, groups, artboards and pages, curves and shapes, fills, gradients and strokes, clipping
and masks, text and images, with what didn't come in (effects, adjustments, brushes, master pages…) listed in the
import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
and masks, text with each paragraph's indents, spacing and leading, tables (as text frames and lines), master
pages, shadows, glows, outlines and overlays, embedded Affinity documents and images, with what didn't come in
(adjustments, blurs, bevels, brushes…) listed in the import warning; a file whose native data can't be read opens as its embedded preview, saying why. VectorCraft
doesn't write Affinity files. Current `.af` validation includes 33 pinned files, native save/reload
and every-board SVG/PDF/PSD export, with fixes for Affinity 3 artboards, source-backed JPEGs and text runs.
[Scope and limits](crates/affinity/README.md); [source audit and remaining gaps](docs/affinity-validation.md).
@@ -253,7 +258,7 @@ The scores are self-assessed from our code against public documentation;
- reliable exchange of .ai files (layer structure in real files; proprietary editing data isn't written);
- an interaction-fidelity pass covering every tool's modifiers and small behaviours;
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
- most of the raster effects (11 of 57 still missing; Cutout, Film Grain, Neon Glow, Plastic Wrap, Poster Edges, Smudge Stick, Sponge and Watercolor are available under Artistic, Bas Relief, Chalk & Charcoal, Charcoal, Chrome, Graphic Pen, Halftone Pattern, Note Paper, Photocopy, Plaster, Reticulation and Stamp under Sketch) and the Effect Gallery;
- filter thumbnails and a preview area inside the Effect Gallery (the gallery itself and all 57 Photoshop-style raster filters, approximated, are in);
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
- scripting, and the rest of Variables (image and graph kinds, dataset import);
- generative AI features.
+26 -5
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
[dependencies]
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-engine = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-format = { workspace = true }
vectorcraft-geom = { workspace = true }
vectorcraft-svg = { workspace = true }
+127 -22
View File
@@ -1,8 +1,8 @@
//! `vectorcraft-cli`: VectorCraft from the command line.
//!
//! ```text
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR] [--automation-write-root DIR]
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}']]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2]
//! vectorcraft-cli commands
//! vectorcraft-cli convert IN OUT [--scale 2] [--artboard 0 | --range 1-3,5] [--outline-text] [--decimals N] [--replace-lossy] [--editing-data] [--ai-version 7|pdf] [--hidden-objects layer|omit|visible] [--transparent-images flatten|omit]
//! vectorcraft-cli info FILE
@@ -68,19 +68,24 @@ const USAGE: &str = "\
vectorcraft-cli — VectorCraft automation
USAGE:
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]...
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
(open, place, relink, library loads…) and write (save, export, package, library saves…) to
DIR, links followed; a root left out grants none of its access. They imply --headless: a
running app enforces its own (vectorcraft --control PORT --automation-read-root DIR …).
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 …).
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON] | --commands FILE|-]... [--export FILE]... [--scale N]
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). --scale sizes raster exports
(pixels per point, default 1); other formats leave it out. Prints one JSON result per step.
--commands reads steps from FILE, or from standard input for -, where it stands among the
--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.
vectorcraft-cli commands
Print the command catalogue as JSON.
@@ -96,8 +101,9 @@ USAGE:
SVG's data-* attributes; --outline-text writes SVG text as paths, --decimals sets SVG's coordinate
precision (1–7, default 3). OUT may not be IN when reading
IN left things out (hidden text, art or layers it could not read; the notes say which) unless
--replace-lossy is given. --editing-data opens a .ai from its editing copy even where that
draws differently from its PDF part (which can be older), as long as it can be read. The
--replace-lossy is given. --editing-data opens a .ai or an EPS with editing data from its editing copy
even where that draws differently from its PDF part or page (which can be older), as long as
it can be read. The
result has the export's warnings, and importWarnings for what reading IN left out.
--ai-version 7 writes a .ai as the legacy version 7 (layers, groups and type kept; one file
per artboard, OUT-<artboard>.ai when there are several; default pdf, PDF-compatible), with
@@ -176,7 +182,7 @@ fn with_stores(mut h: Headless) -> Headless {
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut headless = false;
let (mut read_root, mut write_root): (Option<String>, Option<String>) = (None, None);
let (mut read_roots, mut write_roots): (Vec<String>, Vec<String>) = (vec![], vec![]);
let mut it = args.iter();
while let Some(a) = it.next() {
// The roots also come as `--flag=DIR`, as the other Craft apps take them.
@@ -189,10 +195,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
"--headless" => headless = true,
"--automation-read-root" | "--automation-write-root" => {
let dir = inline.or_else(|| it.next().cloned()).ok_or_else(|| format!("{flag} needs a folder"))?;
let slot = if flag == "--automation-read-root" { &mut read_root } else { &mut write_root };
if slot.replace(dir).is_some() {
return Err(format!("{flag} is given twice: it takes one folder"));
}
if flag == "--automation-read-root" { read_roots.push(dir) } else { write_roots.push(dir) }
}
other => return Err(format!("unknown mcp option `{other}`")),
}
@@ -200,7 +203,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
if headless && connect.is_some() {
return Err("use either --connect or --headless".into());
}
let roots = AutomationRoots::new(read_root.as_deref().map(Path::new), write_root.as_deref().map(Path::new))?;
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the headless server; a running app enforces its own roots: start it with `vectorcraft --control <port> --automation-read-root <dir> --automation-write-root <dir>`"
.into());
@@ -208,8 +213,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
let backend: Box<dyn Backend> = if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("vectorcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
} else if headless {
Box::new(with_stores(Headless::with_document()))
@@ -337,28 +341,127 @@ enum Step {
Export(String),
}
/// Most bytes `run --commands` reads from a file or standard input: the steps are untrusted input.
const MAX_COMMANDS_BYTES: u64 = 64 * 1024 * 1024;
/// Most steps one `run --commands` takes.
const MAX_COMMANDS_STEPS: usize = 100_000;
/// The text of `--commands source`: the file `source`, or standard input for `-`, refused past
/// [`MAX_COMMANDS_BYTES`].
fn read_commands(source: &str) -> Result<String, String> {
use std::io::Read;
let mut bytes = vec![];
let read = if source == "-" {
std::io::stdin().lock().take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes)
} else {
std::fs::File::open(source).and_then(|f| f.take(MAX_COMMANDS_BYTES + 1).read_to_end(&mut bytes))
};
read.map_err(|e| format!("--commands {source}: {e}"))?;
if bytes.len() as u64 > MAX_COMMANDS_BYTES {
return Err(format!("--commands {source}: more than {} MiB of steps", MAX_COMMANDS_BYTES / (1024 * 1024)));
}
String::from_utf8(bytes).map_err(|_| format!("--commands {source}: not UTF-8 text"))
}
/// The steps of `--commands source` from its `text`: a JSON array of `{"command": ID, "params":
/// {…}}` objects (`"id"` may stand for `"command"`, as in MCP's `command_batch`), or one such object
/// per line (JSON Lines, blank lines skipped). `first` is the run's index of the first of them
/// (the `N` of `"$N"`), which an error names with the entry's place in the text.
fn parse_commands(source: &str, text: &str, first: usize) -> Result<Vec<Step>, String> {
// Notepad on Windows starts UTF-8 files with a byte order mark.
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let too_many = || format!("--commands {source}: more than {MAX_COMMANDS_STEPS} steps");
// Each entry with where it is in the text: its line for JSON Lines.
let mut entries: Vec<(Option<usize>, Value)> = vec![];
if text.trim_start().starts_with('[') {
let v: Value = serde_json::from_str(text).map_err(|e| format!("--commands {source}: {e}"))?;
let Value::Array(items) = v else { return Err(format!("--commands {source}: not a JSON array")) };
if items.len() > MAX_COMMANDS_STEPS {
return Err(too_many());
}
entries.extend(items.into_iter().map(|v| (None, v)));
} else {
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty()) {
if entries.len() == MAX_COMMANDS_STEPS {
return Err(too_many());
}
let v = serde_json::from_str(line).map_err(|e| format!("--commands {source}: step {} (line {}): {e}", first + entries.len(), n + 1))?;
entries.push((Some(n + 1), v));
}
}
entries
.into_iter()
.enumerate()
.map(|(k, (line, v))| {
command_step(v).map_err(|e| {
let place = line.map_or_else(|| format!("entry {}", k + 1), |l| format!("line {l}"));
format!("--commands {source}: step {} ({place}): {e}", first + k)
})
})
.collect()
}
/// The step of one `--commands` entry, `{"command": ID, "params": {…}}`.
fn command_step(v: Value) -> Result<Step, String> {
let Value::Object(mut o) = v else { return Err("a step must be an object such as {\"command\": \"select.all\", \"params\": {}}".into()) };
let id = match (o.remove("command"), o.remove("id")) {
(Some(Value::String(id)), None) | (None, Some(Value::String(id))) => id,
(Some(_), Some(_)) => return Err("give `command` or `id`, not both".into()),
(None, None) => return Err("no `command`".into()),
_ => return Err("`command` must be a string, a command id".into()),
};
let params = match o.remove("params") {
None | Some(Value::Null) => json!({}),
Some(p @ Value::Object(_)) => p,
Some(_) => return Err(format!("`params` of {id} must be a JSON object")),
};
if let Some(key) = o.keys().next() {
return Err(format!("unknown key `{key}`: a step has `command` (or `id`) and `params`"));
}
Ok(Step::Cmd(id, params))
}
fn run(args: &[String]) -> Result<(), String> {
let mut input: Option<String> = None;
let mut steps: Vec<Step> = vec![];
// The step of the `--cmd` just given, which a `--params` sets.
let mut params_for: Option<usize> = None;
let mut stdin_read = false;
let mut scale = 1.0;
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}"));
match a.as_str() {
"--in" | "-i" => input = Some(val("a file")?),
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
"--cmd" | "-c" => {
steps.push(Step::Cmd(val("a command id")?, json!({})));
params_for = Some(steps.len() - 1);
}
"--params" | "-p" => {
let raw = val("JSON")?;
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
if !p.is_object() {
return Err(format!("--params must be a JSON object, got {raw}"));
}
match steps.last_mut() {
match params_for.and_then(|i| steps.get_mut(i)) {
Some(Step::Cmd(_, params)) => *params = p,
_ => return Err("--params must follow a --cmd".into()),
}
}
"--export" | "-o" => steps.push(Step::Export(val("a file")?)),
"--commands" => {
let source = val("a file, or - for standard input")?;
if source == "-" && std::mem::replace(&mut stdin_read, true) {
return Err("--commands - is given twice: standard input is read once".into());
}
let text = read_commands(&source)?;
let read = parse_commands(&source, &text, steps.len())?;
steps.extend(read);
params_for = None;
}
"--export" | "-o" => {
steps.push(Step::Export(val("a file")?));
params_for = None;
}
"--scale" | "-s" => {
let raw = val("a number")?;
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
@@ -393,10 +496,12 @@ fn run(args: &[String]) -> Result<(), String> {
// Each step's result, for later steps to refer to (`"$1.id"`, see `vectorcraft_engine::steps`).
let mut results = vec![];
for step in steps {
// The step's index, as `"$N"` names its result.
let n = results.len();
let r = match step {
Step::Cmd(id, params) => {
let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("{id}: {e}"))?;
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
let params = vectorcraft_engine::steps::resolve(&params, &results).map_err(|e| format!("step {n} ({id}): {e}"))?;
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("step {n} ({id}): {e}"))?;
emit(json!({"step": "cmd", "command": id, "result": r}))?;
r
}
@@ -406,7 +511,7 @@ fn run(args: &[String]) -> Result<(), String> {
} else {
h.call("app.export", with_pixel_scale(json!({"path": path}), &path, scale))
};
let r = r.map_err(|e| format!("export {path}: {e}"))?;
let r = r.map_err(|e| format!("step {n}: export {path}: {e}"))?;
emit(json!({"step": "export", "result": r}))?;
r
}
+27 -3
View File
@@ -23,13 +23,16 @@ impl Rng {
}
}
/// The synthetic document's page, in points.
const SYNTHETIC_PAGE: (f64, f64) = (1600.0, 1200.0);
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
pub fn synthetic(n: usize) -> Result<Document, String> {
let mut d = Document::new(1600.0, 1200.0);
let mut d = Document::new(SYNTHETIC_PAGE.0, SYNTHETIC_PAGE.1);
let l = d.layers.first().map(|l| l.id);
let mut r = Rng(42);
for i in 0..n {
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
let (x, y, s) = (r.next() * SYNTHETIC_PAGE.0, r.next() * SYNTHETIC_PAGE.1, 3.0 + r.next() * 22.0);
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
let id = d.alloc_id();
let mut node = match i % 3 {
@@ -282,7 +285,7 @@ fn deep(n: usize) -> Result<(), String> {
let key = format!("photo {i}");
d.images.insert(key.clone(), vectorcraft_doc::ImageBlob::new("image/png", photo));
let xf = Affine::translate((f64::from(i % 2) * 800.0, f64::from(i / 2) * 600.0)) * Affine::scale(800.0 / 6000.0);
let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default() };
let image = vectorcraft_doc::ImageObject { key, width: 6000, height: 4000, xf, link: None, placement: Default::default(), pixels: None };
let id = d.alloc_id();
d.insert(l, usize::MAX, Node::new(id, vectorcraft_doc::NodeKind::Image(Box::new(image)))).map_err(|e| e.to_string())?;
}
@@ -296,6 +299,27 @@ fn deep(n: usize) -> Result<(), String> {
rows.push(("render 4 embedded 24 MP photos with 3 renderers (cold)".into(), ms));
note("after rendering the photos with 3 renderers");
}
// An SVG with one embedded picture opened from its text, as picture-heavy SVGs come: the
// importer's passes over the text before usvg reads it, most of which is the data URL. The
// picture is the synthetic page drawn at 1.25x (about 2 MB as a PNG; the row names its size);
// the hidden rectangle is a marker, so the prepass walks the tree as it does for a file that
// has one, instead of returning at its first check.
{
let k = 1.25;
let (w, h) = ((SYNTHETIC_PAGE.0 * k).round() as u32, (SYNTHETIC_PAGE.1 * k).round() as u32);
let photo = Renderer::new().render(&synthetic(3000)?, w, h, Affine::scale(k), &opts).to_png()?;
let data = vectorcraft_svg::base64_encode(&photo);
let svg = format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}"><image width="{w}" height="{h}" href="data:image/png;base64,{data}"/><rect width="100" height="100" style="display:none"/></svg>"#
);
// One import checked first, so a failure is reported rather than timed; the same text
// imports the same way each time. Its document is kept for the memory reading.
let imported = vectorcraft_svg::import_with_report(&svg).map_err(|e| e.to_string())?;
let ms = median_ms(5, || drop(vectorcraft_svg::import_with_report(&svg)));
rows.push((format!("import SVG, one embedded {} KB PNG", photo.len() / 1024), ms));
note("after the import of an embedded picture");
drop(imported);
}
// Output hashes, to compare builds: the SVG export and a save → open → save round trip.
let fnv = |b: &[u8]| b.iter().fold(0xcbf2_9ce4_8422_2325u64, |h, &x| (h ^ u64::from(x)).wrapping_mul(0x100_0000_01b3));
let doc = synthetic(n)?;
@@ -1,5 +1,3 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};
@@ -84,11 +82,125 @@ fn scale_sizes_raster_exports_only() {
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing"));
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
assert!(!out.status.success());
}
/// The JSON lines `run` printed.
fn json_lines(stdout: &[u8]) -> Vec<Value> {
String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
}
/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
/// placed file's `dataBase64` longer than a Windows command line holds fits.
#[test]
fn run_reads_steps_from_a_commands_file() {
let steps = tmp("steps.json");
let svg = tmp("steps.svg");
// Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
let padding = "x".repeat(40_000);
let placed = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><!-- {padding} --><rect width="40" height="20" fill="#118833"/></svg>"##
);
let data = vectorcraft_format::base64_encode(placed.as_bytes());
assert!(data.len() > 32_767);
let file = json!([
{"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
]);
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let out = Command::new(BIN)
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
// `$2` in the file and in the `--cmd` after it is the placed art.
assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
let written = std::fs::read_to_string(&svg).unwrap();
assert!(written.contains("118833") && written.contains("barcode"), "{written}");
}
/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
/// CRLF line ends are fine, and the first step may be `file.new`.
#[test]
fn run_reads_json_lines_from_standard_input() {
let svg = tmp("stdin.svg");
let mut child = Command::new(BIN)
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
assert_eq!(lines.len(), 4);
assert_eq!(lines[0]["command"], "file.new");
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
}
/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
/// refused; a step that fails names its number.
#[test]
fn run_refuses_malformed_commands_naming_the_step() {
let fails = |args: &[&str]| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
assert!(!out.status.success(), "{args:?}");
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
};
let bad = |name: &str, text: &[u8], wanted: &[&str]| {
let path = tmp(name);
std::fs::write(&path, text).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
assert_eq!(printed, 0, "{name}: nothing runs");
};
bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["step 2 (line 3)"]);
bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
// Past 64 MiB (a sparse file: nothing is written).
let huge = tmp("huge.json");
std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
assert!(e.contains("more than 64 MiB"), "{e}");
let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
assert!(e.contains("missing.json"), "{e}");
// `--params` sets the `--cmd` just before it, never a step of the file.
let ok = tmp("ok.json");
std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
assert!(e.contains("--params must follow a --cmd"), "{e}");
let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
assert!(e.contains("standard input is read once"), "{e}");
// A step that fails is named by its number, after the steps before it ran.
let failing = tmp("failing.json");
std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
assert!(e.contains("step 2 (nope.nothing)"), "{e}");
assert_eq!(printed, 2);
}
#[test]
fn mcp_headless_over_stdio() {
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
@@ -130,6 +242,58 @@ fn mcp_automation_roots_in_a_folder_an_earlier_run_left() {
automation_roots(&empty_dir(dir).join("roots"));
}
/// #3546: each root flag may be given more than once: a path inside any of the read roots opens,
/// one inside any of the write roots is written, and everything else, a write root's sibling read
/// root included, is refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
let base = tmp("several-roots");
for d in ["in", "in2", "out", "out2", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="20" height="10"/></svg>"#;
for f in ["in/a.svg", "in2/b.svg", "outside/c.svg"] {
std::fs::write(base.join(f), svg).unwrap();
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
let mut child = Command::new(BIN)
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
.args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let call = |id: u64, name: &str, args: Value| json!({"jsonrpc":"2.0","id":id,"method":"tools/call","params":{"name":name,"arguments":args}});
let calls = [
(call(1, "open_file", json!({"path": file("in", "a.svg")})), false),
(call(2, "open_file", json!({"path": file("in2", "b.svg")})), false),
(call(3, "open_file", json!({"path": file("outside", "c.svg")})), true),
(call(4, "export", json!({"path": file("out", "a.png")})), false),
(call(5, "export", json!({"path": file("out2", "b.png")})), false),
(call(6, "export", json!({"path": file("outside", "c.png")})), true),
(call(7, "export", json!({"path": file("in2", "d.png")})), true),
(call(8, "open_file", json!({"path": file("out2", "b.png")})), true),
];
{
let mut stdin = child.stdin.take().unwrap();
for (m, _) in &calls {
writeln!(stdin, "{m}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("files confined: read ") && stderr.contains("; write "), "{stderr}");
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, calls.iter().map(|(_, e)| *e).collect::<Vec<_>>(), "{replies:?}");
assert!(replies[2]["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read roots"), "{}", replies[2]);
assert!(base.join("out/a.png").is_file() && base.join("out2/b.png").is_file());
assert!(!base.join("outside/c.png").exists() && !base.join("in2/d.png").exists());
}
fn automation_roots(base: &std::path::Path) {
for d in ["in", "out", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
@@ -153,7 +317,7 @@ fn automation_roots(base: &std::path::Path) {
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
};
fails(&["mcp", "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("out")], "given twice");
fails(&["mcp", "--automation-read-root", &dir("in"), "--automation-read-root", &dir("missing")], "cannot open the automation read root");
fails(&["mcp", "--automation-write-root"], "needs a folder");
fails(&["mcp", "--connect", "127.0.0.1:1", &format!("--automation-write-root={}", dir("out"))], "a running app enforces its own roots");
@@ -1,6 +1,4 @@
//! The CLI opens every readable format (PDF, .ai, images) through the engine's `document.open`.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
+7
View File
@@ -0,0 +1,7 @@
//! Integration tests: one test binary for the crate, one module per area.
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod fileio;
mod mcp_e2e;
@@ -1,6 +1,4 @@
//! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
+1 -1
View File
@@ -14,7 +14,7 @@ name = "vectorcraft-web"
path = "src/main.rs"
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-format = { workspace = true }
vectorcraft-text = { workspace = true }
+12 -2
View File
@@ -17,13 +17,17 @@ accessibility = ["eframe/accesskit"]
windows7 = ["eframe/glow"]
[dependencies]
vectorcraft-engine = { workspace = true }
vectorcraft-engine = { workspace = true, features = ["bundled-fonts"] }
vectorcraft-ui-egui = { workspace = true }
vectorcraft-text = { workspace = true }
# Shared across the Craft suite: decides who draws the window frame per desktop (compositor vs app)
# so VectorCraft matches the rest on Linux. `egui` adds `craft_window::decorate`. See #217.
craft-window = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wayland", "x11"] }
egui = { workspace = true }
serde_json = { workspace = true }
log = { workspace = true }
chrono = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd = { workspace = true }
@@ -47,8 +51,10 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"], o
# DirectWrite's system font collection (src/system_fonts.rs): fonts that font services such as
# Adobe Fonts load in place (#579). MIT OR Apache-2.0, already in the build through accesskit.
# Win32_UI_WindowsAndMessaging has `MSG` and `WM_POINTERDOWN`, which src/windows_stylus.rs uses to
# read a pen's barrel button from its pointer messages.
[target.'cfg(all(windows, not(target_vendor = "win7")))'.dependencies]
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite"] }
windows = { version = "0.62", features = ["Win32_Graphics_DirectWrite", "Win32_UI_WindowsAndMessaging"] }
[target.'cfg(target_os = "macos")'.dependencies]
# The macOS menu bar (src/mac_menu.rs): Tauri's menu crate, AppKit menus behind a safe API (no GTK
@@ -74,6 +80,10 @@ pollster = "1"
[target.'cfg(target_os = "linux")'.dependencies]
# GNOME/Wayland needs client-side window controls without a bundled title font.
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
# A drawing tablet's pen pressure under X11 and XWayland (src/tablet.rs): XInput2 raw events on a
# second X connection, x11rb's pure-Rust `RustConnection`. MIT OR Apache-2.0, already in the build
# through winit with the `xinput` feature.
x11rb = { version = "0.13", default-features = false, features = ["xinput"] }
# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback, src/compositor_gpu.rs),
# so Automatic draws on that one. Both are already in the build through winit.
+36 -23
View File
@@ -6,29 +6,29 @@ use std::ffi::OsString;
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
pub read_roots: Vec<PathBuf>,
pub write_roots: Vec<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
/// The roots the environment gives (`VECTORCRAFT_AUTOMATION_READ_ROOT` / `_WRITE_ROOT`), until
/// the command line gives its own.
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect(), read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. CLI values override environment defaults
/// once; a second occurrence is an error, including when the two spellings are mixed.
/// Returns whether this argument is a root flag. Each flag may be given more than once (#3546),
/// in either spelling: the first replaces the environment's root of its kind, the others add
/// folders.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
let (roots, given) = match flag {
"--automation-read-root" => (&mut self.read_roots, &mut self.read_given),
"--automation-write-root" => (&mut self.write_roots, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
@@ -42,8 +42,10 @@ impl AutomationArgs {
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
if !std::mem::replace(given, true) {
roots.clear();
}
roots.push(value.into());
Ok(true)
}
}
@@ -97,17 +99,24 @@ mod tests {
use super::*;
#[test]
fn roots_override_environment_once_and_leave_other_arguments() {
let mut roots = AutomationArgs::new(Some("environment".into()), None);
fn roots_override_environment_and_add_up() {
let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
let mut args = ["a folder".to_owned(), "drawing.svg".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--in-window-menus", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("drawing.svg"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
// #3546: a flag given again adds a folder, in either spelling.
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
// A kind the command line leaves alone keeps the environment's root.
let mut roots = AutomationArgs::new(Some("environment".into()), Some("environment out".into()));
assert!(roots.consume("--automation-read-root=a", &mut std::iter::empty()).unwrap());
assert_eq!(roots.write_roots, [PathBuf::from("environment out")]);
}
#[test]
@@ -117,11 +126,15 @@ mod tests {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
// A bad second folder is an error too, and keeps the first.
assert!(roots.consume(&format!("{flag}=first"), &mut std::iter::empty()).unwrap());
assert!(roots.consume(flag, &mut std::iter::empty()).is_err());
assert_eq!([roots.read_roots.len(), roots.write_roots.len()].iter().sum::<usize>(), 1);
}
}
@@ -130,8 +143,8 @@ mod tests {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
fn control(args: &[&str]) -> Result<Option<u16>, String> {
+7
View File
@@ -143,6 +143,13 @@ fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
Some(p) if p.ends_with('\r') => PathBuf::from(p.trim_end_matches('\r')),
_ => path,
};
// A paste sent by automation reads only what its roots allow.
match path.to_str() {
Some(p) => vectorcraft_engine::file_access::check_read(p).ok()?,
// A lossy name would check another file than the one opened.
None if vectorcraft_engine::file_access::current().is_none() => {}
None => return None,
}
let mut head = Vec::with_capacity(FILE_HEAD);
std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?;
let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?;
+116
View File
@@ -0,0 +1,116 @@
//! Linux pen tablets: winit reports barrel buttons as [`winit::event::MouseButton::Other`]
//! (for example `BTN_STYLUS`), and egui-winit drops those before they reach the UI (#3632).
use eframe::AppCreator;
use winit::application::ApplicationHandler;
use winit::event::{DeviceEvent, DeviceId, StartCause, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use winit::window::WindowId;
use eframe::UserEvent;
/// `BTN_STYLUS` and neighbours (`linux/input-event-codes.h`): pen barrel buttons Wayland passes
/// through the pointer protocol as non-standard mouse buttons.
const BTN_STYLUS: u16 = 0x014b;
const BTN_STYLUS2: u16 = 0x014c;
const BTN_STYLUS3: u16 = 0x014d;
/// Like [`eframe::run_native`], but turns pen barrel buttons into mouse buttons egui understands.
pub fn run_native(app_name: &str, mut native_options: eframe::NativeOptions, app_creator: AppCreator<'_>) -> eframe::Result {
let mut builder = EventLoop::<UserEvent>::with_user_event();
if let Some(hook) = native_options.event_loop_builder.take() {
hook(&mut builder);
}
let event_loop = builder.build().map_err(eframe::Error::WinitEventLoop)?;
event_loop.set_control_flow(ControlFlow::Poll);
let inner = eframe::create_native(app_name, native_options, app_creator, &event_loop);
let mut app = Forwarder { inner };
event_loop.run_app(&mut app).map_err(eframe::Error::WinitEventLoop)?;
Ok(())
}
struct Forwarder<'a> {
inner: eframe::EframeWinitApplication<'a>,
}
impl ApplicationHandler<UserEvent> for Forwarder<'_> {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
self.inner.resumed(event_loop);
}
fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) {
self.inner.window_event(event_loop, window_id, map_window_event(event));
}
fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) {
self.inner.new_events(event_loop, cause);
}
fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) {
self.inner.user_event(event_loop, event);
}
fn device_event(&mut self, event_loop: &ActiveEventLoop, device_id: DeviceId, event: DeviceEvent) {
self.inner.device_event(event_loop, device_id, event);
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
self.inner.about_to_wait(event_loop);
}
fn suspended(&mut self, event_loop: &ActiveEventLoop) {
self.inner.suspended(event_loop);
}
fn exiting(&mut self, event_loop: &ActiveEventLoop) {
self.inner.exiting(event_loop);
}
fn memory_warning(&mut self, event_loop: &ActiveEventLoop) {
self.inner.memory_warning(event_loop);
}
}
/// Map a tablet button code winit labels `MouseButton::Other` to a standard mouse button.
pub(crate) fn map_tablet_button(code: u16) -> Option<winit::event::MouseButton> {
use winit::event::MouseButton;
match code {
BTN_STYLUS | BTN_STYLUS2 => Some(MouseButton::Right),
BTN_STYLUS3 => Some(MouseButton::Middle),
_ => None,
}
}
fn map_window_event(event: WindowEvent) -> WindowEvent {
if let WindowEvent::MouseInput { device_id, state, button: winit::event::MouseButton::Other(code), .. } = event
&& let Some(button) = map_tablet_button(code)
{
WindowEvent::MouseInput { device_id, state, button }
} else {
event
}
}
#[cfg(test)]
mod tests {
use super::*;
use winit::event::{ElementState, MouseButton};
#[test]
fn stylus_barrel_buttons_become_secondary_clicks() {
assert_eq!(map_tablet_button(BTN_STYLUS), Some(MouseButton::Right));
assert_eq!(map_tablet_button(BTN_STYLUS2), Some(MouseButton::Right));
assert_eq!(map_tablet_button(BTN_STYLUS3), Some(MouseButton::Middle));
assert_eq!(map_tablet_button(0x110), None);
}
#[test]
fn window_mouse_input_is_remapped_before_eframe() {
let device_id = winit::event::DeviceId::dummy();
let press = WindowEvent::MouseInput { device_id, state: ElementState::Pressed, button: MouseButton::Other(BTN_STYLUS) };
let mapped = map_window_event(press);
assert!(matches!(mapped, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Pressed, .. }));
let release = map_window_event(WindowEvent::MouseInput { device_id, state: ElementState::Released, button: MouseButton::Other(BTN_STYLUS2) });
assert!(matches!(release, WindowEvent::MouseInput { button: MouseButton::Right, state: ElementState::Released, .. }));
}
}
+40 -18
View File
@@ -93,28 +93,36 @@ fn matches(name: &str, target: &str) -> bool {
}
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs();
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
// Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
// past `i64::MAX` seconds saturates rather than falling back to the epoch.
let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
}
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch). Saturating, so no clock
/// value can overflow it.
fn civil_from_days(days: u64) -> (u64, u64, u64) {
let z = days.saturating_add(719_468);
let era = z / 146_097;
let doe = z % 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
(y, m, d)
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// so month, day and time of day come from chrono and only the year is carried, which keeps every
/// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
use chrono::Datelike;
let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
match (date, time) {
(Some(d), Some(t)) => {
let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
}
fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -387,6 +395,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
}
#[test]
+25 -32
View File
@@ -26,9 +26,7 @@ use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSPopUpButton, NSSavePan
use objc2_foundation::{NSArray, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString, NSURL};
use raw_window_handle::{HasWindowHandle, RawWindowHandle};
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::{FilePick, i18n};
type Filter = (&'static str, &'static [&'static str]);
use vectorcraft_ui_egui::{FileFilter as Filter, FilePick, i18n};
/// The accessory's height and the room around and between its label and menu, in points.
const HEIGHT: f64 = 38.0;
@@ -70,7 +68,8 @@ define_class!(
/// The file types a Format menu lists: `filters` without "every file" ones. Empty when fewer than
/// two remain, and the panel is then left to rfd.
fn menu_formats(filters: &[Filter]) -> Vec<Filter> {
let named: Vec<Filter> = filters.iter().copied().filter(|(_, extensions)| !extensions.is_empty() && !extensions.contains(&"*")).collect();
let named: Vec<Filter> =
filters.iter().filter(|(_, extensions)| !extensions.is_empty() && !extensions.iter().any(|x| x == "*")).cloned().collect();
if named.len() < 2 { Vec::new() } else { named }
}
@@ -95,10 +94,10 @@ fn filename(name: &str, extension: &str, filters: &[Filter]) -> String {
/// The extensions a panel with menu `filters` allows, those of entry `selected` first. AppKit adds
/// the first one to a name typed without an extension, and a name typed with another of them is
/// saved in that format.
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&'static str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| *extensions);
fn allowed_types(filters: &[Filter], selected: usize) -> Vec<&str> {
let first = filters.get(selected).map_or(&[][..], |(_, extensions)| extensions.as_slice());
let others = filters.iter().enumerate().filter(|(i, _)| *i != selected).flat_map(|(_, (_, extensions))| extensions.iter());
first.iter().chain(others).copied().collect()
first.iter().chain(others).map(String::as_str).collect()
}
/// Set `panel`'s allowed file types ([`allowed_types`]).
@@ -184,26 +183,28 @@ fn accessory(mtm: MainThreadMarker, panel: &NSSavePanel, filters: Vec<Filter>, s
mod tests {
use super::*;
const FILTERS: &[Filter] = &[("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF", &["pdf"])];
fn filters() -> Vec<Filter> {
FilePick::filters([("VectorCraft", &["vectorcraft"][..]), ("SVG", &["svg"]), ("PDF", &["pdf"])])
}
/// Save, Save As and Save a Copy list every save format with the default first; a panel with one
/// named type, or one for every file, gets no menu, and rfd shows it.
#[test]
fn save_panels_with_several_formats_get_the_menu() {
let save = menu_formats(&fileio::save_filters("vectorcraft"));
let save = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
assert!(save.len() >= 2, "{save:?}");
assert_eq!(save.first().map(|(_, e)| *e), Some(&["vectorcraft"][..]));
assert!(menu_formats(&fileio::save_filters("svg")).first().is_some_and(|(_, e)| e.contains(&"svg")));
assert!(menu_formats(&fileio::template_filters("template")).len() >= 2);
assert!(menu_formats(&[("PDF", &["pdf"])]).is_empty());
assert!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("Any", &[])]).is_empty());
assert_eq!(menu_formats(&[("PDF", &["pdf"]), ("All files", &["*"]), ("SVG", &["svg"])]).len(), 2);
assert_eq!(save.first().map(|(_, e)| e.as_slice()), Some(&["vectorcraft".to_owned()][..]));
assert!(menu_formats(&FilePick::filters(fileio::save_filters("svg"))).first().is_some_and(|(_, e)| e.iter().any(|e| e == "svg")));
assert!(menu_formats(&FilePick::filters(fileio::template_filters("template"))).len() >= 2);
assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..])])).is_empty());
assert!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("Any", &[])])).is_empty());
assert_eq!(menu_formats(&FilePick::filters([("PDF", &["pdf"][..]), ("All files", &["*"]), ("SVG", &["svg"])])).len(), 2);
}
#[test]
fn the_suggested_name_selects_its_format_and_an_unknown_one_the_default() {
for (name, index) in [("Untitled-1", 0), ("a b.SVG", 1), ("art.pdf", 2), ("version.2", 0), ("", 0)] {
assert_eq!(initial_format(name, FILTERS), index, "{name}");
assert_eq!(initial_format(name, &filters()), index, "{name}");
}
}
@@ -219,7 +220,7 @@ mod tests {
("photo.png", "photo.svg"),
("", ""),
] {
assert_eq!(filename(name, "svg", FILTERS), expected, "{name}");
assert_eq!(filename(name, "svg", &filters()), expected, "{name}");
}
}
@@ -227,27 +228,19 @@ mod tests {
/// type. The others stay allowed.
#[test]
fn the_chosen_format_is_the_first_allowed_type() {
assert_eq!(allowed_types(FILTERS, 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(FILTERS, 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(FILTERS, 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(FILTERS, 9), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(&filters(), 0), ["vectorcraft", "svg", "pdf"]);
assert_eq!(allowed_types(&filters(), 1), ["svg", "vectorcraft", "pdf"]);
assert_eq!(allowed_types(&filters(), 2), ["pdf", "vectorcraft", "svg"]);
assert_eq!(allowed_types(&filters(), 9), ["vectorcraft", "svg", "pdf"]);
}
/// Each entry of the Save As menu gives a name the save command reads as that entry's format.
#[test]
fn each_menu_entry_names_a_file_the_save_command_writes_in_that_format() {
let filters = menu_formats(&fileio::save_filters("vectorcraft"));
let filters = menu_formats(&FilePick::filters(fileio::save_filters("vectorcraft")));
for (label, extensions) in &filters {
let name = filename("Untitled-1.vectorcraft", extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(*label), "{name}");
}
}
/// The menu's label is translated in every language with complete menus.
#[test]
fn the_label_is_translated() {
for lang in i18n::Lang::all().filter(|l| l.complete_menus()) {
assert!(i18n::has(lang, "Format:"), "{}", lang.code());
let name = filename("Untitled-1.vectorcraft", &extensions[0], &filters);
assert_eq!(fileio::format_for_name(&name).map(|f| f.label), Some(label.as_str()), "{name}");
}
}
}
+275
View File
@@ -0,0 +1,275 @@
//! macOS: a drawing tablet's pen pressure (#852). winit 0.30 hands a pen to the window there as a
//! mouse, without its pressure, so the app reads it from AppKit itself and writes it into the UI's
//! [`StylusFeed`] (`Services::stylus`), which the canvas reads, as on X11 ([`crate::tablet`]).
//!
//! A local event monitor (`addLocalMonitorForEventsMatchingMask:handler:`, which AppKit calls "with
//! each event it is about to dispatch" in this app:
//! <https://developer.apple.com/documentation/appkit/nsevent/addlocalmonitorforevents(matching:handler:)>)
//! reads each mouse and tablet event on the main thread before winit's view handles it, and passes
//! it on unchanged:
//! - A mouse event whose `subtype` is `NSEventSubtypeTabletPoint` comes from a pen: its `pressure`
//! (0..1) goes into the feed, kept for the pointer move winit makes of the event
//! ([`StylusFeed::set_move`]), so each move of a fast stroke has its own pressure. A mouse event
//! of any other subtype is a mouse's or a trackpad's: the feed is set to `None`.
//! - An `NSEventTypeTabletPoint` event carries the same pressure without a move.
//! - A tablet proximity event (`NSEventTypeTabletProximity`, or a mouse event of that subtype) says
//! which end of the pen came near (`pointingDeviceType`): the eraser end's samples are marked so,
//! and the canvas switches to the Eraser tool ([`vectorcraft_ui_egui::stylus`]). A puck
//! (`NSPointingDeviceTypeCursor`) is a mouse.
//!
//! The monitor also turns off mouse event coalescing (`NSEvent.mouseCoalescingEnabled`), which
//! merges the drags that queue up between two reads into one: every report of the pen arrives.
//!
//! The mapping comes from PhotoCraft's `crates/tablet` (`appkit.rs` and `macos.rs`), with the
//! pressure and the pen's end only. It is plain values, tested on every platform; the monitor
//! reads them.
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
/// `NSEventType` values (AppKit headers).
mod event_type {
pub const TABLET_POINT: usize = 23;
pub const TABLET_PROXIMITY: usize = 24;
/// Left, right and other mouse down, up, moved and dragged: winit makes a pointer move of each.
pub const MOUSE: [usize; 10] = [1, 2, 3, 4, 5, 6, 7, 25, 26, 27];
}
/// `NSEventSubtype` values of mouse events.
mod subtype {
pub const TABLET_POINT: i16 = 1;
pub const TABLET_PROXIMITY: i16 = 2;
}
/// `NSPointingDeviceType` values.
mod device {
pub const CURSOR: usize = 2;
pub const ERASER: usize = 3;
}
/// The tablet fields of one `NSEvent`, as plain values.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct RawEvent {
/// `type`.
kind: usize,
/// `subtype` (mouse events only).
subtype: i16,
/// `pressure` (tablet point events and mouse events of that subtype only).
pressure: f32,
/// `pointingDeviceType` (proximity events only).
device: usize,
/// `isEnteringProximity` (proximity events only).
entering: bool,
}
impl RawEvent {
fn mouse(&self) -> bool {
event_type::MOUSE.contains(&self.kind)
}
fn proximity(&self) -> bool {
self.kind == event_type::TABLET_PROXIMITY || (self.mouse() && self.subtype == subtype::TABLET_PROXIMITY)
}
fn point(&self) -> bool {
self.kind == event_type::TABLET_POINT || (self.mouse() && self.subtype == subtype::TABLET_POINT)
}
}
/// The events the monitor reads (`NSEventMask` bits are `1 << type`).
fn event_mask() -> u64 {
event_type::MOUSE.iter().chain(&[event_type::TABLET_POINT, event_type::TABLET_PROXIMITY]).fold(0, |m, t| m | 1u64 << t)
}
/// What an event means for the feed.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Update {
/// Nothing: an event the monitor doesn't map.
Keep,
/// The pressure and end of a pen, or `None` for a mouse.
Set(Option<PenSample>),
/// A pen's sample that comes with a pointer move.
Move(PenSample),
}
impl Update {
fn apply(self, feed: &StylusFeed) {
match self {
Update::Keep => {}
Update::Set(sample) => feed.set_sample(sample),
Update::Move(sample) => feed.set_move(sample),
}
}
}
/// Which end of the pen is near the tablet.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
struct Pen {
eraser: bool,
}
impl Pen {
fn handle(&mut self, e: &RawEvent) -> Update {
if e.proximity() {
// Entering with the tip or the eraser end: it hovers. A puck, or leaving, is a mouse.
self.eraser = e.entering && e.device == device::ERASER;
let pen = e.entering && e.device != device::CURSOR;
return Update::Set(pen.then_some(PenSample { pressure: 0.0, eraser: self.eraser }));
}
let sample = PenSample { pressure: e.pressure, eraser: self.eraser };
match (e.point(), e.mouse()) {
(true, true) => Update::Move(sample),
(true, false) => Update::Set(Some(sample)),
(false, true) => Update::Set(None),
(false, false) => Update::Keep,
}
}
}
/// Start reading pen pressure: the feed the monitor writes into, or `None` (logged) when AppKit
/// refuses the monitor or this isn't the main thread.
#[cfg(target_os = "macos")]
pub fn start() -> Option<StylusFeed> {
// The monitor writes on the main thread before winit hands the event on: a press never waits.
let feed = StylusFeed::on_ui_thread();
match monitor::install(feed.clone()) {
Ok(()) => Some(feed),
Err(e) => {
log::warn!("tablet pressure: {e}");
None
}
}
}
#[cfg(target_os = "macos")]
mod monitor {
use std::cell::Cell;
use std::ptr::NonNull;
use block2::RcBlock;
use objc2::MainThreadMarker;
use objc2_app_kit::{NSEvent, NSEventMask};
use vectorcraft_ui_egui::stylus::StylusFeed;
use super::{Pen, RawEvent, event_mask};
/// Install the monitor for the life of the app. Main thread only.
pub(super) fn install(feed: StylusFeed) -> Result<(), &'static str> {
MainThreadMarker::new().ok_or("the event monitor can only be installed from the main thread")?;
let pen = Cell::new(Pen::default());
let handler = RcBlock::new(move |event: NonNull<NSEvent>| -> *mut NSEvent {
// SAFETY: AppKit calls the handler with the event it is about to dispatch, which it
// keeps alive while the handler runs.
#[allow(unsafe_code)]
let e = unsafe { event.as_ref() };
let mut p = pen.get();
p.handle(&read(e)).apply(&feed);
pen.set(p);
event.as_ptr()
});
// SAFETY: the handler returns the event it was given, a valid pointer ("the block's return
// must be a valid pointer or null"). AppKit copies the block and keeps it while the monitor
// is installed.
#[allow(unsafe_code)]
let token = unsafe { NSEvent::addLocalMonitorForEventsMatchingMask_handler(NSEventMask(event_mask()), &handler) };
// The monitor stays until the app quits: its token is never handed to `removeMonitor:`.
std::mem::forget(token.ok_or("AppKit refused the event monitor")?);
NSEvent::setMouseCoalescingEnabled(false);
Ok(())
}
/// The tablet fields of `e`. `subtype` raises an exception for events without one, and the
/// pressure and proximity fields are read only for the events that define them.
fn read(e: &NSEvent) -> RawEvent {
let mut raw = RawEvent { kind: e.r#type().0, ..Default::default() };
if raw.mouse() {
raw.subtype = e.subtype().0;
}
if raw.point() {
raw.pressure = e.pressure();
}
if raw.proximity() {
raw.device = e.pointingDeviceType().0;
raw.entering = e.isEnteringProximity();
}
raw
}
}
#[cfg(test)]
mod tests {
use super::*;
const DRAG: usize = 6;
const KEY_DOWN: usize = 10;
fn pen(kind: usize, pressure: f32) -> RawEvent {
RawEvent { kind, subtype: subtype::TABLET_POINT, pressure, ..Default::default() }
}
fn proximity(device: usize, entering: bool) -> RawEvent {
RawEvent { kind: event_type::TABLET_PROXIMITY, device, entering, ..Default::default() }
}
fn tip(pressure: f32) -> PenSample {
PenSample { pressure, eraser: false }
}
#[test]
fn a_pens_mouse_events_are_moves_with_its_pressure() {
let mut p = Pen::default();
assert_eq!(p.handle(&pen(DRAG, 0.5)), Update::Move(tip(0.5)));
assert_eq!(p.handle(&pen(1, 0.25)), Update::Move(tip(0.25)), "a press");
assert_eq!(p.handle(&pen(event_type::TABLET_POINT, 0.8)), Update::Set(Some(tip(0.8))), "no move");
}
#[test]
fn other_mouse_events_are_a_mouse_and_other_events_are_kept() {
let mut p = Pen::default();
for subtype in [0, 3, 99, -1] {
let e = RawEvent { kind: DRAG, subtype, pressure: 1.0, ..Default::default() };
assert_eq!(p.handle(&e), Update::Set(None), "subtype {subtype}");
}
for kind in [0, KEY_DOWN, 22, 29, 34, usize::MAX] {
// Other events' subtypes don't make them a pen's.
let e = RawEvent { kind, subtype: subtype::TABLET_POINT, ..Default::default() };
assert_eq!(p.handle(&e), Update::Keep, "type {kind}");
}
}
#[test]
fn proximity_says_which_end_is_near() {
let mut p = Pen::default();
let eraser = |pressure| PenSample { pressure, eraser: true };
assert_eq!(p.handle(&proximity(device::ERASER, true)), Update::Set(Some(eraser(0.0))));
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(eraser(0.4)));
assert_eq!(p.handle(&proximity(device::ERASER, false)), Update::Set(None), "it left");
assert_eq!(p.handle(&pen(DRAG, 0.4)), Update::Move(tip(0.4)));
assert_eq!(p.handle(&proximity(1, true)), Update::Set(Some(tip(0.0))), "the tip");
assert_eq!(p.handle(&proximity(0, true)), Update::Set(Some(tip(0.0))), "an unknown tool draws as the tip");
assert_eq!(p.handle(&proximity(device::CURSOR, true)), Update::Set(None), "a puck is a mouse");
let e = RawEvent { kind: 5, subtype: subtype::TABLET_PROXIMITY, device: device::ERASER, entering: true, ..Default::default() };
assert_eq!(p.handle(&e), Update::Set(Some(eraser(0.0))), "as a mouse event's subtype");
}
#[test]
fn the_feed_gets_each_moves_pressure_within_0_to_1() {
let feed = StylusFeed::default();
let mut p = Pen::default();
for e in [pen(DRAG, f32::NAN), pen(DRAG, 4.0), pen(DRAG, -1.0), pen(event_type::TABLET_POINT, 0.3)] {
p.handle(&e).apply(&feed);
}
assert_eq!((feed.take_moves(), feed.held()), (vec![1.0, 1.0, 0.0], Some(0.3)));
p.handle(&RawEvent { kind: DRAG, ..Default::default() }).apply(&feed);
assert_eq!(feed.held(), None, "a mouse moved");
}
#[test]
fn the_mask_covers_the_mapped_events() {
let m = event_mask();
for t in [1, 2, 3, 4, 5, 6, 7, 23, 24, 25, 26, 27] {
assert!(m & (1 << t) != 0, "{t}");
}
assert_eq!(m & (1 << KEY_DOWN), 0, "keys are not read");
}
}
+221 -38
View File
@@ -1,6 +1,6 @@
//! VectorCraft desktop app.
//!
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>] [--automation-write-root <dir>]]
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
//! [--in-window-menus] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
//!
//! `--in-window-menus` (or `VECTORCRAFT_IN_WINDOW_MENUS=1`) keeps the menus inside the window on
@@ -30,6 +30,8 @@ mod control_server;
mod cursor_theme;
#[cfg(feature = "wgpu")]
mod gpu;
#[cfg(target_os = "linux")]
mod linux_stylus;
mod logging;
#[cfg(target_os = "macos")]
mod mac_fonts;
@@ -39,6 +41,7 @@ mod mac_menu;
mod mac_save_format;
#[cfg(target_os = "macos")]
mod mac_screen_color;
mod mac_tablet;
#[cfg(target_os = "macos")]
mod mac_window;
#[cfg(target_os = "macos")]
@@ -49,8 +52,10 @@ mod prefs_dir;
mod printing;
#[cfg(all(windows, not(target_vendor = "win7")))]
mod system_fonts;
mod tablet;
mod window;
mod window_frame;
mod windows_stylus;
mod xwayland;
use vectorcraft_engine::Session;
@@ -70,6 +75,12 @@ struct App {
frames: u64,
/// Keeps the custom title bar's window borderless (Windows, #1758).
window_frame: Option<window_frame::WindowFrameFn>,
/// The main window; used to read and set maximized state the UI layer does not see on Linux (#3635).
winit_window: Option<std::sync::Arc<winit::window::Window>>,
/// A saved or fitted geometry asked for maximize once the window is mapped (Linux).
pending_maximize: bool,
/// Frames left to retry [`window::apply_pending_maximize`].
maximize_tries: u8,
}
impl eframe::App for App {
@@ -89,6 +100,9 @@ impl eframe::App for App {
if self.app.services.native_menu.is_some() {
mac_menu::raw_input_hook(raw);
}
// A pen contact with the barrel button held presses the secondary button (Windows Ink).
#[cfg(all(windows, not(target_vendor = "win7")))]
windows_stylus::raw_input_hook(raw);
file_access::unconfined(|| self.app.raw_input_hook(raw));
}
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
@@ -144,14 +158,22 @@ impl App {
// again on its document when it answers.
#[cfg(target_os = "macos")]
if !self.app.file_dialog_open() {
// Files from the Finder and the Dock wait while a file dialog is open: what asked for it runs
// again on its document when it answers.
if !self.app.file_dialog_open() {
open_files(&mut self.app, open_documents::take());
let files = open_documents::take();
if !files.is_empty() {
bring_window_forward(ctx);
}
open_files(&mut self.app, files);
}
self.app.logic(ctx);
window::track(ctx, &mut self.app.ui.window);
if self.pending_maximize {
if let Some(w) = &self.winit_window {
self.pending_maximize = window::apply_pending_maximize(w, true, &mut self.maximize_tries);
} else {
self.pending_maximize = false;
}
}
let host_maximized = self.winit_window.as_ref().map(|w| w.is_maximized());
window::track(ctx, &mut self.app.ui.window, host_maximized);
if self.app.ui.status == "quit" {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
}
@@ -174,6 +196,14 @@ fn end_before_teardown(frames: u64) {
}
}
/// Show the window when files are opened from the Finder or the Dock: a minimized window comes back
/// out of the Dock and the app takes focus, as a document-based Mac app does (#3769).
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
fn bring_window_forward(ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(false));
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
}
/// Open files handed to the app (command line, macOS Finder and Dock) as documents. A file that
/// can't be opened is reported to the user and on stderr; the others still open.
fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
@@ -251,10 +281,13 @@ fn decode_saved_prefs(bytes: &[u8]) -> Option<vectorcraft_ui_egui::UiState> {
let mut value: Value = serde_json::from_slice(bytes).ok()?;
let object = value.as_object_mut()?;
recover(object, "docking_hidden");
if let Some(Value::Array(workspaces)) = object.get_mut("custom_workspaces") {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
// Saved workspaces and the layout changes kept for each workspace carry the same layouts.
for key in ["custom_workspaces", "workspace_changes"] {
if let Some(Value::Array(workspaces)) = object.get_mut(key) {
for workspace in workspaces {
if let Some(object) = workspace.as_object_mut() {
recover(object, "dockingHidden");
}
}
}
}
@@ -293,7 +326,7 @@ type Parent = Option<std::sync::Arc<winit::window::Window>>;
trait DialogSettings: Sized {
fn parent(self, window: &winit::window::Window) -> Self;
fn title(self, title: &str) -> Self;
fn filter(self, name: &str, exts: &[&str]) -> Self;
fn filter(self, name: &str, exts: &[impl ToString]) -> Self;
fn folder(self, folder: &str) -> Self;
fn file_name(self, name: &str) -> Self;
}
@@ -307,7 +340,7 @@ macro_rules! dialog_settings {
fn title(self, title: &str) -> Self {
self.set_title(title)
}
fn filter(self, name: &str, exts: &[&str]) -> Self {
fn filter(self, name: &str, exts: &[impl ToString]) -> Self {
self.add_filter(name, exts)
}
fn folder(self, folder: &str) -> Self {
@@ -333,10 +366,9 @@ fn dialog_for<D: DialogSettings>(d: D, request: &PickRequest) -> D {
}
PickRequest::Folder => return d,
};
let d = pick
.filters
.iter()
.fold(d, |d, (name, exts)| d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name), exts));
let d = pick.filters.iter().fold(d, |d, (name, exts)| {
d.filter(vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", name.as_str()), exts)
});
let d = match &pick.folder {
Some(folder) => d.folder(folder),
None => d,
@@ -528,6 +560,12 @@ fn start_pick(request: PickRequest, parent: &Parent, ui: &egui::Context) -> Opti
fn services(parent: Parent, ui: egui::Context) -> Services {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
let wayland = parent.as_ref().and_then(|w| w.window_handle().ok()).is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Wayland(_)));
// A window on X11 (XWayland included) reads a tablet pen's pressure itself (#764).
#[cfg(target_os = "linux")]
let x11 = parent
.as_ref()
.and_then(|w| w.window_handle().ok())
.is_some_and(|h| matches!(h.as_raw(), RawWindowHandle::Xlib(_) | RawWindowHandle::Xcb(_)));
let (p1, p2, p3, p4, p5) = (parent.clone(), parent.clone(), parent.clone(), parent.clone(), parent);
#[cfg(target_os = "macos")]
let sampled_ui = ui.clone();
@@ -541,6 +579,11 @@ fn services(parent: Parent, ui: egui::Context) -> Services {
dialogs_are_sheets: cfg!(target_os = "macos"),
wayland,
no_cursor_theme: wayland && cursor_theme::theme_missing(),
#[cfg(target_os = "linux")]
stylus: if x11 { tablet::start() } else { None },
// macOS reads it from AppKit's events (#852).
#[cfg(target_os = "macos")]
stylus: mac_tablet::start(),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
exists: Some(Box::new(|p: &str| std::path::Path::new(p).exists())),
@@ -629,13 +672,73 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
/// Windows and Linux: no OS title bar; the app bar is the title bar (`vectorcraft_ui_egui::titlebar`).
/// macOS keeps its traffic lights over the integrated title strip.
///
/// Used on macOS and Windows (where VectorCraft keeps today's frame regardless of the
/// `craft-window` probe) and by the tests; Linux and the BSDs take the decision from the probe.
#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
/// before the window opens; a missing or unreadable value keeps the default.
/// Whether the user asked for the system title bar in Preferences › User Interface › System Title
/// Bar. Read from the saved preferences before the window opens; a missing or unreadable value is
/// `false` (the default). This preference forces the desktop/native frame back on.
fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
}
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
/// decided and the user's System Title Bar preference.
///
/// The suite-wide rule (`craft_window::Choice`) is: the desktop draws the frame
/// ([`craft_window::Frame::Desktop`]) on macOS, Windows, X11 and any Wayland compositor with
/// server-side decorations (KDE, Sway, Hyprland, COSMIC); the app draws it
/// ([`craft_window::Frame::App`]) only where the compositor leaves it to the app (GNOME's Mutter),
/// or when `VECTORCRAFT_TITLEBAR` overrides the probe. VectorCraft draws its own title bar exactly
/// when `craft-window` says the app owns the frame **and** the user has not opted into the system
/// title bar; the preference always forces the native frame back on.
///
/// macOS and Windows keep today's behavior and are decided by [`CUSTOM_TITLEBAR`] rather than the
/// probe: `craft-window` returns `Frame::Desktop` for both (the OS always draws a frame), but
/// VectorCraft insets its content under the macOS traffic lights and draws its own caption buttons
/// on Windows — an overdraw on an OS-decorated window that is VectorCraft's choice, not
/// `craft-window`'s. Only Linux and the BSDs take the decision from the probe.
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn custom_titlebar_from(frame: &craft_window::Frame, opted_into_system: bool) -> bool {
matches!(frame, craft_window::Frame::App(_)) && !opted_into_system
}
/// macOS and Windows: today's behavior, independent of the `craft-window` probe (see
/// [`custom_titlebar_from`]). macOS never draws its own frame (`CUSTOM_TITLEBAR` is `false`);
/// Windows draws its own caption unless the user opted into the system title bar.
#[cfg(any(target_os = "macos", target_os = "windows"))]
fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_system: bool) -> bool {
CUSTOM_TITLEBAR && !opted_into_system
}
/// Whether this start draws its own title bar. Probes the shared `craft-window` crate once (its
/// override variable is `VECTORCRAFT_TITLEBAR`) and combines its decision with the saved System
/// Title Bar preference. Call once at startup, before [`native_options`].
///
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
let opted_into_system = opted_into_system_title_bar(saved);
#[cfg(target_os = "linux")]
{
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
// present, the same two conditions the launch path checks below — that run is effectively
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
// the probe's own decision (app-drawn on GNOME) must stand.
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
if choice.overridden().is_none() && forced_to_x11 {
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
}
}
custom_titlebar_from(choice.frame(), opted_into_system)
}
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
@@ -667,7 +770,9 @@ Options:
--control <port> Start the localhost JSON-lines control server on <port>
(or VECTORCRAFT_CONTROL_PORT)
--automation-read-root <dir> Confine the files control requests read to <dir>
(may be given more than once)
--automation-write-root <dir> Confine the files control requests write to <dir>
(may be given more than once)
--in-window-menus Keep the menus inside the window on macOS
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
--version Print the version and exit
@@ -716,7 +821,7 @@ fn main() -> std::process::ExitCode {
_ => files.push(a),
}
}
let automation_args::AutomationArgs { read_root, write_root, .. } = roots_args;
let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
// After the arguments: `--control` and `--version` win over a bad environment value.
let control_port = match control.port {
Some(port) => Some(port),
@@ -755,12 +860,14 @@ fn main() -> std::process::ExitCode {
// The roots confine the control channel: a bad one keeps the app from starting rather than
// leaving agents unconfined. Without a control port there is nothing to confine.
let roots = if control_port.is_none() {
if read_root.is_some() || write_root.is_some() {
if !read_roots.is_empty() || !write_roots.is_empty() {
log::warn!("--automation-read-root and --automation-write-root confine the control channel: ignored without --control");
}
None
} else {
match AutomationRoots::new(read_root.as_deref(), write_root.as_deref()) {
let read: Vec<&std::path::Path> = read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = write_roots.iter().map(std::path::PathBuf::as_path).collect();
match AutomationRoots::new(&read, &write) {
Ok(roots) => roots,
Err(e) => {
log::error!("{e}");
@@ -832,6 +939,18 @@ fn main() -> std::process::ExitCode {
}
options
};
// With Windows Ink, winit reports a pen as a touch and drops its barrel button; the message
// hook notes each pointer contact and whether it starts with the barrel button held
// (`windows_stylus`).
#[cfg(all(windows, not(target_vendor = "win7")))]
let options = {
use winit::platform::windows::EventLoopBuilderExtWindows as _;
let mut options = options;
options.event_loop_builder = Some(Box::new(|builder| {
builder.with_msg_hook(windows_stylus::msg_hook);
}));
options
};
#[cfg(feature = "wgpu")]
let options = {
let mut options = options;
@@ -876,9 +995,18 @@ fn main() -> std::process::ExitCode {
#[cfg(feature = "wgpu")]
let created = startup.clone();
// A panic that ends the window (egui-wgpu's own, #502) is caught here: never a crash.
let run = |options: eframe::NativeOptions, app_creator: eframe::AppCreator<'_>| {
#[cfg(target_os = "linux")]
{
linux_stylus::run_native("VectorCraft", options, app_creator)
}
#[cfg(not(target_os = "linux"))]
{
eframe::run_native("VectorCraft", options, app_creator)
}
};
let outcome = vectorcraft_engine::guard::catch_panic(|| {
eframe::run_native(
"VectorCraft",
run(
options,
Box::new(move |cc| {
let mut session = Session::new();
@@ -891,16 +1019,22 @@ fn main() -> std::process::ExitCode {
// which takes over from the copy the preferences file used to hold.
prefs_dir::attach_pdf_preset_store(&mut app.session);
// Fit the window to its monitor, or put it back where it was (still hidden).
if let Some(w) = cc.winit_window() {
app.ui.window = Some(window::restore(w, saved_window));
let winit_window = cc.winit_window().cloned();
if let Some(w) = &winit_window {
let g = window::restore(w, saved_window);
app.ui.window = Some(g);
#[cfg(windows)]
set_taskbar_icon(w);
}
// User Defined swatch and graphic style libraries live next to the preferences.
// Linux may not map the window before the first frames, so a saved maximize is retried then (#3635).
let pending_maximize = cfg!(target_os = "linux") && app.ui.window.as_ref().is_some_and(|g| g.maximized);
// User Defined swatch, graphic style and symbol libraries live next to the preferences.
let swatches = prefs_path().and_then(|p| Some(p.parent()?.join("Swatches").to_string_lossy().to_string()));
app.session.swatch_libraries.set_user_dir(swatches);
let styles = prefs_path().and_then(|p| Some(p.parent()?.join("Graphic Styles").to_string_lossy().to_string()));
app.session.style_libraries.set_user_dir(styles);
let symbols = prefs_path().and_then(|p| Some(p.parent()?.join("Symbols").to_string_lossy().to_string()));
app.session.symbol_libraries.set_user_dir(symbols);
// With the User Defined libraries listed, the persistent library panels reopen.
vectorcraft_ui_egui::panels::library_panel::restore(&mut app);
// So do the Libraries panel's libraries; runs without preferences (agents' test
@@ -957,7 +1091,7 @@ fn main() -> std::process::ExitCode {
// Everywhere the app doesn't say whose work it is (worker threads), the
// roots hold: unconfined is only what the person at the keyboard does.
file_access::confine_process(roots.clone());
log::info!("control channel confined to the automation roots: read {:?}, write {:?}", roots.read_root(), roots.write_root());
log::info!("control channel confined to the automation roots: {}", roots.describe());
}
}
#[cfg(target_os = "macos")]
@@ -976,7 +1110,16 @@ fn main() -> std::process::ExitCode {
created.ready();
// The content and the pointer stay aligned on Windows (#1758).
let window_frame = window_frame::service(cc, custom_titlebar);
Ok(Box::new(App { app, graphics_loss, graphics_lost: false, frames: 0, window_frame }))
Ok(Box::new(App {
app,
graphics_loss,
graphics_lost: false,
frames: 0,
window_frame,
winit_window,
pending_maximize,
maximize_tries: 8,
}))
}),
)
});
@@ -1001,6 +1144,17 @@ mod tests {
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
}
#[test]
fn opening_files_unminimizes_and_focuses_the_window() {
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| bring_window_forward(ui.ctx()));
// Nothing paints in the test: drop the font atlas upload egui expects to be applied.
out.textures_delta.clear();
let cmds = &out.viewport_output[&egui::ViewportId::ROOT].commands;
assert!(cmds.contains(&egui::ViewportCommand::Minimized(false)));
assert!(cmds.contains(&egui::ViewportCommand::Focus));
}
#[test]
fn unreadable_saved_docking_keeps_preferences_and_every_workspace() {
use serde_json::json;
@@ -1010,6 +1164,7 @@ mod tests {
app.ui.engine_prefs = json!({"interfaceLanguage":"fr", "uiBrightness":"light"});
app.ui.custom_workspaces =
vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future"), vectorcraft_ui_egui::workspaces::capture(&app.ui, "Valid")];
app.ui.workspace_changes = vec![vectorcraft_ui_egui::workspaces::capture(&app.ui, "Future")];
let original = serde_json::to_value(&app.ui).unwrap();
let valid_location = original["docking_hidden"]["layers"].clone();
assert!(valid_location.is_object());
@@ -1020,6 +1175,8 @@ mod tests {
raw["docking_hidden"] = invalid_hidden.clone();
raw["custom_workspaces"][0]["docking"] = invalid_layout.clone();
raw["custom_workspaces"][0]["dockingHidden"] = invalid_hidden.clone();
raw["workspace_changes"][0]["docking"] = invalid_layout.clone();
raw["workspace_changes"][0]["dockingHidden"] = invalid_hidden.clone();
assert!(serde_json::from_value::<vectorcraft_ui_egui::UiState>(raw.clone()).is_err(), "runtime serde stays strict");
let loaded = decode_saved_prefs(&serde_json::to_vec(&raw).unwrap()).unwrap();
assert_eq!(loaded.engine_prefs, app.ui.engine_prefs);
@@ -1036,6 +1193,8 @@ mod tests {
assert!(loaded.custom_workspaces[0].docking.is_none());
assert!(!loaded.custom_workspaces[0].docking_hidden.contains_key("layers"));
assert_eq!(serde_json::to_value(&loaded.custom_workspaces[1]).unwrap(), original["custom_workspaces"][1]);
assert_eq!(loaded.workspace_changes.len(), 1, "a remembered layout is recovered, not the whole file lost");
assert!(loaded.workspace_changes[0].docking.is_none());
}
}
let roundtrip = decode_saved_prefs(&serde_json::to_vec(&original).unwrap()).unwrap();
@@ -1048,17 +1207,41 @@ mod tests {
#[test]
fn the_system_title_bar_preference_keeps_the_window_decorations() {
// Preferences › User Interface › System Title Bar gives the window back its system
// decorations on Windows and Linux; macOS always has them.
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
use craft_window::{Frame, Layout};
// The System Title Bar preference (Preferences › User Interface) is read the same way on
// every platform; a missing or unreadable value is `false`.
assert!(!super::opted_into_system_title_bar(None), "no preferences: the default");
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
assert!(!super::opted_into_system_title_bar(Some(&off)));
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
assert!(!super::custom_titlebar(Some(&on)));
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
if super::CUSTOM_TITLEBAR {
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
assert!(super::opted_into_system_title_bar(Some(&on)));
// The suite-wide mapping (Frame + preference -> whether VectorCraft draws its own title
// bar). The desktop frame (macOS, Windows, X11, KDE/Wayland, …) always keeps native
// decorations; the app frame (GNOME/Wayland, or a `VECTORCRAFT_TITLEBAR` override) draws
// our own title bar — unless the user opted into the system title bar, which forces the
// native frame back on.
let app = Frame::App(Layout::default());
let desktop = Frame::Desktop;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
assert!(super::custom_titlebar_from(&app, false), "GNOME/Wayland: VectorCraft draws its own");
assert!(!super::custom_titlebar_from(&app, true), "System Title Bar preference forces native");
assert!(!super::custom_titlebar_from(&desktop, false), "KDE/X11/…: keep the compositor's frame");
assert!(!super::custom_titlebar_from(&desktop, true));
}
// macOS and Windows keep today's behavior, independent of the probed frame: the pref still
// forces the native frame back on where a custom one would otherwise be drawn.
#[cfg(any(target_os = "macos", target_os = "windows"))]
{
assert_eq!(super::custom_titlebar_from(&desktop, false), super::CUSTOM_TITLEBAR);
assert_eq!(super::custom_titlebar_from(&app, false), super::CUSTOM_TITLEBAR, "the probe does not change macOS/Windows");
assert!(!super::custom_titlebar_from(&desktop, true));
}
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
}
/// The file extensions the macOS bundle declares: its document types and its own exported type.
+5 -1
View File
@@ -326,7 +326,11 @@ mod tests {
let pick = FilePick {
name: "Untitled-1.vectorcraft".into(),
folder: Some("/home/me/Art".into()),
filters: vec![("VectorCraft", &["vectorcraft"]), ("SVG", &["svg"]), ("PDF-compatible .ai", &["ai"])],
filters: vec![
FilePick::filter("VectorCraft", &["vectorcraft"]),
FilePick::filter("SVG", &["svg"]),
FilePick::filter("PDF-compatible .ai", &["ai"]),
],
};
SaveRequest::new(&pick, "x11:3a0000f".into())
}
+417
View File
@@ -0,0 +1,417 @@
//! Linux: a drawing tablet's pen pressure under X11, XWayland included (#764). winit 0.30 hands a
//! pen to the window there as a mouse, without its pressure, so the app reads the pressure from
//! XInput2 itself and writes it into the UI's [`StylusFeed`] (`Services::stylus`), which the canvas
//! reads.
//!
//! The reader opens a second connection to the X server (x11rb's `RustConnection`, without
//! `unsafe` or libxcb) and selects XInput2 raw events for every device on the root window. Raw
//! events reach every client that selects them, also while winit holds the pointer grab of a
//! drag, and name the device that moved. A pointer device with an "Abs Pressure" valuator is a pen
//! (xf86-input-wacom, xf86-input-libinput and XWayland give a pen's pressure axis that label),
//! unless its XInput type is a touchpad's or a touchscreen's: its pressure, scaled from the
//! valuator's range to 0..1, goes into the feed. Any other pointer
//! device moving sets the feed to `None`. Raw events carry only the valuators that changed, so
//! the last pressure of each pen is kept.
//!
//! The X server lists a pen's eraser end as a device of its own, whose name or XInput type contains
//! "eraser" (xf86-input-wacom's "… Pen eraser" of type `ERASER`, xf86-input-libinput's
//! "… Eraser (0x…)" of type `ERASER`, XWayland's "xwayland-tablet eraser:N"). Its samples go into
//! the feed marked as the eraser end, and the canvas switches to the Eraser tool
//! ([`vectorcraft_ui_egui::stylus`]).
//!
//! The reader starts only for a window on X11. On native Wayland, `DISPLAY` names an XWayland
//! that receives none of the window's input.
//!
//! The mapping and the reader come from PhotoCraft's `crates/tablet` (`xi.rs` and `x11.rs`), with
//! the pressure axis only.
#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
use std::collections::HashMap;
use vectorcraft_ui_egui::stylus::{PenSample, StylusFeed};
/// Valuator numbers from this one on are ignored (the protocol allows 65,535; tablets have fewer
/// than 16).
const MAX_AXES: u16 = 64;
/// Devices kept (a server could list thousands).
const MAX_DEVICES: usize = 256;
/// The XInput types of finger devices (`XI_TOUCHPAD` and `XI_TOUCHSCREEN`): xf86-input-evdev labels
/// a touchpad's or a touchscreen's pressure axis "Abs Pressure" too, and they are not pens.
const FINGER_TYPES: &[&str] = &["TOUCHPAD", "TOUCHSCREEN"];
/// The "Abs Pressure" valuator of a pen: its number in a raw event's valuator mask, and its range.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Axis {
number: u16,
min: f64,
max: f64,
}
impl Axis {
/// A raw value of this valuator as a pressure of 0..1; a value that is not a number is a
/// mouse's, 1.
fn pressure(&self, v: f64) -> f32 {
if !v.is_finite() {
return 1.0;
}
let span = self.max - self.min;
let unit = if span.is_finite() && span > 0.0 { (v - self.min) / span } else { v };
unit.clamp(0.0, 1.0) as f32
}
}
/// A pointer device the X server lists.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Device {
id: u16,
/// The pressure valuator: a pen has one, a mouse, a touchpad or a touchscreen has none.
pressure: Option<Axis>,
/// The eraser end of a pen: its name or its XInput type contains "eraser", in any case.
eraser: bool,
}
impl Device {
/// From what the X server lists for the device: its name and each valuator's number, label,
/// minimum and maximum (`XIQueryDevice`), and its XInput type (`ListInputDevices`).
fn new(id: u16, name: &str, kind: Option<&str>, valuators: impl IntoIterator<Item = (u16, String, f64, f64)>) -> Self {
let finger = kind.is_some_and(|k| FINGER_TYPES.contains(&k));
let pressure = valuators
.into_iter()
.filter(|(number, ..)| *number < MAX_AXES)
.find(|(_, label, ..)| label == "Abs Pressure")
.map(|(number, _, min, max)| Axis { number, min, max })
.filter(|_| !finger);
let eraser = [Some(name), kind].into_iter().flatten().any(|s| s.to_ascii_lowercase().contains("eraser"));
Self { id, pressure, eraser }
}
}
/// What a raw event means for the feed.
#[derive(Clone, Copy, Debug, PartialEq)]
enum Update {
/// Nothing: a device the last listing didn't have (a master, or one added since).
Keep,
/// The pressure and end of a pen, or `None` for another pointer.
Set(Option<PenSample>),
}
/// The pointer devices and the last pressure of each pen.
#[derive(Debug, Default)]
struct Pens {
devices: HashMap<u16, Device>,
last: HashMap<u16, f32>,
}
impl Pens {
/// Replace the device list (at the start, and when devices come and go or change).
fn set_devices(&mut self, devices: impl IntoIterator<Item = Device>) {
self.devices = devices.into_iter().take(MAX_DEVICES).map(|d| (d.id, d)).collect();
self.last.retain(|id, _| self.devices.contains_key(id));
}
/// A raw motion or button event of device `id`, with the valuators that changed.
fn handle(&mut self, id: u16, valuators: impl IntoIterator<Item = (u16, f64)>) -> Update {
let Some(device) = self.devices.get(&id) else { return Update::Keep };
let Some(axis) = device.pressure else { return Update::Set(None) };
let eraser = device.eraser;
let last = self.last.entry(id).or_insert(0.0);
for (number, v) in valuators {
if number == axis.number {
*last = axis.pressure(v);
}
}
Update::Set(Some(PenSample { pressure: *last, eraser }))
}
/// A raw event as the X server sends it: its valuator mask and the values of the valuators
/// set in it. A pen's sample or another pointer's `None` goes into `feed`.
fn raw_event(&mut self, feed: &StylusFeed, id: u16, mask: &[u32], values: &[f64]) {
if let Update::Set(sample) = self.handle(id, valuators(mask, values)) {
feed.set_sample(sample);
}
}
}
/// The values a raw event carries: the valuator numbers set in `mask` (32 to a word, lowest bit
/// first), each paired in order with one of `values`. Mask bits past the last value are ignored.
fn valuators<'a>(mask: &'a [u32], values: &'a [f64]) -> impl Iterator<Item = (u16, f64)> + 'a {
let numbers = mask
.iter()
.enumerate()
.flat_map(|(w, bits)| (0..32u32).filter(move |b| bits & (1 << b) != 0).map(move |b| w.saturating_mul(32).saturating_add(b as usize)));
numbers.zip(values.iter().copied()).filter_map(|(n, v)| u16::try_from(n).ok().map(|n| (n, v)))
}
/// An X 32.32 fixed-point value.
fn fp3232(integral: i32, frac: u32) -> f64 {
f64::from(integral) + f64::from(frac) / 4_294_967_296.0
}
/// Start reading pen pressure for a window on X11: the feed the reader writes into, or `None`
/// (logged) when the X server can't be reached or has no XInput 2.1.
#[cfg(target_os = "linux")]
pub fn start() -> Option<StylusFeed> {
let feed = StylusFeed::default();
match x11::spawn(feed.clone()) {
Ok(()) => Some(feed),
Err(e) => {
log::warn!("tablet pressure: {e}");
None
}
}
}
#[cfg(target_os = "linux")]
mod x11 {
use std::collections::HashMap;
use vectorcraft_ui_egui::stylus::StylusFeed;
use x11rb::connection::{Connection as _, RequestConnection as _};
use x11rb::protocol::Event;
use x11rb::protocol::xinput::{self, ConnectionExt as _, DeviceClassData, DeviceType, XIEventMask};
use x11rb::protocol::xproto::ConnectionExt as _;
use x11rb::rust_connection::RustConnection;
use super::{Device, MAX_AXES, MAX_DEVICES, Pens, fp3232};
/// `XIAllDevices`: every device, the slave devices that name a pen included.
const ALL_DEVICES: u16 = 0;
/// Connect to `$DISPLAY` (the display winit opened the window on), check for XInput 2.1 (raw
/// events for every client that selects them), select the raw events, and read them on a
/// thread of its own until the connection closes.
pub fn spawn(feed: StylusFeed) -> Result<(), String> {
let (conn, screen) = RustConnection::connect(None).map_err(|e| format!("can't connect to the X server: {e}"))?;
let root = conn.setup().roots.get(screen).map(|s| s.root).ok_or_else(|| format!("X screen {screen} not found"))?;
let version =
conn.xinput_xi_query_version(2, 2).map_err(|e| e.to_string())?.reply().map_err(|e| format!("the X server has no XInput 2: {e}"))?;
if (version.major_version, version.minor_version) < (2, 1) {
return Err(format!("the X server has XInput {}.{}, and raw events need 2.1", version.major_version, version.minor_version));
}
let mask = XIEventMask::RAW_MOTION
| XIEventMask::RAW_BUTTON_PRESS
| XIEventMask::RAW_BUTTON_RELEASE
| XIEventMask::HIERARCHY
| XIEventMask::DEVICE_CHANGED;
conn.xinput_xi_select_events(root, &[xinput::EventMask { deviceid: ALL_DEVICES, mask: vec![mask] }])
.map_err(|e| e.to_string())?
.check()
.map_err(|e| format!("XISelectEvents failed: {e}"))?;
let mut pens = Pens::default();
pens.set_devices(query_devices(&conn));
std::thread::Builder::new()
.name("vectorcraft-tablet-x11".into())
.spawn(move || run(&conn, &mut pens, &feed))
.map(drop)
.map_err(|e| format!("can't start the tablet thread: {e}"))
}
fn run(conn: &RustConnection, pens: &mut Pens, feed: &StylusFeed) {
// A connection error (the display went away) ends the loop; an event that x11rb cannot
// parse is skipped, and `Pens` bounds what the rest can hold.
while let Ok(raw) = conn.wait_for_raw_event() {
let Ok(event) = conn.parse_event(&raw) else { continue };
match event {
Event::XinputRawMotion(e) | Event::XinputRawButtonPress(e) | Event::XinputRawButtonRelease(e) => {
let values: Vec<f64> = e.axisvalues.iter().map(|v| fp3232(v.integral, v.frac)).collect();
pens.raw_event(feed, e.deviceid, &e.valuator_mask, &values);
}
Event::XinputHierarchy(_) | Event::XinputDeviceChanged(_) => pens.set_devices(query_devices(conn)),
_ => {}
}
}
}
/// The slave pointer devices with their names and pressure valuators (none on any error).
fn query_devices(conn: &RustConnection) -> Vec<Device> {
let Ok(reply) = conn.xinput_xi_query_device(ALL_DEVICES).map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return Vec::new() };
let kinds = device_kinds(conn);
let mut out = Vec::new();
for info in reply.infos.iter().take(MAX_DEVICES) {
if info.type_ != DeviceType::SLAVE_POINTER && info.type_ != DeviceType::FLOATING_SLAVE {
continue;
}
let valuators = info.classes.iter().take(usize::from(MAX_AXES)).filter_map(|c| match &c.data {
DeviceClassData::Valuator(v) => {
let label = atom_name(conn, v.label)?;
Some((v.number, label, fp3232(v.min.integral, v.min.frac), fp3232(v.max.integral, v.max.frac)))
}
_ => None,
});
let name = String::from_utf8_lossy(&info.name);
out.push(Device::new(info.deviceid, &name, kinds.get(&info.deviceid).map(String::as_str), valuators.collect::<Vec<_>>()));
}
out
}
/// Each device's XInput type name, by device id (none on any error).
fn device_kinds(conn: &RustConnection) -> HashMap<u16, String> {
let Ok(reply) = conn.xinput_list_input_devices().map_err(drop).and_then(|c| c.reply().map_err(drop)) else { return HashMap::new() };
reply.devices.iter().take(MAX_DEVICES).filter_map(|d| Some((u16::from(d.device_id), atom_name(conn, d.device_type)?))).collect()
}
fn atom_name(conn: &RustConnection, atom: u32) -> Option<String> {
if atom == 0 {
return None;
}
let reply = conn.get_atom_name(atom).ok()?.reply().ok()?;
Some(String::from_utf8_lossy(&reply.name).into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The valuators of a Wacom pen as the X server lists them: x and y, then pressure from 0 to
/// 65,535.
fn pen_valuators() -> Vec<(u16, String, f64, f64)> {
vec![
(0, "Abs X".to_string(), 0.0, 44704.0),
(1, "Abs Y".to_string(), 0.0, 27940.0),
(2, "Abs Pressure".to_string(), 0.0, 65535.0),
(3, "Abs Tilt X".to_string(), -64.0, 63.0),
]
}
fn pen(id: u16) -> Device {
Device::new(id, "Wacom Intuos Pro M Pen stylus", Some("STYLUS"), pen_valuators())
}
fn mouse(id: u16) -> Device {
Device::new(id, "Logitech USB Optical Mouse", Some("MOUSE"), [(0, "Rel X".to_string(), -1.0, -1.0), (1, "Rel Y".to_string(), -1.0, -1.0)])
}
#[test]
fn the_mask_pairs_valuator_numbers_with_values() {
let v: Vec<_> = valuators(&[0b10_0101, 1], &[1.0, 2.0, 3.0, 4.0]).collect();
assert_eq!(v, vec![(0, 1.0), (2, 2.0), (5, 3.0), (32, 4.0)]);
// Fewer values than mask bits (malformed): the values that are there.
assert_eq!(valuators(&[u32::MAX; 4], &[7.0]).count(), 1);
assert_eq!(valuators(&[], &[7.0]).count(), 0);
}
#[test]
fn a_device_with_an_abs_pressure_valuator_is_a_pen() {
assert_eq!(pen(9).pressure, Some(Axis { number: 2, min: 0.0, max: 65535.0 }));
assert_eq!(mouse(12).pressure, None);
let pad = Device::new(13, "Wacom Intuos Pro M Pad pad", Some("PAD"), [(5, "Abs Wheel".to_string(), 0.0, 71.0)]);
assert_eq!(pad.pressure, None, "a tablet's pad is no pen");
let far = Device::new(14, "", Some("STYLUS"), [(MAX_AXES, "Abs Pressure".to_string(), 0.0, 1.0)]);
assert_eq!(far.pressure, None, "valuator numbers past the limit are ignored");
// The types libinput and XWayland give pens, and a device whose type wasn't listed.
for kind in [Some("TABLET"), Some("ERASER"), Some("xwayland-tablet stylus"), None] {
assert!(Device::new(15, "", kind, pen_valuators()).pressure.is_some(), "{kind:?}");
}
// xf86-input-evdev labels a touchpad's and a touchscreen's finger pressure "Abs Pressure".
for kind in ["TOUCHPAD", "TOUCHSCREEN"] {
assert_eq!(Device::new(16, "", Some(kind), pen_valuators()).pressure, None, "{kind}");
}
}
/// The eraser end of a pen is a device of its own, whose name or XInput type contains
/// "eraser", as xf86-input-wacom, xf86-input-libinput and XWayland list it.
#[test]
fn an_eraser_device_is_named_so() {
let erasers = [
("Wacom Intuos Pro M Pen eraser", Some("ERASER")),
("HUION Kamvas Pen Eraser (0x1)", Some("ERASER")),
("xwayland-tablet eraser:16", Some("xwayland-tablet eraser")),
("Wacom Intuos Pro M Pen eraser", None),
("Tablet Pen (0x2)", Some("ERASER")),
];
for (name, kind) in erasers {
let d = Device::new(10, name, kind, pen_valuators());
assert!(d.eraser && d.pressure.is_some(), "{name} ({kind:?})");
}
let tips = [
("Wacom Intuos Pro M Pen stylus", Some("STYLUS")),
("HUION Kamvas Pen Pen (0x1)", Some("STYLUS")),
("xwayland-tablet stylus:16", Some("xwayland-tablet stylus")),
("Logitech USB Optical Mouse", Some("MOUSE")),
("", None),
];
for (name, kind) in tips {
assert!(!Device::new(9, name, kind, pen_valuators()).eraser, "{name} ({kind:?})");
}
}
#[test]
fn the_eraser_device_sets_the_eraser_end() {
let mut pens = Pens::default();
pens.set_devices([pen(9), Device::new(10, "Wacom Intuos Pro M Pen eraser", Some("ERASER"), pen_valuators()), mouse(12)]);
let feed = StylusFeed::default();
pens.raw_event(&feed, 9, &[0b100], &[32767.5]);
assert_eq!(pens.handle(9, []), Update::Set(Some(PenSample { pressure: 0.5, eraser: false })));
assert_eq!(feed.eraser(), Some(false));
// Turned over, the pen hovers with its eraser end: the eraser device's first event, with
// no pressure yet.
pens.raw_event(&feed, 10, &[0b1], &[100.0]);
assert_eq!(pens.handle(10, []), Update::Set(Some(PenSample { pressure: 0.0, eraser: true })));
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(0.0)));
pens.raw_event(&feed, 10, &[0b100], &[65535.0]);
assert_eq!((feed.eraser(), feed.held()), (Some(true), Some(1.0)));
// Back to the tip, with the tip's own last pressure.
pens.raw_event(&feed, 9, &[0b1], &[200.0]);
assert_eq!((feed.eraser(), feed.held()), (Some(false), Some(0.5)));
// A mouse: no pen end.
pens.raw_event(&feed, 12, &[0b1], &[3.0]);
assert_eq!(feed.eraser(), None);
}
#[test]
fn a_pens_valuator_becomes_its_pressure_and_another_pointer_clears_it() {
let mut pens = Pens::default();
pens.set_devices([pen(9), mouse(12)]);
let feed = StylusFeed::default();
pens.raw_event(&feed, 9, &[0b101], &[100.0, 32767.5]);
assert_eq!(feed.held(), Some(0.5));
// Raw events carry only the valuators that changed: a move without pressure keeps it.
pens.raw_event(&feed, 9, &[0b1], &[200.0]);
assert_eq!(feed.held(), Some(0.5));
pens.raw_event(&feed, 9, &[0b100], &[65535.0]);
assert_eq!(feed.held(), Some(1.0));
// The mouse moves: no pen pressure until the pen moves again.
pens.raw_event(&feed, 12, &[0b1], &[3.0]);
assert_eq!(feed.held(), None);
pens.raw_event(&feed, 9, &[0b1], &[300.0]);
assert_eq!(feed.held(), Some(1.0), "the pen's last pressure");
// A master device or one not listed yet changes nothing.
pens.raw_event(&feed, 2, &[0b100], &[0.0]);
assert_eq!(feed.held(), Some(1.0));
// A pen whose first event carries no pressure is hovering: it presses with 0.
pens.set_devices([pen(9), pen(10)]);
pens.raw_event(&feed, 10, &[0b1], &[5.0]);
assert_eq!(feed.press(), Some(0.0));
}
#[test]
fn hostile_device_data_gives_a_pressure_within_0_to_1() {
let ranges = [(5.0, 5.0), (1.0, -1.0), (f64::NEG_INFINITY, f64::INFINITY), (f64::NAN, f64::NAN)];
for (min, max) in ranges {
let mut pens = Pens::default();
pens.set_devices([Device::new(1, "", None, [(2, "Abs Pressure".to_string(), min, max)])]);
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, f64::MIN, -0.0, 1e300, 0.5] {
let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(1, [(2, v)]) else { panic!("{min}..{max}: {v} gave no pressure") };
assert!((0.0..=1.0).contains(&p), "{min}..{max}: {v} gave {p}");
}
}
let mut pens = Pens::default();
pens.set_devices((0..2000u16).map(|i| Device::new(i, "", None, [])));
assert!(pens.devices.len() <= MAX_DEVICES, "the device list is bounded");
// Values for valuators the pen doesn't have are ignored.
pens.set_devices([pen(3)]);
let Update::Set(Some(PenSample { pressure: p, .. })) = pens.handle(3, valuators(&[u32::MAX; 2048], &[0.5; 4096]).collect::<Vec<_>>()) else {
panic!("the pen gave no pressure")
};
assert!(p < 0.001, "{p}");
}
#[test]
fn fixed_point_values() {
assert_eq!(fp3232(3, 1 << 31), 3.5);
assert_eq!(fp3232(-2, 0), -2.0);
}
}
+39 -4
View File
@@ -110,7 +110,8 @@ fn fit(size: [f32; 2], room: [f32; 2]) -> [f32; 2] {
}
/// Size and place the new window (eframe shows it after drawing the first frame) and return its
/// geometry.
/// geometry. On Linux the window may not be mapped yet, so [`apply_pending_maximize`] must run
/// until the host reports maximized (fixes #3635).
pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) -> WindowGeometry {
let handles: Vec<_> = window.available_monitors().collect();
let monitors: Vec<Monitor> = handles.iter().map(|m| Monitor::new(m.position(), m.size(), m.scale_factor())).collect();
@@ -121,22 +122,41 @@ pub fn restore(window: &winit::window::Window, saved: Option<WindowGeometry>) ->
}
// Returns the new size when it applies at once; either way the window gets it.
let _ = window.request_inner_size(LogicalSize::new(g.size[0], g.size[1]));
#[cfg(not(target_os = "linux"))]
if g.maximized {
window.set_maximized(true);
}
g
}
/// Keep asking the host to maximize until it sticks, or give up after a few frames.
pub fn apply_pending_maximize(window: &winit::window::Window, want: bool, tries: &mut u8) -> bool {
if !want {
return false;
}
if window.is_maximized() {
return false;
}
if *tries == 0 {
return false;
}
*tries -= 1;
window.set_maximized(true);
true
}
/// Per frame: remember the window's geometry for the next launch. Minimized or full screen keeps
/// the last one; maximized only sets its flag, so un-maximizing next time restores the last size.
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>) {
/// `host_maximized` is the window manager's idea of maximized when egui does not report it (#3635).
pub fn track(ctx: &egui::Context, geometry: &mut Option<WindowGeometry>, host_maximized: Option<bool>) {
let zoom = ctx.zoom_factor();
ctx.input(|i| {
let v = i.viewport();
if v.minimized == Some(true) || v.fullscreen == Some(true) {
return;
}
if v.maximized == Some(true) {
let maximized = v.maximized == Some(true) || host_maximized == Some(true);
if maximized {
if let Some(g) = geometry {
g.maximized = true;
}
@@ -222,7 +242,7 @@ mod tests {
let mut frame = |info: egui::ViewportInfo| {
let mut input = egui::RawInput::default();
input.viewports.insert(egui::ViewportId::ROOT, info);
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry)).textures_delta.clear();
ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, None)).textures_delta.clear();
geometry
};
let rect = egui::Rect::from_min_size(egui::pos2(20.0, 30.0), egui::vec2(1000.0, 600.0));
@@ -253,4 +273,19 @@ mod tests {
let back: vectorcraft_ui_egui::UiState = serde_json::from_value(json).unwrap();
assert_eq!(back.window, None);
}
#[test]
fn the_host_maximized_flag_is_remembered_when_egui_does_not_report_it() {
let ctx = egui::Context::default();
let mut geometry = Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: false });
let rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), egui::vec2(1920.0, 1080.0));
let mut input = egui::RawInput::default();
input.viewports.insert(
egui::ViewportId::ROOT,
egui::ViewportInfo { inner_rect: Some(rect), outer_rect: Some(rect), maximized: None, ..Default::default() },
);
let mut out = ctx.run_ui(input, |ui| track(ui.ctx(), &mut geometry, Some(true)));
out.textures_delta.clear();
assert_eq!(geometry, Some(WindowGeometry { pos: Some([10, 20]), size: [800.0, 600.0], maximized: true }));
}
}
+299
View File
@@ -0,0 +1,299 @@
//! Windows pen tablets: with Windows Ink, winit reports a pen in contact as a touch and drops its
//! barrel button, and egui-winit presses the primary button for the touch it makes the pointer,
//! so a pen pressed with its barrel button held clicks and drags with the left button. The
//! `WM_POINTERDOWN` message winit is about to handle still has the barrel button's state in its
//! second-button flag ("analogous to a mouse right button down",
//! <https://learn.microsoft.com/en-us/windows/win32/inputmsg/wm-pointerdown>): [`msg_hook`] notes
//! it, and [`raw_input_hook`] makes the primary button of that contact the secondary one.
#![cfg_attr(not(all(windows, not(target_vendor = "win7"))), allow(dead_code))]
use std::cell::RefCell;
use egui::{Event, Modifiers, PointerButton, Pos2, TouchId, TouchPhase};
/// `POINTER_MESSAGE_FLAG_SECONDBUTTON` (`winuser.h`): in the high word of a pointer message's
/// `wParam`, a pen in contact with its barrel button pressed.
const POINTER_MESSAGE_FLAG_SECONDBUTTON: usize = 0x0020;
/// At most this many pointer contacts wait for their touch event.
const MAX_PENDING: usize = 16;
thread_local! {
static STROKES: RefCell<BarrelStrokes> = RefCell::new(BarrelStrokes::default());
}
/// winit's message hook (`EventLoopBuilderExtWindows::with_msg_hook`), which winit calls for each
/// message it takes from the thread's queue, before dispatching it: notes each pointer contact's id
/// and whether the barrel button was held.
/// It never consumes the message.
#[cfg(all(windows, not(target_vendor = "win7")))]
#[allow(unsafe_code)]
pub fn msg_hook(msg: *const std::ffi::c_void) -> bool {
use windows::Win32::UI::WindowsAndMessaging::{MSG, WM_POINTERDOWN};
// SAFETY: winit passes the `MSG` that `PeekMessageW` has just filled, which lives for this
// call; `as_ref` gives `None` for a null pointer.
let Some(msg) = (unsafe { msg.cast::<MSG>().as_ref() }) else {
return false;
};
if msg.message == WM_POINTERDOWN {
STROKES.with(|s| {
if let Ok(mut s) = s.try_borrow_mut() {
s.pointer_down(msg.wParam.0);
}
});
}
false
}
/// Before egui reads a frame's input: the primary button of a contact that started with the
/// barrel button held becomes the secondary button.
pub fn raw_input_hook(raw: &mut egui::RawInput) {
STROKES.with(|s| {
if let Ok(mut s) = s.try_borrow_mut() {
s.rewrite(&mut raw.events);
}
});
}
/// The pen contacts that press the secondary button.
#[derive(Default)]
struct BarrelStrokes {
/// Each `WM_POINTERDOWN`'s pointer id and whether the barrel button was held, oldest first,
/// until the touch of that contact starts.
pending: Vec<(u64, bool)>,
/// The contact whose touch started with the barrel button held, until it ends, the same pointer
/// starts another contact, or another contact starts with the barrel button held.
right: Option<Right>,
}
/// A contact that started with the barrel button held.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Right {
id: TouchId,
/// Its secondary button is down: the press went to egui and the release has not.
down: bool,
}
impl BarrelStrokes {
/// A `WM_POINTERDOWN` with this `wParam`, whose low word is the pointer id and whose high word
/// holds the pointer flags.
fn pointer_down(&mut self, wparam: usize) {
if self.pending.len() >= MAX_PENDING {
self.pending.remove(0);
}
self.pending.push(((wparam & 0xffff) as u64, (wparam >> 16) & POINTER_MESSAGE_FLAG_SECONDBUTTON != 0));
}
/// Makes the primary button of the right contact the secondary one. egui-winit sends a touch's
/// pointer button with the touch event it comes from: the press after the touch's start and
/// the pointer's move to it, the release right after its end. A canceled contact, or one
/// whose end never came (Windows does not promise one), gets a secondary release of its own.
fn rewrite(&mut self, events: &mut Vec<Event>) {
let release = |pos: Pos2| Event::PointerButton { pos, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE };
let mut out = Vec::with_capacity(events.len() + 1);
// The button event that comes next for the right contact: `Some(true)` for its press,
// `Some(false)` for its release.
let mut next = None;
for mut event in events.drain(..) {
let mut after = None;
match &mut event {
Event::Touch { id, phase, pos, .. } => {
let (id, phase, pos) = (*id, *phase, *pos);
if phase == TouchPhase::Start {
// Each start decides afresh, from the oldest contact of its pointer still waiting.
let barrel = match self.pending.iter().position(|&(p, _)| p == id.0) {
Some(i) => self.pending.remove(i).1,
None => false,
};
if let Some(r) = self.right
&& (barrel || r.id == id)
{
if r.down {
out.push(release(pos));
}
self.right = None;
}
if barrel {
self.right = Some(Right { id, down: false });
}
}
next = None;
if let Some(r) = self.right.filter(|r| r.id == id) {
match phase {
TouchPhase::Start => next = Some(true),
TouchPhase::Move => {}
TouchPhase::End => {
self.right = None;
next = Some(false);
}
TouchPhase::Cancel => {
self.right = None;
if r.down {
after = Some(release(pos));
}
}
}
}
}
Event::PointerMoved(_) if next == Some(true) => {}
Event::PointerButton { button, pressed, .. } if next == Some(*pressed) && *button == PointerButton::Primary => {
*button = PointerButton::Secondary;
if let Some(r) = self.right.as_mut() {
r.down = *pressed;
}
next = None;
}
_ => next = None,
}
out.push(event);
out.extend(after);
}
*events = out;
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{TouchDeviceId, pos2};
/// `POINTER_MESSAGE_FLAG_INRANGE`, `_INCONTACT` and `_FIRSTBUTTON` (`winuser.h`): a pen tip
/// touching down.
const TIP: usize = 0x0002 | 0x0004 | 0x0010;
/// `POINTER_MESSAGE_FLAG_INRANGE` and `_INCONTACT` with the barrel button.
const BARREL: usize = 0x0002 | 0x0004 | POINTER_MESSAGE_FLAG_SECONDBUTTON;
fn down(strokes: &mut BarrelStrokes, id: usize, flags: usize) {
strokes.pointer_down((flags << 16) | id);
}
/// The events egui reads after the hook.
fn rewrite(strokes: &mut BarrelStrokes, events: &[Event]) -> Vec<Event> {
let mut events = events.to_vec();
strokes.rewrite(&mut events);
events
}
fn touch(id: u64, phase: TouchPhase) -> Event {
Event::Touch { device_id: TouchDeviceId(1), id: TouchId(id), phase, pos: pos2(10.0, 20.0), force: Some(0.5) }
}
fn button(button: PointerButton, pressed: bool) -> Event {
Event::PointerButton { pos: pos2(10.0, 20.0), button, pressed, modifiers: Modifiers::NONE }
}
/// What egui-winit sends for the touch that drives the pointer.
fn start(id: u64) -> Vec<Event> {
vec![touch(id, TouchPhase::Start), Event::PointerMoved(pos2(10.0, 20.0)), button(PointerButton::Primary, true)]
}
fn end(id: u64) -> Vec<Event> {
vec![touch(id, TouchPhase::End), button(PointerButton::Primary, false), Event::PointerGone]
}
fn buttons(events: &[Event]) -> Vec<(PointerButton, bool)> {
events.iter().filter_map(|e| if let Event::PointerButton { button, pressed, .. } = e { Some((*button, *pressed)) } else { None }).collect()
}
const SECONDARY_DOWN: (PointerButton, bool) = (PointerButton::Secondary, true);
const SECONDARY_UP: (PointerButton, bool) = (PointerButton::Secondary, false);
const PRIMARY_DOWN: (PointerButton, bool) = (PointerButton::Primary, true);
const PRIMARY_UP: (PointerButton, bool) = (PointerButton::Primary, false);
#[test]
fn a_pen_contact_with_the_barrel_button_presses_the_secondary_button() {
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, BARREL);
assert_eq!(buttons(&rewrite(&mut strokes, &start(7))), [SECONDARY_DOWN]);
let moved = [touch(7, TouchPhase::Move), Event::PointerMoved(pos2(15.0, 25.0))];
assert_eq!(rewrite(&mut strokes, &moved), moved);
assert_eq!(buttons(&rewrite(&mut strokes, &end(7))), [SECONDARY_UP]);
assert_eq!(strokes.right, None, "the next contact starts afresh");
assert!(strokes.pending.is_empty());
}
#[test]
fn a_pen_contact_with_the_tip_alone_keeps_the_primary_button() {
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, TIP);
let events = [start(7), end(7)].concat();
assert_eq!(rewrite(&mut strokes, &events), events);
}
#[test]
fn contacts_of_a_pen_within_one_frame_keep_their_order() {
// A tap with the barrel button held, then one with the tip alone, before the next frame.
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, BARREL);
down(&mut strokes, 7, TIP);
let events = rewrite(&mut strokes, &[start(7), end(7), start(7), end(7)].concat());
assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]);
}
#[test]
fn a_mouse_button_right_after_a_barrel_contact_keeps_its_button() {
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, BARREL);
let events = [start(7), vec![button(PointerButton::Primary, true)], end(7), vec![button(PointerButton::Primary, false)]].concat();
assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, SECONDARY_UP, PRIMARY_UP]);
}
#[test]
fn other_touches_and_the_mouse_keep_the_primary_button_during_a_barrel_contact() {
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, BARREL);
down(&mut strokes, 9, TIP);
let events = [start(7), start(9), vec![button(PointerButton::Primary, true)], end(9)].concat();
assert_eq!(buttons(&rewrite(&mut strokes, &events)), [SECONDARY_DOWN, PRIMARY_DOWN, PRIMARY_DOWN, PRIMARY_UP]);
assert_eq!(strokes.right, Some(Right { id: TouchId(7), down: true }), "the barrel contact goes on");
}
#[test]
fn a_contact_after_a_lost_end_releases_the_secondary_button_first() {
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, BARREL);
rewrite(&mut strokes, &start(7));
// No end arrives for that contact; the pen touches down again with the tip alone.
down(&mut strokes, 7, TIP);
let events = rewrite(&mut strokes, &[start(7), end(7)].concat());
assert_eq!(buttons(&events), [SECONDARY_UP, PRIMARY_DOWN, PRIMARY_UP]);
assert_eq!(strokes.right, None);
// Another pen's barrel contact takes over from one whose end was lost.
down(&mut strokes, 7, BARREL);
rewrite(&mut strokes, &start(7));
down(&mut strokes, 8, BARREL);
let events = rewrite(&mut strokes, &[start(8), end(8)].concat());
assert_eq!(buttons(&events), [SECONDARY_UP, SECONDARY_DOWN, SECONDARY_UP]);
}
#[test]
fn a_canceled_barrel_contact_releases_the_secondary_button() {
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, BARREL);
let events = rewrite(&mut strokes, &[start(7), vec![touch(7, TouchPhase::Cancel), Event::PointerGone]].concat());
assert_eq!(buttons(&events), [SECONDARY_DOWN, SECONDARY_UP]);
assert_eq!(strokes.right, None);
}
#[test]
fn a_barrel_contact_that_never_pressed_releases_nothing() {
// A finger drives the pointer, so egui-winit sends no button for the pen.
let mut strokes = BarrelStrokes::default();
down(&mut strokes, 7, BARREL);
let events = [vec![touch(7, TouchPhase::Start)], vec![touch(7, TouchPhase::Cancel)]].concat();
assert_eq!(rewrite(&mut strokes, &events), events);
down(&mut strokes, 7, BARREL);
down(&mut strokes, 7, TIP);
let events = [vec![touch(7, TouchPhase::Start)], start(7)].concat();
assert_eq!(buttons(&rewrite(&mut strokes, &events)), [PRIMARY_DOWN]);
}
#[test]
fn contacts_whose_touch_never_arrives_are_bounded() {
let mut strokes = BarrelStrokes::default();
for id in 0..40 {
down(&mut strokes, id, BARREL);
}
assert_eq!(strokes.pending.len(), MAX_PENDING);
assert_eq!(strokes.pending.last(), Some(&(39, true)));
}
}
@@ -1,6 +1,3 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
@@ -25,7 +22,9 @@ fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
.stderr(Stdio::piped())
.spawn()
.unwrap();
let deadline = Instant::now() + Duration::from_secs(5);
// Long enough for the binary to start on a loaded machine (the workspace's other tests run
// meanwhile), short enough to catch a desktop that started instead of exiting.
let deadline = Instant::now() + Duration::from_secs(30);
let timed_out = loop {
if child.try_wait().unwrap().is_some() {
break false;
@@ -58,11 +57,17 @@ fn missing_empty_or_option_operands_fail_before_startup() {
}
}
/// #3546: a root flag may be given more than once, in either spelling; a bad later operand still
/// stops the app before it starts.
#[test]
fn duplicate_roots_fail_for_both_spellings() {
fn repeated_roots_are_accepted_for_both_spellings() {
for flag in ["--automation-read-root", "--automation-write-root"] {
rejects(&[flag, ".", &format!("{flag}=.")], &format!("{flag} given twice"));
rejects(&[&format!("{flag}=."), flag, "."], &format!("{flag} given twice"));
let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
rejects(&[flag, ".", flag, ""], &format!("{flag} needs a non-empty folder"));
rejects(&[&format!("{flag}=."), flag], &format!("{flag} needs a folder"));
}
}
@@ -1,6 +1,3 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
//! `vectorcraft --help` prints the usage and exits before the app starts (#2110): run from a
//! terminal, SSH or an agent's shell it used to open the window (or hang without a display).
+6
View File
@@ -0,0 +1,6 @@
//! Integration tests: one test binary for the crate, one module per area.
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_args;
mod help;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="butt" stroke-linejoin="miter"><circle cx="4.5" cy="6" r="1.6" fill="currentColor" stroke="none"/><circle cx="4.5" cy="12" r="1.6" fill="currentColor" stroke="none"/><circle cx="4.5" cy="18" r="1.6" fill="currentColor" stroke="none"/><path d="M9 6H21" stroke-width="1.5" /><path d="M9 12H21" stroke-width="1.5" /><path d="M9 18H21" stroke-width="1.5" /></svg>

After

Width:  |  Height:  |  Size: 511 B

+19
View File
@@ -140,3 +140,22 @@ sync_github_name = false
[people.KaboomFox]
sync_github_name = true
[people.odysseexp]
real_name = "Harley Paiva"
sync_github_name = true
[people.addibble]
sync_github_name = true
[people.mockydevs]
real_name = "Don Omondi"
display_name = "Don Omondi"
[people.alpha-rock]
display_name = "Aditya Sisodiya"
[people.Ronfo2013]
real_name = "Angelo Bernardini"
display_name = "Angelo Bernardini"
sync_github_name = false
+51 -19
View File
@@ -11,6 +11,7 @@ Affinity files.
| Affinity | VectorCraft |
|---|---|
| Pages, Publisher spreads (one or two pages) | artboards, spreads side by side |
| Master pages (`MpCh`) shown on pages through master page instances (`MPIN`: master, page offset, master page, count) | a locked "Master A, B…" layer at the bottom holding each master page's objects on the pages that show it |
| Artboards (legacy `ABEn` or Affinity 3 `phrp`/`aprp`), with name, paint and any transform | artboards plus a clip group with the actual vector outline, background and stroke |
| Layers (`Scop`), groups (`Grup`), pass-through or isolated | layers and sublayers, groups |
| Curves (`PCrv`): cubic subpaths, closed flags, live corners (`CnrD`) | paths (multi-subpath curves fill even-odd) |
@@ -21,22 +22,24 @@ Affinity files.
| Pixel masks (`MRst`, attached or as a layer in a group) | opacity mask |
| Solid fills: RGBA, HSLA, CMYK, grey, Lab (D50) | fill appearance (alpha as fill opacity) |
| Linear, elliptical and radial gradients with Affinity's midpoint bias | gradients (the bias as the midpoint where the blend is half way) |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill | stroke appearance |
| Strokes: weight, scale with object, dash pattern and phase, alignment, miter limit, behind the fill; a stroke whose style is None (every shape stores one, black and 1 px) is left out | stroke appearance |
| Fill layers (`FRst`) | filled rectangles |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, size, tracking, fixed leading, colour, paragraph alignment, first baseline | point type and area type |
| Artistic and frame text: Unicode scalar-indexed runs, stored fallback font, family/weight/style, boolean OpenType features, all caps, underline, strikethrough, size, tracking, horizontal scale, fixed leading, colour, outlined characters, first baseline; each paragraph's alignment, indents, space before and after, fixed and automatic leading; frame text sized by its text scale (`FTxS`), not its frame's transform | point type and area type, one paragraph style per paragraph; artistic text whose paragraphs align differently keeps them aligned within its longest line (by indents), as Affinity draws it; a frame's first line hangs from its capitals' tops, and a frame whose last lines' descenders hang out of it is made just tall enough to show them; line breaks inside a paragraph become paragraphs without spacing between them; table cells place their text as Affinity does (centred from the capitals' tops to the last baseline, reaching out of a cell too short for it) |
| Tables (`TxtT`): grid, cell text, merged cells, cell insets and vertical alignment, the line on every cell edge | a group of area type per cell and a stroked line per edge (VectorCraft has no tables) |
| Placed images (`ImgN`): the original JPEG/PNG embedded in the file | embedded images |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin | embedded PNG images |
| Embedded documents and symbols (`EmbN`) | the picture Affinity cached of them |
| Pixel layers (`Rstr`): RGBA 8/16-bit and CMYK 8-bit tiles, cropped to their content; source-backed RGBA8 JPEG/PNG tiles with matching dimensions and zero origin; the document's CMYK colour profile (`CMYP`) | embedded PNG images; CMYK layers through the document's profile (relative colorimetric, black-point compensation, as Affinity converts them), or, without one, CMYK images that keep their inks |
| Embedded Affinity documents (`EmbN` with an `EmDc` entry), four deep at most | their first page's content, editable (their artboards only clip, they don't become artboards; their master pages are left out); other embedded documents and symbols as the picture Affinity cached of them |
| Layer effects: drop shadows, outer glows, inner shadows; outlines; colour and gradient overlays in any blend mode | Stylize › Drop Shadow and Outer Glow, Inner Glow at the edge (approximated); one more stroke on shapes (around their own stroke), the characters' stroke on text; overlays baked into fill, stroke, text colours and pixels (a group's over its children's) |
| Opacity, visibility, lock, names, the blend modes VectorCraft has | the same |
Everything else is reported in the import warnings, one line per kind with a count, never
dropped silently: layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages, conical gradients (as radial),
dropped silently: bevel, blur and other layer effects, adjustment layers and live filters, brush and pressure strokes,
transparency gradients, fill opacity, bitmap fills, master pages based on other master pages, conical gradients (as radial),
special shapes (cloud, heart, cog, callouts, arrows…, imported as their bounding ellipse), corner
types other than round or straight, stars with rounded points, several fills or strokes on one object (the
active one is used), outlined or scaled text, text fields such as page numbers, frames with
columns or a curved outline, paragraph indents/spacing, mixed paragraph alignments, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels (converted to RGB without the
document's profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
active one is used), skewed or flipped text, characters of different transparency in one text, text fields such as page numbers, text threading, frames with
columns or a curved outline, table cell fills, unsupported font selectors, grey/Lab/32-bit pixels, CMYK pixels with transparency in a document
without a CMYK profile (converted through VectorCraft's working CMYK profile) and Affinity-only blend modes (Add, Linear Light… as Normal).
When the native document can't be read at all, the engine opens the embedded PNG preview instead,
with a warning that names the reason; that preview can't be placed, used as a template or mined
@@ -78,10 +81,22 @@ of CC0 sample files.
* **Read-only.** Nothing is written in Affinity's format; export waits until someone can check
written files in Affinity itself.
The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
The reader also has independent copies in PhotoCraft's `photocraft-affinity` and DesignCraft's
`designcraft-affinity`. Tables, paragraph formats, text scale, master pages, layer effects,
overlays, embedded documents, large pixel layers and HEIC photos were added in DesignCraft
([storytold/designcraft#291](https://github.com/storytold/designcraft/pull/291)) and brought back
here, from the same public fixtures and the thumbnails of documents their owners made, used only
locally. Automatic leading (the font size plus the font's line gap, for artistic and frame text)
was fitted on the thumbnails of one such local document with 56 artboards and on the public Patchy
text-frame fixture.
## Validation
* `engine/tests/it/affinity_features.rs` covers pages, several artboards per canvas and per spread,
master pages, tables, paragraph formats, text scale, layer effects, overlays and embedded
documents (whose artboards don't become artboards) on synthetic documents, and mutates them.
`engine/tests/it/affinity_local.rs` compares every artboard of local documents with their thumbnail
when `AFFINITY_SAMPLES` points at a folder (never in CI; nothing is committed).
* Unit tests on synthetic containers built by the `synth` feature (stored, zlib and zstd entries,
checksums, budgets, cycles, hostile streams, every truncation) and property tests of random
mutations.
@@ -91,7 +106,7 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
feature probes plus one separately identified derived regression input from
[SethRobinson/Patchy](https://github.com/SethRobinson/Patchy). Only document data and provenance
notices were inspected, never that project's importer or authoring scripts.
* `tests/real_files.rs` parses every file and freezes the modeled features of all 33 current
* `tests/it/real_files.rs` parses every file and freezes the modeled features of all 33 current
`.af` files. Native raster tests compare decoded pixels with independent embedded images;
text tests assert Unicode run boundaries, fallback fonts and feature retention.
* `engine/tests/affinity_corpus.rs` requires editable native import, exact `.vectorcraft`
@@ -104,9 +119,14 @@ The reader also has an independent copy in PhotoCraft's `photocraft-affinity`.
pass CI. Run locally with `AFFINITY_CORPUS_REQUIRED=1` for the same strict gate.
* Synthetic engine regressions cover nested, rotated and curved artboards, negative origins,
multiple spreads and resolution conversion, alongside text recovery and native save/export.
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive and checks
that whatever opens also renders and exports; `fuzz/` has `cargo-fuzz` targets for the whole
reader (`container`) and the object stream (`stream`):
* `engine/tests/import_fuzz.rs` mutates a synthetic native document's stream and archive, and
documents with layer effects, colour overlays (also over tables), line styles, paragraph and
character formats, tables, master pages, CMYK pixel layers and embedded documents (the embedding
and the embedded stream), and checks that whatever opens also renders and exports.
`tests/it/import_limits.rs` builds hostile documents against the limits below: grids of 16 million
cells, grids and stories shared by many layers, 200 000 paragraphs, a document embedded hundreds
of times. `fuzz/` has `cargo-fuzz` targets for the whole reader (`container`) and the object
stream (`stream`):
```sh
cd crates/affinity
@@ -132,11 +152,23 @@ anything is allocated: a value takes about 40 bytes in memory however few it too
those 189 public documents uses 3.3 million), 384 levels of object nesting, 128 levels of layers, 500 000 layers, four million curve
nodes per curve; compound outlines also cap recursion at 128 levels, operand visits at 100 000
and cumulative geometry work at four million, rejecting cycles and partial results on limits.
Pie sweeps normalize finite angles in constant time. Pixel layers are limited to 64 megapixels
(cropped to their content first); gradients to 1024 stops. Text collection is bounded to a
million characters per node before allocation, with a cumulative 16 MiB budget for owned font
names, features and gradient stops. Exhaustion warns and uses default attributes for remaining
text, preserving its characters.
Pie sweeps normalize finite angles in constant time. Pixel layers are decoded at no more than 64
megapixels each (cropped to their content first; larger ones are read at a half, a quarter… of
their resolution) and 512 megapixels for the whole document; pixel masks are read only over what
they mask (all of it under artistic text, whose glyphs only the layout places). One budget covers
the whole read: layers, pixels, 16 million characters of stories and 262 144 table cells, so
content that many layers link, or a document embedded many times, can't multiply them. Embedded
documents nest four deep at most; both of their readings are charged to that budget and to the
importing document's archive limits, also when they fail part of the way. Tables have at most
4096 rows and columns and 65 536 cells, and 64 layer effects per node are read. Gradients are
limited to 1024 stops. Text collection is bounded to a million characters per node before
allocation, with a cumulative 16 MiB budget for owned font names (256 bytes at most each),
features and gradient stops; paragraph formats are matched to runs by binary search. Exhaustion
warns and uses default attributes for remaining text, preserving its characters. Colour and
gradient overlays share one work budget per import (`overlay::MAX_WORK`: the nodes and pixels
each is baked into); past it they are left out, and said. In VectorCraft, master pages are
copied onto at most as many objects, characters and pixels as a document may hold, a few times
over, and objects beyond `MAX_COORD` of the origin are left out.
Every archive entry's CRC-32 and size must match. Malformed input returns an `Error`; the
crate has no panics outside tests.
+15
View File
@@ -263,6 +263,21 @@ impl<'a> Archive<'a> {
}
/// Extract an entry by name: decompress, undo the byte predictor and verify the CRC.
/// What is left of the limits for documents embedded in this one.
pub(crate) fn remaining(&self) -> Limits {
Limits { max_entry: self.limits.max_entry, max_total: self.limits.max_total.saturating_sub(self.extracted) }
}
/// Count `n` bytes extracted elsewhere (from embedded documents) against this import.
pub(crate) fn charge(&mut self, n: usize) -> Result<(), Error> {
let total = self.extracted.checked_add(n).ok_or(Error::Limit("document too large"))?;
if total > self.limits.max_total {
return Err(Error::Limit("document too large"));
}
self.extracted = total;
Ok(())
}
pub fn read(&mut self, name: &str) -> Result<Vec<u8>, Error> {
let e = self.entries.get(name).ok_or(Error::Malformed("missing archive entry"))?.clone();
let size = usize::try_from(e.size).map_err(|_| Error::Limit("archive entry too large"))?;
+7 -1
View File
@@ -215,7 +215,13 @@ fn anchors(records: &[Value]) -> Option<Vec<Anchor>> {
fn subpath(anchors: &[Anchor], closed: bool, radii: Option<&Vec<(usize, f64)>>) -> Option<SubPath> {
let first = anchors.first()?;
if let Some(radii) = radii.filter(|r| !r.is_empty()) {
let corner = |a: &Anchor| radii.iter().filter(|(rec, _)| *rec == a.record).map(|(_, r)| *r).fold(0.0, f64::max);
// The largest radius of each record, once (not a scan of all of them for each anchor).
let mut largest: std::collections::HashMap<usize, f64> = std::collections::HashMap::new();
for (rec, r) in radii {
let e = largest.entry(*rec).or_insert(0.0);
*e = e.max(*r);
}
let corner = |a: &Anchor| largest.get(&a.record).copied().unwrap_or(0.0);
let polyline = anchors.iter().all(|a| a.inh.is_none_or(|h| h == a.p) && a.out.is_none_or(|h| h == a.p));
if polyline {
let mut pts: Vec<(Point, f64)> = anchors.iter().map(|a| (a.p, corner(a))).collect();
+1
View File
@@ -14,6 +14,7 @@ mod access;
pub mod container;
mod geometry;
pub mod model;
pub mod overlay;
pub mod paint;
pub mod preview;
mod raster;
+630 -60
View File
@@ -1,4 +1,4 @@
//! The document model VectorCraft imports: spreads, artboards, layers, groups, curves, shapes,
//! The document model the Craft apps import: spreads, artboards, layers, groups, curves, shapes,
//! text and images, with transforms composed into document space. Anything else is reported
//! by [`Document::warnings`] instead of being dropped silently.
@@ -175,17 +175,54 @@ pub struct TextRun {
/// Boolean OpenType feature overrides, as tags or `-tag` for disabled features.
pub features: Vec<String>,
pub all_caps: bool,
/// Underlined characters (`Ints` slot 0 is 1; identified on underlined words in a document its
/// owner made, all other characters there and in the public corpus have 0).
pub underline: bool,
/// Struck-through characters (`Ints` slot 1 is 1; identified the same way, on prices struck
/// through in another document its owner made).
pub strikethrough: bool,
/// Font size in document pixels, before the node's transform.
pub size: f64,
/// Tracking in em.
pub tracking: f64,
/// Horizontal scale of the glyphs (1 = as designed).
pub h_scale: f64,
/// Fixed line pitch in document pixels, when the run overrides automatic leading.
pub leading: Option<f64>,
pub fill: Paint,
/// Outlined characters: the outline's paint and width (node space).
pub stroke: Option<crate::paint::Stroke>,
/// The run's paragraph: alignment, indents and spacing.
pub paragraph: Paragraph,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
/// Paragraph alignment, indents, spacing and leading, in node space (document pixels before the
/// node's text scale).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Paragraph {
pub align: Align,
/// Indent of every line but the first, from the left.
pub left: f64,
pub right: f64,
/// Indent of the first line, from the left.
pub first: f64,
pub space_before: f64,
pub space_after: f64,
/// Fixed distance between baselines, when the paragraph sets one.
pub leading: Option<f64>,
/// Automatic leading as a multiple of the font's own line height (ascent plus descent).
pub auto_leading: f64,
}
impl Default for Paragraph {
fn default() -> Self {
Self { align: Align::Left, left: 0.0, right: 0.0, first: 0.0, space_before: 0.0, space_after: 0.0, leading: None, auto_leading: 1.0 }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Align {
#[default]
Left,
Center,
Right,
@@ -202,12 +239,69 @@ pub struct Text {
pub frame: Option<Rect>,
/// Node space to document pixels.
pub transform: Affine,
/// Node space to document pixels for the type (its linear part matters): artistic text scales
/// with its node, frame text only with its frame's text scale.
pub text_transform: Affine,
}
/// Vertical alignment of a table cell's text.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VAlign {
Top,
Center,
Bottom,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TableCell {
pub runs: Vec<TextRun>,
/// Space between the cell edges and its text, in node space: left, top, right, bottom.
pub inset: [f64; 4],
pub valign: VAlign,
/// Part of the cell to its left (merged): it has no text of its own.
pub merged_left: bool,
}
/// A table: a grid of cells with text, and a line on every cell edge.
#[derive(Debug, Clone, PartialEq)]
pub struct Table {
/// Column boundaries (one more than the columns), increasing, in node space.
pub columns: Vec<f64>,
/// Row boundaries (one more than the rows), increasing, in node space.
pub rows: Vec<f64>,
/// Row by row.
pub cells: Vec<TableCell>,
/// Vertical edges row by row, one more per row than the columns; `None` draws no line, and
/// the list is empty when the file's didn't match the grid.
pub vertical: Vec<Option<Stroke>>,
/// Horizontal edges boundary by boundary (one more than the rows), one per column (empty
/// as `vertical` can be).
pub horizontal: Vec<Option<Stroke>>,
/// Node space to document pixels.
pub transform: Affine,
/// Node space to document pixels for the cells' type: resizing a table moves its grid, not
/// its type.
pub text_transform: Affine,
}
impl Table {
pub fn ncols(&self) -> usize {
self.columns.len().saturating_sub(1)
}
pub fn nrows(&self) -> usize {
self.rows.len().saturating_sub(1)
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum Pixels {
/// Straight-alpha RGBA, 8 bits per channel, row by row.
Rgba8(Vec<u8>),
/// A CMYK pixel layer: cyan, magenta, yellow and black ink amounts (0 none, 255 full) and
/// straight alpha, 8 bits each, row by row. Left in CMYK for the importer to show through a
/// colour profile: the profile-free formula turns the extra cyan of a separation's neutrals
/// into a blue cast.
Cmyka8(Vec<u8>),
/// The original file a placed image was made from (PNG, JPEG…), as embedded.
Encoded(Vec<u8>),
}
@@ -246,11 +340,78 @@ pub enum Kind {
even_odd: bool,
},
Text(Text),
Table(Table),
Image(Image),
/// A master page shown on this spread's pages: pages `page..page + count` show the master
/// spread [`Document::masters`]`[master]`'s pages from `master_page` on.
MasterInstance {
master: usize,
page: usize,
master_page: usize,
count: usize,
},
/// Something this reader does not import; its children are still imported.
Unsupported,
}
/// A layer effect. Lengths are document pixels; `angle` is the direction the shadow is offset in
/// (radians, clockwise from +x as y points down: π/2 puts it below).
#[derive(Debug, Clone, PartialEq)]
pub enum Effect {
DropShadow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
distance: f64,
angle: f64,
},
InnerShadow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
distance: f64,
angle: f64,
},
OuterGlow {
color: crate::paint::Color,
opacity: f64,
radius: f64,
},
/// An outline of `width` around the node's shape: `align` 0 outside, 1 inside, 2 centred.
Outline {
color: crate::paint::Color,
opacity: f64,
width: f64,
align: u16,
},
/// Bevel and emboss: lit and shaded edges `size` wide. `angle` is the light's azimuth
/// (radians, counter-clockwise from +x with y up), `depth` Affinity's depth (5 by default);
/// `inner` is false for the outer, emboss and pillow kinds.
Bevel {
size: f64,
depth: f64,
angle: f64,
highlight: crate::paint::Color,
highlight_opacity: f64,
shadow: crate::paint::Color,
shadow_opacity: f64,
inner: bool,
},
/// Colour overlay: everything the node paints takes `color`, laid over it in `blend` mode at
/// `opacity` (the node's own alpha is kept).
ColorOverlay {
color: crate::paint::Color,
opacity: f64,
blend: Blend,
},
/// Gradient overlay: a colour overlay whose colour comes from `gradient` (document pixels).
GradientOverlay {
gradient: crate::paint::Gradient,
opacity: f64,
blend: Blend,
},
}
#[derive(Debug, Clone, PartialEq)]
pub struct Node {
pub class: Tag,
@@ -264,6 +425,8 @@ pub struct Node {
pub mask: Option<Path>,
/// Pixel mask: grey levels (white shows the node, black hides it); outside it the node is hidden.
pub pixel_mask: Option<Image>,
/// Layer effects the importer can show or bake in (others are reported).
pub effects: Vec<Effect>,
/// Bottom to top.
pub children: Vec<Node>,
}
@@ -285,12 +448,21 @@ pub struct Document {
/// Pixels per inch: all geometry is in document pixels at this resolution.
pub dpi: f64,
pub spreads: Vec<Spread>,
/// Master spreads, which [`Kind::MasterInstance`] nodes show on pages.
pub masters: Vec<Spread>,
/// Application that last saved the file, as stored (`Affinity 3.0.2`), when present.
pub saved_by: Option<String>,
/// Content that was not imported or was approximated, one line per kind, deduplicated.
pub warnings: Vec<String>,
/// The document's CMYK colour profile (an ICC file), when it embeds one: the root's `CMYP`,
/// which each CMYK pixel layer's colour settings (`Prof`) also name, with relative
/// colorimetric intent and black-point compensation in every document checked.
pub cmyk_profile: Option<Vec<u8>>,
}
/// Largest embedded colour profile kept (real CMYK profiles are a few megabytes).
const MAX_PROFILE_BYTES: usize = 32 << 20;
/// Deepest layer nesting followed; deeper content is reported, not imported.
const MAX_TREE_DEPTH: usize = 128;
/// Total layer nodes imported.
@@ -300,6 +472,32 @@ const MAX_NODES: usize = 500_000;
/// count the bytes extracted from it, and a layer of one flat colour needs none (#1704).
const MAX_LAYER_PIXELS: u64 = 512 << 20;
/// Embedded documents inside embedded documents are followed this deep.
const MAX_EMBED_DEPTH: usize = 4;
/// Characters of all stories together (a story may be shared by many text layers).
const MAX_DOCUMENT_CHARS: usize = 16 << 20;
/// Table cells of all tables together (each becomes a text object with its edge lines).
const MAX_DOCUMENT_CELLS: usize = 1 << 18;
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
/// documents are read with what their embedding document left, so linking or embedding the
/// same content many times can't multiply the work.
#[derive(Clone, Copy, Debug)]
pub(crate) struct Budget {
/// Layer nodes ([`MAX_NODES`] in all).
pub(crate) nodes: usize,
/// Pixels the pixel layers may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels: u64,
/// Characters of stories ([`MAX_DOCUMENT_CHARS`] in all).
pub(crate) chars: usize,
/// Table cells ([`MAX_DOCUMENT_CELLS`] in all).
pub(crate) cells: usize,
}
impl Budget {
pub(crate) const FULL: Budget = Budget { nodes: MAX_NODES, pixels: MAX_LAYER_PIXELS, chars: MAX_DOCUMENT_CHARS, cells: MAX_DOCUMENT_CELLS };
}
/// Read a document: archive, `doc.dat` and the model.
pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
read_within(bytes, limits, MAX_LAYER_PIXELS)
@@ -307,35 +505,84 @@ pub fn read(bytes: &[u8], limits: Limits) -> Result<Document, Error> {
/// [`read`], its pixel layers holding at most `pixels` pixels together.
pub(crate) fn read_within(bytes: &[u8], limits: Limits, pixels: u64) -> Result<Document, Error> {
let mut archive = Archive::open(bytes, limits)?;
read_at(bytes, limits, &mut Budget { pixels, ..Budget::FULL }, Affine::IDENTITY, 0).document
}
/// A document read by [`read_at`] (or why it couldn't be), with the bytes its archive extracted
/// either way.
struct ReadAt {
document: Result<Document, Error>,
extracted: usize,
}
/// Read a document whose spreads are placed by `base` (for embedded documents, nested `depth`
/// deep). What it uses is taken from `budget`, also when it fails part of the way.
/// The most characters of a layer's name kept.
const MAX_NAME_CHARS: usize = 1024;
fn read_at(bytes: &[u8], limits: Limits, budget: &mut Budget, base: Affine, depth: usize) -> ReadAt {
let mut archive = match Archive::open(bytes, limits) {
Ok(a) => a,
Err(e) => return ReadAt { document: Err(e), extracted: 0 },
};
let document = read_archive(&mut archive, budget, base, depth);
ReadAt { document, extracted: archive.extracted() }
}
fn read_archive(archive: &mut Archive, budget: &mut Budget, base: Affine, depth: usize) -> Result<Document, Error> {
let doc = archive.read("doc.dat")?;
let s = stream::parse(&doc)?;
let mut reader = Reader::new(&s, &mut archive);
reader.pixels_left = pixels;
reader.document()
let mut reader = Reader::new(&s, archive, base, depth);
reader.budget = *budget;
let document = reader.document();
*budget = reader.budget;
document
}
pub(crate) struct Reader<'s, 'a, 'b> {
pub(crate) s: &'s Stream,
pub(crate) archive: &'b mut Archive<'a>,
pub(crate) dpi: f64,
warnings: BTreeMap<String, usize>,
nodes: usize,
pub(crate) warnings: BTreeMap<String, usize>,
active: HashSet<ObjId>,
/// Pixels the pixel layers still to come may decode ([`MAX_LAYER_PIXELS`] in all).
pub(crate) pixels_left: u64,
/// Where the spreads go: the identity, or an embedding document's placement.
base: Affine,
/// The linear part of `base`, which also scales frame text.
pub(crate) type_base: Affine,
/// How deep inside embedded documents this one is.
depth: usize,
/// An embedded document's content, waiting to become its node's children.
pending: Option<Vec<Node>>,
/// The master spreads, in the order of [`Document::masters`].
master_ids: Vec<ObjId>,
/// What the rest of the read may use.
pub(crate) budget: Budget,
}
impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
fn new(s: &'s Stream, archive: &'b mut Archive<'a>) -> Self {
Self { s, archive, dpi: 72.0, warnings: BTreeMap::new(), nodes: 0, active: HashSet::new(), pixels_left: MAX_LAYER_PIXELS }
pub(crate) fn new(s: &'s Stream, archive: &'b mut Archive<'a>, base: Affine, depth: usize) -> Self {
let [a, b, c, d, ..] = base.0;
Self {
s,
archive,
dpi: 72.0,
warnings: BTreeMap::new(),
active: HashSet::new(),
base,
type_base: Affine([a, b, c, d, 0.0, 0.0]),
depth,
pending: None,
master_ids: Vec::new(),
budget: Budget::FULL,
}
}
pub(crate) fn warn(&mut self, what: impl Into<String>) {
*self.warnings.entry(what.into()).or_default() += 1;
}
fn document(mut self) -> Result<Document, Error> {
/// The document.
fn document(&mut self) -> Result<Document, Error> {
let s = self.s;
let root = s.root;
self.dpi = s.obj(root, b"UVCn").and_then(|u| s.f64(u, b"UPPI")).filter(|d| *d > 0.0 && *d < 100_000.0).unwrap_or(72.0);
@@ -347,40 +594,65 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
});
let doc = s.obj(root, b"DocR").ok_or(Error::Malformed("document has no root node"))?;
self.master_ids = s.objs(doc, b"MpCh").into_iter().filter(|m| s.is(*m, b"Sprd")).collect();
let mut spreads = Vec::new();
for spread in s.objs(doc, b"Chld") {
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
continue;
if let Some(sp) = self.spread(doc, spread)? {
spreads.push(sp);
}
let pages = pages(s, spread);
let default_size =
s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, Affine::IDENTITY, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
spreads.push(Spread { bounds, pages, transparent, nodes });
}
if spreads.is_empty() {
return Err(Error::Malformed("document has no pages"));
}
if !s.objs(doc, b"MpCh").is_empty() {
self.warn("master pages (their content is not placed on the pages that use them)");
let mut masters = Vec::new();
for master in self.master_ids.clone() {
match self.spread(doc, master)? {
Some(sp) => masters.push(sp),
// Keep the indices of `MasterInstance::master` valid.
None => masters.push(Spread {
bounds: Rect { x0: 0.0, y0: 0.0, x1: 0.0, y1: 0.0 },
pages: Vec::new(),
transparent: true,
nodes: Vec::new(),
}),
}
}
let warnings = self.warnings.into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, saved_by, warnings })
let cmyk_profile = s
.obj(root, b"CMYP")
.and_then(|p| s.bytes(p, b"Data"))
.filter(|b| b.len() <= MAX_PROFILE_BYTES && b.get(16..20) == Some(b"CMYK".as_slice()))
.map(<[u8]>::to_vec);
let warnings = std::mem::take(&mut self.warnings).into_iter().map(|(w, n)| if n > 1 { format!("{w} ({n}×)") } else { w }).collect();
Ok(Document { dpi: self.dpi, spreads, masters, saved_by, warnings, cmyk_profile })
}
/// The children of `parent`, and the pixel mask a mask layer among them puts on the parent.
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<Image>), Error> {
/// A spread (`Sprd`) of the document `doc`, or of its master spreads.
fn spread(&mut self, doc: ObjId, spread: ObjId) -> Result<Option<Spread>, Error> {
let s = self.s;
if !s.is(spread, b"Sprd") {
self.warn("an unknown kind of page");
return Ok(None);
}
let pages = pages(s, spread);
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
x0: 0.0,
y0: 0.0,
x1: 0.0,
y1: 0.0,
});
let transparent = s.bool(spread, b"SprT").unwrap_or(false);
let (nodes, mask) = self.children(spread, self.base, 0)?;
if mask.is_some() {
self.warn("mask layers directly on a page (imported without them)");
}
Ok(Some(Spread { bounds, pages, transparent, nodes }))
}
/// The children of `parent`, and the mask layer among them that masks the parent (with its
/// placement), read later over just what it masks.
#[allow(clippy::type_complexity)]
fn children(&mut self, parent: ObjId, world: Affine, depth: usize) -> Result<(Vec<Node>, Option<(ObjId, Affine)>), Error> {
let s = self.s;
let mut out = Vec::new();
let mut mask = None;
@@ -391,10 +663,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
continue;
}
let local = s.floats::<6>(c, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
match crate::raster::mask(self, c, local.then(world))? {
Some(m) if mask.is_none() => mask = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
if mask.is_none() {
mask = Some((c, local.then(world)));
} else {
self.warn("several pixel masks on one layer (only the first is used)");
}
continue;
}
@@ -411,10 +683,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
self.warn("layers nested deeper than 128 levels");
return Ok(None);
}
self.nodes += 1;
if self.nodes > MAX_NODES {
let Some(left) = self.budget.nodes.checked_sub(1) else {
return Err(Error::Limit("too many layers"));
}
};
self.budget.nodes = left;
// A node that contains itself (a shared object linked back into its own subtree).
if !self.active.insert(id) {
self.warn("a layer that contains itself");
@@ -423,7 +695,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let local = s.floats::<6>(id, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY);
let world = local.then(parent);
let class = s.class(id).unwrap_or(Tag(0));
let name = s.str(id, b"Desc").unwrap_or_default().to_string();
// A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect();
let visible = s.bool(id, b"Visi").unwrap_or(true);
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
@@ -441,15 +714,28 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
} else {
self.kind(id, class, world, parent)?
};
// This node's embedded content, before its children's `node` calls can take it.
let embedded = self.pending.take();
// A compound's children are its operands, already merged into its outline.
let (children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
let (mask, attached_mask) = self.attached_masks(id, world)?;
let (mut children, child_mask) = if class == Tag::of(b"Comp") { (Vec::new(), None) } else { self.children(id, world, depth)? };
if let Some(embedded) = embedded {
children.extend(embedded);
}
// Pixel masks are read only over what they mask (they can span a whole spread at print
// resolution, a gigapixel).
let over = mask_extent(&kind, &children);
let child_mask = match child_mask {
Some((c, at)) => crate::raster::mask(self, c, at, over)?,
None => None,
};
let (mask, attached_mask) = self.attached_masks(id, world, over)?;
let pixel_mask = attached_mask.or(child_mask);
self.active.remove(&id);
if s.objs(id, b"FiEf").iter().any(|e| s.bool(*e, b"Enab") != Some(false)) {
self.warn("layer effects (shadows, glows, outlines, bevels…)");
}
Ok(Some(Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, children }))
let mut node = Node { class, name, visible, locked, opacity, blend, kind, mask, pixel_mask, effects: Vec::new(), children };
// Gradient overlays are laid out over the node's bounds, also while it is hidden.
let bounds = tree_bounds(&node);
node.effects = self.effects(id, world, bounds);
Ok(Some(node))
}
fn kind(&mut self, id: ObjId, class: Tag, world: Affine, parent: Affine) -> Result<Kind, Error> {
@@ -471,6 +757,13 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
let (fills, strokes) = paint::node_paint(self, id, world);
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
}
b"TxtT" => match crate::text::table(self, id, world) {
Some(t) => Kind::Table(t),
None => {
self.warn("tables that could not be read");
Kind::Unsupported
}
},
b"TxtA" | b"TxtF" | b"TxtC" => match crate::text::read(self, id, world) {
Some(t) => Kind::Text(t),
None => {
@@ -482,15 +775,35 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Some(img) => Kind::Image(img),
None => Kind::Unsupported,
},
b"EmbN" => match crate::raster::embedded(self, id, world)? {
Some(img) => {
self.warn("embedded documents and symbols (imported as pictures of them)");
Kind::Image(img)
b"MPIN" => {
// `SLnk` links the instance with the master spread it shows.
let linked = s.obj(id, b"SLnk").map(|l| s.objs(l, b"ILOb")).unwrap_or_default();
let master = linked.iter().find_map(|o| self.master_ids.iter().position(|m| m == o));
let at = |t: &[u8; 4], default: usize| s.int(id, t).and_then(|v| usize::try_from(v).ok()).unwrap_or(default).min(4096);
match master {
Some(master) => Kind::MasterInstance { master, page: at(b"PgOf", 0), master_page: at(b"MPOf", 0), count: at(b"PgCt", 1) },
None => {
self.warn("master page instances without their master");
Kind::Unsupported
}
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
b"EmbN" => match self.embedded_document(id, world) {
// The embedded document itself, as editable content.
Some(nodes) => {
self.pending = Some(nodes);
Kind::Group
}
None => match crate::raster::embedded(self, id, world)? {
Some(img) => {
self.warn("embedded documents and symbols (imported as pictures of them)");
Kind::Image(img)
}
None => {
self.warn("embedded documents and symbols without a cached picture");
Kind::Unsupported
}
},
},
b"FRst" => {
// A fill layer paints its whole bitmap area; its gradient lives in page space.
@@ -515,9 +828,89 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
})
}
/// The enabled layer effects (`FiEf`) of node `id`. The angle convention was measured on the
/// public MIT fx fixture (Patchy): an angle of π/2 with offset 10 shadows 10 below.
fn effects(&mut self, id: ObjId, world: Affine, bounds: Option<Rect>) -> Vec<Effect> {
let s = self.s;
let mut out = Vec::new();
for e in s.objs(id, b"FiEf").into_iter().take(64) {
if s.bool(e, b"Enab") == Some(false) {
continue;
}
// "Scale with object": lengths follow the node's transform; otherwise they are absolute.
let k = if s.bool(e, b"SclO") == Some(true) { world.scale() } else { 1.0 };
let len = |t: &[u8; 4]| s.f64(e, t).filter(|v| v.is_finite() && *v >= 0.0 && *v < 1e6).unwrap_or(0.0) * k;
let opacity = s.f64(e, b"Opac").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(0.0, 1.0);
let angle = s.f64(e, b"Angl").filter(|v| v.is_finite()).unwrap_or(0.0);
let color = s.obj(e, b"Colr").and_then(|c| crate::paint::color(self, c));
let class = s.class(e).map(|c| c.0.to_be_bytes());
match (class.as_ref(), color) {
(Some(b"Shad"), Some(color)) => out.push(Effect::DropShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
(Some(b"InnS"), Some(color)) => out.push(Effect::InnerShadow { color, opacity, radius: len(b"Radi"), distance: len(b"Offs"), angle }),
(Some(b"OutG"), Some(color)) => out.push(Effect::OuterGlow { color, opacity, radius: len(b"Radi") }),
(Some(b"Strk"), Some(color)) if s.enumeration(e, b"Ftyp").is_none_or(|(t, _)| t == 0) => {
let align = s.enumeration(e, b"Alig").map_or(0, |(a, _)| a);
out.push(Effect::Outline { color, opacity, width: len(b"Radi"), align });
}
(Some(b"Strk"), _) => self.warn("gradient outline effects (left out)"),
(Some(b"BevE" | b"EmbE"), _) => {
// No radius, no bevel (as Affinity draws it).
let size = len(b"Radi");
if size > 0.0 {
let white = crate::paint::Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let black = crate::paint::Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 };
let highlight = s.obj(e, b"HiCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(white);
let shadow = s.obj(e, b"ShCl").and_then(|c| crate::paint::color(self, c)).unwrap_or(black);
let shadow_opacity = s.f64(e, b"ShOp").filter(|v| v.is_finite()).unwrap_or(0.75).clamp(0.0, 1.0);
let depth = s.f64(e, b"Dept").filter(|v| v.is_finite()).unwrap_or(5.0);
let azimuth = s.f64(e, b"Azim").filter(|v| v.is_finite()).unwrap_or(std::f64::consts::FRAC_PI_4 * 3.0);
let inner = s.enumeration(e, b"Beve").is_none_or(|(k, _)| k == 0);
out.push(Effect::Bevel { size, depth, angle: azimuth, highlight, highlight_opacity: opacity, shadow, shadow_opacity, inner });
}
}
(Some(b"ColO"), Some(color)) => {
let blend = self.effect_blend(e);
out.push(Effect::ColorOverlay { color, opacity, blend });
}
(Some(b"GrdO"), _) => {
let blend = self.effect_blend(e);
// The gradient's unit space spans the node's bounding box from -1 to 1, centred
// on it (y down): checked against the stored previews of real documents.
let space =
bounds.map(|b| Affine([(b.x1 - b.x0) / 2.0, 0.0, 0.0, (b.y1 - b.y0) / 2.0, (b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0]));
match (s.obj(e, b"GrFl"), space) {
(Some(fill), Some(space)) => match paint::descriptor(self, fill, space) {
Some(Paint::Gradient(gradient)) => out.push(Effect::GradientOverlay { gradient, opacity, blend }),
Some(Paint::Solid(color)) => out.push(Effect::ColorOverlay { color, opacity, blend }),
_ => {}
},
// Nothing visible to lay it over.
(Some(_), None) => {}
(None, _) => self.warn("gradient overlay effects that could not be read (left out)"),
}
}
(Some(b"ColO"), None) => self.warn("colour overlay effects that could not be read (left out)"),
(Some(b"Gaus"), _) => self.warn("blur effects (left out)"),
_ => self.warn("layer effects of other kinds (left out)"),
}
}
out
}
/// A layer effect's blend mode (`BlnM`); unknown modes are Normal (warned).
fn effect_blend(&mut self, e: ObjId) -> Blend {
match self.s.enumeration(e, b"BlnM") {
None => Blend::Normal,
Some((i, v)) => Blend::from_enum(i, v).unwrap_or_else(|| {
self.warn("an unknown blend mode (imported as Normal)");
Blend::Normal
}),
}
}
/// Vector shapes attached to a node (`AdCh`) mask it: it shows only inside their outlines;
/// an attached mask layer is a pixel mask. Adjustments attached the same way are reported.
fn attached_masks(&mut self, id: ObjId, world: Affine) -> Result<(Option<Path>, Option<Image>), Error> {
fn attached_masks(&mut self, id: ObjId, world: Affine, over: Option<Rect>) -> Result<(Option<Path>, Option<Image>), Error> {
let s = self.s;
let attached = s.objs(id, b"AdCh");
let mut mask: Option<Path> = None;
@@ -534,7 +927,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
} else if class == Tag::of(b"MRst") {
let local = s.floats::<6>(a, b"Xfrm").map(Affine::from_xfrm).unwrap_or(Affine::IDENTITY).then(world);
match crate::raster::mask(self, a, local)? {
match crate::raster::mask(self, a, local, over)? {
Some(m) if pixels.is_none() => pixels = Some(m),
Some(_) => self.warn("several pixel masks on one layer (only the first is used)"),
None => {}
@@ -546,6 +939,93 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
Ok((mask, pixels))
}
/// The content of an Affinity document embedded in this one (`EmbN` whose `EmbC` entry holds
/// `EmDc`, four more bytes and a complete Affinity document): its first spread's nodes,
/// placed so the centre of its page, first artboard or content is the node's origin.
fn embedded_document(&mut self, id: ObjId, world: Affine) -> Option<Vec<Node>> {
let s = self.s;
let entry = s.obj(id, b"Bitm").and_then(|b| s.obj(b, b"EmCn")).and_then(|c| s.entry(c, b"EmbC"))?.to_string();
if entry.is_empty() {
return None;
}
if self.depth >= MAX_EMBED_DEPTH {
self.warn("embedded documents nested more than four deep");
return None;
}
let data = self.archive.read(&entry).ok()?;
let inner = data.strip_prefix(b"EmDc").and_then(|d| d.get(4..))?;
if !crate::is_affinity(inner) {
return None;
}
// A first reading finds its page and resolution, the second places it.
// Both readings are charged to the budget and the archive's limits, also when they fail:
// each does the work.
let mut budget = self.budget;
let probe = read_at(inner, self.archive.remaining(), &mut budget, Affine::IDENTITY, self.depth + 1);
self.budget = budget;
if self.archive.charge(probe.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let probe = match probe.document {
Ok(d) => d,
Err(e) => {
self.warn(format!("embedded documents that could not be read ({e})"));
return None;
}
};
let spread = probe.spreads.first()?;
// `PBBx` 2 places the content's own bounds (as with a cached picture); otherwise its page.
let content = s.obj(id, b"Bitm").and_then(|b| s.enumeration(b, b"PBBx")).is_some_and(|(k, _)| k == 2);
let first_board = spread.nodes.iter().find_map(|n| if let Kind::Artboard { rect, .. } = &n.kind { Some(*rect) } else { None });
let page = if content { None } else { spread.pages.first().copied().or(first_board) };
let page = page.or_else(|| nodes_bounds(&spread.nodes)).unwrap_or(spread.bounds);
// Its pixels aren't needed any more: never hold both readings' at once.
drop(probe);
// Its document pixels are the node's units, whatever either document's resolution.
let k = 1.0;
let (cx, cy) = ((page.x0 + page.x1) / 2.0, (page.y0 + page.y1) / 2.0);
let base = Affine([k, 0.0, 0.0, k, -cx * k, -cy * k]).then(world);
if !base.is_finite() {
self.warn("embedded documents with an invalid placement");
return None;
}
let mut budget = self.budget;
let placed = read_at(inner, self.archive.remaining(), &mut budget, base, self.depth + 1);
self.budget = budget;
if self.archive.charge(placed.extracted).is_err() {
self.warn("embedded documents beyond the size limit (left out)");
return None;
}
let doc = placed.document.ok()?;
for w in doc.warnings {
self.warn(format!("{w} [in embedded documents]"));
}
if doc.spreads.len() > 1 {
self.warn("embedded documents with several pages (the first is used)");
}
let mut nodes = doc.spreads.into_iter().next()?.nodes;
// Its master page instances count its own masters, which aren't kept: in this document
// they would show other masters.
fn strip_masters(nodes: &mut [Node], depth: usize) -> bool {
let mut found = false;
for n in nodes {
if matches!(n.kind, Kind::MasterInstance { .. }) {
n.kind = Kind::Unsupported;
found = true;
}
if depth < MAX_TREE_DEPTH {
found |= strip_masters(&mut n.children, depth + 1);
}
}
found
}
if strip_masters(&mut nodes, 0) {
self.warn("master pages of embedded documents (left out)");
}
Some(nodes)
}
fn artboard(&mut self, id: ObjId, world: Affine) -> Kind {
let s = self.s;
let Some(b) = s.floats::<4>(id, b"ShpB").map(rect).filter(|b| b.x1 > b.x0 && b.y1 > b.y0) else {
@@ -580,6 +1060,96 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
}
}
/// The bounds of what visible `nodes` draw (control points of outlines, corners of images, frames
/// and tables), in document pixels.
pub fn nodes_bounds(nodes: &[Node]) -> Option<Rect> {
let mut out = None;
walk_bounds(nodes, true, 0, &mut out);
out
}
/// The bounds of `node` and everything in it, hidden or not.
fn tree_bounds(node: &Node) -> Option<Rect> {
let mut out = None;
walk_bounds(std::slice::from_ref(node), false, 0, &mut out);
out
}
fn walk_bounds(nodes: &[Node], visible_only: bool, depth: usize, out: &mut Option<Rect>) {
if depth > MAX_TREE_DEPTH {
return;
}
for n in nodes.iter().filter(|n| n.visible || !visible_only) {
kind_points(&n.kind, &mut |p| add_point(out, p));
walk_bounds(&n.children, visible_only, depth + 1, out);
}
}
/// What a pixel mask over a node of `kind` with `children` is read over: the bounds of what they
/// draw, strokes included, or `None` (all of the mask) when some of it has no bounds known here:
/// artistic text, whose glyphs only the layout places.
fn mask_extent(kind: &Kind, children: &[Node]) -> Option<Rect> {
fn visit(kind: &Kind, children: &[Node], depth: usize, out: &mut Option<Rect>) -> bool {
if depth > MAX_TREE_DEPTH || matches!(kind, Kind::Text(t) if t.frame.is_none()) {
return false;
}
let mut own = kind_bounds(kind);
if let (Some(r), Kind::Shape { strokes, .. } | Kind::Artboard { strokes, .. }) = (own, kind) {
// As far as a stroke can reach out of its path, miter joins included.
let reach = strokes.iter().map(|s| s.width * s.miter_limit.clamp(1.0, 10.0)).filter(|w| w.is_finite()).fold(0.0, f64::max);
own = Some(Rect { x0: r.x0 - reach, y0: r.y0 - reach, x1: r.x1 + reach, y1: r.y1 + reach });
}
if let Some(r) = own {
*out = Some(out.map_or(r, |o| union(o, r)));
}
children.iter().filter(|n| n.visible).all(|n| visit(&n.kind, &n.children, depth + 1, out))
}
let mut out = None;
if visit(kind, children, 0, &mut out) { out } else { None }
}
/// The bounds of what one node's own kind draws (not its children): its outline's points, its
/// image's or frame's corners, or artistic text's anchor.
pub fn kind_bounds(kind: &Kind) -> Option<Rect> {
let mut out = None;
kind_points(kind, &mut |p| add_point(&mut out, p));
out
}
fn add_point(out: &mut Option<Rect>, p: Point) {
if p.x.is_finite() && p.y.is_finite() {
let r = Rect { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
*out = Some(out.map_or(r, |o| union(o, r)));
}
}
fn kind_points(kind: &Kind, add: &mut dyn FnMut(Point)) {
let corners = |r: Rect, m: Affine| [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| m.apply(Point { x, y }));
match kind {
Kind::Shape { path, .. } | Kind::Artboard { path, .. } => {
for sp in &path.subpaths {
add(sp.start);
for seg in &sp.segments {
seg.iter().for_each(|p| add(*p));
}
}
}
Kind::Image(img) => {
corners(Rect { x0: 0.0, y0: 0.0, x1: f64::from(img.width), y1: f64::from(img.height) }, img.transform).into_iter().for_each(add)
}
Kind::Text(t) => match t.frame {
Some(f) => corners(f, t.transform).into_iter().for_each(add),
None => add(t.transform.apply(t.anchor)),
},
Kind::Table(t) => {
if let (Some(&x0), Some(&x1), Some(&y0), Some(&y1)) = (t.columns.first(), t.columns.last(), t.rows.first(), t.rows.last()) {
corners(Rect { x0, y0, x1, y1 }, t.transform).into_iter().for_each(add);
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
}
/// Page rectangles: `SpMd.PagR[].rctp` (Publisher 2, Affinity 3) or `SprB` split into `PagC`
/// equal pages (Publisher 1 facing spreads).
fn pages(s: &Stream, spread: ObjId) -> Vec<Rect> {
+663
View File
@@ -0,0 +1,663 @@
//! Colour and gradient overlays. VectorCraft has no such effect, so an overlay is baked into what
//! its node paints: fill, stroke and text colours, and pixels. The overlay lies over the node's
//! own paint in its blend mode at its opacity, inside the node's alpha (which it keeps).
//!
//! Blend modes follow the W3C Compositing and Blending formulas; the modes PDF lacks (Vivid
//! Light, Linear Light, Pin Light, Hard Mix, Add, Subtract, Average, Negation, Reflect, Glow,
//! Darker/Lighter Color) use their usual definitions.
use std::borrow::Cow;
use crate::model::{Affine, Blend, Effect, Image, Kind, Node, Pixels, Point};
use crate::paint::{Color, Gradient, GradientKind, Paint, Stop, Stroke};
/// Deepest subtree an overlay is baked into (the reader bounds trees well below this).
const MAX_DEPTH: usize = 256;
/// Most pixels an overlay is baked into per image.
const MAX_PIXELS: usize = 64 << 20;
/// Work the overlays of one import may take together: each overlay counts the nodes and pixels
/// of the subtree it is baked into (which is copied once more for them all). Nested overlays
/// and shared content would otherwise multiply it; past it, overlays are left out and said.
pub const MAX_WORK: u64 = 1 << 29;
#[derive(Clone, Copy)]
enum Source<'a> {
Solid(Color),
Gradient(&'a Gradient),
}
#[derive(Clone, Copy)]
struct Overlay<'a> {
source: Source<'a>,
opacity: f64,
blend: Blend,
}
/// `node` with its colour and gradient overlays baked in, and those of everything in it
/// (borrowed unchanged when `node` has none, or when what is left of `work`, see [`MAX_WORK`],
/// can't pay for copying it). `warn` hears what could only be approximated or was left out.
pub fn apply<'a>(node: &'a Node, work: &mut u64, warn: &mut dyn FnMut(&str)) -> Cow<'a, Node> {
if overlays(&node.effects).is_empty() {
return Cow::Borrowed(node);
}
let Some(left) = work.checked_sub(subtree_cost(node, *work, 0)) else {
warn("overlay effects beyond the work one import may take (left out)");
return Cow::Borrowed(node);
};
*work = left;
let mut out = node.clone();
bake_tree(&mut out, work, 0, warn);
Cow::Owned(out)
}
/// The visible colour and gradient overlays among `effects`.
fn overlays(effects: &[Effect]) -> Vec<Overlay<'_>> {
effects
.iter()
.filter_map(|e| match e {
Effect::ColorOverlay { color, opacity, blend } => Some(Overlay { source: Source::Solid(*color), opacity: *opacity, blend: *blend }),
Effect::GradientOverlay { gradient, opacity, blend } => {
Some(Overlay { source: Source::Gradient(gradient), opacity: *opacity, blend: *blend })
}
_ => None,
})
.filter(|o| o.opacity > 0.0)
.collect()
}
fn is_overlay(e: &Effect) -> bool {
matches!(e, Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. })
}
/// Bakes the overlays of `node` and its subtree into their paint, innermost first: an overlay
/// lies over what its node shows, its children's overlays included. Each overlay pays for the
/// subtree it is baked into out of `work`; past it, it is left out (and said).
fn bake_tree(node: &mut Node, work: &mut u64, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
for c in &mut node.children {
bake_tree(c, work, depth + 1, warn);
}
if !node.effects.iter().any(is_overlay) {
return;
}
let (effects, rest): (Vec<Effect>, Vec<Effect>) = std::mem::take(&mut node.effects).into_iter().partition(is_overlay);
node.effects = rest;
let overlays = overlays(&effects);
if overlays.is_empty() {
return;
}
let count = u64::try_from(overlays.len()).unwrap_or(u64::MAX);
let cost = subtree_cost(node, *work / count, 0).saturating_mul(count);
let Some(left) = work.checked_sub(cost) else {
warn("overlay effects beyond the work one import may take (left out)");
return;
};
*work = left;
for o in &overlays {
if matches!(o.blend, Blend::Erase | Blend::PassThrough) {
warn("overlay effects in a blend mode VectorCraft doesn't have (imported as Normal)");
}
bake_node(node, o, 0, warn);
}
}
/// Nodes and pixels in `node`'s subtree, counted only until they pass `limit`.
fn subtree_cost(node: &Node, limit: u64, depth: usize) -> u64 {
if depth > MAX_DEPTH {
return 0;
}
let mut cost: u64 = match &node.kind {
Kind::Image(img) => (u64::from(img.width) * u64::from(img.height)).saturating_add(1),
Kind::Text(t) => u64::try_from(t.runs.len()).unwrap_or(u64::MAX).saturating_add(1),
Kind::Table(t) => u64::try_from(t.cells.len()).unwrap_or(u64::MAX).saturating_add(1),
_ => 1,
};
for c in &node.children {
if cost > limit {
break;
}
cost = cost.saturating_add(subtree_cost(c, limit - cost, depth + 1));
}
cost
}
fn bake_node(node: &mut Node, o: &Overlay, depth: usize, warn: &mut dyn FnMut(&str)) {
if depth > MAX_DEPTH {
return;
}
match &mut node.kind {
Kind::Shape { fills, strokes, .. } => {
for f in fills.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Artboard { background, strokes, .. } => {
for f in background.iter_mut() {
*f = paint(f, o, warn);
}
strokes.iter_mut().for_each(|s| stroke(s, o, warn));
}
Kind::Text(t) => {
for r in &mut t.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
Kind::Table(t) => {
for c in &mut t.cells {
for r in &mut c.runs {
r.fill = paint(&r.fill, o, warn);
if let Some(s) = &mut r.stroke {
stroke(s, o, warn);
}
}
}
for s in t.vertical.iter_mut().chain(t.horizontal.iter_mut()).flatten() {
stroke(s, o, warn);
}
}
Kind::Image(img) => {
if !pixels(img, o) {
warn("overlay effects on images too large to recolour (left out)");
}
}
Kind::Layer | Kind::Group | Kind::Unsupported | Kind::MasterInstance { .. } => {}
}
for e in &mut node.effects {
match e {
Effect::Outline { color, .. } => *color = over(*color, o.source.at_solid(), o),
Effect::DropShadow { .. } | Effect::InnerShadow { .. } | Effect::OuterGlow { .. } | Effect::Bevel { .. } => {}
Effect::ColorOverlay { .. } | Effect::GradientOverlay { .. } => {}
}
}
for c in &mut node.children {
bake_node(c, o, depth + 1, warn);
}
}
fn stroke(s: &mut Stroke, o: &Overlay, warn: &mut dyn FnMut(&str)) {
s.paint = paint(&s.paint, o, warn);
}
/// A paint with the overlay over it.
fn paint(p: &Paint, o: &Overlay, warn: &mut dyn FnMut(&str)) -> Paint {
match (p, o.source) {
(Paint::None, _) => Paint::None,
(Paint::Solid(c), Source::Solid(s)) => Paint::Solid(over(*c, s, o)),
(Paint::Gradient(g), Source::Solid(s)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(st.color, s, o), ..*st }).collect(), ..g.clone() })
}
// Over one colour, the result is the overlay's gradient with each stop laid over it.
(Paint::Solid(c), Source::Gradient(g)) => {
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(*c, st.color, o), ..*st }).collect(), ..g.clone() })
}
(Paint::Gradient(base), Source::Gradient(g)) => {
if !(o.blend == Blend::Normal && o.opacity >= 1.0) {
warn("gradient overlays on gradients (approximated)");
}
// Exact when the overlay covers it; otherwise over the base gradient's average colour.
let avg = average(base);
Paint::Gradient(Gradient { stops: g.stops.iter().map(|st| Stop { color: over(avg, st.color, o), ..*st }).collect(), ..g.clone() })
}
}
}
impl Source<'_> {
/// One colour for the source (a gradient's average) where only one fits.
fn at_solid(&self) -> Color {
match self {
Source::Solid(c) => *c,
Source::Gradient(g) => average(g),
}
}
}
fn average(g: &Gradient) -> Color {
let n = g.stops.len().max(1) as f64;
let (mut r, mut gr, mut b, mut a) = (0.0, 0.0, 0.0, 0.0);
for s in &g.stops {
let [sr, sg, sb] = rgb(s.color);
r += sr;
gr += sg;
b += sb;
a += s.color.alpha();
}
Color::Rgb { r: r / n, g: gr / n, b: b / n, a: a / n }
}
/// `source` laid over `base` in the overlay's mode and opacity; `base`'s alpha is kept.
fn over(base: Color, source: Color, o: &Overlay) -> Color {
let k = (o.opacity * source.alpha()).clamp(0.0, 1.0);
let alpha = base.alpha();
// Normal mixes within the colour model when both share it, so CMYK stays CMYK.
if matches!(o.blend, Blend::Normal | Blend::PassThrough | Blend::Erase) {
let mix = |a: f64, b: f64| a + (b - a) * k;
match (base, source) {
(Color::Cmyk { c, m, y, k: kk, .. }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2, .. }) => {
return Color::Cmyk { c: mix(c, c2), m: mix(m, m2), y: mix(y, y2), k: mix(kk, k2), a: alpha };
}
(Color::Gray { v, .. }, Color::Gray { v: v2, .. }) => return Color::Gray { v: mix(v, v2), a: alpha },
(_, s) if k >= 1.0 => return with_alpha(s, alpha),
_ => {}
}
}
let b = rgb(base);
let s = rgb(source);
let blended = blend(o.blend, b, s);
let [r, g, bl] = [0, 1, 2].map(|i| b[i] + (blended[i] - b[i]) * k);
Color::Rgb { r, g, b: bl, a: alpha }
}
fn with_alpha(c: Color, a: f64) -> Color {
match c {
Color::Rgb { r, g, b, .. } => Color::Rgb { r, g, b, a },
Color::Cmyk { c, m, y, k, .. } => Color::Cmyk { c, m, y, k, a },
Color::Gray { v, .. } => Color::Gray { v, a },
Color::Lab { l, a: la, b, .. } => Color::Lab { l, a: la, b, alpha: a },
}
}
/// sRGB components (0..1) of a colour. CMYK uses the profile-free formula (the engine shows the
/// node's other CMYK colours through the document's profile), Lab its D50 sRGB colour.
fn rgb(c: Color) -> [f64; 3] {
let f = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
match c {
Color::Rgb { r, g, b, .. } => [f(r), f(g), f(b)],
Color::Gray { v, .. } => [f(v); 3],
Color::Cmyk { c, m, y, k, .. } => {
let w = 1.0 - f(k);
[(1.0 - f(c)) * w, (1.0 - f(m)) * w, (1.0 - f(y)) * w]
}
Color::Lab { l, a, b, .. } => lab_to_srgb(l, a, b).map(f),
}
}
/// CIE L*a*b* (D50) to sRGB, through XYZ and the Bradford adaptation to D65.
fn lab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
let (l, a, b) = (finite(l), finite(a), finite(b));
let fy = (l + 16.0) / 116.0;
let (fx, fz) = (fy + a / 500.0, fy - b / 200.0);
let inv = |t: f64| if t > 6.0 / 29.0 { t * t * t } else { 3.0 * (6.0f64 / 29.0).powi(2) * (t - 4.0 / 29.0) };
// D50 white.
let (x, y, z) = (0.964_22 * inv(fx), inv(fy), 0.825_21 * inv(fz));
// XYZ (D50) to linear sRGB, Bradford-adapted.
let r = 3.133_856 * x - 1.616_867 * y - 0.490_615 * z;
let g = -0.978_768 * x + 1.916_142 * y + 0.033_454 * z;
let bl = 0.071_945 * x - 0.228_991 * y + 1.405_243 * z;
let gamma = |v: f64| if v <= 0.003_130_8 { 12.92 * v } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 };
[gamma(r), gamma(g), gamma(bl)]
}
/// Bake the overlay into an image's pixels. False if they couldn't be read or are too many.
fn pixels(img: &mut Image, o: &Overlay) -> bool {
let Some(n) = (img.width as usize).checked_mul(img.height as usize).filter(|n| *n <= MAX_PIXELS) else { return false };
let Some(len) = n.checked_mul(4) else { return false };
let mut px = match &img.pixels {
Pixels::Rgba8(p) => match p.get(..len) {
Some(p) => p.to_vec(),
None => return false,
},
Pixels::Encoded(bytes) => match decode(bytes, img.width, img.height) {
Some(p) => p,
None => return false,
},
// An overlay is blended in RGB: CMYK pixels take the profile-free formula here.
Pixels::Cmyka8(p) => match p.get(..n.saturating_mul(5)) {
Some(p) if p.len() == n * 5 => p
.as_chunks::<5>()
.0
.iter()
.flat_map(|[c, m, y, k, a]| {
let w = 255 - u16::from(*k);
let ch = |v: u8| ((u16::from(255 - v) * w + 127) / 255) as u8;
[ch(*c), ch(*m), ch(*y), *a]
})
.collect(),
_ => return false,
},
};
let width = img.width.max(1) as usize;
// A solid source is converted once; a gradient is sampled at each pixel's centre.
let solid = match o.source {
Source::Solid(c) => Some((rgb(c), c.alpha())),
Source::Gradient(_) => None,
};
let to_unit = match o.source {
Source::Gradient(g) => match invert(g.transform) {
Some(m) => Some((img.transform.then(m), g)),
None => return false,
},
Source::Solid(_) => None,
};
for (i, p) in px.as_chunks_mut::<4>().0.iter_mut().enumerate() {
if p[3] == 0 {
continue;
}
let (s, sa) = match (solid, &to_unit) {
(Some(s), _) => s,
(None, Some((m, g))) => {
let q = m.apply(Point { x: (i % width) as f64 + 0.5, y: (i / width) as f64 + 0.5 });
let c = sample(g, q);
(rgb(c), c.alpha())
}
(None, None) => continue,
};
let k = (o.opacity * sa).clamp(0.0, 1.0);
let b = [p[0], p[1], p[2]].map(|v| f64::from(v) / 255.0);
let m = blend(o.blend, b, s);
for c in 0..3 {
p[c] = ((b[c] + (m[c] - b[c]) * k).clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
img.pixels = Pixels::Rgba8(px);
true
}
/// An encoded original's RGBA pixels, decoded only if it is `width` × `height` as its layer says.
fn decode(bytes: &[u8], width: u32, height: u32) -> Option<Vec<u8>> {
let probe = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
if probe.into_dimensions().ok()? != (width, height) {
return None;
}
let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?;
let mut limits = image::Limits::default();
limits.max_image_width = Some(width);
limits.max_image_height = Some(height);
limits.max_alloc = u64::try_from(MAX_PIXELS).ok().map(|n| n * 8);
reader.limits(limits);
Some(reader.decode().ok()?.into_rgba8().into_raw())
}
/// The gradient's colour at unit-space point `q`.
fn sample(g: &Gradient, q: Point) -> Color {
let t = match g.kind {
GradientKind::Linear => q.x,
GradientKind::Radial => q.x.hypot(q.y),
GradientKind::Conical => q.y.atan2(q.x).rem_euclid(std::f64::consts::TAU) / std::f64::consts::TAU,
};
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
let (Some(first), Some(last)) = (g.stops.first(), g.stops.last()) else { return Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 0.0 } };
if t <= first.offset {
return first.color;
}
for w in g.stops.windows(2) {
let [a, b] = [&w[0], &w[1]];
if t <= b.offset {
let span = b.offset - a.offset;
let u = if span > 1e-9 { (t - a.offset) / span } else { 1.0 };
// Affinity's midpoint bias (see `Stop::half_point`).
let k = 1.0 + 8.0 * (a.bias - 0.5).abs();
let u = if a.bias >= 0.5 { u.powf(k) } else { 1.0 - (1.0 - u).powf(k) };
let (x, y) = (rgb(a.color), rgb(b.color));
let [r, g, bl] = [0, 1, 2].map(|i| x[i] + (y[i] - x[i]) * u);
return Color::Rgb { r, g, b: bl, a: a.color.alpha() + (b.color.alpha() - a.color.alpha()) * u };
}
}
last.color
}
fn invert(m: Affine) -> Option<Affine> {
let [a, b, c, d, e, f] = m.0;
let det = a * d - b * c;
if !(det.is_finite() && det.abs() > 1e-12) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// The blend of backdrop `b` and source `s` (sRGB, 0..1).
fn blend(mode: Blend, b: [f64; 3], s: [f64; 3]) -> [f64; 3] {
use Blend::*;
let sep = |f: fn(f64, f64) -> f64| [0, 1, 2].map(|i| f(b[i], s[i]).clamp(0.0, 1.0));
match mode {
Normal | PassThrough | Erase => s,
Darken => sep(f64::min),
Lighten => sep(f64::max),
Multiply => sep(|b, s| b * s),
Screen => sep(screen),
ColorBurn => sep(burn),
ColorDodge => sep(dodge),
Blend::Overlay => sep(|b, s| hard_light(s, b)),
HardLight => sep(hard_light),
SoftLight => sep(soft_light),
Difference => sep(|b, s| (b - s).abs()),
Exclusion => sep(|b, s| b + s - 2.0 * b * s),
Add => sep(|b, s| b + s),
Subtract => sep(|b, s| b - s),
VividLight => sep(|b, s| if s <= 0.5 { burn(b, 2.0 * s) } else { dodge(b, 2.0 * s - 1.0) }),
LinearLight => sep(|b, s| b + 2.0 * s - 1.0),
PinLight => sep(|b, s| if s <= 0.5 { b.min(2.0 * s) } else { b.max(2.0 * s - 1.0) }),
HardMix => sep(|b, s| if b + s >= 1.0 { 1.0 } else { 0.0 }),
Average => sep(|b, s| (b + s) / 2.0),
Negation => sep(|b, s| 1.0 - (1.0 - b - s).abs()),
Reflect => sep(reflect),
Glow => sep(|b, s| reflect(s, b)),
DarkerColor => {
if lum(s) < lum(b) {
s
} else {
b
}
}
LighterColor => {
if lum(s) > lum(b) {
s
} else {
b
}
}
Hue => set_lum(set_sat(s, sat(b)), lum(b)),
Saturation => set_lum(set_sat(b, sat(s)), lum(b)),
Blend::Color => set_lum(s, lum(b)),
Luminosity => set_lum(b, lum(s)),
}
}
fn screen(b: f64, s: f64) -> f64 {
b + s - b * s
}
fn burn(b: f64, s: f64) -> f64 {
if b >= 1.0 {
1.0
} else if s <= 0.0 {
0.0
} else {
1.0 - ((1.0 - b) / s).min(1.0)
}
}
fn dodge(b: f64, s: f64) -> f64 {
if b <= 0.0 {
0.0
} else if s >= 1.0 {
1.0
} else {
(b / (1.0 - s)).min(1.0)
}
}
fn hard_light(b: f64, s: f64) -> f64 {
if s <= 0.5 { b * 2.0 * s } else { screen(b, 2.0 * s - 1.0) }
}
fn soft_light(b: f64, s: f64) -> f64 {
if s <= 0.5 {
b - (1.0 - 2.0 * s) * b * (1.0 - b)
} else {
let d = if b <= 0.25 { ((16.0 * b - 12.0) * b + 4.0) * b } else { b.sqrt() };
b + (2.0 * s - 1.0) * (d - b)
}
}
fn reflect(b: f64, s: f64) -> f64 {
if s >= 1.0 { 1.0 } else { (b * b / (1.0 - s)).min(1.0) }
}
fn lum(c: [f64; 3]) -> f64 {
0.3 * c[0] + 0.59 * c[1] + 0.11 * c[2]
}
fn clip_color(c: [f64; 3]) -> [f64; 3] {
let l = lum(c);
let n = c[0].min(c[1]).min(c[2]);
let x = c[0].max(c[1]).max(c[2]);
c.map(|v| {
let mut v = v;
if n < 0.0 && l - n > 1e-12 {
v = l + (v - l) * l / (l - n);
}
if x > 1.0 && x - l > 1e-12 {
v = l + (v - l) * (1.0 - l) / (x - l);
}
v.clamp(0.0, 1.0)
})
}
fn set_lum(c: [f64; 3], l: f64) -> [f64; 3] {
let d = l - lum(c);
clip_color(c.map(|v| v + d))
}
fn sat(c: [f64; 3]) -> f64 {
c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])
}
fn set_sat(c: [f64; 3], s: f64) -> [f64; 3] {
let max = c[0].max(c[1]).max(c[2]);
let min = c[0].min(c[1]).min(c[2]);
if max - min <= 1e-12 {
return [0.0; 3];
}
c.map(|v| (v - min) * s / (max - min))
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(c: Color, opacity: f64, blend: Blend) -> Node {
Node {
class: crate::stream::Tag::of(b"ShpN"),
name: String::new(),
visible: true,
locked: false,
opacity: 1.0,
blend: Blend::Normal,
kind: Kind::Shape {
path: Default::default(),
fills: vec![Paint::Solid(Color::Rgb { r: 1.0, g: 0.5, b: 0.0, a: 1.0 })],
strokes: vec![],
even_odd: false,
},
mask: None,
pixel_mask: None,
effects: vec![Effect::ColorOverlay { color: c, opacity, blend }],
children: vec![],
}
}
fn fill(n: &Node) -> Color {
match &n.kind {
Kind::Shape { fills, .. } => match fills.first() {
Some(Paint::Solid(c)) => *c,
other => panic!("{other:?}"),
},
other => panic!("{other:?}"),
}
}
#[test]
fn a_full_colour_overlay_replaces_the_fill_and_keeps_its_alpha() {
let white = Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 };
let n = apply(&solid(white, 1.0, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), white);
assert!(n.effects.is_empty(), "the overlay is baked, not kept");
// Half way, mixed in RGB.
let n = apply(&solid(white, 0.5, Blend::Normal), &mut { MAX_WORK }, &mut |_| {}).into_owned();
assert_eq!(fill(&n), Color::Rgb { r: 1.0, g: 0.75, b: 0.5, a: 1.0 });
}
#[test]
fn blend_modes_follow_their_formulas() {
let b = [0.25, 0.5, 1.0];
assert_eq!(blend(Blend::Multiply, b, [0.5; 3]), [0.125, 0.25, 0.5]);
assert_eq!(blend(Blend::Screen, b, [0.5; 3]), [0.625, 0.75, 1.0]);
assert_eq!(blend(Blend::VividLight, [0.5; 3], [0.75; 3]), [1.0; 3]);
assert_eq!(blend(Blend::HardMix, [0.5; 3], [0.6; 3]), [1.0; 3]);
// Color keeps the backdrop's luminosity with the source's hue and saturation.
let c = blend(Blend::Color, [0.5; 3], [1.0, 0.0, 0.0]);
assert!((lum(c) - 0.5).abs() < 1e-9 && c[0] > c[1] && (c[1] - c[2]).abs() < 1e-9, "{c:?}");
}
#[test]
fn overlays_recolour_pixels_inside_their_alpha() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 2, height: 1, pixels: Pixels::Rgba8(vec![255, 0, 0, 255, 255, 0, 0, 0]), transform: Affine::IDENTITY });
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Image(img) = &out.kind else { panic!() };
assert_eq!(img.pixels, Pixels::Rgba8(vec![0, 0, 255, 255, 255, 0, 0, 0]));
}
#[test]
fn a_gradient_overlay_over_one_colour_becomes_that_gradient() {
let g = Gradient {
kind: GradientKind::Linear,
stops: vec![
Stop { offset: 0.0, color: Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, bias: 0.5 },
Stop { offset: 1.0, color: Color::Rgb { r: 1.0, g: 1.0, b: 1.0, a: 1.0 }, bias: 0.5 },
],
transform: Affine([10.0, 0.0, 0.0, 10.0, 0.0, 0.0]),
};
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }, 1.0, Blend::Normal);
n.effects = vec![Effect::GradientOverlay { gradient: g.clone(), opacity: 1.0, blend: Blend::Normal }];
let out = apply(&n, &mut { MAX_WORK }, &mut |_| {}).into_owned();
let Kind::Shape { fills, .. } = &out.kind else { panic!() };
assert_eq!(fills.first(), Some(&Paint::Gradient(g)));
}
#[test]
fn a_group_overlay_lies_over_its_childrens_overlays() {
let red = Color::Rgb { r: 1.0, g: 0.0, b: 0.0, a: 1.0 };
let blue = Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 };
let mut group = solid(red, 0.5, Blend::Normal);
group.kind = Kind::Group;
group.children = vec![solid(blue, 1.0, Blend::Normal)];
// The copy (2 nodes), the child's overlay (1) and the group's (2).
let mut work = 5;
let out = apply(&group, &mut work, &mut |_| {}).into_owned();
assert_eq!(work, 0);
let child = out.children.first().unwrap();
// Blue first, then half of the group's red over it.
assert_eq!(fill(child), Color::Rgb { r: 0.5, g: 0.0, b: 0.5, a: 1.0 });
assert!(out.effects.is_empty() && child.effects.is_empty(), "both overlays are baked");
// With no work left for the group's overlay, the child's still applies.
let mut warnings = Vec::new();
let out = apply(&group, &mut { 4 }, &mut |w| warnings.push(w.to_string())).into_owned();
assert_eq!(fill(out.children.first().unwrap()), blue);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
#[test]
fn overlays_stop_at_the_work_one_import_may_take() {
let mut n = solid(Color::Rgb { r: 0.0, g: 0.0, b: 1.0, a: 1.0 }, 1.0, Blend::Normal);
n.kind = Kind::Image(Image { width: 4, height: 4, pixels: Pixels::Rgba8(vec![255; 64]), transform: Affine::IDENTITY });
// 16 pixels and the node, for the copy and for the overlay: 34.
let mut work = 40;
let mut warnings = Vec::new();
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Owned(_)));
assert_eq!((work, warnings.len()), (6, 0));
assert!(matches!(apply(&n, &mut work, &mut |w| warnings.push(w.to_string())), Cow::Borrowed(_)));
assert_eq!(work, 6);
assert!(warnings.iter().any(|w| w.contains("beyond the work")), "{warnings:?}");
}
}
+27 -2
View File
@@ -126,6 +126,9 @@ pub struct Stroke {
/// Gradient stops per gradient.
const MAX_STOPS: usize = 1024;
/// Dash and gap lengths per dash pattern (a pattern has a few; strokes and text runs copy theirs).
const MAX_DASHES: usize = 64;
fn floats(v: &Value) -> Option<Vec<f64>> {
match v {
Value::Floats(f) => Some(f.clone()),
@@ -136,6 +139,14 @@ fn floats(v: &Value) -> Option<Vec<f64>> {
/// A colour object (`RGBA`, `HSLA`, `CMYK`, `GRAY`, `LABA`, `Pant`).
pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
color_within(r, id, 0)
}
/// The most spot colours that stand for one another followed.
const MAX_SPOT_CHAIN: u32 = 8;
/// [`color`], `depth` spot colours deep: one that stands for itself (or a ring of them) ends here.
fn color_within(r: &mut Reader, id: ObjId, depth: u32) -> Option<Color> {
let s = r.s;
let class = s.class(id)?;
let col = || -> Option<Vec<f64>> {
@@ -167,7 +178,10 @@ pub(crate) fn color(r: &mut Reader, id: ObjId) -> Option<Color> {
}
b"Pant" => {
r.warn("spot colours (imported as their RGB equivalent)");
return s.obj(id, b"srgb").and_then(|c| color(r, c)).or_else(|| s.obj(id, b"base").and_then(|c| color(r, c)));
if depth >= MAX_SPOT_CHAIN {
return None;
}
return s.obj(id, b"srgb").and_then(|c| color_within(r, c, depth + 1)).or_else(|| s.obj(id, b"base").and_then(|c| color_within(r, c, depth + 1)));
}
b"RegC" => Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0, a: 1.0 },
_ => {
@@ -283,7 +297,7 @@ pub(crate) fn node_paint(r: &mut Reader, id: ObjId, local: Affine) -> (Vec<Paint
(fills, stroke.into_iter().collect())
}
fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
pub(crate) fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool) -> Option<Stroke> {
let s = r.s;
let paint = descriptor(r, pen, local)?;
if paint == Paint::None {
@@ -295,6 +309,13 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
let scales = s.bool(ldsc, b"LDSc").unwrap_or(true);
let width = if scales { weight * local.scale() } else { weight };
let data = s.bytes(lsty, b"Data").unwrap_or_default();
// Byte 10 is the stroke's style: 0 none, 1 solid, 2 dashed, 3 a brush. A shape without a
// stroke still stores one (black, 1 px wide), with style 0. Identified on two documents their
// owner made, where every such stroke is invisible in Affinity's own PDF export, and checked
// against the public corpus's thumbnails.
if data.get(10) == Some(&0) {
return None;
}
let miter_limit = data.get(0..8).and_then(|b| b.try_into().ok()).map(f64::from_le_bytes).filter(|m| m.is_finite() && *m >= 1.0).unwrap_or(4.0);
// Cap and join bytes are only partly understood: (0, 0) is butt/miter in every clean sample;
// (2, 2) ends plain lines square and brush lines round; joins import round, which never
@@ -312,6 +333,10 @@ fn stroke(r: &mut Reader, pen: ObjId, ldsc: ObjId, local: Affine, behind: bool)
_ => Align::Center,
};
let dash = match s.field(lsty, b"Patn") {
Some(Value::Array(a)) if a.len() > MAX_DASHES => {
r.warn("dash patterns of more than 64 lengths (imported as solid lines)");
None
}
Some(Value::Array(a)) if data.get(10) == Some(&2) || !a.is_empty() => {
let mut v: Vec<f64> = a.iter().filter_map(|x| if let Value::Float(f) = x { Some(*f) } else { None }).collect();
while v.len() >= 2 && v.iter().rev().take(2).all(|x| *x == 0.0) {
+246 -59
View File
@@ -39,6 +39,9 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
&& (s.is(id, b"ImgN") || !has_tiles)
{
match original(r, name) {
// HEIF/HEIC (phone photos) and AVIF originals can't be decoded here: the same pixels
// stored as tiles are used instead (see `decode` when the tiles point back at the original).
Ok(bytes) if is_heif(&bytes) && has_tiles => {}
Ok(bytes) => return Ok(Some(Image { width, height, pixels: Pixels::Encoded(bytes), transform: world })),
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
Err(_) => r.warn("an embedded image file that could not be read (its cached pixels were used)"),
@@ -66,8 +69,17 @@ pub(crate) fn node_image(r: &mut Reader, id: ObjId, world: Affine) -> Result<Opt
decode(r, bitmap, crop, world)
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides).
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Image>, Error> {
/// An ISO base media file holding a HEIF image (HEIC, AVIF): `ftyp` with an image brand.
fn is_heif(bytes: &[u8]) -> bool {
bytes.get(4..8) == Some(b"ftyp".as_slice())
&& bytes.get(8..12).is_some_and(|brand| {
[b"heic", b"heix", b"hevc", b"hevx", b"heim", b"heis", b"mif1", b"msf1", b"avif", b"avis"].iter().any(|b| brand == b.as_slice())
})
}
/// A mask layer (`MRst`): one channel of coverage as grey pixels (white shows, black hides), read
/// over `over` (document pixels) when given; outside its pixels a mask hides everything.
pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine, over: Option<crate::model::Rect>) -> Result<Option<Image>, Error> {
let s = r.s;
let Some(bitmap) = s.obj(id, b"Bitm") else { return Ok(None) };
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
@@ -76,7 +88,41 @@ pub(crate) fn mask(r: &mut Reader, id: ObjId, world: Affine) -> Result<Option<Im
r.warn("pixel masks in an unknown format (imported without them)");
return Ok(None);
}
decode(r, bitmap, (0, 0, width, height), world)
let crop = match over.map(|o| crop_to(o, world, width, height)) {
Some(Some(c)) => c,
// What it masks lies outside it: all hidden, which one black pixel says as well.
Some(None) => return Ok(Some(Image { width: 1, height: 1, pixels: Pixels::Rgba8(vec![0, 0, 0, 255]), transform: world })),
None => (0, 0, width, height),
};
decode(r, bitmap, crop, world)
}
/// The bitmap pixels (x0, y0, x1, y1) of a `width` × `height` bitmap placed by `world` that cover
/// document rectangle `over`, one pixel wider all round; `None` if they don't overlap.
fn crop_to(over: crate::model::Rect, world: Affine, width: u32, height: u32) -> Option<(u32, u32, u32, u32)> {
let inv = invert(world)?;
let pts = [(over.x0, over.y0), (over.x1, over.y0), (over.x1, over.y1), (over.x0, over.y1)].map(|(x, y)| inv.apply(crate::model::Point { x, y }));
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in pts {
if !(p.x.is_finite() && p.y.is_finite()) {
return Some((0, 0, width, height));
}
(x0, y0, x1, y1) = (x0.min(p.x), y0.min(p.y), x1.max(p.x), y1.max(p.y));
}
let clamp = |v: f64, max: u32| v.clamp(0.0, f64::from(max)) as u32;
let (cx0, cy0, cx1, cy1) =
(clamp(x0.floor() - 1.0, width), clamp(y0.floor() - 1.0, height), clamp(x1.ceil() + 1.0, width), clamp(y1.ceil() + 1.0, height));
(cx1 > cx0 && cy1 > cy0).then_some((cx0, cy0, cx1, cy1))
}
fn invert(m: Affine) -> Option<Affine> {
let [a, b, c, d, e, f] = m.0;
let det = a * d - b * c;
if !(det.is_finite() && det.abs() > 1e-12) {
return None;
}
let (ia, ib, ic, id) = (d / det, -b / det, -c / det, a / det);
Some(Affine([ia, ib, ic, id, -(ia * e + ic * f), -(ib * e + id * f)]))
}
/// An embedded document or symbol instance (`EmbN`), as the picture of it Affinity caches.
@@ -99,33 +145,41 @@ pub(crate) fn embedded(r: &mut Reader, id: ObjId, world: Affine) -> Result<Optio
/// Decode the tiles of a bitmap inside `crop` (pixels), placed by `world` (bitmap pixels to document).
fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine) -> Result<Option<Image>, Error> {
decode_within(r, bitmap, crop, world, MAX_PIXELS)
}
/// [`decode`] with at most `max_pixels` decoded pixels.
fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affine, max_pixels: u64) -> Result<Option<Image>, Error> {
let s = r.s;
let (x0, y0, x1, y1) = crop;
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (w, h) = (x1 - x0, y1 - y0);
let pixels = u64::from(w) * u64::from(h);
if pixels > MAX_PIXELS {
r.warn("pixel layers larger than 64 megapixels");
return Ok(None);
// Larger layers (mostly masks of whole spreads) are read at a half, a quarter… of their
// resolution: every `step`th pixel of every `step`th row.
let mut step = 1u32;
while u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step)) > max_pixels {
step *= 2;
if step > 1 << 12 {
r.warn("pixel layers too large to read (left out)");
return Ok(None);
}
}
// Before anything is allocated: a layer's tiles can be flat colour, with nothing to extract.
let Some(left) = r.pixels_left.checked_sub(pixels) else {
let pixels = u64::from(w.div_ceil(step)) * u64::from(h.div_ceil(step));
let Some(left) = r.budget.pixels.checked_sub(pixels) else {
r.warn("pixel layers beyond the pixels one document may hold");
return Ok(None);
};
r.pixels_left = left;
r.budget.pixels = left;
let format = s.enumeration(bitmap, b"Frmt").map(|(f, _)| f);
let (channels, bps) = match format {
Some(0) => (4, 1),
Some(1) => (4, 2),
Some(6) => (1, 1),
Some(7) => (1, 2),
Some(4) => {
r.warn("CMYK pixel layers (converted to RGB without the document's colour profile)");
(5, 1)
}
Some(4) => (5, 1),
_ => {
r.warn("pixel layers in grey, Lab or 32-bit formats");
return Ok(None);
@@ -152,11 +206,48 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
} else {
None
};
// Tiles kept only in an original that can't be decoded here are left empty (transparent). Where
// a tile's colour is in the original but its alpha is stored, as in CMYK layers, it would come
// out opaque white instead: such a layer (a HEIC photo in a CMYK document) is left out, and said.
if needs_source && source.is_none() && channels > 1 {
let states = |c: usize| match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.clone(),
_ => Vec::new(),
};
let alpha = states(channels);
let blank = (1..channels).any(|c| states(c).iter().enumerate().any(|(i, v)| *v == Value::UInt(5) && alpha.get(i) != Some(&Value::UInt(5))));
if blank {
r.warn("placed images whose pixels are only in an original the reader can't decode, such as HEIC photos (left out)");
return Ok(None);
}
}
if step > 1 {
// Its stored tiles must fit what is left of the import's extraction budget.
let stored: usize = (1..=channels).map(|c| tiles_in(s, bitmap, c, bps, (x0, y0, w, h)).len()).sum();
if stored.saturating_mul(TILE_BYTES) > r.archive.remaining().max_total {
r.warn("pixel layers too large to read (left out)");
return Ok(None);
}
r.warn("pixel layers larger than 64 megapixels (read at a lower resolution)");
}
let mut cache = CachedPixels { tiles: HashMap::new(), source };
for c in 1..=channels {
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), &mut cache)?);
planes.push(plane(r, bitmap, c, bps, (x0, y0, w, h), step, &mut cache)?);
}
let (w, h) = (w.div_ceil(step), h.div_ceil(step));
let n = (w as usize) * (h as usize);
let k = f64::from(step);
let transform = Affine([k, 0.0, 0.0, k, f64::from(x0), f64::from(y0)]).then(world);
if channels == 5 {
// Ink amounts and alpha as stored (8 bits), interleaved.
let mut cmyka = vec![0u8; n * 5];
for (i, px) in cmyka.as_chunks_mut::<5>().0.iter_mut().enumerate() {
for (c, v) in px.iter_mut().enumerate() {
*v = planes.get(c).and_then(|p| p.get(i)).copied().unwrap_or(0);
}
}
return Ok(Some(Image { width: w, height: h, pixels: Pixels::Cmyka8(cmyka), transform }));
}
let mut rgba = vec![0u8; n * 4];
let sample = |p: &Vec<u8>, i: usize| -> f64 {
if bps == 2 {
@@ -167,17 +258,9 @@ fn decode(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), world: Affi
};
for (i, px) in rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v: Vec<f64> = planes.iter().map(|p| sample(p, i)).collect();
let (r2, g, b, a) = if channels == 1 {
(v[0], v[0], v[0], 1.0)
} else if channels == 5 {
let k = 1.0 - v[3];
((1.0 - v[0]) * k, (1.0 - v[1]) * k, (1.0 - v[2]) * k, v[4])
} else {
(v[0], v[1], v[2], v[3])
};
let (r2, g, b, a) = if channels == 1 { (v[0], v[0], v[0], 1.0) } else { (v[0], v[1], v[2], v[3]) };
px.copy_from_slice(&[r2, g, b, a].map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8));
}
let transform = Affine([1.0, 0.0, 0.0, 1.0, f64::from(x0), f64::from(y0)]).then(world);
Ok(Some(Image { width: w, height: h, pixels: Pixels::Rgba8(rgba), transform }))
}
@@ -229,28 +312,57 @@ fn tag(prefix: &[u8; 3], c: usize) -> [u8; 4] {
[prefix[0], prefix[1], prefix[2], b'0' + c as u8]
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels; `bps` bytes per sample.
/// Channel `c`'s tile grid: (tiles across, tiles down, each tile's state).
fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usize, Vec<u8>) {
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
(tw, th, states)
}
/// The stored tiles (state 4) of channel `c` that overlap the crop, by their index in `Idx`.
fn tiles_in(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize, (x0, y0, w, h): (u32, u32, u32, u32)) -> Vec<usize> {
let (tw, th, states) = grid(s, bitmap, c, bps);
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut out = Vec::new();
let mut next = 0usize;
for (t, state) in states.iter().enumerate().take(tw.saturating_mul(th)) {
if *state != 4 {
continue;
}
let (ox, oy) = ((t % tw.max(1)) * TILE, (t / tw.max(1)) * TILE);
if !(ox >= bx1 || ox + TILE <= bx0 || oy >= py1 || oy + TILE <= py0) {
out.push(next);
}
next += 1;
}
out
}
/// One channel of level 0, cropped to (x0, y0, w, h) pixels and taking every `step`th pixel of
/// every `step`th row; `bps` bytes per sample.
fn plane(
r: &mut Reader,
bitmap: ObjId,
c: usize,
bps: usize,
(x0, y0, w, h): (u32, u32, u32, u32),
step: u32,
cache: &mut CachedPixels,
) -> Result<Vec<u8>, Error> {
let CachedPixels { tiles: cache, source } = cache;
let s = r.s;
let width = s.int(bitmap, b"BmpW").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| (width * bps).div_ceil(TILE));
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
_ => Vec::new(),
};
let (tw, th, states) = grid(s, bitmap, c, bps);
let tiles = s.objs(bitmap, &tag(b"Idx", c));
let row = (w as usize) * bps;
let mut out = vec![0u8; row * h as usize];
let at = Crop { bps, x0: x0 as usize, y0: y0 as usize, w: w as usize, h: h as usize, step: step.max(1) as usize };
let row = at.w.div_ceil(at.step) * bps;
let mut out = vec![0u8; row * at.h.div_ceil(at.step)];
let (bx0, bx1) = (x0 as usize * bps, (x0 + w) as usize * bps);
let (py0, py1) = (y0 as usize, (y0 + h) as usize);
let mut next = 0usize;
@@ -267,7 +379,7 @@ fn plane(
continue;
}
let name = s.entry(blck, b"Data").ok_or(Error::Malformed("pixel tile without data"))?.to_string();
if !cache.contains_key(&name) {
let read = |r: &mut Reader| -> Result<Vec<u8>, Error> {
let mut data = r.archive.read(&name)?;
// Older files wrap a tile in a one-field stream: header, then a 64 KiB blob.
if data.len() == TILE_BYTES + 22 && data.starts_with(b"\x00\xffKS") {
@@ -276,10 +388,20 @@ fn plane(
if data.len() != TILE_BYTES {
return Err(Error::Malformed("pixel tile size"));
}
Ok(data)
};
// A layer read at a lower resolution is too large to keep its tiles around.
if at.step > 1 {
let tile = read(r)?;
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
if !cache.contains_key(&name) {
let data = read(r)?;
cache.insert(name.clone(), data);
}
let tile = cache.get(&name).ok_or(Error::Malformed("pixel tile"))?;
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
copy(&mut out, &at, (ox, oy), |x, y| tile.get(y * TILE + x).copied().unwrap_or(0));
continue;
}
3 => {
@@ -289,9 +411,7 @@ fn plane(
5 => {
if let Some((source_width, pixels)) = source.as_ref() {
let (ox, oy) = (tx * TILE, ty * TILE);
copy(&mut out, row, (bx0, py0, bx1, py1), (ox, oy), |x, y| {
pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0)
});
copy(&mut out, &at, (ox, oy), |x, y| pixels.get(((oy + y) * source_width + ox + x) * 4 + c - 1).copied().unwrap_or(0));
continue;
}
r.warn("pixel layers drawn from an embedded image (left empty)");
@@ -300,17 +420,38 @@ fn plane(
_ => return Err(Error::Malformed("unknown pixel tile state")),
};
if let Some(v) = fill {
copy(&mut out, row, (bx0, py0, bx1, py1), (tx * TILE, ty * TILE), |_, _| v);
copy(&mut out, &at, (tx * TILE, ty * TILE), |_, _| v);
}
}
Ok(out)
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane.
fn copy(out: &mut [u8], row: usize, (bx0, py0, bx1, py1): (usize, usize, usize, usize), (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
for y in oy.max(py0)..(oy + TILE).min(py1) {
/// Where a plane's pixels come from: the crop (pixels) and the sampling step.
struct Crop {
bps: usize,
x0: usize,
y0: usize,
w: usize,
h: usize,
step: usize,
}
/// Copy the overlap of a tile at byte column `ox`, row `oy` into the cropped plane, keeping every
/// `step`th pixel of every `step`th row.
fn copy(out: &mut [u8], c: &Crop, (ox, oy): (usize, usize), at: impl Fn(usize, usize) -> u8) {
let row = c.w.div_ceil(c.step) * c.bps;
let (bx0, bx1) = (c.x0 * c.bps, (c.x0 + c.w) * c.bps);
for y in oy.max(c.y0)..(oy + TILE).min(c.y0 + c.h) {
if !(y - c.y0).is_multiple_of(c.step) {
continue;
}
let out_row = (y - c.y0) / c.step * row;
for x in ox.max(bx0)..(ox + TILE).min(bx1) {
if let Some(b) = out.get_mut((y - py0) * row + (x - bx0)) {
let px = x / c.bps - c.x0;
if !px.is_multiple_of(c.step) {
continue;
}
if let Some(b) = out.get_mut(out_row + px / c.step * c.bps + x % c.bps) {
*b = at(x - ox, y - oy);
}
}
@@ -323,6 +464,15 @@ mod tests {
use crate::model::Kind;
use crate::synth::{self, F, Method, tag};
#[test]
fn heif_originals_are_recognised_by_their_brand() {
assert!(is_heif(b"\0\0\0\x18ftypheic\0\0\0\0"));
assert!(is_heif(b"\0\0\0\x1cftypavif"));
assert!(!is_heif(b"\0\0\0\x18ftypisom"), "a video is not a photo");
assert!(!is_heif(&png()));
assert!(!is_heif(b"ftyp"));
}
fn png() -> Vec<u8> {
let pixels = image::RgbaImage::from_fn(300, 2, |x, y| image::Rgba([(x % 251) as u8, (y * 31) as u8, 77, 255]));
let mut bytes = std::io::Cursor::new(Vec::new());
@@ -333,18 +483,29 @@ mod tests {
/// An original synthetic mixed source/cache bitmap. It exercises reader recovery,
/// not a claim that Affinity itself accepts the synthetic container.
fn fixture(source: &[u8], origin: i32) -> Vec<u8> {
fixture_in(source, origin, false)
}
/// `cmyk`: a CMYK bitmap whose colour tiles are all in the source and whose alpha is stored.
fn fixture_in(source: &[u8], origin: i32, cmyk: bool) -> Vec<u8> {
let channels = if cmyk { 5 } else { 4 };
let mut bitmap = vec![
(tag(b"Frmt"), F::Enum(0, 0)),
(tag(b"Frmt"), F::Enum(if cmyk { 4 } else { 0 }, 0)),
(tag(b"BmpW"), F::I32(300)),
(tag(b"BmpH"), F::I32(2)),
(tag(b"LInf"), F::I32(origin)),
(tag(b"TInf"), F::I32(0)),
(tag(b"Bckg"), F::Entry("c/1".into())),
];
for c in 1..=4 {
for c in 1..=channels {
let states = match (cmyk, c) {
(false, _) => vec![5, 4],
(true, 5) => vec![4, 4],
(true, _) => vec![5, 5],
};
bitmap.push((tag(&super::tag(b"TWi", c)), F::I32(2)));
bitmap.push((tag(&super::tag(b"THi", c)), F::I32(1)));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(vec![5, 4])));
bitmap.push((tag(&super::tag(b"Sta", c)), F::U8s(states)));
bitmap.push((
tag(&super::tag(b"Idx", c)),
F::Shared(vec![F::Def(c as u32, vec![tag(b"Blck")], vec![(tag(b"Data"), F::Entry(format!("d/{c}")))])]),
@@ -369,18 +530,44 @@ mod tests {
blob.extend((source.len() as u32).to_le_bytes());
blob.extend(source);
let block = synth::stream(&[(tag(b"Data"), F::Raw(blob))]);
let tiles: Vec<_> = [200u8, 10, 30, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
synth::container(
&[
("doc.dat", &doc, Method::Zlib),
("c/1", &block, Method::Zlib),
("d/1", &tiles[0], Method::Zlib),
("d/2", &tiles[1], Method::Zlib),
("d/3", &tiles[2], Method::Zlib),
("d/4", &tiles[3], Method::Zlib),
],
None,
)
let tiles: Vec<_> = [200u8, 10, 30, 255, 255].into_iter().map(|v| vec![v; TILE_BYTES]).collect();
let names: Vec<String> = (1..=channels).map(|c| format!("d/{c}")).collect();
let mut entries = vec![("doc.dat", doc.as_slice(), Method::Zlib), ("c/1", block.as_slice(), Method::Zlib)];
for (name, tile) in names.iter().zip(&tiles) {
entries.push((name.as_str(), tile.as_slice(), Method::Zlib));
}
synth::container(&entries, None)
}
#[test]
fn a_cmyk_layer_whose_colours_are_only_in_an_unreadable_original_is_left_out_not_white() {
let doc = crate::read(&fixture_in(b"\0\0\0\x18ftypheic not decodable", 0, true), crate::Limits::default()).unwrap();
assert!(doc.warnings.iter().any(|w| w.starts_with("placed images whose pixels are only in an original")), "{:?}", doc.warnings);
assert!(matches!(doc.spreads[0].nodes[0].kind, Kind::Unsupported), "{:?}", doc.spreads[0].nodes[0].kind);
}
#[test]
fn layers_over_the_pixel_budget_are_read_at_a_lower_resolution() {
let bytes = fixture(&png(), 0);
let mut archive = crate::Archive::open(&bytes, crate::Limits::default()).unwrap();
let doc = archive.read("doc.dat").unwrap();
let s = stream::parse(&doc).unwrap();
let bitmap = s.objects.iter().position(|o| o.class == Tag::of(b"DyBm")).unwrap();
let mut r = Reader::new(&s, &mut archive, Affine::IDENTITY, 0);
// 20 × 2 pixels over a budget of 10: every other pixel of every other row.
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 10).unwrap().unwrap();
assert_eq!((image.width, image.height), (10, 1));
assert_eq!(image.transform, Affine([2.0, 0.0, 0.0, 2.0, 250.0, 0.0]), "each pixel covers two");
let Pixels::Rgba8(pixels) = &image.pixels else { panic!() };
for x in 0..10 {
let sx = 2 * x;
let expected = if sx + 250 < 256 { [((sx + 250) % 251) as u8, 0, 77, 255] } else { [200, 10, 30, 255] };
assert_eq!(&pixels[x * 4..x * 4 + 4], &expected, "pixel {x}");
}
assert!(r.warnings.contains_key("pixel layers larger than 64 megapixels (read at a lower resolution)"));
// Within budget, nothing changes.
let image = decode_within(&mut r, bitmap, (250, 0, 270, 2), Affine::IDENTITY, 40).unwrap().unwrap();
assert_eq!((image.width, image.height), (20, 2));
}
#[test]
+4
View File
@@ -303,6 +303,10 @@ impl Parser<'_> {
0x2c => {
let n = usize::try_from(self.c.u32()?).map_err(|_| Error::Malformed("array length"))?;
let size = usize::from(self.c.u16()?);
// Records of no bytes take nothing from the data, whatever their number.
if n > 0 && size == 0 {
return Err(Error::Malformed("array of records without bytes"));
}
if n.checked_mul(size).is_none_or(|b| b > self.c.remaining()) {
return Err(Error::Malformed("array longer than its data"));
}
+27 -1
View File
@@ -1,4 +1,4 @@
//! Unit tests on synthetic inputs; `tests/real_files.rs` covers public Affinity documents.
//! Unit tests on synthetic inputs; `tests/it/real_files.rs` covers public Affinity documents.
mod preview {
use crate::container::header;
@@ -261,6 +261,32 @@ mod archive {
assert!(matches!(a.read("doc.dat"), Err(Error::Limit(_))));
}
// Regression test for #1710: ruzstd 0.9.0 let a block decompress to more than its frame's
// window allows (RUSTSEC-2026-0344), so a crafted entry made the decoder produce far more than
// the format permits for a block. 0.9.1 refuses it.
#[test]
fn a_zstd_block_larger_than_its_frames_window_is_refused() {
const LEN: u32 = 100_000;
let plain = vec![0x61u8; LEN as usize];
let file = synth::container(&[("doc.dat", &plain, Method::Zstd)], None);
// A Zstandard frame with a 1 KiB window (descriptor byte 0, window byte 0) holding one RLE
// block, the last, that repeats a byte LEN times: block header = last | RLE << 1 | LEN << 3.
let mut frame = vec![0x28, 0xB5, 0x2F, 0xFD, 0x00, 0x00];
frame.extend((1 | (1 << 1) | (LEN << 3)).to_le_bytes().get(..3).unwrap());
frame.push(0x61);
// Swap it in for the entry's compressed bytes (they follow the "#Fil" tag at offset 72).
let fat = u64::from_le_bytes(file[16..24].try_into().unwrap()) as usize;
let old_len = u64::from_le_bytes(file[fat + 59 + 21..fat + 59 + 29].try_into().unwrap()) as usize;
let mut swapped = file[..76].to_vec();
swapped.extend(&frame);
swapped.extend(&file[76 + old_len..]);
let new_fat = fat - old_len + frame.len();
swapped[16..24].copy_from_slice(&(new_fat as u64).to_le_bytes());
swapped[new_fat + 59 + 21..new_fat + 59 + 29].copy_from_slice(&(frame.len() as u64).to_le_bytes());
let mut a = Archive::open(&swapped, Limits::default()).unwrap();
assert!(a.read("doc.dat").is_err(), "a block of {LEN} bytes was decompressed from a frame with a 1 KiB window");
}
#[test]
fn a_declared_size_smaller_than_the_data_is_rejected_not_truncated() {
let big = vec![7u8; 1 << 20];
+330 -99
View File
@@ -2,7 +2,7 @@
//! leading, the paragraph alignment and the first baseline. Layout (line breaks in frames, kerning,
//! OpenType features) is left to the importing application.
use crate::model::{Affine, Align, Point, Reader, Text, TextRun, rect};
use crate::model::{Affine, Align, Paragraph, Point, Reader, Table, TableCell, Text, TextRun, VAlign, rect};
use crate::paint::{self, Color, Paint};
use crate::stream::{ObjId, Value};
@@ -11,92 +11,16 @@ const MAX_CHARS: usize = 1 << 20;
/// Shared character attributes must not amplify into unbounded font/feature/gradient data.
const MAX_ATTRIBUTE_BYTES: usize = 16 << 20;
/// Ends a table cell in a table's story (Affinity's break glyph of kind 4).
pub const CELL_BREAK: char = '\u{1F}';
pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let frame = s.obj(id, b"TxtH")?;
let artistic = s.is(frame, b"ArFr");
let bounds = s.floats::<4>(frame, b"FrmB").map(rect)?;
let mut runs = Vec::new();
let mut align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer.
let budget = MAX_CHARS - total;
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors) each take one index before the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
let inline = s.objs(seg, b"Glys").len();
v.extend(std::iter::repeat_n('\u{FFFC}', inline.min(budget + 1 - v.len())));
placeholders |= inline > 0;
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take(budget + 1 - v.len()));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
r.warn("text longer than a million characters (truncated)");
}
total += chars.len();
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
let text = characters(&chars, start, end);
start = end;
push_run(r, &mut runs, s.obj(run, b"Item"), text, world, &mut attribute_budget);
}
if start < chars.len() {
let text = characters(&chars, start, chars.len());
push_run(r, &mut runs, None, text, world, &mut attribute_budget);
}
for paragraph in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
if let Some(attrs) = s.obj(paragraph, b"Item") {
let paragraph = paragraph_align(r, attrs);
match align {
None => align = Some(paragraph),
Some(first) if first != paragraph => r.warn("mixed paragraph alignments (the first alignment is used)"),
_ => {}
}
}
}
if truncated {
break;
}
}
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
let align = align.unwrap_or(Align::Left);
let (runs, align) = story_runs(r, story, world);
let first_baseline = bounds.y0 + s.f64(frame, b"ArtV").unwrap_or(0.0);
let anchor_x = match align {
Align::Center => (bounds.x0 + bounds.x1) / 2.0,
@@ -111,19 +35,256 @@ pub(crate) fn read(r: &mut Reader, id: ObjId, world: Affine) -> Option<Text> {
r.warn("text frames with a curved outline (imported as rectangular frames)");
}
}
Some(Text { runs, align, anchor: Point { x: anchor_x, y: first_baseline }, frame: if artistic { None } else { Some(bounds) }, transform: world })
let text_transform = if artistic { world } else { text_scale(r, id).then(r.type_base) };
Some(Text {
runs,
align,
anchor: Point { x: anchor_x, y: first_baseline },
frame: if artistic { None } else { Some(bounds) },
transform: world,
text_transform,
})
}
/// Rows and columns a table may have.
const MAX_TABLE_LINES: usize = 4096;
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
const MAX_TABLE_CELLS: usize = 1 << 16;
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
let s = r.s;
let story = s.obj(id, b"StSt")?;
let tabl = s.obj(id, b"TxtH").and_then(|h| s.obj(h, b"Tabl"))?;
let bounds = |pos: Option<ObjId>| -> Option<Vec<f64>> {
let Some(Value::Array(a)) = pos.and_then(|p| s.field(p, b"Posn")) else { return None };
let v: Vec<f64> = a.iter().take(MAX_TABLE_LINES + 2).map(float).collect::<Option<_>>()?;
(v.len() >= 2 && v.len() <= MAX_TABLE_LINES + 1 && v.windows(2).all(|w| w[1] >= w[0])).then_some(v)
};
let columns = bounds(s.obj(tabl, b"CPos"))?;
let rows = bounds(s.obj(tabl, b"RPos"))?;
let (ncols, nrows) = (columns.len() - 1, rows.len() - 1);
let count = ncols.checked_mul(nrows)?;
if count > MAX_TABLE_CELLS {
r.warn("tables with more than 65536 cells (left out)");
return None;
}
let Some(left) = r.budget.cells.checked_sub(count) else {
r.warn("tables beyond the cells one document may hold (left out)");
return None;
};
r.budget.cells = left;
let cell_objs = s.obj(tabl, b"Cell").map(|c| s.objs(c, b"Cell")).unwrap_or_default();
// Formatting that doesn't match the grid would land on the wrong cells (and merging them,
// move text between cells): only text then.
let cell_objs = if cell_objs.len() == count {
cell_objs
} else {
r.warn("tables whose cells don't match their grid (cell formatting left out)");
Vec::new()
};
let blank = || TableCell { runs: Vec::new(), inset: [0.0; 4], valign: VAlign::Top, merged_left: false };
let mut cells: Vec<TableCell> = (0..count).map(|_| blank()).collect();
for (i, (cell, obj)) in cells.iter_mut().zip(&cell_objs).enumerate() {
if let Some(inset) = s.floats::<4>(*obj, b"Inse").filter(|v| v.iter().all(|x| x.is_finite() && *x >= 0.0)) {
cell.inset = inset;
}
cell.valign = match s.enumeration(*obj, b"AliY").map(|(i, _)| i) {
Some(1) => VAlign::Center,
Some(2) => VAlign::Bottom,
_ => VAlign::Top,
};
// A cell merged into the one on its left; the first column has nothing to its left.
cell.merged_left = i % ncols != 0 && s.bool(*obj, b"BrLf") == Some(true);
}
// The story holds the text of every cell that isn't merged away, row by row, each ended by a
// cell break.
let owner_list: Vec<usize> = (0..count).filter(|i| cells.get(*i).is_some_and(|c| !c.merged_left)).collect();
let mut owners = owner_list.into_iter();
let mut target = owners.next();
let mut extra = false;
let (runs, _) = story_runs(r, story, world);
for run in runs {
let mut parts = run.text.split(CELL_BREAK).peekable();
while let Some(part) = parts.next() {
if !part.is_empty() {
match target.and_then(|i| cells.get_mut(i)) {
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
None => extra = true,
}
}
if parts.peek().is_some() {
target = owners.next();
}
}
}
if extra {
r.warn("table text beyond its last cell (left out)");
}
let mut edges = |tag: &[u8; 4], expected: usize| -> Vec<Option<crate::paint::Stroke>> {
let objs = s.obj(tabl, tag).map(|e| s.objs(e, b"Edge")).unwrap_or_default();
if objs.len() != expected {
r.warn("tables whose lines don't match their grid (lines left out)");
return Vec::new();
}
objs.into_iter()
.map(|e| match (s.obj(e, b"Fill"), s.obj(e, b"Line")) {
(Some(pen), Some(line)) => paint::stroke(r, pen, line, world, false),
_ => None,
})
.collect()
};
let vertical = edges(b"CEdg", (ncols + 1) * nrows);
let horizontal = edges(b"REdg", ncols * (nrows + 1));
if let Some(format) = s.obj(tabl, b"TFmt") {
let filled =
s.objs(format, b"Clls").into_iter().filter_map(|c| s.obj(c, b"Fill")).any(|f| s.obj(f, b"FDeF").is_some_and(|d| !s.is(d, b"FilN")));
if filled {
r.warn("table cell fills (left out)");
}
}
let text_transform = text_scale(r, id).then(r.type_base);
Some(Table { columns, rows, cells, vertical, horizontal, transform: world, text_transform })
}
/// A story's character runs, each with its paragraph's format, and the first paragraph's
/// alignment. Table cells end with [`CELL_BREAK`].
pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<TextRun>, Align) {
let s = r.s;
let mut runs = Vec::new();
let mut first_align = None;
let mut total = 0usize;
let mut placeholders = false;
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
for block in s.objs(story, b"Blok") {
let Some(glyphs) = s.obj(block, b"Glyp") else {
r.warn("a text block without readable characters (left out)");
continue;
};
// Bound collection itself, including placeholders, before allocating the character buffer:
// by the story's limit and by what the document's other stories left.
let budget = (MAX_CHARS - total).min(r.budget.chars.saturating_sub(total));
let mut chars: Vec<char> = match s.str(glyphs, b"Utf8") {
Some(t) => t.chars().take(budget + 1).collect(),
None => {
// Inline objects (page numbers, anchors, cell ends) each take one index before
// the segment text.
let segments = s.objs(glyphs, b"Mixd");
if segments.is_empty() {
r.warn("a text block without readable characters (left out)");
continue;
}
let mut v = Vec::new();
for seg in segments {
for inline in s.objs(seg, b"Glys") {
if v.len() > budget {
break;
}
if s.is(inline, b"BrGl") && s.enumeration(inline, b"HdBk").is_some_and(|(kind, _)| kind == 4) {
v.push(CELL_BREAK);
} else {
v.push('\u{FFFC}');
placeholders = true;
}
}
v.extend(s.str(seg, b"Utf8").unwrap_or_default().chars().take((budget + 1).saturating_sub(v.len())));
if v.len() > budget {
break;
}
}
v
}
};
let truncated = chars.len() > budget;
if truncated {
chars.truncate(budget);
if budget < MAX_CHARS - total {
r.warn("text beyond the characters one document may hold (truncated)");
} else {
r.warn("text longer than a million characters (truncated)");
}
}
total += chars.len();
// Paragraph formats by end index; a run without an index covers the rest of the block.
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
let mut open: Option<Paragraph> = None;
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
let Some(attrs) = s.obj(run, b"Item") else { continue };
let a = paragraph(r, attrs);
first_align.get_or_insert(a.align);
match s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) {
Some(end) => paragraphs.push((end, a)),
None => match open {
None => open = Some(a),
Some(first) if first != a => r.warn("mixed paragraph formats in one block (the first is used)"),
_ => {}
},
}
}
// Sorted by end (they are in order in every file), so both lookups below are binary
// searches: a block can have as many paragraphs and runs as characters.
paragraphs.sort_by_key(|(end, _)| *end);
let align_at = |i: usize| paragraphs.get(paragraphs.partition_point(|(end, _)| *end <= i)).map(|(_, a)| *a).or(open).unwrap_or_default();
let mut cuts: Vec<usize> = paragraphs.iter().map(|(end, _)| *end).filter(|e| *e < chars.len()).collect();
cuts.dedup();
let push = |r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, from: usize, to: usize, budget: &mut Option<usize>| {
// Split at paragraph ends so every run has one paragraph format.
let inside = cuts.get(cuts.partition_point(|c| *c <= from)..cuts.partition_point(|c| *c < to)).unwrap_or_default();
let mut a = from;
for &c in inside.iter().chain(std::iter::once(&to)) {
push_run(r, runs, attrs, characters(&chars, a, c), world, budget, align_at(a));
a = c;
}
};
let mut start = 0usize;
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
continue;
};
let end = index.min(chars.len());
if index > chars.len() || end < start {
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
}
if end <= start {
continue;
}
push(r, &mut runs, s.obj(run, b"Item"), start, end, &mut attribute_budget);
start = end;
}
if start < chars.len() {
push(r, &mut runs, None, start, chars.len(), &mut attribute_budget);
}
if truncated {
break;
}
}
r.budget.chars = r.budget.chars.saturating_sub(total);
if placeholders {
r.warn("text fields such as page numbers (left out of the text)");
}
(runs, first_align.unwrap_or(Align::Left))
}
fn characters(chars: &[char], start: usize, end: usize) -> String {
chars.get(start..end).unwrap_or_default().iter().filter(|c| **c != '\0' && **c != '\u{FFFC}').collect()
}
fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text: String, world: Affine, attribute_budget: &mut Option<usize>) {
fn push_run(
r: &mut Reader,
runs: &mut Vec<TextRun>,
attrs: Option<ObjId>,
text: String,
world: Affine,
attribute_budget: &mut Option<usize>,
paragraph: Paragraph,
) {
if text.is_empty() {
return;
}
let run = match attrs {
Some(attrs) if attribute_budget.is_some() => run_attrs(r, attrs, text, world, attribute_budget),
Some(attrs) if attribute_budget.is_some() => TextRun { paragraph, ..run_attrs(r, attrs, text, world, attribute_budget) },
_ => {
// This is a recovery policy, not an assertion about unspecified Affinity attributes.
if attrs.is_none() {
@@ -137,10 +298,15 @@ fn push_run(r: &mut Reader, runs: &mut Vec<TextRun>, attrs: Option<ObjId>, text:
italic: false,
features: Vec::new(),
all_caps: false,
underline: false,
strikethrough: false,
size: 12.0 * r.dpi / 72.0,
tracking: 0.0,
h_scale: 1.0,
leading: None,
fill: Paint::Solid(Color::Gray { v: 0.0, a: 1.0 }),
stroke: None,
paragraph,
}
}
};
@@ -195,13 +361,33 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
fill = Paint::Solid(Color::Gray { v: 0.0, a: 1.0 });
}
}
if attribute_budget.is_some() && objects.get(1).copied().flatten().and_then(|f| paint::descriptor(r, f, world)).is_some_and(|p| p != Paint::None)
{
r.warn("outlined (stroked) text");
}
if slot(doubles, 10, float).is_some_and(|h| (h - 1.0).abs() > 1e-3) {
r.warn("horizontally scaled text");
// Outlined characters: `Objs` 1 is the outline's paint, 2 its line style (as for shapes).
let mut stroke = match (attribute_budget.is_some(), objects.get(1).copied().flatten(), objects.get(2).copied().flatten()) {
(true, Some(pen), Some(style)) => paint::stroke(r, pen, style, Affine::IDENTITY, false),
_ => None,
};
// Its gradient stops and dashes are owned data too, charged like the fill's.
if let Some(st) = &stroke {
let stops = match &st.paint {
Paint::Gradient(g) => g.stops.len().saturating_mul(std::mem::size_of::<paint::Stop>()),
_ => 0,
};
let dash = st.dash.as_ref().map_or(0, |(d, _)| d.len().saturating_mul(std::mem::size_of::<f64>()));
if !charge_attributes(r, attribute_budget, stops.saturating_add(dash)) {
stroke = None;
}
}
// Underline (slot 0) and strikethrough (slot 1): 1 is a single line.
let mut decoration = |i: usize| match slot(ints, i, int) {
Some(1) => true,
Some(0) | None => false,
Some(_) => {
r.warn("text decorations of an unknown kind (left out)");
false
}
};
let (underline, strikethrough) = (decoration(0), decoration(1));
let h_scale = slot(doubles, 10, float).filter(|h| *h > 0.0 && *h < 100.0).unwrap_or(1.0);
// A resolved font is populated on the emoji run of the public Affinity 3 text-runs file
// (Courier New requested, Segoe UI Emoji stored in RFnt). Empty RFnt records are common.
// Source: https://github.com/SethRobinson/Patchy/tree/de84eab550758b30fa062e479f5778cce7693b73/test-fixtures/af
@@ -220,8 +406,8 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
if font.and_then(|f| s.int(f, b"Widh")).is_some_and(|w| w != 5) {
r.warn("font width variants (exact face depends on the installed PostScript font)");
}
let postscript = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = metadata_string(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let postscript = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Post")).unwrap_or_default());
let family = font_name(r, attribute_budget, font.and_then(|f| s.str(f, b"Famy")).unwrap_or_default());
let (features, all_caps) = font_features(r, objects.get(7).copied().flatten(), attribute_budget);
let weight = font.and_then(|f| s.int(f, b"Wegt")).unwrap_or(400);
let weight = if (1..=1000).contains(&weight) {
@@ -238,10 +424,15 @@ fn run_attrs(r: &mut Reader, attrs: ObjId, text: String, world: Affine, attribut
italic: font.and_then(|f| s.bool(f, b"Ital")).unwrap_or(false),
features,
all_caps,
underline,
strikethrough,
size,
tracking,
h_scale,
leading,
fill,
stroke,
paragraph: Paragraph::default(),
}
}
@@ -260,6 +451,17 @@ fn charge_attributes(r: &mut Reader, budget: &mut Option<usize>, bytes: usize) -
}
}
/// Longest font name kept (OpenType names are far shorter); every run and paragraph carries it.
const MAX_FONT_NAME: usize = 256;
fn font_name(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
if value.len() > MAX_FONT_NAME {
r.warn("font names longer than 256 bytes (the default font is used)");
return String::new();
}
metadata_string(r, budget, value)
}
fn metadata_string(r: &mut Reader, budget: &mut Option<usize>, value: &str) -> String {
let bytes = value.len().saturating_add(std::mem::size_of::<String>());
if value.is_empty() || !charge_attributes(r, budget, bytes) { String::new() } else { value.to_owned() }
@@ -303,13 +505,18 @@ fn font_features(r: &mut Reader, attrs: Option<ObjId>, attribute_budget: &mut Op
(features, all_caps)
}
fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
// Nonzero offsets in these slots are present in the public text-indent and text-para-spacing
// fixtures. Their layout is not represented by this importer; retain text and report the gap.
if (1..=6).any(|i| slot(r.s.field(p, b"Doub"), i, float).is_some_and(|v| v.abs() > 1e-9)) {
r.warn("paragraph indents or spacing (imported with defaults)");
}
match slot(r.s.field(p, b"Ints"), 0, int) {
/// A paragraph's alignment, indents, spacing and leading. The `Doub` slots: 0 the automatic
/// leading as a multiple of the font's line height (1 in every document checked), 1 fixed leading
/// (when `Ints` slot 1 is 2; in every paragraph checked the two go together), 2 indent of the
/// lines after the first, 3 right indent, 4 indent of the first line, 5 space before, 6 space
/// after. Slots 2, 4, 5 and 6 were identified on the public MIT text-indent and text-para-spacing
/// fixtures (Patchy) by measuring their thumbnails, slot 1 by measuring the baseline pitch in the
/// thumbnail of a document whose owner made it earlier.
fn paragraph(r: &mut Reader, p: ObjId) -> Paragraph {
let doubles = r.s.field(p, b"Doub");
let ints = r.s.field(p, b"Ints");
let at = |i: usize| slot(doubles, i, float).filter(|v| v.abs() < 1e6).unwrap_or(0.0);
let align = match slot(ints, 0, int) {
Some(1) => Align::Center,
Some(2) => Align::Right,
Some(3) => Align::Justify,
@@ -318,5 +525,29 @@ fn paragraph_align(r: &mut Reader, p: ObjId) -> Align {
r.warn("an unknown paragraph alignment (imported as left)");
Align::Left
}
};
let leading = match slot(ints, 1, int) {
Some(2) => Some(at(1)).filter(|l| *l > 0.0),
Some(0) | None => None,
Some(_) => {
r.warn("paragraph leading of an unknown kind (imported as automatic)");
None
}
};
let auto_leading = slot(doubles, 0, float).filter(|v| *v > 0.0 && *v < 100.0).unwrap_or(1.0);
Paragraph { align, left: at(2), right: at(3), first: at(4), space_before: at(5).max(0.0), space_after: at(6).max(0.0), leading, auto_leading }
}
/// The scale a text frame or table gives its type (`FTxS`, stored like `Xfrm`): none unless set.
/// Neither the frame's own transform nor its groups' scale its type (checked against the
/// thumbnails of tables and frames inside scaled groups).
fn text_scale(r: &mut Reader, id: ObjId) -> Affine {
match r.s.floats::<6>(id, b"FTxS").map(Affine::from_xfrm) {
Some(m) if m.is_finite() && m.scale() > 1e-9 => Affine([m.0[0], m.0[1], m.0[2], m.0[3], 0.0, 0.0]),
Some(_) => {
r.warn("text frames with an invalid text scale (imported unscaled)");
Affine::IDENTITY
}
None => Affine::IDENTITY,
}
}
+176
View File
@@ -0,0 +1,176 @@
//! Crafted documents can't make the reader allocate or work without bound: tables, shared
//! stories, paragraphs and embedded documents stay within the whole read's budget, however often
//! a file links or embeds the same content. Original synthetic documents in the reader's
//! accepted schema; no Affinity software or files were used.
use crate::text_fixture::*;
use vectorcraft_affinity::{
Document, Error, Limits,
model::{Kind, Node},
read,
synth::{self, F, Method, tag},
};
type Field = (vectorcraft_affinity::stream::Tag, F);
/// A document of one spread holding `nodes`, plus these other archive entries.
fn file(nodes: Vec<F>, entries: &[(&str, &[u8])]) -> Vec<u8> {
let spread = F::Obj(tag(b"Sprd"), vec![(tag(b"SprB"), F::F64s(vec![0.0, 0.0, 400.0, 300.0])), (tag(b"Chld"), F::Shared(nodes))]);
let mut root = vec![(tag(b"DocR"), F::Obj(tag(b"DocN"), vec![(tag(b"Chld"), F::Shared(vec![spread]))]))];
let mut next_id = 1000;
for (_, value) in &mut root {
shared_objects(value, &mut next_id);
}
let data = synth::stream(&root);
let mut all = vec![("doc.dat", data.as_slice(), Method::Zlib)];
all.extend(entries.iter().map(|(name, bytes)| (*name, *bytes, Method::Stored)));
synth::container(&all, None)
}
fn open(nodes: Vec<F>) -> Document {
read(&file(nodes, &[]), Limits::default()).unwrap()
}
fn all(nodes: &[Node]) -> Vec<&Node> {
let mut out = Vec::new();
for n in nodes {
out.push(n);
out.extend(all(&n.children));
}
out
}
fn warned(d: &Document, needle: &str) -> bool {
d.warnings.iter().any(|w| w.contains(needle))
}
fn story(text: &str) -> F {
F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![block(utf8(text), vec![run(text.chars().count() as i32, None)], &[])]))])
}
fn artistic(story: F) -> F {
F::Obj(
tag(b"TxtA"),
vec![
(tag(b"StSt"), story),
(tag(b"TxtH"), F::Obj(tag(b"ArFr"), vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, 100.0, 20.0])), (tag(b"ArtV"), F::F64(16.0))])),
],
)
}
/// A table frame (`TbFr`) with a grid of `cols` × `rows` cells and no cell objects or lines.
fn grid(cols: usize, rows: usize) -> Vec<Field> {
let pos = |n: usize| {
let v: Vec<f64> = (0..=n).map(|i| i as f64).collect();
F::Obj(tag(b"TPos"), vec![(tag(b"Posn"), array_f64(b"Posn", &v))])
};
let table = F::Obj(tag(b"Tabl"), vec![(tag(b"CPos"), pos(cols)), (tag(b"RPos"), pos(rows))]);
vec![(tag(b"FrmB"), F::F64s(vec![0.0, 0.0, cols as f64, rows as f64])), (tag(b"Tabl"), table)]
}
fn table(frame: F) -> F {
F::Obj(tag(b"TxtT"), vec![(tag(b"StSt"), story("A")), (tag(b"TxtH"), frame)])
}
fn tables(d: &Document) -> Vec<&vectorcraft_affinity::model::Table> {
all(&d.spreads[0].nodes).into_iter().filter_map(|n| if let Kind::Table(t) = &n.kind { Some(t) } else { None }).collect()
}
#[test]
fn a_table_of_more_than_65536_cells_is_left_out_before_its_cells_are_made() {
// 4096 × 4096 is within each dimension's limit but 16.7 million cells.
let d = open(vec![table(F::Obj(tag(b"TbFr"), grid(4096, 4096))), table(F::Obj(tag(b"TbFr"), grid(256, 256)))]);
let t = tables(&d);
assert_eq!(t.len(), 1);
assert_eq!((t[0].cells.len(), t[0].vertical.len(), t[0].horizontal.len()), (65536, 0, 0), "no lines when they don't match");
assert!(warned(&d, "tables with more than 65536 cells"), "{:?}", d.warnings);
}
#[test]
fn tables_sharing_one_grid_stay_within_the_cells_of_one_document() {
// One 65536-cell grid linked from five tables: four fill the document's 262144 cells.
let shared = F::Def(2_000_000, vec![tag(b"TbFr")], grid(256, 256));
let d = open((0..5).map(|i| table(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
assert_eq!(tables(&d).len(), 4);
assert!(warned(&d, "tables beyond the cells one document may hold"), "{:?}", d.warnings);
}
#[test]
fn a_shared_story_is_read_within_the_characters_of_one_document() {
// A story of a million characters linked from twenty text layers: 16 million in all.
let text = "x".repeat(1 << 20);
let shared = F::Def(2_000_000, vec![tag(b"Stry")], vec![(tag(b"Blok"), F::Shared(vec![block(utf8(&text), vec![run(1 << 20, None)], &[])]))]);
let d = open((0..20).map(|i| artistic(if i == 0 { shared.clone() } else { F::Ref(2_000_000) })).collect());
let chars: usize = all(&d.spreads[0].nodes)
.into_iter()
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t.runs.iter().map(|r| r.text.len()).sum::<usize>()) } else { None })
.sum();
assert_eq!(chars, 16 << 20);
assert!(warned(&d, "text beyond the characters one document may hold"), "{:?}", d.warnings);
}
#[test]
fn many_paragraphs_and_runs_are_matched_in_about_linear_time() {
// 200 000 paragraphs, each with its own run and format.
let n = 200_000;
let text = "a\u{2029}".repeat(n);
let fmt =
|end: usize| vec![(tag(b"Indx"), F::I32(end as i32)), (tag(b"Item"), F::Obj(tag(b"PAtt"), vec![(tag(b"Ints"), array_i32(b"Ints", &[1]))]))];
let blk = F::Obj(
tag(b"StBl"),
vec![
(tag(b"Glyp"), utf8(&text)),
(tag(b"GAtt"), F::Obj(tag(b"GlAS"), vec![(tag(b"Runs"), F::Objs(tag(b"GlAR"), (1..=n).map(|i| run(2 * i as i32, None)).collect()))])),
(tag(b"PAtt"), F::Obj(tag(b"PaAS"), vec![(tag(b"Runs"), F::Objs(tag(b"PaAR"), (1..=n).map(|i| fmt(2 * i)).collect()))])),
],
);
let started = std::time::Instant::now();
let d = open(vec![artistic(F::Obj(tag(b"Stry"), vec![(tag(b"Blok"), F::Shared(vec![blk]))]))]);
let Kind::Text(t) = &d.spreads[0].nodes[0].kind else { panic!("text") };
assert_eq!(t.runs.len(), n);
// (About half a second; quadratic matching took over half a minute on the same machine.)
assert!(started.elapsed().as_secs() < 10, "{:?}", started.elapsed());
}
/// A shape node of `size` px.
fn rect(size: f64) -> F {
F::Obj(tag(b"ShpN"), vec![(tag(b"ShpB"), F::F64s(vec![0.0, 0.0, size, size])), (tag(b"Shpe"), F::Obj(tag(b"ShNR"), vec![]))])
}
/// An instance of the embedded document in archive entry `edc/1`, with `children` of its own.
fn embedded(children: Vec<F>) -> F {
let content = F::Obj(tag(b"EmbC"), vec![(tag(b"EmbC"), F::Entry("edc/1".into()))]);
let mut fields = vec![(tag(b"Bitm"), F::Obj(tag(b"EmbR"), vec![(tag(b"EmCn"), content)]))];
if !children.is_empty() {
fields.push((tag(b"Chld"), F::Shared(children)));
}
F::Obj(tag(b"EmbN"), fields)
}
fn embedding(inner: &[u8], nodes: Vec<F>) -> Vec<u8> {
let mut entry = b"EmDc\0\0\0\0".to_vec();
entry.extend_from_slice(inner);
file(nodes, &[("edc/1", &entry)])
}
#[test]
fn an_embedded_documents_content_stays_with_its_node() {
// Read before the node's own children, which must not take it.
let inner = file(vec![rect(10.0), rect(20.0)], &[]);
let d = read(&embedding(&inner, vec![embedded(vec![rect(5.0)])]), Limits::default()).unwrap();
let node = &d.spreads[0].nodes[0];
assert_eq!(node.children.len(), 3, "its own child and the embedded document's two shapes");
assert!(node.children.iter().all(|c| c.children.is_empty()));
}
#[test]
fn embedding_one_document_many_times_shares_one_budget() {
// 2000 shapes, embedded 300 times and read twice each: 1.2 million nodes, past the 500 000
// a whole read may take (each embedding used to have an allowance of its own).
let inner = file((0..2000).map(|_| rect(10.0)).collect(), &[]);
let outer = embedding(&inner, (0..300).map(|_| embedded(Vec::new())).collect());
assert!(matches!(read(&outer, Limits::default()), Err(Error::Limit(_))));
// Embedded fewer times, it opens.
let d = read(&embedding(&inner, (0..50).map(|_| embedded(Vec::new())).collect()), Limits::default()).unwrap();
assert_eq!(all(&d.spreads[0].nodes).len(), 50 + 50 * 2000);
}
+20
View File
@@ -0,0 +1,20 @@
//! Integration tests: one test binary for the crate, one module per area.
// Unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
#[path = "../support/corpus.rs"]
mod corpus;
#[path = "../support/features.rs"]
mod features;
#[cfg(feature = "synth")]
#[path = "../support/text_fixture.rs"]
mod text_fixture;
#[cfg(feature = "synth")]
mod import_limits;
#[cfg(feature = "synth")]
mod outline_limits;
mod raster;
mod real_files;
#[cfg(feature = "synth")]
mod text;
@@ -1,7 +1,5 @@
//! Original synthetic graph tests for bounded `.af` outline expansion. These model the reader's
//! accepted schema, without using Affinity software, third-party parser code or external assets.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Document, Limits,
@@ -1,7 +1,6 @@
//! Pixel retention on current Affinity 3 documents published under MIT by Patchy.
//! Assertions use this reader's archive/stream and the original embedded image bytes;
//! no third-party Affinity parser, source code or application is used as an oracle.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use vectorcraft_affinity::{
Archive, Limits,
@@ -9,8 +8,7 @@ use vectorcraft_affinity::{
stream,
};
#[path = "support/corpus.rs"]
mod corpus;
use crate::corpus;
fn images(nodes: &[Node], out: &mut Vec<Image>) {
for node in nodes {
@@ -103,13 +101,16 @@ fn rotated_scaled_pixels_keep_the_full_affine_transform() {
}
#[test]
fn cmyk_and_lab_losses_are_reported() {
fn cmyk_pixels_stay_cmyk_and_lab_losses_are_reported() {
// CMYK layers keep their inks for the importer to convert through a colour profile.
let Some((_, cmyk, images)) = read_fixture("patchy-cmyk.af") else { return };
assert_eq!(images.len(), 1);
assert_eq!((images[0].width, images[0].height), (64, 48));
let Pixels::Rgba8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(crc32fast::hash(pixels), 0x5872bb76);
assert_eq!(cmyk.warnings, ["CMYK pixel layers (converted to RGB without the document's colour profile)"]);
let Pixels::Cmyka8(pixels) = &images[0].pixels else { panic!() };
assert_eq!(pixels.len(), 64 * 48 * 5);
assert_eq!(crc32fast::hash(pixels), 0x56b1f659);
assert!(cmyk.warnings.is_empty(), "{:?}", cmyk.warnings);
assert_eq!(cmyk.cmyk_profile, None, "this file embeds no profile");
let (_, lab, images) = read_fixture("patchy-lab.af").unwrap();
assert!(images.is_empty());
assert_eq!(lab.warnings, ["pixel layers in grey, Lab or 32-bit formats"]);
@@ -2,16 +2,13 @@
//! `corpus/affinity`, or any folder of `.af`/`.afdesign`/`.afphoto`/`.afpub` files named by
//! `AFFINITY_SAMPLES` (searched recursively). CI requires a complete, checksum-verified pinned
//! corpus via `AFFINITY_CORPUS_REQUIRED=1`; ordinary local runs may skip an absent corpus.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use vectorcraft_affinity::{Archive, Limits, stream};
#[path = "support/corpus.rs"]
mod corpus;
#[path = "support/features.rs"]
mod features;
use crate::corpus;
use crate::features;
fn files(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
@@ -90,7 +87,7 @@ fn public_documents_map_to_the_model() {
#[test]
fn affinity3_feature_retention_matches_pinned_signatures() {
let Some(dir) = corpus::pinned() else { return };
let expected = include_str!("support/affinity3-signatures.txt");
let expected = include_str!("../support/affinity3-signatures.txt");
let mut checked = std::collections::BTreeSet::new();
for line in expected.lines().filter(|l| !l.starts_with('#') && !l.is_empty()) {
let (name, signature) = line.split_once('\t').expect("signature row must have a tab");
@@ -108,7 +105,7 @@ fn affinity3_feature_retention_matches_pinned_signatures() {
doc.saved_by
);
}
let manifest = include_str!("../../../xtask/affinity-corpus.sha256");
let manifest = include_str!("../../../../xtask/affinity-corpus.sha256");
let af_names: std::collections::BTreeSet<_> =
manifest.lines().filter_map(|l| l.split_once(" ")).map(|(_, n)| n).filter(|n| n.ends_with(".af")).collect();
assert_eq!(checked, af_names, "every pinned .af file needs a reviewed structural baseline");
@@ -1,17 +1,11 @@
//! Synthetic `.af` version-12 text regression oracles; see support/text_fixture.rs for provenance.
//! Unicode ranges below test the scalar-index contract corroborated by the pinned Affinity 3
//! mixed-run fixture. Broad complex typography still needs independent visual validation.
#![cfg(feature = "synth")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod support {
pub mod corpus;
pub mod text_fixture;
}
use support::text_fixture::*;
use crate::text_fixture::*;
use vectorcraft_affinity::{
Document, Limits,
model::{Align, Kind, Text},
model::{Align, Kind, Paragraph, Text},
paint::{Color, Paint},
synth::{F, tag},
};
@@ -108,7 +102,7 @@ fn mixed_paragraph_alignments_are_explicitly_reported() {
let d = read(vec![block(utf8("one\u{2029}"), vec![run(4, None)], &[2]), block(utf8("two"), vec![run(3, None)], &[0, 1])]);
assert_eq!(content(text(&d)), "one\u{2029}two");
assert_eq!(text(&d).align, Align::Right);
assert!(warning(&d, "mixed paragraph alignments"));
assert!(warning(&d, "mixed paragraph formats"));
}
#[test]
@@ -179,7 +173,7 @@ fn stored_fallback_font_is_used_only_when_it_names_a_font() {
#[test]
fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
let Some(corpus) = support::corpus::pinned() else { return };
let Some(corpus) = crate::corpus::pinned() else { return };
// Files fetched at the commit and sha256 in xtask/affinity-corpus.sha256. No Patchy parser
// source was read; assertions describe this crate's parsing of its published MIT documents.
let load = |name: &str| vectorcraft_affinity::read(&std::fs::read(corpus.join(name)).unwrap(), Limits::default()).unwrap();
@@ -194,10 +188,22 @@ fn real_affinity3_text_fixtures_confirm_unicode_and_caps_fields() {
assert_eq!(texts.len(), 2);
assert!(texts[0].runs[0].all_caps);
assert_eq!(texts[1].runs[0].features, ["smcp"]);
for name in ["patchy-text-indent.af", "patchy-text-para-spacing.af"] {
// Indents and paragraph spacing are read (values in document pixels, as these fixtures set
// them in Affinity's paragraph panel).
let paragraphs = |name: &str| -> Vec<Paragraph> {
let d = load(name);
assert!(warning(&d, "paragraph indents or spacing"), "{name}: {:?}", d.warnings);
}
assert!(!warning(&d, "paragraph"), "{name}: {:?}", d.warnings);
d.spreads[0]
.nodes
.iter()
.filter_map(|n| if let Kind::Text(t) = &n.kind { Some(t) } else { None })
.flat_map(|t| t.runs.iter().map(|r| r.paragraph))
.collect()
};
let p = paragraphs("patchy-text-indent.af");
assert_eq!(p.iter().map(|p| (p.left, p.first, p.space_after)).collect::<Vec<_>>(), [(40.0, 0.0, 12.0), (0.0, 25.0, 12.0)]);
let p = paragraphs("patchy-text-para-spacing.af");
assert_eq!(p.iter().map(|p| (p.space_before, p.space_after)).collect::<Vec<_>>(), [(9.0, 17.0)]);
}
proptest::proptest! {
@@ -247,9 +253,19 @@ fn excessive_feature_settings_are_bounded_and_reported() {
assert!(warning(&d, "more than 256 text feature settings"));
}
#[test]
fn font_names_longer_than_256_bytes_are_dropped() {
// Each paragraph and run carries its font's name: a huge one would be copied with each.
let d = read(vec![block(utf8("x"), vec![run(1, Some(attrs(&"F".repeat(257), "Inter-Regular", 400, false, 12.0)))], &[])]);
let r = &text(&d).runs[0];
assert!(r.family.is_empty() && r.postscript == "Inter-Regular");
assert!(warning(&d, "font names longer than 256 bytes"));
}
#[test]
fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
let mut a = attrs(&"F".repeat(16 * 1024), &"P".repeat(16 * 1024), 400, false, 12.0);
// The longest font names kept (see `font_names_longer_than_256_bytes_are_dropped`).
let mut a = attrs(&"F".repeat(256), &"P".repeat(256), 400, false, 12.0);
if let F::Obj(_, fields) = &mut a {
let settings = F::Obj(
tag(b"OtAt"),
@@ -266,11 +282,12 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
}
let F::Obj(class, fields) = a else { panic!("attributes must be an object") };
let mut runs = vec![run(1, Some(F::Def(42, vec![class], fields)))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
// About 7.7 kB of names and features per run: the budget runs out after about 2200 runs.
runs.extend((2..=4000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(4000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
assert_eq!(content(t), "x".repeat(4000));
assert_eq!(t.runs.len(), 4000);
let metadata: usize = t
.runs
.iter()
@@ -279,7 +296,7 @@ fn shared_font_metadata_amplification_is_bounded_without_losing_characters() {
.map(|s| s.len() + std::mem::size_of::<String>())
.sum();
assert!(metadata <= 16 << 20, "shared metadata was amplified to {metadata} bytes");
assert_eq!(t.runs[0].family.len(), 16 * 1024);
assert_eq!(t.runs[0].family.len(), 256);
assert_eq!(t.runs[0].features.len(), 256);
assert!(t.runs.last().unwrap().family.is_empty());
assert!(t.runs.last().unwrap().features.is_empty());
@@ -320,3 +337,39 @@ fn shared_gradient_attributes_are_bounded_without_losing_characters() {
assert!(warning(&d, "text attribute budget exceeded"));
assert!(!warning(&d, "text without character attributes"), "budget recovery must not claim source attributes are absent");
}
#[test]
fn shared_gradient_outlines_are_bounded_without_losing_characters() {
let color =
F::Def(43, vec![tag(b"RGBA")], vec![(tag(b"_col"), F::Struct([0.2f32, 0.4, 0.6, 1.0].iter().flat_map(|v| v.to_le_bytes()).collect()))]);
let mut colors = vec![color];
colors.extend((1..1024).map(|_| F::Ref(43)));
let positions = (0..1024).map(|i| [(i as f32) / 1023.0, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect()).collect();
let pen = F::Obj(
tag(b"FilG"),
vec![(tag(b"Grad"), F::Obj(tag(b"Grad"), vec![(tag(b"Cols"), F::Shared(colors)), (tag(b"Posn"), F::Records(8, positions))]))],
);
let style = F::Obj(tag(b"LSty"), vec![(tag(b"Wght"), F::F64(2.0))]);
// `Objs` 1 and 2: the outline's paint and line style, shared by every run.
let attrs = F::Def(42, vec![tag(b"GAtt")], vec![(tag(b"Objs"), F::Shared(vec![solid(1.0), pen, style]))]);
let mut runs = vec![run(1, Some(attrs))];
runs.extend((2..=1000).map(|end| run(end, Some(F::Ref(42)))));
let d = read(vec![block(utf8(&"x".repeat(1000)), runs, &[])]);
let t = text(&d);
assert_eq!(content(t), "x".repeat(1000));
assert_eq!(t.runs.len(), 1000);
let stop_count: usize = t
.runs
.iter()
.filter_map(|r| r.stroke.as_ref())
.map(|s| match &s.paint {
Paint::Gradient(g) => g.stops.len(),
_ => 0,
})
.sum();
assert!(stop_count * std::mem::size_of::<vectorcraft_affinity::paint::Stop>() <= 16 << 20);
let Some(Paint::Gradient(first)) = t.runs[0].stroke.as_ref().map(|s| &s.paint) else { panic!("the first outline must fit") };
assert_eq!(first.stops.len(), 1024);
assert!(t.runs.last().unwrap().stroke.is_none());
assert!(warning(&d, "text attribute budget exceeded"));
}
@@ -1,33 +1,33 @@
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=df3c417b;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (114×)", "layer effects (shadows, glows, outlines, bevels…) (156×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=d0387de4;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 559, "subpaths": 881};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (545×)", "layer effects (shadows, glows, outlines, bevels…) (245×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=b966f693;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes) (54×)", "layer effects (shadows, glows, outlines, bevels…) (278×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=ac448123;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 288, "subpaths": 656};boards=[];fonts={};warnings=["brush and pressure strokes (imported as plain strokes)", "layer effects (shadows, glows, outlines, bevels…) (339×)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=10a37502;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=c65409b4;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=f1ae5644;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=f6691ff9;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
patchy-cmyk.af dpi=72;spreads=1;model_crc32=5145b5a1;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["CMYK pixel layers (converted to RGB without the document's colour profile)"]
patchy-dpi300.af dpi=300;spreads=1;model_crc32=0c23e7c8;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=090c674b;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=3f1b8b44;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (2×)"]
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=e868c992;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (3×)"]
patchy-fx.af dpi=72;spreads=1;model_crc32=5a1c34ac;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…) (5×)"]
patchy-group.af dpi=72;spreads=1;model_crc32=c217ab38;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=63ad778e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=["layer effects (shadows, glows, outlines, bevels…)"]
patchy-lab.af dpi=72;spreads=1;model_crc32=01e8a926;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=9ce9937e;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-live-filter.af dpi=72;spreads=1;model_crc32=30c5f8ee;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
patchy-rgba16.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=1bb23571;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=7828d3cd;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=7519f5fe;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=b6137f6e;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=b5527e72;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=7a2cd32d;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=["paragraph indents or spacing (imported with defaults)"]
patchy-text-indent.af dpi=72;spreads=1;model_crc32=30ee834c;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)", "paragraph indents or spacing (imported with defaults) (2×)"]
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=3d580ad2;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)"]
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=ad77772a;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-runs.af dpi=72;spreads=1;model_crc32=a8da3052;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "paragraph indents or spacing (imported with defaults)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=f8807611;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=1851fd90;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b89a91d3;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
affinity3-mexican-guy.af dpi=300;spreads=1;model_crc32=d23a6900;counts={"depth": 8, "fills": 169, "gradients": 0, "groups": 23, "nodes": 283, "opacity": 1, "segments": 1491, "shapes": 260, "strokes": 114, "subpaths": 264};boards=[];fonts={};warnings=["blur effects (left out) (156×)", "brush and pressure strokes (imported as plain strokes) (114×)"]
affinity3-mexican-man.af dpi=300;spreads=1;model_crc32=b8cecb1c;counts={"depth": 7, "fills": 277, "gradients": 2, "groups": 29, "nodes": 903, "segments": 3225, "shapes": 874, "strokes": 540, "subpaths": 881};boards=[];fonts={};warnings=["blur effects (left out) (244×)", "brush and pressure strokes (imported as plain strokes) (534×)"]
affinity3-mexican-woman.af dpi=300;spreads=1;model_crc32=23d1e39c;counts={"depth": 9, "fills": 527, "gradients": 0, "groups": 28, "nodes": 595, "segments": 3562, "shapes": 567, "strokes": 60, "subpaths": 570};boards=[];fonts={};warnings=["blur effects (left out) (278×)", "brush and pressure strokes (imported as plain strokes) (54×)"]
affinity3-playing-cards.af dpi=300;spreads=1;model_crc32=cafb7572;counts={"depth": 5, "fills": 363, "gradients": 0, "groups": 758, "nodes": 1350, "segments": 4864, "shapes": 592, "strokes": 17, "subpaths": 656};boards=[];fonts={};warnings=["blur effects (left out) (309×)", "brush and pressure strokes (imported as plain strokes)"]
patchy-adjust-curves.af dpi=72;spreads=1;model_crc32=36ab962f;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-adjust-hsl.af dpi=72;spreads=1;model_crc32=e2eac05e;counts={"depth": 1, "images": 1, "nodes": 2, "unsupported": 1};boards=[];fonts={};warnings=["adjustment layers"]
patchy-artboards.af dpi=72;spreads=1;model_crc32=0037a28e;counts={"artboards": 2, "depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2};boards=["board-a:0.0000,0.0000,80.0000,60.0000", "board-b:100.0000,10.0000,160.0000,70.0000"];fonts={};warnings=[]
patchy-blend-affinity.af dpi=72;spreads=1;model_crc32=8497c917;counts={"depth": 2, "fills": 8, "gradients": 0, "images": 1, "layers": 1, "nodes": 10, "segments": 24, "shapes": 8, "strokes": 0, "subpaths": 8};boards=[];fonts={};warnings=["an unknown blend mode (imported as Normal) (2×)"]
patchy-cmyk.af dpi=72;spreads=1;model_crc32=56754ce9;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-dpi300.af dpi=300;spreads=1;model_crc32=2561f9d3;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-embedded-jpeg.af dpi=72;spreads=1;model_crc32=ab4b6fbf;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-fx-blur.af dpi=72;spreads=1;model_crc32=29b5083b;counts={"depth": 1, "images": 2, "nodes": 2};boards=[];fonts={};warnings=["blur effects (left out) (2×)"]
patchy-fx-gradient.af dpi=72;spreads=1;model_crc32=96cd10be;counts={"depth": 1, "images": 3, "nodes": 3};boards=[];fonts={};warnings=[]
patchy-fx.af dpi=72;spreads=1;model_crc32=7c8d4d20;counts={"depth": 1, "images": 5, "nodes": 5};boards=[];fonts={};warnings=[]
patchy-group.af dpi=72;spreads=1;model_crc32=dc37ab6c;counts={"depth": 2, "images": 3, "layers": 1, "nodes": 4};boards=[];fonts={};warnings=[]
patchy-incremental-chain.af dpi=72;spreads=1;model_crc32=38c9c5f4;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-lab.af dpi=72;spreads=1;model_crc32=df3a4e40;counts={"depth": 1, "nodes": 1, "unsupported": 1};boards=[];fonts={};warnings=["pixel layers in grey, Lab or 32-bit formats"]
patchy-lazy-placed.af dpi=72;spreads=1;model_crc32=8776a8a0;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-live-filter.af dpi=72;spreads=1;model_crc32=12235fe9;counts={"depth": 2, "fills": 2, "gradients": 0, "nodes": 4, "segments": 6, "shapes": 2, "strokes": 0, "subpaths": 2, "unsupported": 2};boards=[];fonts={};warnings=["adjustment layers", "unknown layers"]
patchy-rgba16.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-rgba8.af dpi=72;spreads=1;model_crc32=688b2475;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-shapes-2.af dpi=72;spreads=1;model_crc32=886e9852;counts={"depth": 1, "fills": 12, "gradients": 0, "nodes": 12, "segments": 62, "shapes": 12, "strokes": 0, "subpaths": 12};boards=[];fonts={};warnings=["cloud, heart, cog, callout, arrow, tear and other special shapes (imported as their bounding ellipse) (9×)"]
patchy-shapes.af dpi=72;spreads=1;model_crc32=c1790e97;counts={"depth": 1, "fills": 4, "gradients": 0, "nodes": 4, "segments": 28, "shapes": 4, "strokes": 0, "subpaths": 4};boards=[];fonts={};warnings=[]
patchy-text-artistic.af dpi=72;spreads=1;model_crc32=1d33192c;counts={"depth": 1, "nodes": 1, "text_chars": 5, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-caps.af dpi=72;spreads=1;model_crc32=24e52518;counts={"depth": 1, "nodes": 2, "text_chars": 21, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-frame.af dpi=72;spreads=1;model_crc32=3bff8ef9;counts={"depth": 1, "nodes": 1, "text_chars": 11, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialMT:400:false"};warnings=[]
patchy-text-indent.af dpi=72;spreads=1;model_crc32=dc36dfc5;counts={"depth": 1, "nodes": 2, "text_chars": 104, "text_runs": 2, "texts": 2};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font) (2×)"]
patchy-text-para-spacing.af dpi=72;spreads=1;model_crc32=6408f39b;counts={"depth": 1, "nodes": 1, "text_chars": 13, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-rotated.af dpi=72;spreads=1;model_crc32=3d1de559;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 1, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)"]
patchy-text-runs.af dpi=72;spreads=1;model_crc32=7820b881;counts={"depth": 1, "nodes": 1, "text_chars": 7, "text_runs": 5, "texts": 1};boards=[];fonts={"Arial:ArialNarrow:400:false", "Courier New:CourierNewPSMT:400:false", "Segoe UI Emoji:SegoeUIEmoji:400:false", "Times New Roman:TimesNewRomanPSMT:400:false"};warnings=["font width variants (exact face depends on the installed PostScript font)", "text uses a stored fallback font (imported with that font)"]
patchy-transform.af dpi=72;spreads=1;model_crc32=8d56df84;counts={"depth": 1, "images": 1, "nodes": 1};boards=[];fonts={};warnings=[]
patchy-vector-mask.af dpi=72;spreads=1;model_crc32=80ca946a;counts={"depth": 1, "fills": 2, "gradients": 0, "nodes": 2, "segments": 7, "shapes": 2, "strokes": 0, "subpaths": 2, "vector_masks": 1};boards=[];fonts={};warnings=["unknown layers attached to other layers (imported without them)"]
patchy-vector.af dpi=72;spreads=1;model_crc32=b36a9767;counts={"depth": 1, "fills": 3, "gradients": 0, "nodes": 4, "segments": 17, "shapes": 4, "strokes": 2, "subpaths": 5};boards=[];fonts={};warnings=[]
@@ -35,7 +35,12 @@ pub fn signature(doc: &Document) -> String {
}
"texts"
}
Kind::Table(t) => {
*counts.entry("table_cells").or_default() += t.cells.len();
"tables"
}
Kind::Image(_) => "images",
Kind::MasterInstance { .. } => "master_instances",
Kind::Unsupported => "unsupported",
};
*counts.entry(key).or_default() += 1;
@@ -140,7 +140,7 @@ pub const IDENTITY: [f64; 6] = [1.0, 0.0, 0.0, 0.0, 1.0, 0.0];
// Shared arrays require definitions/references, whereas inline fields use Obj. Give the original
// fixture objects independent shared IDs without making the fixture descriptions noisy.
fn shared_objects(value: &mut F, next_id: &mut u32) {
pub fn shared_objects(value: &mut F, next_id: &mut u32) {
match value {
F::Shared(items) => {
for item in items {
+46 -4
View File
@@ -3,7 +3,7 @@
use std::sync::Arc;
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{AppearanceItem, Node};
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use crate::Colorization;
@@ -43,7 +43,8 @@ fn map_paint(p: &mut Paint, mode: Colorization, key: &Color) {
}
/// Recolour `n` (recursively) for a stroke painted `stroke`, and strip brushes from its strokes so
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone.
/// brush output never recurses. Non-solid stroke paints leave the art's colours alone. Type is
/// recolored like paths: its characters' solid fills and strokes as well as its own.
pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
let key = match (mode, stroke.color()) {
(Colorization::None, _) | (_, None) => None,
@@ -65,15 +66,43 @@ pub fn colorize(n: &mut Node, mode: Colorization, stroke: &Paint) {
}
}
}
if let (Some(k), NodeKind::Text(t)) = (&key, &mut n.kind) {
for r in &mut t.runs {
map_paint(&mut r.style.fill, mode, k);
map_paint(&mut r.style.stroke, mode, k);
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
walk(Arc::make_mut(c), mode, key);
}
}
// A mask's art is drawn too: a brush on its strokes would run this brush again. Its colours
// are the mask's values, so they stay.
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
strip_brushes(Arc::make_mut(&mut m.art));
}
}
walk(n, mode, key);
}
/// Strip the brushes from every stroke under `n`, its masks' art included.
fn strip_brushes(n: &mut Node) {
for it in &mut n.appearance.items {
if let AppearanceItem::Stroke(s) = it {
s.brush = None;
}
}
if let Some(ch) = n.children_mut() {
for c in ch.iter_mut() {
strip_brushes(Arc::make_mut(c));
}
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) {
strip_brushes(Arc::make_mut(&mut m.art));
}
}
/// Mix every solid paint under `n` towards `color` by `amount` (0..1) — the Symbol Stainer.
pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
let amount = amount.clamp(0.0, 1.0);
@@ -88,8 +117,10 @@ pub fn tint_node(n: &mut Node, color: &Color, amount: f32) {
}
if let vectorcraft_doc::NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if let Paint::Solid { color: c, .. } = &mut r.style.fill {
*c = c.lerp(color, amount);
for p in [&mut r.style.fill, &mut r.style.stroke] {
if let Paint::Solid { color: c, .. } = p {
*c = c.lerp(color, amount);
}
}
}
}
@@ -119,6 +150,8 @@ pub fn instance_art(art: &Node, inst: &Node) -> Node {
/// The solid colours of the fills and strokes under `nodes`, most used first (then in the order
/// they first appear): the colours Brush Options offers as Hue Shift's key colour, the first being
/// its default.
/// Type's characters count as well: each run's fill, and its stroke when the stroke is wider
/// than 0.
pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
fn walk(n: &Node, seen: &mut std::collections::HashMap<String, (usize, usize, Color)>) {
for it in &n.appearance.items {
@@ -131,6 +164,15 @@ pub fn art_colors<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Vec<Color> {
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
if let NodeKind::Text(t) = &n.kind {
for r in &t.runs {
let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
for c in [Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color) {
let order = seen.len();
seen.entry(c.to_hex()).or_insert((0, order, c)).0 += 1;
}
}
}
for c in n.children().into_iter().flatten() {
walk(c, seen);
}
+33 -7
View File
@@ -413,6 +413,8 @@ impl Bristle {
pub const SIZE_RANGE: (f64, f64) = (0.1, 1296.0);
pub const LENGTH_RANGE: (f64, f64) = (25.0, 300.0);
pub const PERCENT_RANGE: (f64, f64) = (1.0, 100.0);
/// The most characters `brush.new` and `brush.options` take for a new bristle brush name.
pub const MAX_NAME_CHARS: usize = 31;
}
impl Default for Bristle {
@@ -456,6 +458,31 @@ impl Brush {
}
}
/// A brush definition from JSON (a stored library entry or a command's params), its values
/// kept within their ranges ([`Brush::sanitize`]). A Hue Shift colorization without a `key`
/// takes the color the art's fills and strokes use most (the first of [`art_colors`]), or
/// black when they use no solid color.
pub fn from_json(mut v: serde_json::Value) -> Result<Brush, serde_json::Error> {
let keyless = v.get("colorization").is_some_and(|c| {
c.get("method").and_then(serde_json::Value::as_str) == Some("hueShift") && c.get("key").is_none_or(serde_json::Value::is_null)
});
if keyless {
// Read the art with the colorization field left out.
let mut plain = v.clone();
if let Some(o) = plain.as_object_mut() {
o.remove("colorization");
}
let b: Brush = serde_json::from_value(plain)?;
let key = art_colors(b.art()).first().copied().unwrap_or(vectorcraft_color::Color::BLACK);
if let Some(c) = v.get_mut("colorization").and_then(serde_json::Value::as_object_mut) {
c.insert("key".into(), serde_json::to_value(key)?);
}
}
let mut b: Brush = serde_json::from_value(v)?;
b.sanitize();
Ok(b)
}
/// Keep every value of the definition within its range (definitions come from files and
/// commands, so they are untrusted): numbers are clamped, non-numbers take the default.
pub fn sanitize(&mut self) {
@@ -517,14 +544,9 @@ pub fn library(doc: &Document) -> Vec<Brush> {
}
/// Parse a stored library value (malformed entries are skipped, values kept within their
/// ranges — see [`Brush::sanitize`]).
/// ranges; see [`Brush::from_json`]).
pub fn parse_library(v: &serde_json::Value) -> Vec<Brush> {
let parse = |b: &serde_json::Value| {
let mut b: Brush = serde_json::from_value(b.clone()).ok()?;
b.sanitize();
Some(b)
};
v.as_array().map(|a| a.iter().filter_map(parse).collect()).unwrap_or_default()
v.as_array().map(|a| a.iter().filter_map(|b| Brush::from_json(b.clone()).ok()).collect()).unwrap_or_default()
}
/// Store `lib` as the document's brush library.
@@ -667,6 +689,10 @@ fn leave_out_brushed(doc: &Document, nodes: &mut Vec<Node>, known: &mut Known) {
/// with the stroke's opacity folded into the piece opacity. `None` if nothing is brushed.
pub fn node_pieces(doc: &Document, n: &Node) -> Option<Vec<Node>> {
let bp = node_bezpath(n)?;
// The library is parsed for a stroke that names a brush, not for every path.
if !n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if st.brush.is_some())) {
return None;
}
let lib = library(doc);
let mut out = vec![];
let mut any = false;
+170
View File
@@ -308,6 +308,63 @@ fn tints_colorization_uses_stroke_colour() {
assert_eq!(white.appearance.fill_paint().color().unwrap().to_rgb(), [1.0, 1.0, 1.0]);
}
/// Point type at (0, 0) whose characters are painted `fill` and `stroke` (1 pt wide).
fn type_art(fill: Paint, stroke: Paint) -> Node {
use vectorcraft_doc::{CharStyle, TextObject};
let style = CharStyle { fill, stroke, stroke_width: 1.0, ..CharStyle::default() };
Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(0.0, 0.0), "Ag", style))))
}
/// The characters' fills and strokes of every piece of type in `nodes`.
fn run_paints(nodes: &[Node]) -> Vec<(Paint, Paint)> {
let mut out = vec![];
for n in nodes {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
out.extend(t.runs.iter().map(|r| (r.style.fill.clone(), r.style.stroke.clone())));
}
});
}
out
}
/// A scatter brush's colorization recolors the type in its art as it recolors the art's paths.
#[test]
fn scatter_colorization_recolors_type() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = StrokeLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), 1.0);
let mut path = rect_art(10.0, 4.0);
path.appearance.set_fill(red.clone());
let art = Node::group(NodeId(0), vec![Arc::new(path), Arc::new(type_art(red.clone(), red.clone()))]);
let b = Brush { name: "s".into(), kind: BrushKind::Scatter(Scatter { art, colorization: Colorization::Tints, ..Default::default() }) };
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
let path_fill = out[0].children().unwrap()[0].appearance.fill_paint();
assert_ne!(path_fill, red);
let paints = run_paints(&out);
assert_eq!(paints.len(), out.len());
assert!(paints.iter().all(|p| *p == (path_fill.clone(), path_fill.clone())), "{paints:?}");
// Black type takes the stroke color itself.
let black = Paint::solid(Color::BLACK);
let b = Brush {
name: "s".into(),
kind: BrushKind::Scatter(Scatter { art: type_art(black.clone(), black), colorization: Colorization::Tints, ..Default::default() }),
};
let out = stroke_pieces(&Document::new(1.0, 1.0), &b, &line(0.0, 0.0, 100.0, 0.0), &blue);
assert!(!out.is_empty());
assert!(run_paints(&out).iter().all(|p| *p == (blue.paint.clone(), blue.paint.clone())));
}
/// The Symbol Stainer mixes the characters' strokes of type toward the stain, as it mixes their fills.
#[test]
fn stain_mixes_type_strokes_too() {
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
let blue = Color::rgb(0.0, 0.0, 1.0);
let mut art = type_art(red.clone(), red);
tint_node(&mut art, &blue, 1.0);
assert_eq!(run_paints(&[art]), [(Paint::solid(blue), Paint::solid(blue))]);
}
#[test]
fn library_defaults_store_and_find() {
let mut d = Document::new(100.0, 100.0);
@@ -557,11 +614,46 @@ fn art_colors_list_the_most_used_first() {
|c: Color| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(Paint::solid(c), Paint::None, 0.0));
let g = Node::group(NodeId(0), vec![Arc::new(path(red)), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red]);
// Type counts its characters' fills and drawn strokes.
let text = type_art(Paint::solid(red), Paint::solid(red));
let g = Node::group(NodeId(0), vec![Arc::new(path(blue)), Arc::new(text.clone())]);
assert_eq!(art_colors([&g]), vec![red, blue]);
let NodeKind::Text(mut t) = text.kind else { panic!("not type") };
t.runs[0].style.stroke_width = 0.0;
let text = Node::new(NodeId(0), NodeKind::Text(t));
let g = Node::group(NodeId(0), vec![Arc::new(text), Arc::new(path(blue)), Arc::new(path(blue))]);
assert_eq!(art_colors([&g]), vec![blue, red], "a stroke 0 wide is not counted");
let b = defaults().iter().find(|b| b.name == "Chain").unwrap();
assert_eq!(b.art().len(), 3, "the side and both corner tiles");
assert!(calli(0.0, 100.0, 3.0).art().is_empty());
}
/// A Hue Shift definition without a key color takes the art's most used color, or black when the
/// art has no solid color; a key that is given is kept.
#[test]
fn hue_shift_without_a_key_takes_the_most_used_art_color() {
let red = Color::rgb(1.0, 0.0, 0.0);
let blue = Color::rgb(0.0, 0.0, 1.0);
let path = |fill: Paint| Node::path(NodeId(0), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::basic(fill, Paint::None, 0.0));
let art = Node::group(NodeId(0), vec![Arc::new(path(Paint::solid(red))), Arc::new(path(Paint::solid(blue))), Arc::new(path(Paint::solid(blue)))]);
let lib = parse_library(&serde_json::json!([
{"name": "s", "type": "scatter", "art": art, "colorization": {"method": "hueShift"}},
{"name": "a", "type": "art", "art": art, "colorization": {"method": "hueShift", "key": null}},
{"name": "p", "type": "pattern", "side": art, "colorization": {"method": "hueShift", "key": red}},
{"name": "n", "type": "art", "art": path(Paint::None), "colorization": {"method": "hueShift"}},
]));
let keys: Vec<(&str, Colorization)> = lib.iter().map(|b| (b.name.as_str(), b.kind.colorization())).collect();
assert_eq!(
keys,
vec![
("s", Colorization::HueShift { key: blue }),
("a", Colorization::HueShift { key: blue }),
("p", Colorization::HueShift { key: red }),
("n", Colorization::HueShift { key: Color::BLACK }),
]
);
}
/// Brush pieces draw no brushed stroke, also not by name: a symbol instance whose art is brushed
/// is left out, a pattern paint whose tile is brushed becomes no paint, and plain ones stay.
#[test]
@@ -595,3 +687,81 @@ fn pieces_leave_out_what_draws_a_brushed_stroke_by_name() {
assert_eq!(fills, [Paint::Pattern { pattern: "plain".into(), xf: vectorcraft_geom::Affine::IDENTITY }, Paint::None]);
}
}
/// Anchors a brushed stroke emitted, which is what the output budget counts.
fn anchors(nodes: &[Node]) -> usize {
nodes
.iter()
.map(|n| match &n.kind {
NodeKind::Path { path, .. } => path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>(),
_ => 0,
})
.sum()
}
/// A path of `subpaths` long parallel lines, each long enough to want more tiles than the
/// per-run cap allows.
fn many_long_subpaths(subpaths: usize) -> BezPath {
let mut bp = BezPath::new();
for i in 0..subpaths {
let y = i as f64 * 50.0;
bp.move_to((0.0, y));
bp.line_to((300_000.0, y));
}
bp
}
/// #3594: the per-factor caps are per track, and a path has as many tracks as it has subpaths,
/// so they multiply instead of bounding. Measured before the budget: 20,000 nodes and 320,000
/// anchors for one subpath, and exactly N times that for N, so sixteen gave 5,120,000.
#[test]
fn pattern_output_is_capped_across_subpaths_not_only_per_run() {
let brush = pattern_brush(PatternFit::Stretch, false);
let one = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(1), &stroke(1.0));
assert_eq!(one.len(), 20_000, "one track still fills its own per-run cap");
let a1 = anchors(&one);
assert!(a1 < 2_000_000, "one track stays under the total budget: {a1}");
// Sixteen tracks would be sixteen times that without a total budget.
// Four tracks are 4 x 320,000 anchors, still inside the budget, so nothing is cut: the
// budget must not bite before the per-factor caps have actually multiplied past it.
let four = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(4), &stroke(1.0));
assert_eq!(four.len(), 4 * 20_000, "four tracks are under the budget and keep every piece");
assert_eq!(anchors(&four), 4 * a1);
// Sixteen would be 5,120,000 anchors. The budget is what stops it, not the per-run caps.
let many = stroke_pieces(&Document::new(1.0, 1.0), &brush, &many_long_subpaths(16), &stroke(1.0));
let an = anchors(&many);
assert!(an <= 2_000_000, "sixteen subpaths must not exceed the budget: {an}");
assert!(an > anchors(&four), "but more is drawn than four tracks' worth: {an}");
assert!(many.len() < 16 * 20_000, "and fewer pieces than the per-run caps alone would allow: {}", many.len());
}
/// The same for scatter, whose per-track guard is also 20,000 copies.
#[test]
fn scatter_output_is_capped_across_subpaths() {
let b = Brush {
name: "s".into(),
kind: BrushKind::Scatter(Scatter { art: rect_art(4.0, 4.0), colorization: Colorization::None, ..Default::default() }),
};
let few = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(1), &stroke(1.0));
let per_track = anchors(&few);
assert!(per_track > 0, "one track draws something");
// Enough tracks that the per-track guards alone would pass the budget.
let tracks = 2_000_000 / per_track + 4;
let many = stroke_pieces(&Document::new(1.0, 1.0), &b, &many_long_subpaths(tracks), &stroke(1.0));
let an = anchors(&many);
assert!(an <= 2_000_000, "{tracks} tracks must not exceed the budget: {an}");
assert!(an > per_track, "but more is drawn than one track's worth: {an}");
assert!(many.len() < tracks * few.len().max(1), "fewer copies than the per-track guards alone would allow");
}
/// A short, ordinary stroke is untouched by the budget: it is the absurd ones that are cut.
#[test]
fn an_ordinary_stroke_is_unaffected_by_the_budget() {
let brush = pattern_brush(PatternFit::Stretch, false);
let sq = shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)).to_bezpath();
let out = stroke_pieces(&Document::new(1.0, 1.0), &brush, &sq, &stroke(1.0));
assert_eq!(out.len(), 40, "ten tiles on each of the four sides, as before the budget");
assert!(anchors(&out) < 2_000_000);
}
+14 -1
View File
@@ -156,6 +156,9 @@ impl Track {
}
}
/// The most flattened elements of a path kept.
const MAX_FLATTENED: usize = 4_000_000;
/// Flatten `bp` into tracks (one per subpath) with `tol` accuracy.
pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
let mut out = vec![];
@@ -165,8 +168,18 @@ pub fn tracks(bp: &BezPath, tol: f64) -> Vec<Track> {
out.push(t);
}
};
// Far outside any drawing a curve flattens to millions of points; past a few million, the
// rest are not kept.
let b = bp.control_box();
if !(b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()) <= 1e9) {
return out;
}
let mut els = vec![];
kurbo::flatten(bp.iter(), tol.max(1e-4), |el| els.push(el));
kurbo::flatten(bp.iter(), tol.max(1e-4), |el| {
if els.len() < MAX_FLATTENED {
els.push(el);
}
});
for el in els {
match el {
PathEl::MoveTo(p) => {
+92 -11
View File
@@ -16,6 +16,21 @@ use crate::{ArtBrush, ArtScale, Direction, PatternBrush, PatternFit, Scatter, Va
const MAX_SEG: f64 = 1.5;
/// Hard cap on subdivisions per art segment.
const MAX_SUB: usize = 4000;
/// Anchors one brushed stroke may emit in total, across every track, tile and copy.
///
/// Each factor is capped on its own (20,000 tiles a run, [`MAX_SUB`] subdivisions a segment,
/// 20,000 copies a track), but those caps are per track and a path has as many tracks as it has
/// subpaths, so they multiply rather than bound: one track at its cap measures 20,000 nodes and
/// 320,000 anchors, and sixteen subpaths measure exactly sixteen times that (#3594). This is the
/// cap on the product. The per-factor caps still shape each one.
///
/// The same figure as the stacked-effects budget (`effects::distort::MAX_COPY_ANCHORS`, #3578),
/// and bounded the same way: per brushed stroke rather than per document, so many strokes that
/// each reach it still add up. Six times what one track at its own cap draws, so it bites only
/// where the caps were multiplying.
const MAX_ANCHORS: usize = 2_000_000;
/// Nodes [`anchors_of`] walks into before it gives up and charges the whole budget.
const MAX_WALK: usize = 10_000;
/// Bend every path under `n` through `f`. Paths are flattened with `flat_tol` (art space) and
/// segments subdivided so none is longer than `max_du` along x. Stroke widths scale by
@@ -235,8 +250,11 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
let (aw, ah) = (b.width().max(1e-3), b.height().max(1e-3));
let mut rng = Rng::new(seed_of(bp, name));
let [size_mode, spacing_mode, scatter_mode, rotation_mode] = sc.modes();
let mut out = vec![];
let mut out = Budget::default();
for t in tracks(bp, 0.05) {
if out.full() {
break;
}
let l = t.len();
// The pen pressure at arc length `at` (half way without a recorded one).
let pressure_at = |at: f64| pressure.map_or(PressureProfile::MID, |p| p.at(if l > 1e-9 { at / l } else { 0.0 }));
@@ -270,10 +288,61 @@ pub(crate) fn scatter(sc: &Scatter, bp: &BezPath, weight: f64, pressure: Option<
let xf = Affine::translate(place.to_vec2()) * Affine::rotate(base - rot) * Affine::scale(size) * Affine::translate(-b.center().to_vec2());
let mut n = sc.art.clone();
n.transform(xf, true);
out.push(n);
out.take(Some(n));
if out.full() {
break;
}
}
}
out
out.out
}
/// Anchors `n` and its children hold. Walked with a stack rather than by recursion, and a tree
/// deeper or wider than [`MAX_WALK`] nodes is charged the whole budget rather than undercounted,
/// so an art tile cannot buy room by nesting.
fn anchors_of(n: &Node) -> usize {
let (mut total, mut seen) = (0usize, 0usize);
let mut stack = vec![n];
while let Some(n) = stack.pop() {
seen += 1;
if seen > MAX_WALK {
return MAX_ANCHORS;
}
if let NodeKind::Path { path, .. } = &n.kind {
total = total.saturating_add(path.subpaths.iter().map(|s| s.anchors.len()).sum::<usize>());
}
if let Some(c) = n.children() {
stack.extend(c.iter().map(Arc::as_ref));
}
}
total
}
/// Brush output collected under [`MAX_ANCHORS`].
///
/// A piece is taken whole or not at all, so the art a brush places is never half-drawn; the
/// total is therefore the budget plus at most one piece.
#[derive(Default)]
struct Budget {
out: Vec<Node>,
anchors: usize,
}
impl Budget {
/// Take a piece if there is room. `None` (art that could not be placed) costs nothing.
fn take(&mut self, n: Option<Node>) {
let Some(n) = n else { return };
if self.full() {
return;
}
self.anchors = self.anchors.saturating_add(anchors_of(&n));
self.out.push(n);
}
/// Whether the budget is spent, so the caller can stop walking the path.
fn full(&self) -> bool {
self.anchors >= MAX_ANCHORS
}
}
/// A tile placed on [s0, s1] of the track (stretched along, `cross` across).
@@ -291,8 +360,11 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
let end = pb.end.as_ref().and_then(orient);
let tw = side.1.width() * k;
let gap = tw * pb.spacing.max(0.0) / 100.0;
let mut out = vec![];
let mut out = Budget::default();
for t in tracks(bp, 0.05) {
if out.full() {
break;
}
let l = t.len();
if l <= 1e-9 {
continue;
@@ -314,7 +386,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
* Affine::translate(-bb.center().to_vec2());
let mut n = art.clone();
n.transform(xf, true);
out.push(n);
out.take(Some(n));
}
// Runs between corners (closed tracks wrap past the end).
let mut bounds: Vec<f64> = corners.iter().map(|c| c.1).collect();
@@ -346,7 +418,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
{
let w = st.1.width() * k;
if b - a > w {
out.extend(tile_on(&t, st, a, a + w, k));
out.take(tile_on(&t, st, a, a + w, k));
a += w;
}
}
@@ -356,7 +428,7 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
{
let w = en.1.width() * k;
if b - a > w {
out.extend(tile_on(&t, en, b - w, b, k));
out.take(tile_on(&t, en, b - w, b, k));
b -= w;
}
}
@@ -372,7 +444,10 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
let (w, g) = (tw * f, gap * f);
for i in 0..n.min(20000) {
let s0 = a + i as f64 * (w + g);
out.extend(tile_on(&t, &side, s0, s0 + w, k));
out.take(tile_on(&t, &side, s0, s0 + w, k));
if out.full() {
break;
}
}
}
PatternFit::AddSpace => {
@@ -381,7 +456,10 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
let lead = if n == 1 { (avail - tw) / 2.0 } else { 0.0 };
for i in 0..n.min(20000) {
let s0 = a + lead + i as f64 * (tw + g.max(0.0));
out.extend(tile_on(&t, &side, s0, s0 + tw, k));
out.take(tile_on(&t, &side, s0, s0 + tw, k));
if out.full() {
break;
}
}
}
PatternFit::Approximate => {
@@ -390,11 +468,14 @@ pub(crate) fn pattern(pb: &PatternBrush, bp: &BezPath, weight: f64) -> Vec<Node>
let lead = (avail - used) / 2.0;
for i in 0..n.min(20000) {
let s0 = a + lead + i as f64 * unit;
out.extend(tile_on(&t, &side, s0, s0 + tw, k));
out.take(tile_on(&t, &side, s0, s0 + tw, k));
if out.full() {
break;
}
}
}
}
}
}
out
out.out
}
+44 -7
View File
@@ -3,7 +3,7 @@
//! dimensions, as groups), and colours, lineweights, linetypes and transparency resolved through
//! layers and blocks.
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::rc::Rc;
use std::sync::Arc;
@@ -23,12 +23,17 @@ const PT_PER_MM: f64 = 72.0 / 25.4;
const THINNEST: f64 = 0.25;
/// The most objects an import makes.
const MAX_NODES: usize = 2_000_000;
/// The most block visits (one per insert copy) an import makes, whatever they produce.
const MAX_VISITS: usize = 10_000_000;
/// The most path elements an import makes (blocks drawn as art are copied for every placement).
const MAX_ELEMENTS: usize = if cfg!(test) { 1_000_000 } else { 4_000_000 };
/// How far from the origin art may lie (points): the canvas.
pub(crate) const MAX_EXTENT: f64 = 4.0e6;
const NESTED: &str = "blocks nested more than 8 deep, or inside themselves, are left out";
const XREFS: &str = "blocks referencing other drawings (external references) are left out";
const TOO_MANY: &str = "the drawing has more than 2,000,000 objects: the rest are left out";
const TOO_MANY_POINTS: &str = "the drawing has more than 4,000,000 path points: the rest are left out";
/// What a block's art takes from the insert that places it: its colour, lineweight, linetype
/// and opacity where the art says "by block", and its layer for art on layer 0.
@@ -52,6 +57,9 @@ pub(crate) struct Build<'d, 'a> {
sensitive: HashMap<String, bool>,
/// Symbol names by block and what it inherits (`None`: the block has no art).
symbols: HashMap<(String, Inherit), Option<String>>,
/// Names of the symbols made so far, and the next suffix to try for each label.
symbol_names: HashSet<String>,
next_suffix: HashMap<String, usize>,
/// Blocks being drawn, outermost first.
stack: Vec<String>,
/// Document space per drawing unit, and its symbols' (no translation).
@@ -60,6 +68,9 @@ pub(crate) struct Build<'d, 'a> {
/// Lineweights are multiplied by this.
lw_scale: f64,
nodes: usize,
visits: usize,
/// Path elements made so far.
elements: usize,
warnings: Vec<String>,
}
@@ -108,11 +119,15 @@ impl<'d, 'a> Build<'d, 'a> {
bounds: HashMap::new(),
sensitive: HashMap::new(),
symbols: HashMap::new(),
symbol_names: HashSet::new(),
next_suffix: HashMap::new(),
stack: vec![],
map: Affine::IDENTITY,
symbol_map: Affine::IDENTITY,
lw_scale: 1.0,
nodes: 0,
visits: 0,
elements: 0,
warnings: vec![],
}
}
@@ -362,7 +377,8 @@ impl<'d, 'a> Build<'d, 'a> {
let k = self.d.header.ltscale * p.ltscale * scale;
// Dashes (positive), gaps (negative) and dots (0), merged into dash, gap, dash, gap…
let mut out: Vec<f64> = vec![];
for v in pattern {
// (A linetype has a few elements; every path keeps its own copy of the dashes.)
for v in pattern.iter().take(64) {
let dash = *v >= 0.0;
let len = v.abs() * k;
match out.len().is_multiple_of(2) {
@@ -442,6 +458,12 @@ impl<'d, 'a> Build<'d, 'a> {
}
fn path(&mut self, bp: &BezPath, fill: bool, width: f64, p: &Props, map: Affine, inh: &Inherit) -> Option<Node> {
let n = bp.elements().len();
if self.elements.saturating_add(n) > MAX_ELEMENTS {
self.warn(TOO_MANY_POINTS);
return None;
}
self.elements += n;
let mut bp = bp.clone();
bp.apply_affine(map);
super::curve::bounds(&bp)?;
@@ -552,6 +574,13 @@ impl<'d, 'a> Build<'d, 'a> {
/// The art of block `name` drawn by `map`, as one object.
fn art(&mut self, name: &str, map: Affine, inh: &Inherit, depth: u32) -> Option<Node> {
self.visits += 1;
if self.visits > MAX_VISITS {
if self.visits == MAX_VISITS + 1 {
self.warn(TOO_MANY);
}
return None;
}
let items = self.block(name)?;
if !self.enter(name, depth) {
return None;
@@ -584,7 +613,8 @@ impl<'d, 'a> Build<'d, 'a> {
let art = self.art(name, map, &key.1, depth);
let made = art.map(|art| {
let label = self.d.blocks.get(name).map_or(name, |b| b.name.as_str());
let symbol = unique(label, |n| self.doc.symbols.iter().any(|s| s.name == n));
let symbol = unique(label, &mut self.next_suffix, &self.symbol_names);
self.symbol_names.insert(symbol.clone());
self.doc.symbols.push(Symbol { name: symbol.clone(), art: Arc::new(art) });
symbol
});
@@ -611,10 +641,17 @@ fn rgb_color([r, g, b]: [u8; 3]) -> Color {
Color::rgb8(r, g, b)
}
/// `base`, or `base 2`, `base 3`… when taken.
fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String {
if !taken(base) {
/// `base`, or `base 2`, `base 3`… when taken; `next` remembers where each base left off.
fn unique(base: &str, next: &mut HashMap<String, usize>, taken: &HashSet<String>) -> String {
if !taken.contains(base) {
return base.to_string();
}
(2..).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| base.to_string())
let i = next.entry(base.to_string()).or_insert(2);
loop {
let name = format!("{base} {i}");
*i += 1;
if !taken.contains(&name) {
return name;
}
}
}
+30 -2
View File
@@ -17,6 +17,8 @@ const MAX_DEPTH: u32 = 12;
const MAX_ELEMENTS: usize = 200_000;
/// The highest spline degree read.
const MAX_DEGREE: usize = 11;
/// Spline weights below this fraction of the largest are read as this fraction.
const MIN_RELATIVE_WEIGHT: f64 = 1e-100;
/// A polyline vertex: its point and the bulge of the segment that starts there (the tangent of a
/// quarter of the arc's angle; positive turns counter-clockwise).
@@ -121,7 +123,9 @@ struct H {
impl H {
fn lerp(self, o: Self, t: f64) -> Self {
Self { x: self.x + (o.x - self.x) * t, y: self.y + (o.y - self.y) * t, w: self.w + (o.w - self.w) * t }
// Exact at both ends, so a weight far smaller than its neighbour's doesn't cancel to 0.
let s = 1.0 - t;
Self { x: self.x * s + o.x * t, y: self.y * s + o.y * t, w: self.w * s + o.w * t }
}
fn point(self) -> Option<Point> {
(self.w.abs() > 1e-300).then(|| Point::new(self.x / self.w, self.y / self.w)).filter(|p| p.x.is_finite() && p.y.is_finite())
@@ -139,7 +143,11 @@ pub(crate) fn nurbs(degree: usize, knots: &[f64], ctrl: &[Point], weights: Optio
if knots.windows(2).any(|w| w[1] < w[0]) {
return None;
}
let weight = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0);
let raw = |i: usize| weights.and_then(|w| w.get(i)).copied().filter(|w| *w > 0.0 && w.is_finite()).unwrap_or(1.0);
// Scaling all weights alike leaves the curve unchanged: relative to the largest, so tiny ones
// don't underflow, and floored so a weight tiny next to the others can't either.
let max_w = (0..n).map(raw).fold(0.0, f64::max);
let weight = |i: usize| (raw(i) / max_w).max(MIN_RELATIVE_WEIGHT);
let hs: Vec<H> = ctrl.iter().enumerate().map(|(i, p)| H { x: p.x * weight(i), y: p.y * weight(i), w: weight(i) }).collect();
let rational = (0..n).any(|i| (weight(i) - weight(0)).abs() > 1e-12);
let (lo, hi) = ctrl.iter().fold((Point::new(f64::MAX, f64::MAX), Point::new(f64::MIN, f64::MIN)), |(lo, hi), p| {
@@ -225,6 +233,11 @@ fn fit(bp: &mut BezPath, bez: &[H], t0: f64, t1: f64, tol: f64, depth: u32) -> O
let (p3, d3) = eval(bez, t1)?;
let h = (t1 - t0) / 3.0;
let (c1, c2) = (p0 + d0 * h, p3 - d3 * h);
// Non-finite tangents never fit, so splitting wouldn't help: a line.
if ![c1, c2].iter().all(|p| p.x.is_finite() && p.y.is_finite()) {
bp.line_to(p3);
return Some(());
}
let cubic = kurbo::CubicBez::new(p0, c1, c2, p3);
let close = (1..8).all(|i| {
let s = f64::from(i) / 8.0;
@@ -371,6 +384,21 @@ mod tests {
}
}
#[test]
fn tiny_spline_weights_flatten_to_a_handful_of_pieces() {
// Degree 5, three knot spans: fitted, not exact.
let ctrl: Vec<Point> = (0..8).map(|i| Point::new(f64::from(i) * 10.0, if i % 2 == 0 { 0.0 } else { 30.0 })).collect();
let knots = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0];
let normal = nurbs(5, &knots, &ctrl, Some(&[1.0; 8])).unwrap().elements().len();
// Scaling every weight alike leaves the curve as it is.
let tiny = nurbs(5, &knots, &ctrl, Some(&[1e-200; 8])).unwrap().elements().len();
assert!(tiny <= normal + 8, "normal weights: {normal} elements, 1e-200 weights: {tiny} elements");
// Weights tiny next to the others don't split every span to the limit either.
let mixed = [1.0, 1e-200, 1.0, 1e-200, 1.0, 1e-200, 1.0, 1e-200];
let mixed = nurbs(5, &knots, &ctrl, Some(&mixed)).unwrap().elements().len();
assert!(mixed < 500, "mixed weights: {mixed} elements");
}
#[test]
fn fit_points_make_a_smooth_curve_through_them() {
let pts = [Point::new(0.0, 0.0), Point::new(10.0, 10.0), Point::new(20.0, 0.0)];
+17 -1
View File
@@ -17,6 +17,8 @@ const DESCENT: f64 = 0.3;
const LINE_SPACING: f64 = 5.0 / 3.0;
/// The most copies one array insert (MINSERT) makes.
const MAX_CELLS: i64 = 10_000;
/// The most copies all the array inserts of a file make together.
const MAX_TOTAL_CELLS: i64 = 2_000_000;
/// Art farther than this from the origin (drawing units) is damaged data, left out.
const MAX_COORD: f64 = 1e12;
/// How far fit and aligned text may be squeezed or stretched to span their two points: at width
@@ -184,12 +186,15 @@ pub(crate) struct Converter {
/// Entity types left out, with how many.
pub skipped: BTreeMap<String, usize>,
pub warnings: Vec<String>,
/// Array insert copies made so far, against [`MAX_TOTAL_CELLS`].
cells: i64,
}
const PATTERN_HATCH: &str = "pattern hatches come in as their boundaries";
const GRADIENT_HATCH: &str = "gradient hatches are filled with their first colour";
const MESHES: &str = "3D polygon meshes and polyface meshes are left out";
const BIG_ARRAY: &str = "array inserts are limited to 10,000 copies each";
const MANY_ARRAYS: &str = "array inserts are limited to 2,000,000 copies in all";
const FAR_AWAY: &str = "entities with coordinates out of range are left out";
impl Converter {
@@ -294,6 +299,15 @@ impl Converter {
} else {
(cols, rows)
};
// Past the file-wide budget, each insert keeps only its first copy.
let left = (MAX_TOTAL_CELLS - self.cells).max(1);
let (cols, rows) = if cols * rows > left {
self.warn(MANY_ARRAYS);
(cols.min(left), (left / cols.min(left)).max(1).min(rows))
} else {
(cols, rows)
};
self.cells = self.cells.saturating_add(cols * rows);
let cells = (0..rows)
.flat_map(|r| (0..cols).map(move |c| (r, c)))
.map(|(r, c)| place * Affine::translate((c as f64 * cs, r as f64 * rs)) * Affine::scale_non_uniform(sx, sy))
@@ -536,7 +550,9 @@ fn boundary(c: &mut Cursor<'_, '_>) -> Option<BezPath> {
// The boundary objects it was made from.
if let Some(n) = c.count(97) {
for _ in 0..n {
c.opt(330);
if c.opt(330).is_none() {
break;
}
}
}
Some(bp)
+2 -1
View File
@@ -423,6 +423,7 @@ fn font_family(g: &[Pair<'_>]) -> String {
/// objects but with paper-space art has one paper layout.
fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, entities: &[Record<'_>]) -> Vec<LayoutDef> {
let mut paper: Vec<(i64, LayoutDef)> = vec![];
let mut seen = std::collections::HashSet::new();
for r in objects.iter().filter(|r| r.kind == "LAYOUT") {
// The layout's own groups follow its subclass marker.
let own = r.g.iter().position(|p| p.code == 100 && p.value.trim() == "AcDbLayout").and_then(|i| r.g.get(i + 1..)).unwrap_or(&[]);
@@ -432,7 +433,7 @@ fn layouts(objects: &[Record<'_>], block_records: &HashMap<String, String>, enti
continue;
}
let Some(block) = block else { continue };
if !paper.iter().any(|(_, l)| l.name.eq_ignore_ascii_case(name)) {
if seen.insert(name.to_ascii_lowercase()) {
paper.push((own.int_or(71, i64::MAX), LayoutDef { name: name.to_string(), block: Some(block) }));
}
}
+130
View File
@@ -380,6 +380,27 @@ fn solid_hatches_fill_and_pattern_hatches_outline() {
assert!(square.appearance.fill().is_some_and(|f| !f.paint.is_none()));
}
#[test]
fn hatch_declaring_many_absent_source_handles_parses_quickly() {
let base = hatch("1");
let at = |code: i32, from: usize| from + base[from..].iter().position(|p| p.0 == code).unwrap();
let first = at(92, 0);
let end = at(97, first) + 1;
let mut boundary = base[first..end].to_vec();
*boundary.last_mut().unwrap() = (97, "10000000");
let n = 300;
let mut e: Vec<(i32, &'static str)> = base[..first].to_vec();
e[at(91, 0)] = (91, "300");
for _ in 0..n {
e.extend_from_slice(&boundary);
}
e.extend_from_slice(&base[end..]);
let start = std::time::Instant::now();
let r = open(&entities(&e), &points());
assert!(start.elapsed() < std::time::Duration::from_millis(400), "took {:?}", start.elapsed());
assert_eq!(r.document.layers[0].children().unwrap().len(), 1, "{:?}", r.warnings);
}
/// A solid hatch: a loop of a line and a spline edge (`fit` its fit data, if any), followed by
/// `source` (the loop's source-object count and handles), and a square hole.
fn spline_edged_hatch(fit: &[(i32, &'static str)], source: &[(i32, &'static str)]) -> Vec<(i32, &'static str)> {
@@ -712,6 +733,27 @@ fn by_block_colours_make_a_symbol_per_look() {
assert_eq!(doc.symbols.len(), 1);
}
#[test]
fn many_by_block_looks_import_in_linear_time() {
let by_block: &[(i32, &str)] = &[(0, "LINE"), (8, "0"), (62, "0"), (10, "5"), (20, "5"), (11, "15"), (21, "5")];
let n = 4000;
let mut inserts: Vec<(i32, &'static str)> = vec![];
for i in 0..n {
let colour: &'static str = Box::leak((i + 1).to_string().into_boxed_str());
inserts.extend([(0, "INSERT"), (2, "Door"), (420, colour), (10, "0"), (20, "0")]);
}
let text = with_door(by_block, &inserts);
let start = std::time::Instant::now();
let doc = open(&text, &points()).document;
let took = start.elapsed();
let mut names: Vec<&str> = doc.symbols.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names.len(), n);
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), n, "names stay unique");
assert!(took < std::time::Duration::from_secs(4), "import took {took:?}");
}
#[test]
fn array_inserts_attributes_and_dimensions() {
let inserts = [
@@ -757,6 +799,48 @@ fn array_inserts_attributes_and_dimensions() {
assert_eq!(bounds(&leaves(&doc)[0]).width(), 50.0);
}
#[test]
fn nested_array_inserts_of_nothing_stop_at_the_work_budget() {
let block = |name: &'static str, inner: Option<&'static str>| {
let mut b = vec![(0, "BLOCK"), (2, name), (70, "1"), (10, "0"), (20, "0")];
if let Some(i) = inner {
b.extend([(0, "INSERT"), (2, i), (10, "0"), (20, "0"), (70, "300"), (44, "1")]);
}
b.push((0, "ENDBLK"));
b
};
let mut blocks = block("*A", Some("*B"));
blocks.extend(block("*B", Some("*C")));
blocks.extend(block("*C", None));
let top = [(0, "INSERT"), (2, "*A"), (10, "0"), (20, "0"), (70, "300"), (44, "1")];
let r = open(&drawing(&[], &[], &blocks, &top), &points());
assert!(r.warnings.iter().any(|w| w.contains("more than")), "{:?}", r.warnings);
}
#[test]
fn an_array_of_a_large_anonymous_block_stops_at_the_path_element_budget() {
// A 2,000-vertex polyline in *U1, placed in a 100 × 10 array: 2,000,000 path elements when
// every cell is copied, twice the budget tests import with.
let mut owned: Vec<(i32, String)> = vec![(0, "BLOCK".into()), (2, "*U1".into()), (70, "1".into()), (10, "0".into()), (20, "0".into())];
owned.extend([(0, "LWPOLYLINE".into()), (90, "2000".into()), (70, "0".into())]);
for i in 0..2000 {
owned.extend([(10, i.to_string()), (20, (i % 2).to_string())]);
}
owned.push((0, "ENDBLK".into()));
let blocks: Vec<(i32, &str)> = owned.iter().map(|(c, v)| (*c, v.as_str())).collect();
let inserts = [(0, "INSERT"), (2, "*U1"), (10, "0"), (20, "0"), (70, "100"), (71, "10"), (44, "2000"), (45, "2")];
let r = open(&drawing(&[], &[], &blocks, &inserts), &points());
let mut elements = 0;
r.document.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
elements += path.to_bezpath().elements().len();
}
});
assert!(elements > 0);
assert!(elements <= 1_000_000, "{elements} path elements");
assert!(r.warnings.iter().any(|w| w.contains("path points")), "{:?}", r.warnings);
}
#[test]
fn blocks_inside_themselves_are_left_out_without_hanging() {
let blocks = [
@@ -1019,3 +1103,49 @@ fn entities_extruded_along_minus_z_are_mirrored() {
let b = bounds(&leaves(&doc)[0]);
assert!((b.center().x + 50.0).abs() < 1e-6, "the centre's x is mirrored: {b:?}");
}
/// A drawing with one paper block record (handle `A`) and a LAYOUT object per name.
fn with_layouts(names: &[String]) -> Vec<u8> {
let mut s = String::from(
" 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nBLOCK_RECORD\n 0\nBLOCK_RECORD\n 5\nA\n 2\n*Paper_Space\n 0\nENDTAB\n 0\nENDSEC\n 0\nSECTION\n 2\nOBJECTS\n",
);
for n in names {
s.push_str(&format!(" 0\nLAYOUT\n100\nAcDbLayout\n 1\n{n}\n330\nA\n"));
}
s.push_str(" 0\nENDSEC\n 0\nEOF\n");
s.into_bytes()
}
#[test]
fn many_paper_layouts_are_read_in_linear_time() {
let names: Vec<String> = (0..100_000).map(|i| format!("L{i}")).collect();
let bytes = with_layouts(&names);
let t = std::time::Instant::now();
let info = info(&bytes).unwrap();
assert_eq!(info.layouts.len(), names.len() + 1);
assert!(t.elapsed() < std::time::Duration::from_secs(5), "layout de-duplication is quadratic: {:?}", t.elapsed());
}
#[test]
fn paper_layout_names_are_unique_ignoring_case() {
let names = ["Layout1", "LAYOUT1", "Other"].map(String::from);
let info = info(&with_layouts(&names)).unwrap();
assert_eq!(info.layouts, ["Model", "Layout1", "Other"]);
}
#[test]
fn many_array_inserts_of_a_missing_block_stay_bounded() {
let one = [(0, "INSERT"), (8, "0"), (2, "NOPE"), (10, "0"), (20, "0"), (70, "100"), (71, "100"), (44, "1"), (45, "1")];
let e: Vec<(i32, &str)> = (0..3000).flat_map(|_| one).collect();
let bytes = entities(&e);
let text = reader::decode(&bytes);
let (pairs, _) = reader::pairs(&text).unwrap();
let d = reader::Drawing::read(&pairs);
let model = d.layout("Model").or(d.layouts.first()).unwrap();
let mut conv = entity::Converter::default();
let items = conv.items(&d.layout_records(model));
assert_eq!(items.len(), 3000);
let total: usize = items.iter().map(|i| if let entity::Geom::Insert(ins) = &i.geom { ins.cells.len() } else { 0 }).sum();
assert!(total <= 2_000_000 + 3000, "total cells materialised: {total}");
assert!(conv.warnings.iter().any(|w| w.contains("array inserts")), "{:?}", conv.warnings);
}
+97 -34
View File
@@ -33,6 +33,9 @@ const WIDE_LINES: &str =
const STROKE_EXTRAS: &str = "stroke alignment, width profiles, arrowheads and brushes are left out (Maximize Editability writes strokes as lines)";
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
const TYPE_ITEMS: &str = "fills and strokes added to whole type objects are left out of text entities (outline the text to keep them)";
/// The warning for an image left out because it can't be decoded: the note raster exports give
/// (`vectorcraft-engine`'s `rasterfx::undecoded_warning`).
const UNDECODED: &str = "an image couldn't be decoded and is missing from the picture";
/// One segment of a subpath, cubics for every curve.
#[derive(Clone, Copy)]
@@ -165,12 +168,32 @@ fn subpaths(bp: &BezPath) -> Vec<Sub> {
out
}
/// The most pixels of an image written (it is held decoded, as RGBA and encoded at once).
const MAX_IMAGE_PIXELS: u64 = 64 << 20;
/// The most entities (handles) a drawing is written with.
const MAX_ENTITIES: u32 = 10_000_000;
/// The largest x or y a path may have to be flattened, and the most points its polygons keep.
const MAX_COORD: f64 = 1e9;
const MAX_POINTS: usize = 4_000_000;
/// `bp` flattened into polygons (each closing implicitly), consecutive repeats dropped.
fn polygons(bp: &BezPath) -> Vec<Vec<Point>> {
// Far outside any drawing, a curve flattens to millions of points at this tolerance.
let b = bp.control_box();
if !matches!(
b.x0.abs().max(b.x1.abs()).max(b.y0.abs()).max(b.y1.abs()).partial_cmp(&MAX_COORD),
Some(std::cmp::Ordering::Less | std::cmp::Ordering::Equal)
) {
return vec![];
}
let mut out: Vec<Vec<Point>> = vec![];
let mut cur: Vec<Point> = vec![];
let push = |p: Point, cur: &mut Vec<Point>| {
if cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
let mut points = 0usize;
let mut push = |p: Point, cur: &mut Vec<Point>| {
if points < MAX_POINTS && cur.last().is_none_or(|q| q.distance(p) > 1e-9) {
points += 1;
cur.push(p);
}
};
@@ -205,21 +228,37 @@ fn symbol_name(name: &str, long: bool) -> String {
s.trim().to_string()
}
/// `base` made unique among `taken` (any case) by `_2`, `_3`…, within `max` characters.
fn unique(base: &str, taken: &[&str], max: usize) -> String {
let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() };
let free = |n: &str| !taken.iter().any(|t| t.eq_ignore_ascii_case(n));
if free(&base) {
return base;
/// The names a DXF table holds (compared in any case), and the next suffix to try for each base,
/// so identical names don't each search the suffixes before.
#[derive(Default)]
struct Names {
taken: std::collections::HashSet<String>,
next: HashMap<String, usize>,
}
impl Names {
/// Names that are there already.
fn with(names: &[&str]) -> Self {
Self { taken: names.iter().map(|n| n.to_lowercase()).collect(), next: HashMap::new() }
}
(2..)
.map(|i| {
let suffix = format!("_{i}");
/// `base` made unique by `_2`, `_3`…, within `max` characters; the name is taken from now on.
fn unique(&mut self, base: &str, max: usize) -> String {
let base = if base.is_empty() { "Layer".to_string() } else { base.to_string() };
if self.taken.insert(base.to_lowercase()) {
return base;
}
let next = self.next.entry(base.to_lowercase()).or_insert(2);
loop {
let suffix = format!("_{next}");
*next += 1;
let keep: String = base.chars().take(max.saturating_sub(suffix.len())).collect();
format!("{keep}{suffix}")
})
.find(|n| free(n))
.unwrap_or(base)
let name = format!("{keep}{suffix}");
if self.taken.insert(name.to_lowercase()) {
return name;
}
}
}
}
fn overlaps(a: Rect, b: Rect) -> bool {
@@ -248,12 +287,14 @@ pub(crate) struct Scene<'a> {
e: Codes,
next: u32,
layers: Vec<Layer>,
layer_names: Names,
/// The DXF layer art goes on, and its flags.
layer: String,
flags: LayerFlags,
linetypes: Vec<LineType>,
/// (style name, font family).
styles: Vec<(String, String)>,
style_names: Names,
images: Vec<ImageDef>,
files: Vec<DxfImage>,
/// Image keys → index into `images` (`None`: left out).
@@ -280,10 +321,12 @@ impl<'a> Scene<'a> {
e: Codes::new(o.version),
next: fixed::FIRST_FREE,
layers: vec![],
layer_names: Names::with(&["0", "Defpoints"]),
layer: "0".into(),
flags: LayerFlags { off: false, locked: false, plot: true },
linetypes: vec![],
styles: vec![],
style_names: Names::with(&[crate::writer::STANDARD_STYLE]),
images: vec![],
files: vec![],
by_key: HashMap::new(),
@@ -347,6 +390,12 @@ impl<'a> Scene<'a> {
if let NodeKind::Layer { template: true, .. } = n.kind {
return;
}
// Symbols that hold symbols are written in full for each instance: depth alone doesn't
// bound how many entities that is.
if self.next.saturating_sub(fixed::FIRST_FREE) > MAX_ENTITIES {
self.warn("the drawing is too large for DXF: what follows 10 million entities was left out");
return;
}
// Hidden layers are written, switched off.
let layer = n.is_layer();
if !force && !n.visible && !layer {
@@ -449,9 +498,7 @@ impl<'a> Scene<'a> {
fn add_layer(&mut self, n: &Node) -> String {
let long = self.o.version.r2000();
let base = symbol_name(n.name.as_deref().unwrap_or("Layer"), long);
let mut taken: Vec<&str> = self.layers.iter().map(|l| l.name.as_str()).collect();
taken.extend(["0", "Defpoints"]);
let name = unique(&base, &taken, if long { 255 } else { 31 });
let name = self.layer_names.unique(&base, if long { 255 } else { 31 });
let aci = match n.kind {
NodeKind::Layer { color, .. } => nearest_aci(color.rgb(), ColorDepth::Aci256),
_ => 7,
@@ -852,17 +899,25 @@ impl<'a> Scene<'a> {
let angle = b.atan2(a).to_degrees();
for line in &layout.lines {
let Some(glyphs) = layout.glyphs.get(line.glyph_start..line.glyph_end) else { continue };
for run in glyphs.chunk_by(|x, y| x.run == y.run) {
let Some(first) = run.first() else { continue };
// A list marker is a TEXT entity of its own, holding the marker's characters where its
// characters' glyphs are drawn (not its tab's).
let marked: Vec<(usize, &vectorcraft_text::PositionedGlyph, Option<&vectorcraft_text::ListMarker>)> =
glyphs.iter().enumerate().map(|(k, g)| (line.glyph_start + k, g, layout.marker_of(line.glyph_start + k))).collect();
for chunk in marked.chunk_by(|x, y| x.1.run == y.1.run && x.2.is_some() == y.2.is_some()) {
let run: Vec<&vectorcraft_text::PositionedGlyph> =
chunk.iter().filter(|(k, _, m)| m.is_none_or(|m| m.tab != *k)).map(|(_, g, _)| *g).collect();
let Some(&first) = run.first() else { continue };
let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte);
let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);
let Some(s) = plain.get(lo..hi).map(str::trim_end).filter(|s| !s.trim().is_empty()) else { continue };
let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| m.text.as_str());
let Some(s) = marker.or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };
let Some(cs) = t.runs.get(first.run).map(|r| &r.style) else { continue };
let paint = if cs.fill.is_none() { &cs.stroke } else { &cs.fill };
let Some(rgb) = self.paint_rgb(paint) else { continue };
let style = self.style(rgb, 1.0, None, None);
let text_style = self.text_style(&cs.font_family);
let height = cs.size * cs.v_scale / 100.0 * CAP_HEIGHT * across;
// The height is that of the size the layout draws: Shrink Text to Fit scales the size by `layout.fit_scale`.
let height = cs.size * layout.fit_scale * cs.v_scale / 100.0 * CAP_HEIGHT * across;
let width = if cs.v_scale > 0.0 { cs.h_scale / cs.v_scale } else { 1.0 };
let origin = run.iter().map(|g| g.origin).min_by(|p, q| p.x.total_cmp(&q.x)).unwrap_or(first.origin);
let p = self.pt(t.xf * origin);
@@ -893,9 +948,7 @@ impl<'a> Scene<'a> {
return name.clone();
}
let long = self.o.version.r2000();
let mut taken: Vec<&str> = self.styles.iter().map(|(n, _)| n.as_str()).collect();
taken.push(crate::writer::STANDARD_STYLE);
let name = unique(&symbol_name(family, long), &taken, if long { 255 } else { 31 });
let name = self.style_names.unique(&symbol_name(family, long), if long { 255 } else { 31 });
self.styles.push((name.clone(), family.to_string()));
name
}
@@ -906,7 +959,7 @@ impl<'a> Scene<'a> {
self.warn(NO_IMAGES);
return;
}
let Some(i) = self.image_def(&im.key) else { return };
let Some(i) = self.image_def(im) else { return };
let Some((dw, dh)) = self.images.get(i).map(|d| (f64::from(d.size.0), f64::from(d.size.1))) else { return };
let (w, h) = (f64::from(im.width.max(1)), f64::from(im.height.max(1)));
let map = self.to_dxf * im.xf;
@@ -948,12 +1001,13 @@ impl<'a> Scene<'a> {
}
}
/// The image file for image `key`, encoded once in the chosen format (`None`: left out).
fn image_def(&mut self, key: &str) -> Option<usize> {
/// The image file for image `im`, encoded once in the chosen format (`None`: left out).
fn image_def(&mut self, im: &ImageObject) -> Option<usize> {
let key = im.key.as_str();
if let Some(i) = self.by_key.get(key) {
return *i;
}
let made = self.encode_image(key);
let made = self.encode_image(im);
let i = match made {
Ok((file, size)) => {
let handle = self.handle();
@@ -970,10 +1024,19 @@ impl<'a> Scene<'a> {
i
}
/// Image `key` as a file in the chosen format, and its size in pixels.
fn encode_image(&self, key: &str) -> Result<(DxfImage, (u32, u32)), String> {
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()).ok_or_else(|| format!("image '{key}' has no pixels and was left out"))?;
let img = image::load_from_memory(&blob.bytes).map_err(|_| format!("image '{key}' could not be decoded and was left out"))?;
/// Image `im` as a file in the chosen format, and its size in pixels.
fn encode_image(&self, im: &ImageObject) -> Result<(DxfImage, (u32, u32)), String> {
let blob = self.doc.image_blob(im).filter(|b| !b.bytes.is_empty()).ok_or_else(|| "an image has no pixels and was left out".to_string())?;
// The pixels it converts to (RGBA, 4 bytes each) are read from its header before decoding.
let (w, h) = image::ImageReader::new(Cursor::new(blob.bytes.as_slice()))
.with_guessed_format()
.ok()
.and_then(|r| r.into_dimensions().ok())
.ok_or_else(|| UNDECODED.to_string())?;
if u64::from(w) * u64::from(h) > MAX_IMAGE_PIXELS {
return Err("an image is too large for DXF and was left out".to_string());
}
let img = image::load_from_memory(&blob.bytes).map_err(|_| UNDECODED.to_string())?;
let size = (img.width(), img.height());
let mut bytes = vec![];
let written = match self.o.raster {
@@ -991,7 +1054,7 @@ impl<'a> Scene<'a> {
rgb.write_with_encoder(image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 90))
}
};
written.map_err(|e| format!("image '{key}' could not be written: {e}"))?;
written.map_err(|e| format!("an image could not be written: {e}"))?;
Ok((DxfImage { name: format!("{}.{}", blob.content_key(), self.o.raster.extension()), bytes }, size))
}
}
+84
View File
@@ -456,6 +456,63 @@ fn type_is_text_or_outlines() {
}
}
/// A list paragraph's bullet is a TEXT entity of its own where it is drawn, and the paragraph's
/// text starts after it, at the left indent.
#[test]
fn a_bullet_is_text_of_its_own_before_its_paragraph() {
let mut d = text_doc("Item", 20.0);
let id = d.layers[0].children().unwrap()[0].id;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
t.para.left_indent = 18.0;
t.para.first_line_indent = -18.0;
}
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(kinds(&out), ["TEXT", "TEXT"]);
assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("\u{2022}"), Some("Item")));
assert_eq!((all(&e[0], 10)[0], all(&e[1], 10)[0]), (10.0, 28.0), "the bullet at the anchor, the text 18 pt on");
// In a right-to-left paragraph the bullet is drawn after the text, at the right, and its tab
// lies between the two.
let mut d = text_doc("שלום", 20.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut bullet_x = f64::NAN;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.para.list = Some(vectorcraft_doc::text::ParaList::bulleted(vectorcraft_doc::text::Bullet::Dot));
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let m = &lay.markers[0];
let bullet = m.glyphs.clone().find(|&i| i != m.tab).unwrap();
assert!(lay.glyphs[m.tab].origin.x < lay.glyphs[bullet].origin.x, "the tab before the bullet");
bullet_x = 10.0 + lay.glyphs[bullet].origin.x;
}
let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&out);
assert_eq!(kinds(&out), ["TEXT", "TEXT"]);
assert_eq!((get(&e[0], 1), get(&e[1], 1)), (Some("שלום"), Some("\u{2022}")));
assert!((all(&e[1], 10)[0] - bullet_x).abs() < 1e-6, "the bullet where it is drawn: {} and {bullet_x}", all(&e[1], 10)[0]);
}
/// Area type that Shrink Text to Fit scales down is written at the height it draws at.
#[test]
fn shrunk_area_type_is_text_at_the_size_it_draws_at() {
let mut d = text_doc("Plan A, plan B and plan C", 20.0);
let id = d.layers[0].children().unwrap()[0].id;
let mut fit = 1.0;
if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {
t.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 30.0)) };
t.area.fit = vectorcraft_doc::AreaFit::ShrinkText { min_percent: 10.0 };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
assert!(!lay.overflow && lay.fit_scale < 0.9, "the text is shrunk to fit: {}", lay.fit_scale);
fit = lay.fit_scale;
}
let out = export(&d, &DxfOptions { preserve: Preserve::Editability, ..opts(&d) }).unwrap();
let e = entities(&out);
assert!(!e.is_empty() && e.iter().all(|e| kind(e) == "TEXT"), "{:?}", kinds(&out));
for e in &e {
assert!((all(e, 40)[0] - 14.0 * fit).abs() < 1e-6, "the cap height of {} pt type: {:?}", 20.0 * fit, all(e, 40));
}
}
#[test]
fn text_outside_ascii_is_escaped_before_2007() {
let d = text_doc("Größe", 12.0);
@@ -479,6 +536,7 @@ fn images_link_png_or_jpeg_files() {
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
pixels: None,
};
vec![Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))]
});
@@ -505,6 +563,32 @@ fn images_link_png_or_jpeg_files() {
assert!(r12.warnings.iter().any(|w| w.contains("no images")));
}
#[test]
fn images_without_pixels_are_left_out_with_one_note() {
// One image has no data in the document, and the other image's data is empty.
let mut d = doc_with(|d| {
["raster-1", "raster-2"]
.into_iter()
.map(|key| {
let im = ImageObject {
key: key.into(),
width: 4,
height: 2,
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
};
Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)))
})
.collect()
});
d.images.insert("raster-2".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(vec![]), proxy: None });
let out = export(&d, &opts(&d)).unwrap();
assert!(entities(&out).is_empty() && out.images.is_empty());
assert_eq!(out.warnings, ["an image has no pixels and was left out"]);
}
#[test]
fn crop_leaves_out_art_outside_the_region() {
let d = shapes_doc();
+4 -4
View File
@@ -113,11 +113,11 @@ fn black_point_compensation_maps_the_darkest_ink_to_rgb_black() {
assert_eq!(abs(true), abs(false));
}
/// #1584: the profiles written here (built-in RGB spaces) have every tag on a 4-byte boundary and
/// a size that is a multiple of 4 (ICC.1 §7.1.2), and still read back as the same space.
/// #1584: every built-in profile (RGB, CMYK and gray) has each tag on a 4-byte boundary and a size
/// that is a multiple of 4 (ICC.1 §7.1.2), and still reads back as the same space.
#[test]
fn written_profiles_align_their_tags() {
for name in [super::SRGB, super::DISPLAY_P3, super::WIDE_GAMUT_RGB, super::PROPHOTO_RGB] {
fn builtin_profiles_align_their_sizes_and_tags() {
for name in BUILTIN_RGB.into_iter().chain(BUILTIN_CMYK).chain([GRAY]) {
let icc = super::icc_bytes(name).unwrap();
let be = |at: usize| u32::from_be_bytes([icc[at], icc[at + 1], icc[at + 2], icc[at + 3]]) as usize;
assert_eq!(be(0), icc.len(), "{name}: header size");
+394 -11
View File
@@ -31,6 +31,75 @@ impl GradientKind {
}
}
/// How a gradient mixes the colours between its stops (the Gradient panel's Method).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientMethod {
/// Mixed in display RGB, as gradients always were.
#[default]
Classic,
/// Mixed in a perceptual colour space (OKLab), so the steps look even to the eye: blue to
/// yellow passes through a light blue-grey rather than a dull mid grey.
Perceptual,
}
impl GradientMethod {
/// Parse a method name (`classic`, `perceptual`; any case).
pub fn parse(s: &str) -> Option<Self> {
[Self::Classic, Self::Perceptual].into_iter().find(|m| m.label().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
Self::Classic => "Classic",
Self::Perceptual => "Perceptual",
}
}
pub fn is_classic(&self) -> bool {
*self == Self::Classic
}
/// `a` and `b` mixed at `t` (0..1): in display RGB (Classic), or in OKLab (Perceptual: an
/// RGB colour between the two).
pub fn mix(self, a: &Color, b: &Color, t: f32) -> Color {
match self {
Self::Classic => Color::lerp(a, b, t),
Self::Perceptual => {
use crate::libraries::{oklab_to_srgb, srgb_to_oklab};
let (p, q, t) = (srgb_to_oklab(a.to_rgb()), srgb_to_oklab(b.to_rgb()), f64::from(t));
let [r, g, bl] = oklab_to_srgb(p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t, p[2] + (q[2] - p[2]) * t);
let c = |v: f64| if v.is_finite() { v.clamp(0.0, 1.0) as f32 } else { 0.0 };
Color::rgb(c(r), c(g), c(bl))
}
}
}
}
/// How far (display RGB, 0..1) the straight line between a Perceptual gradient's neighbouring
/// expanded stops may stray from the perceptual mix: under half a colour level.
const PERCEPTUAL_TOLERANCE: f32 = 0.4 / 255.0;
/// Most times each half of a Perceptual gradient's segment (either side of its midpoint) is
/// halved to keep within [`PERCEPTUAL_TOLERANCE`].
const PERCEPTUAL_DEPTH: u32 = 10;
/// The mix positions (0..1, after `v0`, before `v1`) between which display RGB mixed linearly
/// stays within [`PERCEPTUAL_TOLERANCE`] of the perceptual mix of `a` and `b`: the span halved
/// where a straight line strays, at most [`PERCEPTUAL_DEPTH`] times, in order.
fn perceptual_steps(a: &Color, b: &Color, (v0, v1): (f32, f32), depth: u32, out: &mut Vec<f32>) {
let at = |v: f32| GradientMethod::Perceptual.mix(a, b, v).to_rgb();
let (c0, c1) = (at(v0), at(v1));
let stray = [0.25, 0.5, 0.75]
.into_iter()
.flat_map(|u: f32| {
let c = at(v0 + (v1 - v0) * u);
(0..3).map(move |k| (c0[k] + (c1[k] - c0[k]) * u - c[k]).abs())
})
.fold(0.0, f32::max);
if depth < PERCEPTUAL_DEPTH && stray > PERCEPTUAL_TOLERANCE {
let m = (v0 + v1) / 2.0;
perceptual_steps(a, b, (v0, m), depth + 1, out);
out.push(m);
perceptual_steps(a, b, (m, v1), depth + 1, out);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct GradientStop {
/// 0..=1 along the gradient.
@@ -75,19 +144,39 @@ fn half() -> f32 {
pub struct Gradient {
pub kind: GradientKind,
pub stops: Vec<GradientStop>,
/// Dither: a fill of this gradient is drawn as pixels with fine noise of up to a colour level
/// added, which hides the steps (banding) between the levels 8-bit colour has.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dither: bool,
/// How the colours between the stops are mixed (Classic unless set; written only when not).
#[serde(default, skip_serializing_if = "GradientMethod::is_classic")]
pub method: GradientMethod,
}
impl Default for Gradient {
/// Illustrator's default "White, Black" gradient.
fn default() -> Self {
Self { kind: GradientKind::Linear, stops: vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)] }
Self::new(GradientKind::Linear, vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)])
}
}
impl Gradient {
/// Colour and opacity at `t` (honours midpoints).
/// A gradient of `kind` through `stops`, with the default options.
pub fn new(kind: GradientKind, stops: Vec<GradientStop>) -> Self {
Self { kind, stops, dither: false, method: GradientMethod::Classic }
}
/// Colour and opacity at `t` (honours midpoints and the method).
pub fn sample(&self, t: f32) -> (Color, f32) {
self.sample_with(t, Color::lerp)
self.sample_with(t, |a, b, v| self.method.mix(a, b, v))
}
/// [`Self::sample`], a Classic gradient's neighbouring stop colours of one model mixed in it
/// (CMYK stays CMYK; [`Color::lerp_keep_model`]) rather than in display RGB, a Perceptual
/// gradient's as [`Self::sample`] mixes them.
pub fn sample_keep_model(&self, t: f32) -> (Color, f32) {
match self.method {
GradientMethod::Classic => self.sample_with(t, Color::lerp_keep_model),
m => self.sample_with(t, |a, b, v| m.mix(a, b, v)),
}
}
/// [`Gradient::sample`] with neighbouring stop colours mixed by `mix` (e.g. in their own colour
/// model rather than display RGB).
@@ -115,20 +204,47 @@ impl Gradient {
None => (Color::BLACK, 1.0),
}
}
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without midpoints).
/// Stops expanded so that midpoints are represented as explicit stops (for renderers without
/// midpoints), and a Perceptual gradient's segments as stops on its curve (for renderers that
/// mix linearly; within [`PERCEPTUAL_TOLERANCE`] of it).
pub fn expanded_stops(&self) -> Vec<(f32, Color, f32)> {
self.expanded().map(|(t, c, o, _)| (t, c, o)).collect()
}
/// [`Self::expanded_stops`], each stop that stands for a midpoint with that midpoint (`None`
/// on the gradient's own stops).
/// on the gradient's own stops and a Perceptual gradient's other in-between stops).
pub fn expanded(&self) -> impl Iterator<Item = (f32, Color, f32, Option<f32>)> + '_ {
self.stops.iter().enumerate().flat_map(move |(i, s)| {
let mid = self.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
let next = self.stops.get(i + 1);
let off_centre = (s.midpoint - 0.5).abs() > 1e-3;
// Classic: a stop at the midpoint, when it is off-centre.
let classic = next.filter(|_| off_centre && self.method.is_classic()).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = self.sample(t);
(t, c, o, Some(s.midpoint))
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
// Perceptual: stops along the mix (`v`) where a straight line would stray from it, the
// midpoint (v = ½) among them, placed through the midpoint as [`Self::sample`] maps them.
let perceptual = next.filter(|_| !self.method.is_classic()).map(|n| {
let mut vs = Vec::new();
perceptual_steps(&s.color, &n.color, (0.0, 0.5), 1, &mut vs);
vs.push(0.5);
perceptual_steps(&s.color, &n.color, (0.5, 1.0), 1, &mut vs);
let m = s.midpoint.clamp(0.01, 0.99);
vs.into_iter().map(move |v| {
let mid = v == 0.5;
let u = if mid {
s.midpoint
} else if v < 0.5 {
2.0 * v * m
} else {
m + (v - 0.5) * 2.0 * (1.0 - m)
};
let t = s.offset + (n.offset - s.offset) * u;
let o = s.opacity + (n.opacity - s.opacity) * v;
(t, self.method.mix(&s.color, &n.color, v), o, (mid && off_centre).then_some(s.midpoint))
})
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(classic).chain(perceptual.into_iter().flatten())
})
}
/// The same ramp the other way round: the stops in reverse order at mirrored offsets, each
@@ -172,7 +288,7 @@ impl Gradient {
);
}
stops.push(stop(1.0, to));
Gradient { kind: GradientKind::Linear, stops }
Gradient::new(GradientKind::Linear, stops)
}
}
@@ -534,6 +650,117 @@ impl GradientPaint {
f.transform(a);
}
}
/// Is this gradient drawn as pixels rather than as a vector gradient when it paints a fill
/// (`fill`) or a stroke (SVG and PDF draw such gradients as images): a freeform gradient, a
/// dithered fill (dither applies to fills only), or a Perceptual one (its colours don't mix
/// linearly between the stops, as SVG's and PDF's gradients do)?
pub fn draws_as_pixels(&self, fill: bool) -> bool {
self.gradient.kind == GradientKind::Freeform || (fill && self.gradient.dither) || !self.gradient.method.is_classic()
}
/// A linear or radial gradient placed on box `bounds` as a `cols` × `rows` grid of
/// unpremultiplied RGBA8 pixels over `area`, row by row, each sampled at its centre (colours
/// as `rgb` gives them, mixed as the renderer mixes them: [`Ramp`]). Dithered and painting a
/// fill (`fill`; dither applies to fills only), each colour channel of pixel (column, row) gets
/// [`dither_noise`] before it is rounded to a level.
pub fn pixels(
&self,
bounds: Rect,
area: Rect,
cols: u16,
rows: u16,
fill: bool,
rgb: &dyn Fn(&Color) -> [f32; 3],
) -> impl Iterator<Item = [u8; 4]> + use<> {
self.pixels_within(bounds, area, cols, rows, [0..cols, 0..rows], fill, rgb)
}
/// [`Self::pixels`] for the cells in columns `cells[0]` and rows `cells[1]` of the grid only,
/// row by row: the same pixels, noise and all, the whole grid has there.
#[allow(clippy::too_many_arguments)]
pub fn pixels_within(
&self,
bounds: Rect,
area: Rect,
cols: u16,
rows: u16,
cells: [std::ops::Range<u16>; 2],
fill: bool,
rgb: &dyn Fn(&Color) -> [f32; 3],
) -> impl Iterator<Item = [u8; 4]> + use<> {
let (kind, geom, dither) = (self.gradient.kind, self.resolve(bounds), fill && self.gradient.dither);
let ramp = Ramp::new(&self.gradient, rgb);
let (cw, ch) = (area.width() / f64::from(cols.max(1)), area.height() / f64::from(rows.max(1)));
let (x0, y0) = (area.x0, area.y0);
let [across, down] = cells.map(|r| u32::from(r.start)..u32::from(r.end));
down.flat_map(move |j| across.clone().map(move |i| (i, j))).map(move |(i, j)| {
let p = Point::new(x0 + (f64::from(i) + 0.5) * cw, y0 + (f64::from(j) + 0.5) * ch);
let c = ramp.at(geom.param_at(kind, p) as f32);
let level = |k: usize| {
let noise = if dither { dither_noise(i, j, k as u32) } else { 0.0 };
(c[k].clamp(0.0, 1.0) * 255.0 + noise).round().clamp(0.0, 255.0) as u8
};
[level(0), level(1), level(2), (c[3].clamp(0.0, 1.0) * 255.0).round() as u8]
})
}
}
/// A gradient's colours ready to be sampled pixel by pixel, in a colour space of the caller's
/// (display RGB, or an ink's coverage): its stops, midpoints and a Perceptual gradient's
/// in-between colours as stops (as [`Gradient::expanded_stops`]), each turned into that space
/// once, with colour and opacity mixed linearly between them, each on its own (as the renderer's
/// vector gradients mix them).
pub struct Ramp {
stops: Vec<(f32, [f32; 4])>,
}
impl Ramp {
pub fn new(g: &Gradient, rgb: &dyn Fn(&Color) -> [f32; 3]) -> Self {
let stops = g
.expanded()
.map(|(t, c, o, _)| {
let [r, gr, b] = rgb(&c);
(t, [r, gr, b, o])
})
.collect();
Self { stops }
}
/// Unpremultiplied colour and opacity at `t`, padded past the ends (transparent black
/// without stops).
pub fn at(&self, t: f32) -> [f32; 4] {
let Some(&(t0, first)) = self.stops.first() else { return [0.0; 4] };
// Not after the first stop (or not a number): its colour.
if t.partial_cmp(&t0) != Some(std::cmp::Ordering::Greater) {
return first;
}
for w in self.stops.windows(2) {
let ((a, ca), (b, cb)) = (w[0], w[1]);
if t <= b {
let u = if b - a > 1e-9 { (t - a) / (b - a) } else { 1.0 };
return std::array::from_fn(|k| ca[k] + (cb[k] - ca[k]) * u);
}
}
self.stops.last().map_or([0.0; 4], |s| s.1)
}
}
/// Seed of [`dither_noise`]: fixed, so a dithered gradient draws and exports the same pixels on
/// every run.
const DITHER_SEED: u32 = 0x5eed_d17e;
/// An integer hash that mixes every input bit into every output bit (the "lowbias32" mix).
fn mix32(mut v: u32) -> u32 {
v ^= v >> 16;
v = v.wrapping_mul(0x7feb_352d);
v ^= v >> 15;
v = v.wrapping_mul(0x846c_a68b);
v ^ (v >> 16)
}
/// Dither noise for colour channel `channel` of pixel (`x`, `y`), in colour levels: -1..1,
/// densest around 0 (the sum of two uniform values: noise that hides steps between levels without
/// a pattern of its own), the same for the same pixel on every run.
pub fn dither_noise(x: u32, y: u32, channel: u32) -> f32 {
let h = mix32(x ^ mix32(y ^ mix32(channel ^ DITHER_SEED)));
((h & 0xffff) as f32 + (h >> 16) as f32) / 65535.0 - 1.0
}
#[cfg(test)]
@@ -582,9 +809,8 @@ mod tests {
#[test]
fn reverse_mirrors_midpoints() {
let stop = |offset, hex, midpoint| GradientStop { midpoint, ..GradientStop::new(offset, Color::from_hex(hex).unwrap()) };
let two = Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)] };
let three =
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)] };
let two = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(1.0, "#0000ff", 0.5)]);
let three = Gradient::new(GradientKind::Linear, vec![stop(0.0, "#ff0000", 0.2), stop(0.3, "#00ff00", 0.8), stop(1.0, "#0000ff", 0.5)]);
for g in [two, three] {
let mut r = g.clone();
r.reverse();
@@ -825,4 +1051,161 @@ mod tests {
h.keep_focal_from(&before);
assert!(close(h.focal.unwrap(), Point::new(0.0, -20.0)), "{h:?}");
}
#[test]
fn pixels_sample_the_ramp_at_each_cell_centre() {
// White → black left to right over a 10 × 2 box, a midpoint at 30% and a half-opaque end.
let mut g = GradientPaint::new(Gradient::default());
g.gradient.stops[0].midpoint = 0.3;
g.gradient.stops[1].opacity = 0.5;
let b = Rect::new(0.0, 0.0, 10.0, 2.0);
let px: Vec<[u8; 4]> = g.pixels(b, b, 10, 2, true, &Color::to_rgb).collect();
assert_eq!(px.len(), 20);
let ramp = Ramp::new(&g.gradient, &Color::to_rgb);
for (i, p) in px.iter().take(10).enumerate() {
let c = ramp.at((i as f32 + 0.5) / 10.0).map(|v| (v * 255.0).round() as u8);
assert_eq!(*p, c, "pixel {i}");
}
assert_eq!(px[3], px[13], "rows alike");
// The midpoint stop: grey at 30%; padded past the ends.
assert!((ramp.at(0.3)[0] - 0.5).abs() < 0.02, "{:?}", ramp.at(0.3));
assert_eq!(ramp.at(-1.0), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(ramp.at(2.0), [0.0, 0.0, 0.0, 0.5]);
assert_eq!(ramp.at(f32::NAN), [1.0, 1.0, 1.0, 1.0]);
}
fn blue_yellow(method: GradientMethod) -> Gradient {
let mut g = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)), GradientStop::new(1.0, Color::rgb(1.0, 1.0, 0.0))],
);
g.method = method;
g
}
/// Illustrator 30's Perceptual method mixes colours in a perceptually even way
/// (https://helpx.adobe.com/illustrator/desktop/paint-and-fill/create-and-edit-gradients/control-perceptual-interpolation-in-gradients.html).
/// Here that is OKLab: blue → yellow passes through a light blue-grey, not the flat mid grey
/// a display RGB mix gives, and its lightness climbs evenly.
#[test]
fn perceptual_blue_to_yellow_is_not_the_grey_rgb_mix() {
let classic = blue_yellow(GradientMethod::Classic).sample(0.5).0.to_rgb();
assert!(classic.iter().all(|v| (v - 0.5).abs() < 1e-5), "classic: the RGB mix {classic:?}");
let p = blue_yellow(GradientMethod::Perceptual);
let [r, g, b] = p.sample(0.5).0.to_rgb();
assert!(b > r + 0.1 && b > g + 0.05, "a blue-grey, not grey: {:?}", [r, g, b]);
assert!(r + g + b > 1.5 + 0.2, "lighter than the RGB mix: {:?}", [r, g, b]);
let lightness = |t: f32| crate::libraries::to_oklab(&p.sample(t).0)[0];
let steps: Vec<f64> = (0..4).map(|k| lightness((k + 1) as f32 / 4.0) - lightness(k as f32 / 4.0)).collect();
assert!(steps.iter().all(|d| (d - steps[0]).abs() < 1e-3), "even lightness steps: {steps:?}");
// Ends and opacity as before.
assert_eq!(p.sample(0.0).0.to_hex(), "#0000ff");
assert_eq!(p.sample(1.0).0.to_hex(), "#ffff00");
}
/// Renderers mix linearly between stops: a Perceptual segment becomes stops on the perceptual
/// curve (the midpoint diamond among them), more where it bends, close enough that the linear
/// ramp between them stays within half a colour level of the curve; a segment that hardly
/// bends needs few.
#[test]
fn perceptual_segments_expand_into_stops_on_the_curve() {
let pairs = [
(Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0)),
(Color::BLACK, Color::rgb(1.0, 0.0, 0.0)),
(Color::rgb(0.0, 1.0, 0.0), Color::rgb(1.0, 0.0, 1.0)),
(Color::cmyk(0.0, 0.0, 0.0, 1.0), Color::WHITE),
];
for (a, b) in pairs {
let mut p = Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, a), GradientStop::new(1.0, b)]);
p.method = GradientMethod::Perceptual;
p.stops[0].midpoint = 0.3;
p.stops[1].opacity = 0.5;
let ex: Vec<_> = p.expanded().collect();
assert!((3..=100).contains(&ex.len()), "{a:?} → {b:?}: {} stops", ex.len());
assert!(ex.windows(2).all(|w| w[0].0 < w[1].0), "in order");
assert_eq!(ex.iter().filter(|s| s.3.is_some()).count(), 1, "one stop stands for the midpoint");
assert!(ex.iter().any(|s| s.3 == Some(0.3) && (s.0 - 0.3).abs() < 1e-6));
let ramp = Ramp::new(&p, &Color::to_rgb);
for k in 0..=2000 {
let t = k as f32 / 2000.0;
let (c, o) = p.sample(t);
let [r, g, b] = c.to_rgb();
let off = [r, g, b, o].iter().zip(ramp.at(t)).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max);
assert!(off < 0.5 / 255.0, "{a:?} → {b:?} at {t}: {off}");
}
}
let mut flat = Gradient::new(
GradientKind::Linear,
vec![GradientStop::new(0.0, Color::rgb(0.2, 0.4, 0.6)), GradientStop::new(1.0, Color::rgb(0.25, 0.45, 0.6))],
);
flat.method = GradientMethod::Perceptual;
assert_eq!(flat.expanded().count(), 3, "the ends and the midpoint");
}
/// Classic gradients expand as they always did: their stops, and a stop at each off-centre
/// midpoint, sampled in display RGB (so the renderer draws the same pixels as before).
#[test]
fn classic_gradients_expand_as_before() {
let mut g = Gradient::new(
GradientKind::Linear,
vec![
GradientStop { midpoint: 0.2, ..GradientStop::new(0.0, Color::rgb(0.0, 0.0, 1.0)) },
GradientStop { opacity: 0.4, ..GradientStop::new(0.45, Color::cmyk(0.1, 0.8, 0.0, 0.2)) },
GradientStop { midpoint: 0.87, ..GradientStop::new(0.6, Color::rgb(1.0, 1.0, 0.0)) },
GradientStop::new(1.0, Color::gray(0.3)),
],
);
// The expansion gradients had before they had a method.
let before: Vec<_> = g
.stops
.iter()
.enumerate()
.flat_map(|(i, s)| {
let mid = g.stops.get(i + 1).filter(|_| (s.midpoint - 0.5).abs() > 1e-3).map(|n| {
let t = s.offset + (n.offset - s.offset) * s.midpoint;
let (c, o) = g.sample_with(t, Color::lerp);
(t, c, o, Some(s.midpoint))
});
std::iter::once((s.offset, s.color, s.opacity, None)).chain(mid)
})
.collect();
assert_eq!(g.expanded().collect::<Vec<_>>(), before);
assert_eq!(before.len(), 6);
assert_eq!(g.sample(0.3), g.sample_with(0.3, Color::lerp));
assert_eq!(g.sample_keep_model(0.5), g.sample_with(0.5, Color::lerp_keep_model));
g.method = GradientMethod::Perceptual;
assert_eq!(g.sample_keep_model(0.5), g.sample(0.5), "perceptual: one mix");
}
/// The method is written only when Perceptual, so files from before it (and Classic
/// gradients) read as Classic and new Classic files stay as they were.
#[test]
fn method_is_saved_only_when_perceptual() {
let classic = serde_json::to_value(Gradient::default()).unwrap();
assert!(classic.get("method").is_none(), "{classic}");
let old: Gradient = serde_json::from_value(serde_json::json!({"kind": "Linear", "stops": []})).unwrap();
assert_eq!(old.method, GradientMethod::Classic);
let p = blue_yellow(GradientMethod::Perceptual);
let v = serde_json::to_value(&p).unwrap();
assert_eq!(v["method"], "Perceptual");
assert_eq!(serde_json::from_value::<Gradient>(v).unwrap(), p);
assert_eq!(GradientMethod::parse("perceptual"), Some(GradientMethod::Perceptual));
assert_eq!(GradientMethod::parse("CLASSIC"), Some(GradientMethod::Classic));
assert_eq!(GradientMethod::parse("smooth"), None);
}
/// SVG and PDF can't mix colours perceptually: a Perceptual gradient is drawn as pixels there,
/// as Illustrator rasterises perceptual gradients saved as PDF or SVG, as a fill or a stroke,
/// dithered or not.
#[test]
fn perceptual_gradients_draw_as_pixels() {
for fill in [true, false] {
assert!(!GradientPaint::new(blue_yellow(GradientMethod::Classic)).draws_as_pixels(fill));
assert!(GradientPaint::new(blue_yellow(GradientMethod::Perceptual)).draws_as_pixels(fill));
let mut radial = blue_yellow(GradientMethod::Perceptual);
radial.kind = GradientKind::Radial;
radial.dither = true;
assert!(GradientPaint::new(radial).draws_as_pixels(fill));
}
}
}
+31 -1
View File
@@ -16,7 +16,7 @@ pub mod swatch;
pub use blend::BlendMode;
pub use freeform::{Freeform, FreeformMode, FreeformPoint};
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
pub use gradient::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientPaint, GradientStop};
pub use libraries::SwatchLibrary;
pub use swatch::{Swatch, SwatchGroup, default_swatches};
@@ -111,6 +111,16 @@ impl Color {
pub fn out_of_gamut(&self) -> bool {
cms::active().out_of_gamut(self)
}
/// The colour exactly, as a key: its model and its components' bits (colours that only look
/// alike, as a grey and the same RGB grey, differ).
pub fn key(&self) -> [u32; 5] {
match *self {
Color::Rgb { r, g, b } => [0, r.to_bits(), g.to_bits(), b.to_bits(), 0],
Color::Cmyk { c, m, y, k } => [1, c.to_bits(), m.to_bits(), y.to_bits(), k.to_bits()],
Color::Gray { k } => [2, k.to_bits(), 0, 0, 0],
Color::Lab { l, a, b } => [3, l.to_bits(), a.to_bits(), b.to_bits(), 0],
}
}
pub fn to_rgba8(&self, alpha: f32) -> [u8; 4] {
let [r, g, b] = self.to_rgb();
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
@@ -146,6 +156,8 @@ impl Color {
} else {
60.0 * ((r - g) / d + 4.0)
};
// f32 rounding in rem_euclid can yield exactly 360; wrap back to 0.
let h = if h >= 360.0 { 0.0 } else { h };
let s = if max <= 0.0 { 0.0 } else { d / max };
[h, s, max]
}
@@ -378,6 +390,18 @@ impl Paint {
_ => None,
}
}
/// This stroke paint as the fill of the stroke's outline, so the outline looks as the stroke
/// did: without dither, which strokes ignore ([`GradientPaint::draws_as_pixels`]).
pub fn stroke_as_fill(&self) -> Paint {
match self {
Paint::Gradient(g) if g.gradient.dither => {
let mut g = g.clone();
g.gradient.dither = false;
Paint::Gradient(g)
}
p => p.clone(),
}
}
/// The Registration colour: 100% of every ink, linked to [`swatch::REGISTRATION`].
pub fn registration() -> Self {
Paint::Solid { color: Color::cmyk(1.0, 1.0, 1.0, 1.0), swatch: Some(swatch::REGISTRATION.into()), tint: 1.0 }
@@ -426,6 +450,12 @@ impl Paint {
mod tests {
use super::*;
#[test]
fn hue_below_360() {
let h = Color::Rgb { r: 1.0, g: 0.0, b: 1e-7 }.to_hsb()[0];
assert!((0.0..360.0).contains(&h), "hue {h}");
}
#[test]
fn hex_roundtrip() {
let c = Color::from_hex("#ff8000").unwrap();
+8 -3
View File
@@ -98,7 +98,7 @@ pub fn builtin_library(id: &str) -> Option<Arc<SwatchLibrary>> {
// ---------- colour construction ----------
/// sRGB (0..=1, gamma encoded) of OKLab `(l, a, b)`, unclamped.
fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
pub(crate) fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
let l_ = l + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
let m_ = l - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
let s_ = l - 0.089_484_177_5 * a - 1.291_485_548_0 * b;
@@ -113,7 +113,12 @@ fn oklab_to_srgb(l: f64, a: f64, b: f64) -> [f64; 3] {
/// OKLab `[l, a, b]` of a colour's display RGB.
pub fn to_oklab(c: &Color) -> [f64; 3] {
let [r, g, b] = c.to_rgb_uncalibrated().map(|v| {
srgb_to_oklab(c.to_rgb_uncalibrated())
}
/// OKLab `[l, a, b]` of sRGB `rgb` (0..=1, gamma encoded).
pub(crate) fn srgb_to_oklab(rgb: [f32; 3]) -> [f64; 3] {
let [r, g, b] = rgb.map(|v| {
let v = f64::from(v);
if v <= 0.040_45 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
});
@@ -171,7 +176,7 @@ fn group(name: &str, swatches: Vec<Swatch>) -> SwatchGroup {
/// A gradient swatch through `stops` of (offset, colour, opacity).
pub(crate) fn gradient(name: &str, kind: GradientKind, stops: &[(f32, Color, f32)]) -> Swatch {
let stops = stops.iter().map(|&(offset, color, opacity)| GradientStop { opacity, ..GradientStop::new(offset, color) }).collect();
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient { kind, stops }))), global: false, spot: false }
Swatch { name: name.into(), paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(kind, stops)))), global: false, spot: false }
}
/// `n` steps of OKLCH lightness from `l0` to `l1`, chroma `c(t)` and hue `h(t)` (`t` in 0..=1),
+57 -9
View File
@@ -181,14 +181,16 @@ fn css_comment(s: &str) -> String {
fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
let mut out = format!("/* {} */\n:root {{\n", css_comment(&lib.name));
let mut taken: Vec<String> = vec![];
let mut taken: HashSet<String> = HashSet::new();
// The next number to try for each base, so repeated names don't search the ones used before.
let mut next: HashMap<String, usize> = HashMap::new();
let mut props = |out: &mut String, list: &[Swatch]| {
for w in list.iter().filter(&keep) {
let value = match &w.paint {
Paint::Solid { color, .. } => css_color(color, 1.0),
Paint::Gradient(g) if g.gradient.kind != GradientKind::Freeform => {
let stops: Vec<String> =
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), (s.offset * 100.0).round())).collect();
g.gradient.stops.iter().map(|s| format!("{} {}%", css_color(&s.color, s.opacity), s.offset * 100.0)).collect();
match g.gradient.kind {
GradientKind::Radial => format!("radial-gradient(circle, {})", stops.join(", ")),
_ => format!("linear-gradient({}deg, {})", (90.0 - g.angle).rem_euclid(360.0).round(), stops.join(", ")),
@@ -198,8 +200,18 @@ fn write_css(lib: &SwatchLibrary, keep: impl Fn(&&Swatch) -> bool) -> String {
};
// Names differing only in case or punctuation map to one property: number the others.
let base = css_property(&w.name);
let name = (1..).map(|i| if i == 1 { base.clone() } else { format!("{base}-{i}") }).find(|n| !taken.contains(n)).unwrap_or(base);
taken.push(name.clone());
let name = if taken.insert(base.clone()) {
base
} else {
let n = next.entry(base.clone()).or_insert(1);
loop {
*n += 1;
let name = format!("{base}-{n}");
if taken.insert(name.clone()) {
break name;
}
}
};
let note = if w.spot { " /* spot */" } else { "" };
out.push_str(&format!(" {name}: {value};{note}\n"));
}
@@ -371,7 +383,9 @@ fn read_gpl(text: &str, fallback: &str) -> SwatchLibrary {
continue;
}
if let Some(n) = line.strip_prefix("Name:") {
lib.name = n.trim().to_string();
if !n.trim().is_empty() {
lib.name = n.trim().to_string();
}
continue;
}
// Comments, headers (`Columns: 4`) and anything else that isn't a colour are skipped.
@@ -648,6 +662,14 @@ mod tests {
use crate::{Gradient, GradientPaint, GradientStop};
use proptest::prelude::*;
#[test]
fn gpl_blank_name_keeps_fallback() {
let blank = "GIMP Palette\nName: \nColumns: 0\n#\n 1 2 3\tx\n";
assert_eq!(read(blank, "fallback").unwrap().name, "fallback");
let named = "GIMP Palette\nName: Warm\n 1 2 3\tx\n";
assert_eq!(read(named, "fallback").unwrap().name, "Warm");
}
/// A color book's string: a `u32` count of UTF-16 code units, then the units.
fn acb_str(out: &mut Vec<u8>, s: &str) {
let units: Vec<u16> = s.encode_utf16().collect();
@@ -711,13 +733,13 @@ mod tests {
}
fn sample() -> SwatchLibrary {
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient {
kind: GradientKind::Linear,
stops: vec![
let grad = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
GradientKind::Linear,
vec![
GradientStop { midpoint: 0.4, ..GradientStop::new(0.0, Color::cmyk(0.1, 0.2, 0.3, 0.0)) },
GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb8(255, 0, 0)) },
],
})));
))));
SwatchLibrary {
name: "Brand */ Colours".into(),
swatches: vec![
@@ -805,6 +827,32 @@ mod tests {
assert!(read(&text, "x").is_err(), "CSS is written only");
}
#[test]
fn css_preserves_fractional_gradient_stop_positions() {
for kind in [GradientKind::Linear, GradientKind::Radial] {
let lib = SwatchLibrary {
name: "Positions".into(),
swatches: vec![Swatch {
name: "Ramp".into(),
paint: Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
kind,
vec![
GradientStop::new(0.0, Color::BLACK),
GradientStop::new(0.125, Color::rgb8(255, 0, 0)),
GradientStop::new(0.12890625, Color::WHITE),
GradientStop::new(1.0, Color::rgb8(0, 0, 255)),
],
)))),
global: false,
spot: false,
}],
groups: vec![],
};
let text = written(&lib, PaletteFormat::Css);
assert!(text.contains("#000000 0%, #ff0000 12.5%, #ffffff 12.890625%, #0000ff 100%"), "{kind:?}: {text}");
}
}
#[test]
fn holds_counts_the_swatches_each_format_writes() {
// `sample()` holds the spot color Ink, the linear gradient Fade, the pattern Tiles and two solid colors in a group.
+34 -3
View File
@@ -264,6 +264,8 @@ pub struct RowMap {
mean_l: f32,
/// Hue turn from the key colour to the new colour (Hue Shift).
turn: f32,
/// Whether colours take the new colour in its own model, not in the model they had.
own_model: bool,
}
impl RowMap {
@@ -274,7 +276,20 @@ impl RowMap {
Method::TintsShades => mean_l(&row.iter().map(|c| (*c, c.to_lab())).collect::<Vec<_>>()),
_ => 0.0,
};
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0] }
Self { method, to, key, mean_l, turn: to.to_hsb()[0] - key.to_hsb()[0], own_model: false }
}
/// With `own`, current colours take the new colour in the new colour's model (an RGB colour
/// replaced by a CMYK one becomes CMYK) instead of keeping the model they had.
pub fn with_own_model(mut self, own: bool) -> Self {
self.own_model = own;
self
}
/// True when current colour `c` keeps a colour model other than the new colour's (so the new
/// colour is converted into `c`'s model and may look different from what was asked for).
pub fn converts(&self, c: Color) -> bool {
!self.own_model && c.model() != self.to.model()
}
/// The colour of the row the new colour stands for.
@@ -301,7 +316,7 @@ impl RowMap {
/// The new colour of current colour `c`, in `c`'s model.
pub fn map(&self, c: Color) -> Color {
let to = keep_model(c, self.to);
let to = if self.own_model { self.to } else { keep_model(c, self.to) };
match self.method {
Method::Exact => to,
Method::PreserveTints if is_tint(c, self.key) => to.tinted(tint_of(c, self.key)),
@@ -314,7 +329,13 @@ impl RowMap {
Method::HueShift if c == self.key => to,
Method::HueShift => {
let [h, s, v] = c.to_hsb();
if s <= 0.0 { c } else { keep_model(c, Color::from_hsb(h + self.turn, s, v)) }
if s <= 0.0 {
if self.own_model { c.in_model(self.to.model()) } else { c }
} else if self.own_model {
Color::from_hsb(h + self.turn, s, v).in_model(self.to.model())
} else {
keep_model(c, Color::from_hsb(h + self.turn, s, v))
}
}
}
}
@@ -576,6 +597,16 @@ mod tests {
assert!(cluster(&[], &[], 3).is_empty());
}
#[test]
fn hue_shift_with_own_model_puts_every_colour_of_the_row_in_the_new_model() {
let to = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let row = [Color::rgb(0.2, 0.2, 0.9), Color::rgb(0.1, 0.1, 0.5), Color::rgb(0.3, 0.3, 0.3)];
let own = RowMap::new(Method::HueShift, &row, to).with_own_model(true);
assert!(row.iter().all(|c| own.map(*c).model() == to.model()), "key and other colours alike");
let kept = RowMap::new(Method::HueShift, &row, to);
assert!(row.iter().all(|c| kept.map(*c).model() == c.model()));
}
#[test]
fn palette_snaps_to_the_nearest_colour() {
let p = Palette::new([Color::rgb(1.0, 0.0, 0.0), Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::gray(0.5)]);
+3 -3
View File
@@ -123,13 +123,13 @@ fn base_swatches() -> (Vec<Swatch>, Vec<SwatchGroup>) {
.collect();
let grad = |name: &str, a: &str, b: &str, kind: GradientKind| Swatch {
name: name.into(),
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient {
paint: Paint::Gradient(Box::new(crate::GradientPaint::new(Gradient::new(
kind,
stops: vec![
vec![
GradientStop::new(0.0, Color::from_hex(a).unwrap_or(Color::WHITE)),
GradientStop::new(1.0, Color::from_hex(b).unwrap_or(Color::BLACK)),
],
}))),
)))),
global: false,
spot: false,
};
+1
View File
@@ -20,6 +20,7 @@ zune-jpeg = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
chrono = { workspace = true }
[dev-dependencies]
jpeg-encoder = { workspace = true }
+6 -6
View File
@@ -314,15 +314,15 @@ impl PressureProfile {
pub fn at(&self, t: f64) -> f64 {
let p = &self.points;
let (Some(first), Some(last)) = (p.first(), p.last()) else { return Self::MID };
// (Positions go forward; a profile read from a file is searched as if they do, in log time.)
let v = if t <= first.0 {
first.1
} else {
p.windows(2)
.find_map(|w| match w {
[a, b] if t <= b.0 => Some(a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0)),
_ => None,
})
.unwrap_or(last.1)
let i = p.partition_point(|q| q.0 < t);
match (i.checked_sub(1).and_then(|j| p.get(j)), p.get(i)) {
(Some(a), Some(b)) => a.1 + (b.1 - a.1) * ((t - a.0) / (b.0 - a.0).max(1e-9)).clamp(0.0, 1.0),
_ => last.1,
}
};
if v.is_finite() { v.clamp(0.0, 1.0) } else { Self::MID }
}
+173 -21
View File
@@ -44,23 +44,54 @@ fn lerp_f64(x: f64, y: f64, t: f64) -> f64 {
x + (y - x) * t
}
fn solid_gradient(c: Color, like: &Gradient) -> Gradient {
/// `like` with every stop painting `c`, linked as the solid is (`swatch` at `tint`).
fn solid_gradient(c: Color, swatch: Option<&str>, tint: f32, like: &Gradient) -> Gradient {
let mut g = like.clone();
for s in &mut g.stops {
s.color = c;
s.opacity = 1.0;
s.swatch = swatch.map(str::to_string);
s.tint = tint;
}
g
}
/// The swatch and tint `g` paints at `t`: a stop's own link, or the shared swatch of the two stops
/// around `t` at the tint between theirs (unlinked when they differ), as [`Gradient::sample_with`] walks.
fn link_at(g: &Gradient, t: f32) -> (Option<String>, f32) {
let stops = &g.stops;
let link = |s: &GradientStop| (s.swatch.clone(), s.tint);
let Some(first) = stops.first() else { return (None, 1.0) };
if t <= first.offset {
return link(first);
}
for w in stops.windows(2) {
let (a, b) = (&w[0], &w[1]);
if t <= b.offset {
let span = (b.offset - a.offset).max(1e-6);
let u = (t - a.offset) / span;
let m = a.midpoint.clamp(0.01, 0.99);
let v = if u < m { 0.5 * u / m } else { 0.5 + 0.5 * (u - m) / (1.0 - m) };
return match (&a.swatch, &b.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(a.tint, b.tint, v)),
_ if v <= 0.0 => link(a),
_ if v >= 1.0 => link(b),
_ => (None, 1.0),
};
}
}
stops.last().map_or((None, 1.0), link)
}
/// `g` with its stops at `offsets`, each painting what `g` paints there (midpoints centred).
fn resample_stops(g: &Gradient, offsets: &[f32]) -> Gradient {
let mut out = g.clone();
out.stops = offsets
.iter()
.map(|&o| {
let (color, opacity) = g.sample_with(o, lerp_color);
GradientStop { opacity, ..GradientStop::new(o, color) }
let (color, opacity) = g.sample_keep_model(o);
let (swatch, tint) = link_at(g, o);
GradientStop { opacity, swatch, tint, ..GradientStop::new(o, color) }
})
.collect();
out
@@ -106,10 +137,19 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
.stops
.iter()
.zip(&gb.stops)
.map(|(sa, sb)| GradientStop {
opacity: lerp_f32(sa.opacity, sb.opacity, t),
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
.map(|(sa, sb)| {
// Stops on two tints of one swatch stay linked to it, as solids do (#3234).
let (swatch, tint) = match (&sa.swatch, &sb.swatch) {
(Some(x), Some(y)) if x == y => (Some(x.clone()), lerp_f32(sa.tint, sb.tint, t)),
_ => (None, 1.0),
};
GradientStop {
opacity: lerp_f32(sa.opacity, sb.opacity, t),
midpoint: lerp_f32(sa.midpoint, sb.midpoint, t),
swatch,
tint,
..GradientStop::new(lerp_f32(sa.offset, sb.offset, t), lerp_color(&sa.color, &sb.color, t))
}
})
.collect();
out.angle = a.angle + (b.angle - a.angle) * t as f64;
@@ -131,17 +171,21 @@ fn lerp_gradient(a: &GradientPaint, b: &GradientPaint, t: f32) -> Option<Gradien
/// solid↔gradient; otherwise switch halfway.
pub fn lerp_paint(a: &Paint, b: &Paint, t: f32) -> Paint {
match (a, b) {
// Two tints of one swatch blend into tints of that swatch (a spot stays a spot on export).
(Paint::Solid { color: x, swatch: Some(sa), tint: ta }, Paint::Solid { color: y, swatch: Some(sb), tint: tb }) if sa == sb => {
Paint::Solid { color: lerp_color(x, y, t), swatch: Some(sa.clone()), tint: lerp_f32(*ta, *tb, t) }
}
(Paint::Solid { color: x, .. }, Paint::Solid { color: y, .. }) => Paint::solid(lerp_color(x, y, t)),
(Paint::Gradient(ga), Paint::Gradient(gb)) => match lerp_gradient(ga, gb, t) {
Some(g) => Paint::Gradient(Box::new(g)),
None => (if t < 0.5 { a } else { b }).clone(),
},
(Paint::Solid { color, .. }, Paint::Gradient(g)) => {
let ga = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
(Paint::Solid { color, swatch, tint }, Paint::Gradient(g)) => {
let ga = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(&ga, g, t).unwrap_or_else(|| (**g).clone())))
}
(Paint::Gradient(g), Paint::Solid { color, .. }) => {
let gb = GradientPaint { gradient: solid_gradient(*color, &g.gradient), ..(**g).clone() };
(Paint::Gradient(g), Paint::Solid { color, swatch, tint }) => {
let gb = GradientPaint { gradient: solid_gradient(*color, swatch.as_deref(), *tint, &g.gradient), ..(**g).clone() };
Paint::Gradient(Box::new(lerp_gradient(g, &gb, t).unwrap_or_else(|| (**g).clone())))
}
_ => (if t < 0.5 { a } else { b }).clone(),
@@ -535,11 +579,18 @@ impl Lerp {
/// The object at `t` (0 = `a`, 1 = `b`), with id 0.
pub fn at(&self, t: f64) -> Node {
self.at_eased(t, t)
}
/// The object whose shape is at `t` and whose colours (fills, strokes, opacity, mesh and text
/// colours) are at `tc`: a blend with its colours eased apart from its spacing.
pub fn at_eased(&self, t: f64, tc: f64) -> Node {
let (a, b) = (&self.a, &self.b);
let base = if t < 0.5 { a } else { b };
let mut n = match &self.shape {
Shape::Group { children, clip } => {
let mut g = Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at(t))).collect(), clip: *clip });
let mut g =
Node::new(NodeId(0), NodeKind::Group { children: children.iter().map(|c| Arc::new(c.at_eased(t, tc))).collect(), clip: *clip });
g.isolate = base.isolate;
g.knockout = base.knockout;
g
@@ -549,8 +600,8 @@ impl Lerp {
if let (NodeKind::Mesh(m), NodeKind::Mesh(ma), NodeKind::Mesh(mb)) = (&mut n.kind, &a.kind, &b.kind) {
for ((p, x), y) in m.points.iter_mut().zip(&ma.points).zip(&mb.points) {
p.p = x.p.lerp(y.p, t);
p.color = lerp_color(&x.color, &y.color, t as f32);
p.opacity = lerp_f32(x.opacity, y.opacity, t as f32);
p.color = lerp_color(&x.color, &y.color, tc as f32);
p.opacity = lerp_f32(x.opacity, y.opacity, tc as f32);
for h in 0..4 {
p.handles[h] = x.handles[h].lerp(y.handles[h], t);
}
@@ -576,7 +627,7 @@ impl Lerp {
Shape::Placed => {
let mut n = base.clone();
match (&mut n.kind, &a.kind, &b.kind) {
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t),
(NodeKind::Text(o), NodeKind::Text(x), NodeKind::Text(y)) => lerp_text(o, x, y, t, tc),
(NodeKind::SymbolInstance { xf: o, .. }, NodeKind::SymbolInstance { xf: x, .. }, NodeKind::SymbolInstance { xf: y, .. }) => {
*o = lerp_affine(*x, *y, t)
}
@@ -613,8 +664,8 @@ impl Lerp {
}
};
n.id = NodeId(0);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, t).into();
n.opacity = lerp_f32(a.opacity, b.opacity, t as f32);
n.appearance = lerp_appearance(&a.appearance, &b.appearance, tc).into();
n.opacity = lerp_f32(a.opacity, b.opacity, tc as f32);
n.blend = base.blend;
n.visible = true;
n.locked = false;
@@ -644,7 +695,7 @@ fn compound(path: PathData, rule: FillRule, counts: &[usize], total: usize) -> N
/// Interpolate text `x` → `y` into `o` (a copy of the nearer): the transform by its parts, an
/// area frame anchor by anchor, and each run's colours, stroke weight and size from the runs at
/// the same place (by character) of both.
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64) {
o.xf = lerp_affine(x.xf, y.xf, t);
if let (crate::text::TextKind::Area { frame: f }, crate::text::TextKind::Area { frame: fx }, crate::text::TextKind::Area { frame: fy }) =
(&mut o.kind, &x.kind, &y.kind)
@@ -666,7 +717,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64) {
}
let total: usize = o.runs.iter().map(|r| r.text.chars().count()).sum::<usize>().max(1);
let mut start = 0usize;
let tf = t as f32;
let tf = tc as f32;
let mut resized = false;
for r in &mut o.runs {
let len = r.text.chars().count();
@@ -1027,8 +1078,10 @@ pub fn blend_expand(keys: &[Arc<Node>], spec: &BlendSpec) -> Vec<Node> {
let n = blend_step_count(a, b, spec.spacing, len);
let lerp = Lerp::new(a, b, (spec.start(i), spec.start(i + 1)));
for j in 1..=n {
let t = j as f64 / (n + 1) as f64;
let mut s = lerp.at(t);
// Even position of the step, then where the easing puts its shape and its colours.
let u = j as f64 / (n + 1) as f64;
let (t, tc) = (if rail.is_some() { spec.ease.apply(u) } else { u }, spec.color_position(u));
let mut s = lerp.at_eased(t, tc);
if let Some(r) = &rail {
r.place(&mut s, i, t);
}
@@ -1090,6 +1143,55 @@ mod tests {
assert_eq!(out[0].geometric_bounds().unwrap().center(), Point::new(5.0, 5.0), "keys stay put");
}
#[test]
fn easing_keeps_the_ends_and_stays_monotone() {
use crate::live::{BlendEase, BlendEaseType as K};
for kind in [K::In, K::Out, K::InOut, K::OutIn] {
for ramp in [0.0, 0.5, 1.0, f64::NAN, f64::INFINITY] {
let e = BlendEase { kind, ramp };
assert_eq!((e.apply(0.0), e.apply(1.0)), (0.0, 1.0));
let ys: Vec<f64> = (0..=100).map(|i| e.apply(i as f64 / 100.0)).collect();
assert!(ys.windows(2).all(|w| w[0] <= w[1]));
assert_eq!(e.apply(f64::NAN), 0.0);
if ramp == 0.0 {
assert_eq!(e.apply(0.25), 0.25);
}
}
}
let e = |kind| BlendEase { kind, ramp: 1.0 };
assert!(e(K::In).apply(0.25) < 0.25);
assert!(e(K::Out).apply(0.25) > 0.25);
assert!(e(K::InOut).apply(0.25) < 0.25 && e(K::InOut).apply(0.75) > 0.75);
assert!(e(K::OutIn).apply(0.25) > 0.25 && e(K::OutIn).apply(0.75) < 0.75);
}
#[test]
fn colour_easing_moves_colours_apart_from_positions() {
let rect = |x: f64, c: Color| {
let mut n = Node::path(NodeId(1), shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)), Appearance::default());
n.appearance = Appearance::basic(Paint::solid(c), Paint::None, 0.0).into();
Arc::new(n)
};
let keys = vec![rect(0.0, Color::BLACK), rect(100.0, Color::WHITE)];
let mid = |spec: &BlendSpec| {
let out = blend_expand(&keys, spec);
let n = &out[1];
(n.geometric_bounds().unwrap().x0, n.appearance.fill_paint())
};
// Position easing runs along a spine: Align to Path gives the blend one.
let base = BlendSpec { spacing: BlendSpacing::Steps(1), orientation: BlendOrientation::AlignToPath, ..Default::default() };
let (x0, p0) = mid(&base);
// Colour easing alone leaves the position and moves the colour.
let colour = BlendSpec { color_shift: 50.0, ..base.clone() };
let (x1, p1) = mid(&colour);
assert!((x0 - x1).abs() < 1e-9 && p0 != p1);
// Position easing alone leaves the colour and moves the position.
let pos = BlendSpec { ease: crate::live::BlendEase { kind: crate::live::BlendEaseType::In, ramp: 1.0 }, ..base.clone() };
let (x2, p2) = mid(&pos);
assert!((x0 - x2).abs() > 1e-3 && p0 == p2);
assert_eq!(mid(&pos).0, x2, "deterministic");
}
#[test]
fn affine_lerp_turns_the_short_way() {
let x = Affine::rotate(170f64.to_radians());
@@ -1114,6 +1216,56 @@ mod tests {
assert!(pair.at(0.5).bounds().unwrap().height() < 1e-6, "the clicked anchors pair up");
}
#[test]
fn blend_between_tints_of_one_swatch_keeps_the_link() {
let tinted = |tint: f32| Paint::Solid { color: Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint), swatch: Some("Spot".into()), tint };
let Paint::Solid { swatch, tint, .. } = lerp_paint(&tinted(0.2), &tinted(1.0), 0.5) else { panic!() };
assert_eq!(swatch.as_deref(), Some("Spot"));
assert!((tint - 0.6).abs() < 1e-6, "{tint}");
let other = Paint::Solid { color: Color::BLACK, swatch: Some("Other".into()), tint: 1.0 };
assert!(matches!(lerp_paint(&tinted(0.2), &other, 0.5), Paint::Solid { swatch: None, .. }));
// Gradient stops on tints of one swatch stay linked too; a process solid unlinks them.
let spot_gradient = |tint: f32| {
let mut stop = GradientStop::new(0.0, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
stop.swatch = Some("Spot".into());
stop.tint = tint;
let stops = vec![stop.clone(), GradientStop { offset: 1.0, ..stop }];
Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })))
};
let Paint::Gradient(m) = lerp_paint(&spot_gradient(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
let Paint::Gradient(m) = lerp_paint(&tinted(0.2), &spot_gradient(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot") && (s.tint - 0.6).abs() < 1e-6), "{:?}", m.gradient.stops);
let Paint::Gradient(m) = lerp_paint(&Paint::solid(Color::BLACK), &spot_gradient(1.0), 0.5) else { panic!() };
assert!(m.gradient.stops.iter().all(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn resampled_gradient_stops_keep_the_swatch_link() {
let spot = |offset: f32, tint: f32| {
let mut stop = GradientStop::new(offset, Color::cmyk(1.0, 0.7, 0.0, 0.4).tinted(tint));
stop.swatch = Some("Spot".into());
stop.tint = tint;
stop
};
let paint = |stops: Vec<GradientStop>| Paint::Gradient(Box::new(GradientPaint::new(Gradient { stops, ..Gradient::default() })));
let two = paint(vec![spot(0.0, 0.2), spot(1.0, 1.0)]);
let three = paint(vec![spot(0.0, 0.2), spot(0.5, 0.6), spot(1.0, 1.0)]);
let Paint::Gradient(m) = lerp_paint(&two, &three, 0.5) else { panic!() };
assert_eq!(m.gradient.stops.len(), 3);
assert!(m.gradient.stops.iter().all(|s| s.swatch.as_deref() == Some("Spot")), "{:?}", m.gradient.stops);
let tints: Vec<f32> = m.gradient.stops.iter().map(|s| s.tint).collect();
assert!((tints[1] - 0.6).abs() < 1e-6 && (tints[2] - 1.0).abs() < 1e-6, "{tints:?}");
// Spot on one side, process on the other: the stops are unlinked.
let mut plain = spot(0.5, 1.0);
plain.swatch = None;
let mixed = paint(vec![spot(0.0, 1.0), plain, spot(1.0, 1.0)]);
let Paint::Gradient(m) = lerp_paint(&two, &mixed, 0.5) else { panic!() };
assert!(m.gradient.stops.iter().any(|s| s.swatch.is_none()), "{:?}", m.gradient.stops);
}
#[test]
fn gradients_with_different_stop_counts_interpolate() {
let g = |stops: &[(f32, Color)]| {
+30
View File
@@ -92,6 +92,15 @@ pub fn check() -> Result<(), Cancelled> {
if is_cancelled() { Err(Cancelled) } else { Ok(()) }
}
/// Run `f` with this thread's token still in effect and its progress reports dropped: for work
/// that reports its own steps around calls that report theirs (each file of a batch export).
pub fn quiet<T>(f: impl FnOnce() -> T) -> T {
match CURRENT.with(|c| c.borrow().as_ref().map(|s| s.token.clone())) {
Some(token) => scope(&token, None, f),
None => f(),
}
}
/// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`]
/// that has a sink.
pub fn progress(done: u64, total: u64, what: &str) {
@@ -139,4 +148,25 @@ mod tests {
progress(2, 3, "after");
assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]);
}
#[test]
fn quiet_work_reports_nothing_and_can_still_be_cancelled() {
let seen = Rc::new(RefCell::new(vec![]));
let sink: ProgressSink = {
let seen = seen.clone();
Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string())))
};
let t = CancelToken::new();
scope(&t, Some(sink), || {
progress(0, 2, "file");
quiet(|| {
progress(0, 1, "artboard");
t.cancel();
assert_eq!(check(), Err(Cancelled));
});
progress(1, 2, "file");
});
assert_eq!(*seen.borrow(), vec![(0, 2, "file".to_string()), (1, 2, "file".to_string())]);
assert_eq!(quiet(check), Ok(()), "outside a scope nothing is cancelled");
}
}
+179 -34
View File
@@ -7,11 +7,68 @@ use std::collections::BTreeSet;
use vectorcraft_geom::corners::{Corner, cut_corners, fitted_radii, path_corners};
use vectorcraft_geom::shapes::{self, CornerKind};
use vectorcraft_geom::{Affine, PathData, Rect};
use vectorcraft_geom::{Affine, PathData, Point, Rect};
use crate::node::{LiveShape, Node, NodeKind};
use crate::selection::AnchorRef;
/// Stored corner radii as document lengths for cutting and display (fractions when `relative`).
pub fn resolve_stored_radii(base: &PathData, stored: &[f64], relative: bool) -> Vec<f64> {
if !relative {
return stored.to_vec();
}
let corners = path_corners(base);
let mut out = stored.to_vec();
out.resize(base.anchor_count(), 0.0);
for c in corners {
if let Some(r) = out.get_mut(c.index) {
*r = (*r).clamp(0.0, 1.0) * c.max_radius();
}
}
out
}
/// Turn stored absolute radii into fractions of each corner's maximum, or the other way.
pub fn convert_radii_relative(live: &mut LiveShape, relative: bool) {
if live.radii_relative() == relative {
return;
}
let was_relative = live.radii_relative();
let base = match live {
LiveShape::Rectangle { w, h, .. } => shapes::rectangle(Rect::new(0.0, 0.0, *w, *h)),
LiveShape::Polygon { .. } => live.polygon_outline(),
LiveShape::Path { base, .. } => base.clone(),
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
};
let corners = path_corners(&base);
let radii: &mut [f64] = match live {
LiveShape::Rectangle { radii, .. } => radii,
LiveShape::Polygon { radii, .. } => radii,
LiveShape::Path { radii, .. } => radii,
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
};
for c in corners {
if let Some(r) = radii.get(c.index) {
let max = c.max_radius();
radii[c.index] = if relative {
if max > 1e-12 && !was_relative { (*r / max).clamp(0.0, 1.0) } else { *r }
} else if was_relative {
(*r).clamp(0.0, 1.0) * max
} else {
*r
};
}
}
match live {
LiveShape::Rectangle { radii_relative: rel, .. }
| LiveShape::Polygon { radii_relative: rel, .. }
| LiveShape::Path { radii_relative: rel, .. } => {
*rel = relative;
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => {}
}
}
/// The Live Corners of a path.
#[derive(Clone, Debug)]
pub struct LiveCorners<'a> {
@@ -26,6 +83,8 @@ pub struct LiveCorners<'a> {
/// The radius each of `corners` is drawn with ([`fitted_radii`]).
drawn: Vec<f64>,
kinds: Vec<CornerKind>,
/// Stored radii are fractions of each corner's maximum, not document lengths.
relative: bool,
/// For each anchor of the path as drawn (per subpath), the anchor of `base` it comes from.
sources: Vec<Vec<usize>>,
}
@@ -34,16 +93,21 @@ impl<'a> LiveCorners<'a> {
/// The Live Corners of `path` and its live shape; `None` for shapes without corners (ellipses,
/// lines).
pub fn new(path: &'a PathData, live: Option<&'a LiveShape>) -> Option<Self> {
let (base, xf, radii, kinds) = match live {
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![]),
let (base, xf, stored, kinds, relative) = match live {
None => (Cow::Borrowed(path), Affine::IDENTITY, vec![], vec![], false),
Some(l @ LiveShape::Rectangle { .. }) => {
let LiveShape::Rectangle { w, h, radii, kinds, xf } = l.folded() else { return None };
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec())
let LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } = l.folded() else { return None };
(Cow::Owned(shapes::rectangle(Rect::new(0.0, 0.0, w, h))), xf, radii.to_vec(), kinds.to_vec(), radii_relative)
}
Some(l @ LiveShape::Polygon { radii, kinds, radii_relative, .. }) => {
(Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative)
}
Some(LiveShape::Path { base, radii, kinds, radii_relative }) => {
(Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone(), *radii_relative)
}
Some(l @ LiveShape::Polygon { radii, kinds, .. }) => (Cow::Owned(l.polygon_outline()), Affine::IDENTITY, radii.clone(), kinds.clone()),
Some(LiveShape::Path { base, radii, kinds }) => (Cow::Borrowed(base), Affine::IDENTITY, radii.clone(), kinds.clone()),
Some(LiveShape::Ellipse { .. } | LiveShape::Line { .. }) => return None,
};
let radii = resolve_stored_radii(&base, &stored, relative);
let sources = if radii.iter().any(|r| *r > 0.0) {
cut_corners(&base, &radii, &kinds).1
} else {
@@ -58,7 +122,7 @@ impl<'a> LiveCorners<'a> {
};
let corners = path_corners(&base);
let drawn = fitted_radii(&corners, &radii);
Some(Self { corners, base, xf, radii, drawn, kinds, sources })
Some(Self { corners, base, xf, radii: stored, drawn, kinds, relative, sources })
}
/// The Live Corners of a path object.
@@ -72,7 +136,13 @@ impl<'a> LiveCorners<'a> {
self.corners.binary_search_by_key(&index, |c| c.index).ok().and_then(|i| self.corners.get(i))
}
/// The radius corner `index` is set to, in document units (drawn no larger than fits).
/// Whether corner radii are stored as fractions of each corner's maximum.
pub fn radii_relative(&self) -> bool {
self.relative
}
/// The radius corner `index` is stored at: document lengths, or a fraction in 0..1 when
/// [`LiveCorners::radii_relative`].
pub fn radius(&self, index: usize) -> f64 {
self.radii.get(index).copied().unwrap_or(0.0)
}
@@ -119,8 +189,9 @@ impl<'a> LiveCorners<'a> {
&self.sources
}
/// The radius (document units) and kind `corners` share (each `None` when they differ, or
/// for no corners).
/// The radius and kind `corners` share (each `None` when they differ, or for no corners).
/// Radius is what was set ([`LiveCorners::radius`]: document lengths, or a fraction in 0..1
/// when [`LiveCorners::radii_relative`]), not the fitted draw size ([`LiveCorners::drawn_radius`]).
pub fn style(&self, corners: &BTreeSet<usize>) -> (Option<f64>, Option<CornerKind>) {
fn shared<T: Copy>(mut it: impl Iterator<Item = T>, same: impl Fn(T, T) -> bool) -> Option<T> {
let first = it.next()?;
@@ -137,29 +208,31 @@ impl<'a> LiveCorners<'a> {
/// plain path again once no corner is cut. Ellipses and lines have no corners: they stay as they
/// are.
pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(radii: &mut [f64], kinds: &mut [CornerKind], corners: &BTreeSet<usize>, radius: Option<f64>, kind: Option<CornerKind>) {
fn edit(
base: &PathData,
relative: bool,
radii: &mut [f64],
kinds: &mut [CornerKind],
corners: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
let corners_on_base = path_corners(base);
for k in corners {
if let (Some(r), Some(slot)) = (radius, radii.get_mut(*k)) {
*slot = r.max(0.0);
*slot = if relative {
let max = corners_on_base.iter().find(|c| c.index == *k).map(|c| c.max_radius()).unwrap_or(0.0);
if max > 1e-12 { (r / max).clamp(0.0, 1.0) } else { 0.0 }
} else {
r.max(0.0)
};
}
if let (Some(kind), Some(slot)) = (kind, kinds.get_mut(*k)) {
*slot = kind;
}
}
}
/// One radius and kind per corner of an outline of `n` anchors while editing; none saved
/// when all are sharp or round.
fn edit_vec(
n: usize,
radii: &mut Vec<f64>,
kinds: &mut Vec<CornerKind>,
corners: &BTreeSet<usize>,
radius: Option<f64>,
kind: Option<CornerKind>,
) {
radii.resize(n, 0.0);
kinds.resize(n, CornerKind::Round);
edit(radii, kinds, corners, radius, kind);
fn trim_vec_radii(radii: &mut Vec<f64>, kinds: &mut Vec<CornerKind>) {
if radii.iter().all(|r| *r <= 0.0) {
radii.clear();
}
@@ -167,18 +240,35 @@ pub fn set_corners(path: &mut PathData, live: &mut Option<Box<LiveShape>>, corne
kinds.clear();
}
}
let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![] }));
let l = live.get_or_insert_with(|| Box::new(LiveShape::Path { base: path.clone(), radii: vec![], kinds: vec![], radii_relative: false }));
// Radii are document lengths and corners circular, also on a rectangle from a file that kept
// an uneven scale in its transform (#442).
l.fold_scale();
let relative = l.radii_relative();
match &mut **l {
LiveShape::Rectangle { radii, kinds, .. } => edit(radii, kinds, corners, radius, kind),
LiveShape::Polygon { sides, radii, kinds, .. } => edit_vec(*sides as usize, radii, kinds, corners, radius, kind),
LiveShape::Path { base, radii, kinds } => edit_vec(base.anchor_count(), radii, kinds, corners, radius, kind),
LiveShape::Rectangle { w, h, radii, kinds, .. } => {
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
edit(&base, relative, radii, kinds, corners, radius, kind);
}
LiveShape::Polygon { radius: poly_r, sides, xf, radii, kinds, .. } => {
let outline = shapes::polygon(Point::ZERO, *poly_r, *sides, 0.0).transformed(*xf);
let n = *sides as usize;
radii.resize(n, 0.0);
kinds.resize(n, CornerKind::Round);
edit(&outline, relative, radii, kinds, corners, radius, kind);
trim_vec_radii(radii, kinds);
}
LiveShape::Path { base, radii, kinds, .. } => {
let n = base.anchor_count();
radii.resize(n, 0.0);
kinds.resize(n, CornerKind::Round);
edit(base, relative, radii, kinds, corners, radius, kind);
trim_vec_radii(radii, kinds);
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return,
}
match live.as_deref_mut() {
Some(LiveShape::Path { base, radii, kinds }) if radii.is_empty() && kinds.is_empty() => {
Some(LiveShape::Path { base, radii, kinds, .. }) if radii.is_empty() && kinds.is_empty() => {
*path = std::mem::take(base);
*live = None;
}
@@ -202,7 +292,14 @@ mod tests {
#[test]
fn rectangle_corners_map_to_their_anchors() {
let l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
let l = LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [0.0, 10.0, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::IDENTITY,
};
let path = l.to_path();
let c = LiveCorners::new(&path, Some(&l)).unwrap();
// Anchors: top-left, the top-right corner's two, bottom-right, bottom-left.
@@ -229,7 +326,7 @@ mod tests {
let star = shapes::star(Point::new(100.0, 100.0), 50.0, 25.0, 5, 0.0);
let (mut path, mut live) = (star.clone(), None);
set_corners(&mut path, &mut live, &set(&[0, 2]), Some(5.0), None);
let Some(LiveShape::Path { base, radii, kinds }) = live.as_deref() else { panic!("live corners: {live:?}") };
let Some(LiveShape::Path { base, radii, kinds, .. }) = live.as_deref() else { panic!("live corners: {live:?}") };
assert_eq!((base, radii.len(), kinds.len()), (&star, 10, 0));
assert_eq!(path.anchor_count(), 12, "two corners cut");
let c = LiveCorners::new(&path, live.as_deref()).unwrap();
@@ -245,7 +342,8 @@ mod tests {
/// A polygon's corners are cut in the document and survive a change of sides when shared.
#[test]
fn polygon_corners_follow_their_sides() {
let mut l = LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![] };
let mut l =
LiveShape::Polygon { radius: 50.0, sides: 6, xf: Affine::translate((100.0, 100.0)), radii: vec![], kinds: vec![], radii_relative: false };
let (mut path, mut live) = (l.to_path(), Some(Box::new(l.clone())));
set_corners(&mut path, &mut live, &set(&[0, 1, 2, 3, 4, 5]), Some(8.0), None);
assert_eq!(path.anchor_count(), 12);
@@ -265,6 +363,53 @@ mod tests {
assert!(!p.transform(Affine::translate((5.0, 0.0))), "a move needs no new cuts");
}
#[test]
fn relative_radii_scale_with_rectangle_size() {
let l = LiveShape::Rectangle {
w: 100.0,
h: 100.0,
radii: [0.25, 0.25, 0.25, 0.25],
kinds: Default::default(),
radii_relative: true,
xf: Affine::IDENTITY,
};
let path = l.to_path();
let c = LiveCorners::new(&path, Some(&l)).unwrap();
let r0 = c.drawn_radius(0);
let l2 = LiveShape::Rectangle {
w: 200.0,
h: 200.0,
radii: [0.25, 0.25, 0.25, 0.25],
kinds: Default::default(),
radii_relative: true,
xf: Affine::IDENTITY,
};
let path2 = l2.to_path();
let c2 = LiveCorners::new(&path2, Some(&l2)).unwrap();
assert!((c2.drawn_radius(0) / r0 - 2.0).abs() < 0.05, "{r0} → {}", c2.drawn_radius(0));
}
#[test]
fn convert_between_relative_and_absolute_radii() {
let mut l = LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [10.0, 0.0, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::IDENTITY,
};
let before = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
convert_radii_relative(&mut l, true);
assert!(l.radii_relative());
let mid = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
assert!((mid - before).abs() < 0.5);
convert_radii_relative(&mut l, false);
assert!(!l.radii_relative());
let after = LiveCorners::new(&l.to_path(), Some(&l)).unwrap().drawn_radius(0);
assert!((after - before).abs() < 0.5);
}
#[test]
fn open_paths_and_curves_keep_their_ends_and_smooth_anchors() {
let pen = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false));
+196 -20
View File
@@ -66,11 +66,16 @@ pub struct HitOptions {
/// Guide objects (paths made into guides) are not hittable: View › Guides › Lock Guides is
/// on or the guides are hidden.
pub skip_guides: bool,
/// The selection tools: a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips, so that the mask can be reached where the art covers
/// its edge. Off for the tools that act on what is drawn (sampling, type, gradients…);
/// [`hit_test_visible`] never does it.
pub clip_outlines: bool,
}
impl Default for HitOptions {
fn default() -> Self {
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, scope: None }
Self { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: false, scope: None }
}
}
@@ -134,6 +139,11 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
return objects_at_repeated(doc, p, opt, scope);
}
}
// The objects on a locked artboard can't be selected, as `hit_test_skipping` passes over them.
let locks = doc.locked_artboards();
if !locks.is_empty() {
tops.retain(|id| !doc.node(*id).is_some_and(|n| locks.holds(n)));
}
tops
}
@@ -143,8 +153,16 @@ pub fn objects_at(doc: &Document, p: Point, opt: HitOptions, scope: Option<NodeI
/// measured by their art ([`Node::geometric_bounds_with`]).
fn objects_at_layer(layer: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, tops: &mut Vec<NodeId>, seen: &mut HashSet<NodeId>) -> bool {
let Some(children) = layer.children() else { return true };
if layer.clips() && children.first().is_some_and(|clip| !clip_contains(clip, p)) {
return true;
if layer.clips()
&& let Some(clip) = children.first()
{
// As the hit test does: the clipping mask's outline first, then only inside the mask.
if clip_outline_hit(layer, clip, p, opt, symbols, true).is_some() && seen.insert(clip.id) {
tops.push(clip.id);
}
if !clip_contains(clip, p) {
return true;
}
}
let opt = if matches!(layer.kind, NodeKind::Layer { preview: false, .. }) { HitOptions { outline: true, ..opt } } else { opt };
for (i, c) in children.iter().enumerate().rev() {
@@ -267,14 +285,24 @@ fn locked_hit(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> boo
}
}
/// [`hit_test`] passing over the nodes `skip` names (and their contents). Symbol instances are
/// measured by their art ([`Node::geometric_bounds_with`]).
/// [`hit_test`] passing over the nodes `skip` names (and their contents), and with
/// `editable_only` over the objects of layers and sublayers on a locked artboard, and over all of
/// an isolated group on one ([`crate::LockedArtboards::holds`]). Symbol instances are measured by their art
/// ([`Node::geometric_bounds_with`]).
fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(NodeId) -> bool, editable_only: bool) -> Option<Hit> {
let finish = |mut h: Hit| {
h.contents_of = h.ancestry.iter().rev().copied().find(|a| doc.node(*a).is_some_and(edits_contents));
h.layers = h.ancestry.iter().take_while(|a| doc.node(**a).is_some_and(Node::is_layer)).count();
h
};
let locks = doc.locked_artboards();
let on_locked = |id: NodeId| {
editable_only
&& !locks.is_empty()
&& doc.parent_of(id).and_then(|l| doc.node(l)).is_some_and(Node::is_layer)
&& doc.node(id).is_some_and(|n| locks.holds(n))
};
let skip = &|id| skip(id) || on_locked(id);
let symbols = doc.symbol_boxes();
// Isolation mode: only what is inside the isolated container hits. The art around it is
// locked, and clicks pass through it (#833).
@@ -283,6 +311,10 @@ fn hit_test_skipping(doc: &Document, p: Point, opt: HitOptions, skip: &dyn Fn(No
&& let Some(isolated) = doc.node(scope)
&& let Some(mut chain) = doc.ancestry(scope)
{
// Isolated before its artboard was locked: nothing in it hits.
if locks.holds_id(scope) {
return None;
}
if isolated.is_container() {
return hit_children(isolated, p, opt, &symbols, skip, &mut chain, editable_only).map(finish);
}
@@ -363,6 +395,31 @@ fn shaper_hides(parent: &Node, i: usize, scope: Option<NodeId>) -> bool {
parent.extras().shaper.is_some() && (i == 0) != source_of(parent, scope)
}
/// A press on the outline of clipping path `clip` (a path or a compound path; type clips by its
/// glyphs, which this crate cannot outline), within the tolerance.
fn clip_outline(clip: &Node, p: Point, opt: HitOptions) -> Option<HitKind> {
let bp = match &clip.kind {
NodeKind::Path { path, .. } => path.to_bezpath(),
NodeKind::Compound { .. } => clip.stroke_path()?,
_ => return None,
};
stroke_contains(&bp, 0.0, opt.tol, p).then_some(HitKind::Outline)
}
/// For the selection tools ([`HitOptions::clip_outlines`]), a press on the outline of `clip`, the
/// clipping path of `parent` (a clip group or a layer with a clipping mask), within the tolerance:
/// it is hit before the art it clips. There Direct Selection takes its segments, and the
/// Selection tool the clip group. The reference app reshapes a clipping path with Direct Selection
/// (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-clipping-masks.html>);
/// without this, art drawn over the path's edge is hit first and its segments can't be dragged.
/// The clipping path must be one a click may hit ([`may_hit`]: visible, editable, within reach).
fn clip_outline_hit(parent: &Node, clip: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes, editable_only: bool) -> Option<HitKind> {
if !opt.clip_outlines || !editable_only || !may_hit(parent, 0, clip, p, opt, symbols, editable_only) {
return None;
}
clip_outline(clip, p, opt)
}
fn hit_children(
parent: &Node,
p: Point,
@@ -373,12 +430,17 @@ fn hit_children(
editable_only: bool,
) -> Option<Hit> {
let children = parent.children()?;
// A clip group (or a layer with a clipping mask) only hits inside its clipping path.
if parent.clips()
&& let Some(clip) = children.first()
&& !clip_contains(clip, p)
{
return None;
if let Some(kind) = clip_outline_hit(parent, clip, p, opt, symbols, editable_only).filter(|_| !skip(clip.id)) {
chain.push(clip.id);
return Some(Hit { leaf: clip.id, ancestry: chain.clone(), kind, contents_of: None, layers: 1 });
}
// Elsewhere a clip group (or a layer with a clipping mask) only hits inside it.
if !clip_contains(clip, p) {
return None;
}
}
for i in hit_order(parent, children, p) {
let Some(c) = children.get(i) else { continue };
@@ -460,10 +522,18 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
n.geometric_bounds().filter(|b| b.inflate(opt.tol, opt.tol).contains(p))?;
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
}
// An image is hit within its frame, and where its own fills and strokes paint
// ([`image_paint`]) except in outline mode.
NodeKind::Image(_) => {
if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
// An image is hit within its frame (on the frame's edge only with Selection by Path Only,
// #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline
// mode.
NodeKind::Image(im) => {
if opt.path_only {
use vectorcraft_geom::kurbo::Shape;
let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1);
frame.apply_affine(im.xf);
if stroke_contains(&frame, 0.0, opt.tol, p) {
return Some(HitKind::Outline);
}
} else if n.geometric_bounds_with(symbols).is_some_and(|b| b.inflate(opt.tol, opt.tol).contains(p)) {
return Some(HitKind::Bounds);
}
if opt.outline {
@@ -477,7 +547,7 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
NodeKind::Text(_) | NodeKind::PlacedDocument(_) | NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } => {
n.geometric_bounds_with(symbols).filter(|b| b.inflate(opt.tol, opt.tol).contains(p)).map(|_| HitKind::Bounds)
}
NodeKind::Repeat(r) => r.expand().iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Repeat(r) => r.expand(Some(symbols.document())).iter().find_map(|g| hit_any(g, p, opt, symbols)),
NodeKind::Mesh(m) => {
let bp = m.outline().to_bezpath();
if stroke_contains(&bp, 0.0, opt.tol, p) {
@@ -533,10 +603,11 @@ pub fn compound_part_at(n: &Node, p: Point, tol: f64) -> Option<NodeId> {
parts.iter().filter(|(_, bp, _)| fill_contains(bp, *rule, p)).min_by(|a, b| size(&a.2).total_cmp(&size(&b.2))).map(|(id, ..)| *id)
}
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect. Symbol
/// instances are measured by their art ([`Node::geometric_bounds_with`]). The art of a clip group
/// or of a layer with a clipping mask counts only when the marquee meets the region its clipping
/// path clips to, and then only within that region's bounds.
/// Objects (children of layers, or of `scope` in isolation mode) touched by a marquee rect, leaving
/// out those on a locked artboard ([`crate::LockedArtboards::holds_id`]). Symbol instances are measured
/// by their art ([`Node::geometric_bounds_with`]). The art of a clip group or of a layer with a
/// clipping mask counts only when the marquee meets the region its clipping path clips to, and then
/// only within that region's bounds.
pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) -> Vec<NodeId> {
let mut out = Vec::new();
// `tops` pairs each object with the part of `r` in which the marquee can touch it.
@@ -588,7 +659,7 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Intertwine { children } => marquee_in(n, r)
.is_some_and(|r| children.iter().enumerate().any(|(i, c)| c.visible && !shaper_hides(n, i, scope) && touches(c, s, r, scope))),
NodeKind::Compound { children, .. } => children.iter().any(|c| c.visible && touches(c, s, r, scope)),
NodeKind::Repeat(spec) => spec.expand().iter().any(|copy| touches(copy, s, r, scope)),
NodeKind::Repeat(spec) => spec.expand(Some(s.document())).iter().any(|copy| touches(copy, s, r, scope)),
// A box with no width or no height (an instance of a symbol that draws a straight
// line) is touched where the marquee crosses it.
_ => n.geometric_bounds_with(s).is_some_and(|b| {
@@ -615,8 +686,9 @@ pub fn marquee(doc: &Document, r: Rect, scope: Option<NodeId>, leaves: bool) ->
}
}
let symbols = doc.symbol_boxes();
let locks = doc.locked_artboards();
for (t, r) in tops {
if !t.visible || t.locked {
if !t.visible || t.locked || locks.holds_id(t.id) {
continue;
}
if leaves {
@@ -949,6 +1021,81 @@ mod tests {
}
}
/// A rectangle (40, 40)–(140, 140) clipping a filled 200 × 200 square drawn over its edges, in a
/// clip group (or, `layer`, as the first layer's clipping mask): the group (or the layer), the
/// clipping path and the art.
fn masked_square(layer: bool) -> (Document, NodeId, NodeId, NodeId) {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (g, clip, art) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let mut path = Node::path(clip, shapes::rectangle(Rect::new(40.0, 40.0, 140.0, 140.0)), Appearance::basic(Paint::None, Paint::None, 0.0));
if let NodeKind::Path { clipping, .. } = &mut path.kind {
*clipping = true;
}
let picture = Node::path(art, shapes::rectangle(Rect::new(0.0, 0.0, 200.0, 200.0)), Appearance::default_art());
if layer {
d.insert(Some(l), 0, path).unwrap();
d.insert(Some(l), 1, picture).unwrap();
d.node_mut(l).unwrap().set_clips(true);
(d, l, clip, art)
} else {
d.insert(Some(l), 0, Node::new(g, NodeKind::Group { children: vec![Arc::new(path), Arc::new(picture)], clip: true })).unwrap();
(d, g, clip, art)
}
}
const SELECTING: HitOptions =
HitOptions { tol: 3.0, outline: false, path_only: false, type_path_only: false, skip_guides: false, clip_outlines: true, scope: None };
/// For the selection tools a clip group's clipping path is hit on its outline, within the
/// tolerance, before the art it clips and just outside it too; inside, the art is hit.
#[test]
fn a_clipping_path_is_hit_on_its_outline_before_its_art() {
let (d, g, clip, art) = masked_square(false);
for (x, y) in [(140.0, 90.0), (141.5, 90.0), (138.5, 90.0), (90.0, 40.0)] {
let h = hit_test(&d, Point::new(x, y), SELECTING).unwrap();
assert_eq!((h.leaf, h.kind, h.top_object(None)), (clip, HitKind::Outline, g), "at {x}, {y}");
}
let h = hit_test(&d, Point::new(90.0, 90.0), SELECTING).unwrap();
assert_eq!((h.leaf, h.top_object(None)), (art, g));
// Beyond the tolerance outside it, the clipped art is not there to hit.
assert!(hit_test(&d, Point::new(150.0, 90.0), SELECTING).is_none());
}
/// Without `clip_outlines` (the tools that act on what is drawn) and for read-only sampling
/// (the eyedropper), the art is hit on the mask's edge, as before: the clipping path paints
/// nothing there.
#[test]
fn only_the_selection_tools_hit_a_clipping_path_on_its_outline() {
let (d, _, clip, art) = masked_square(false);
let p = Point::new(138.5, 90.0);
assert_eq!(hit_test(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, HitOptions::default()).map(|h| h.leaf), Some(art));
assert_eq!(hit_test_visible(&d, p, SELECTING).map(|h| h.leaf), Some(art));
assert_eq!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
// A hidden or locked clipping path is passed over.
for change in [|n: &mut Node| n.visible = false, |n: &mut Node| n.locked = true] {
let mut d = d.clone();
change(d.node_mut(clip).unwrap());
assert_ne!(hit_test(&d, p, SELECTING).map(|h| h.leaf), Some(clip));
}
}
/// A layer's clipping mask is hit on its outline too, and Select Behind lists what the
/// repeated hit test finds: the mask on and just outside its edge, then the art inside it;
/// well inside, the art alone (the mask paints nothing there).
#[test]
fn select_behind_takes_a_layer_masks_outline_first() {
let (d, _, clip, art) = masked_square(true);
for (x, want) in [(140.0, vec![clip]), (141.5, vec![clip]), (138.5, vec![clip, art]), (90.0, vec![art])] {
let p = Point::new(x, 90.0);
assert_eq!(objects_at(&d, p, SELECTING, None), want, "x = {x}");
assert_eq!(objects_at_repeated(&d, p, SELECTING, None), want, "x = {x}");
}
// Without clip_outlines, as before.
assert_eq!(objects_at(&d, Point::new(138.5, 90.0), HitOptions::default(), None), vec![art, clip]);
}
/// Art in a sublayer is an object of its own: a click or a marquee takes it, not the
/// sublayer; hidden, locked and template sublayers block both.
#[test]
@@ -1016,6 +1163,28 @@ mod tests {
}
}
/// #3291: with Object Selection by Path Only, a placed image is picked on its frame's edge, so
/// a path under it can be picked through it.
#[test]
fn path_only_picks_paths_through_images() {
let mut d = Document::new(200.0, 200.0);
let l = d.layers[0].id;
let (path, image) = (d.alloc_id(), d.alloc_id());
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
// 100×50 pixels, scaled to 100×100 and moved to (10, 10): over the whole path.
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0)) * vectorcraft_geom::Affine::scale_non_uniform(1.0, 2.0);
let im = crate::ImageObject { key: "k".into(), width: 100, height: 50, xf, link: None, placement: Default::default(), pixels: None };
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
let path_only = HitOptions { path_only: true, ..Default::default() };
let hit = |p: Point, opt| hit_test(&d, p, opt).map(|h| h.leaf);
let (path_edge, inside, image_edge) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(80.0, 109.5));
assert_eq!(hit(path_edge, HitOptions::default()), Some(image), "the image covers the path");
assert_eq!(hit(inside, HitOptions::default()), Some(image));
assert_eq!(hit(path_edge, path_only), Some(path), "path only: the path through the image");
assert_eq!(hit(inside, path_only), None, "path only: not inside the image");
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
}
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
/// frame and type on a path on its path, no longer anywhere in its bounds.
#[test]
@@ -1243,9 +1412,16 @@ world",
let mut d = Document::new(120.0, 120.0);
let layer = d.layers[0].id;
fill(&mut d, layer, 0, &mut next);
// A clipping mask on the layer, or on a sublayer, now and then.
let sublayers: Vec<NodeId> = d.layers[0].children().into_iter().flatten().filter(|n| n.is_layer()).map(|n| n.id).collect();
for id in std::iter::once(layer).chain(sublayers) {
if next(3) == 0 {
d.node_mut(id).unwrap().set_clips(true);
}
}
for _ in 0..12 {
let p = Point::new(f64::from(next(120)), f64::from(next(120)));
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, ..Default::default() };
let opt = HitOptions { outline: next(5) == 0, path_only: next(5) == 0, clip_outlines: next(2) == 0, ..Default::default() };
assert_eq!(objects_at(&d, p, opt, None), objects_at_repeated(&d, p, opt, None), "round {round}, {p:?}, {opt:?}");
}
}
+530 -11
View File
@@ -44,6 +44,7 @@ pub mod perspective;
mod pixels;
pub use pixels::MAX_RASTER_ALLOC;
pub mod placed_document;
pub mod plugin_data;
pub mod profiles;
pub mod puppet;
pub mod range;
@@ -99,17 +100,17 @@ pub use graph::{
};
pub use hit::{Hit, HitKind};
pub use links::{LinkInfo, PlacementOptions};
pub use live::{BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use live::{BlendEase, BlendEaseType, BlendOrientation, BlendSpacing, BlendSpec, EnvelopeKind, GradientMesh, MeshPoint};
pub use live_paint::{GapOptions, GapSize};
pub use metadata::{CopyrightStatus, DocMetadata};
pub use node::Knockout;
pub use node::Scaling;
pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask};
pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
pub use node::{ForeignAttribute, ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
pub use orient::OrientedBox;
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
pub use perspective::PerspectiveAttachment;
pub use placed_document::PlacedDocument;
pub use placed_document::{PageBox, PlacedDocument};
pub use profiles::ColorProfiles;
pub use puppet::{PuppetPin, PuppetPins};
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
@@ -119,8 +120,8 @@ pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOption
pub use style_libs::StyleLibrary;
pub use text::{
AreaFit, AreaOptions, Burasagari, CharAlign, CharPosition, CharStyle, Composer, Digits, FirstBaseline, HyphenationOptions, InlineArt,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, ScriptMetrics,
SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
JustificationOptions, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition,
RubySpan, ScriptMetrics, SingleWordJustify, TabAlign, TabStop, TextKind, TextObject, TextRun, TextStyleDef, TextWrap, VerticalAlign, WrapShape,
};
pub use trace::TraceView;
pub use variables::{DataSet, DataValue, Variable, VariableKind, Variables};
@@ -378,7 +379,9 @@ fn parse_measure(s: &str, default: Unit) -> Option<f64> {
/// A number with an optional unit suffix (`12`, `3 mm`, `2in`) → (the number, its unit).
fn number_unit(s: &str) -> Option<(f64, Option<Unit>)> {
let s = s.trim();
let num_end = s.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).unwrap_or(s.len());
// A leading `+` is a sign (`+5`), like `-`.
let sign = usize::from(s.starts_with('+'));
let num_end = s.get(sign..)?.find(|c: char| !(c.is_ascii_digit() || c == '.' || c == '-')).map_or(s.len(), |i| i + sign);
let v: f64 = s.get(..num_end)?.trim().parse().ok()?;
let unit = match s.get(num_end..)?.trim() {
"" => None,
@@ -438,10 +441,165 @@ pub struct Artboard {
pub name: String,
/// Artboard rectangle in document points.
pub rect: Rect,
/// Locked (the Artboards panel's lock column): the Artboard tool and the artboard commands
/// don't move, resize or delete it, Rearrange leaves it where it is, and the art on it can't be
/// selected or edited ([`Document::on_locked_artboard`]), except new art while it stays
/// selected ([`Document::new_art`]).
#[serde(default, skip_serializing_if = "skip::is_default")]
pub locked: bool,
/// Show Center Mark (Artboard Options): the canvas draws a mark at the artboard's center.
/// Exports and print leave it out. Always written, as before it could be set.
#[serde(default)]
pub show_center_mark: bool,
/// Show Cross Hairs (Artboard Options): the canvas draws a line from the middle of each side
/// through the center. Exports and print leave them out. Always written, as before it could
/// be set.
#[serde(default)]
pub show_cross_hairs: bool,
/// Show Video Safe Areas (Artboard Options): the canvas draws the artboard's action-safe and
/// title-safe areas ([`Artboard::video_safe_areas`]). Exports and print leave them out. Written
/// only when on, so a document that never shows them saves as before.
#[serde(default, skip_serializing_if = "skip::is_default")]
pub show_video_safe_areas: bool,
/// Video Ruler Pixel Aspect Ratio (Artboard Options): the width of one video pixel over its
/// height, which View › Rulers › Show Video Rulers counts the artboard in: a pixel is this many
/// points wide and one point tall. 1 (square pixels) unless set, and written only when it isn't
/// 1; always within [`VIDEO_PIXEL_ASPECT_RANGE`]: the commands refuse others, and a file's is
/// brought into it as it opens.
#[serde(default = "square_pixels", deserialize_with = "pixel_aspect_within_range", skip_serializing_if = "is_square_pixels")]
pub video_ruler_pixel_aspect_ratio: f64,
/// Background color (Properties › Artboards › Fill, Artboard Options). The canvas draws the
/// artboard's paper in it, multiplied with the Simulate Colored Paper color while that is on;
/// without one the paper is white, or the Simulate Colored Paper color. Exports and print leave
/// it out.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<vectorcraft_color::Color>,
}
/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times
/// as tall as it is wide to one ten times as wide as it is tall, which holds the anamorphic and
/// standard-definition ratios video uses (0.9, 1.2, 1.33, 2) with room to spare.
pub const VIDEO_PIXEL_ASPECT_RANGE: std::ops::RangeInclusive<f64> = 0.1..=10.0;
/// How far in from each edge of an artboard the video safe areas lie, as a share of its width
/// (left and right) and of its height (top and bottom): the action-safe area 3.5% and the
/// title-safe (graphics-safe) area 5%, as EBU R 95 sets them for 16:9 television
/// (https://tech.ebu.ch/docs/r/r095.pdf, note 5).
pub const VIDEO_SAFE_INSETS: [f64; 2] = [0.035, 0.05];
fn square_pixels() -> f64 {
1.0
}
fn is_square_pixels(v: &f64) -> bool {
*v == 1.0
}
/// A file's Video Ruler Pixel Aspect Ratio, brought into [`VIDEO_PIXEL_ASPECT_RANGE`]: one that
/// isn't a number (NaN, infinite) reads as square pixels.
fn pixel_aspect_within_range<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
let v = f64::deserialize(d)?;
let (lo, hi) = (*VIDEO_PIXEL_ASPECT_RANGE.start(), *VIDEO_PIXEL_ASPECT_RANGE.end());
Ok(if v.is_finite() { v.clamp(lo, hi) } else { 1.0 })
}
impl Artboard {
/// The action-safe and the title-safe area of the artboard, in that order, each inset from its
/// edges by the share of [`VIDEO_SAFE_INSETS`] (Show Video Safe Areas).
pub fn video_safe_areas(&self) -> [Rect; 2] {
let r = self.rect;
VIDEO_SAFE_INSETS.map(|k| r.inflate(-k * r.width(), -k * r.height()))
}
}
/// A document's locked artboards ([`Document::locked_artboards`]).
pub struct LockedArtboards<'a> {
rects: Vec<Rect>,
symbols: SymbolBoxes<'a>,
/// The new art, which isn't on them ([`Document::new_art`]).
new_art: Arc<std::collections::HashSet<NodeId>>,
/// The objects of pattern editing mode's layer ([`Document::pattern_edit`]), which are on no
/// artboard.
pattern_art: std::collections::HashSet<NodeId>,
/// What [`LockedArtboards::overlaps`] found for each object it measured: the members of a
/// group are each answered with one measurement of the group.
held: std::cell::RefCell<std::collections::HashMap<NodeId, bool>>,
}
impl LockedArtboards<'_> {
/// No artboard is locked.
pub fn is_empty(&self) -> bool {
self.rects.is_empty()
}
/// Is `n`, an object of a layer or sublayer, on a locked artboard: shown, not a guide, and its
/// visual bounds (with its strokes, a symbol instance by its art) overlapping the artboard? An
/// object counts for every artboard it overlaps, as Fit to Artwork Bounds counts it. A layer or
/// sublayer is on none, nor is the art of pattern editing mode ([`Document::pattern_edit`]) or
/// new art while it stays selected ([`Document::new_art`]).
pub fn holds(&self, n: &Node) -> bool {
!self.new_art.contains(&n.id) && self.overlaps(n)
}
/// [`LockedArtboards::holds`] for new art too: is `n` on a locked artboard, whether or not it
/// is new art?
pub fn overlaps(&self, n: &Node) -> bool {
if self.rects.is_empty()
|| !n.visible
|| n.is_layer()
|| matches!(n.kind, NodeKind::Path { guide: true, .. })
|| self.pattern_art.contains(&n.id)
{
return false;
}
if let Some(held) = self.held.borrow().get(&n.id) {
return *held;
}
let held =
n.visual_bounds_with(&self.symbols).is_some_and(|b| self.rects.iter().any(|r| b.x0 < r.x1 && b.x1 > r.x0 && b.y0 < r.y1 && b.y1 > r.y0));
self.held.borrow_mut().insert(n.id, held);
held
}
/// Is node `id` on a locked artboard: is the object of a layer or sublayer that holds it (`id`
/// itself when it is one, a group counting as one object) on one ([`LockedArtboards::holds`])?
pub fn holds_id(&self, id: NodeId) -> bool {
!self.rects.is_empty() && self.symbols.document().layer_object(id).is_some_and(|n| self.holds(n))
}
}
/// New art on a locked artboard ([`Document::new_art`]). Setting it changes the document in
/// place, so a selection change that sets it doesn't replace the document.
#[derive(Default)]
pub struct NewArtIds(std::sync::RwLock<Arc<std::collections::HashSet<NodeId>>>);
impl NewArtIds {
/// The objects.
pub fn get(&self) -> Arc<std::collections::HashSet<NodeId>> {
self.0.read().map(|g| g.clone()).unwrap_or_default()
}
/// Make `ids` the objects.
pub fn set(&self, ids: Arc<std::collections::HashSet<NodeId>>) {
if let Ok(mut g) = self.0.write() {
*g = ids;
}
}
}
impl Clone for NewArtIds {
fn clone(&self) -> Self {
NewArtIds(std::sync::RwLock::new(self.get()))
}
}
/// Editing state, not document content: two documents are equal whatever new art they name.
impl PartialEq for NewArtIds {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for NewArtIds {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("NewArtIds").field(&self.get()).finish()
}
}
/// Opacity-mask editing mode: `object`'s mask art lives on the temporary `layer` while editing.
@@ -795,6 +953,12 @@ pub struct Document {
/// state, never saved.
#[serde(skip)]
pub puppet: Option<Arc<PuppetPins>>,
/// New art on a locked artboard: objects of layers on a locked artboard that an edit made and
/// left selected (a group with selected members counts), kept while they stay selected and on
/// one. They and their contents can be selected and edited ([`LockedArtboards::holds`]). The
/// engine sets it from the selection's ([`Selection::new_art`]). Editing state, never saved.
#[serde(skip)]
pub new_art: NewArtIds,
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
/// is set, so documents without any pay nothing for them.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
@@ -827,6 +991,136 @@ type Places = BTreeMap<Vec<usize>, Vec<(usize, usize)>>;
pub const FORMAT_VERSION: u32 = 1;
impl Document {
/// Is everything but the layers the same in `other`, as the next version of the document after
/// an edit of its art? The id counter and the id index are left out, and art outside the layers
/// (symbols, patterns, graph designs) compares by identity, which an edit keeps for what it
/// doesn't change: quick for every frame of an animation.
pub fn same_outside_layers(&self, other: &Self) -> bool {
let same_art = |a: &[Arc<Node>], b: &[Arc<Node>]| a.len() == b.len() && a.iter().zip(b).all(|(a, b)| Arc::ptr_eq(a, b));
let same_pattern = |a: &PatternDef, b: &PatternDef| {
same_art(&a.art, &b.art) && PatternDef { art: Vec::new(), ..a.clone() } == PatternDef { art: Vec::new(), ..b.clone() }
};
let Self {
version,
title,
template,
units,
ruler_origin,
rulers,
artboard_ruler_origins,
color_mode,
artboards,
layers: _,
swatches,
swatch_groups,
graphic_styles,
char_styles,
para_styles,
text_threads,
symbols,
graph_designs,
guides,
views,
saved_selections,
variables,
grid,
raster_effects_ppi,
images,
patterns,
pattern_edit,
mask_edit,
next_id: _,
unknown,
page_isolate,
page_knockout,
spot_use_lab,
setup,
paste_remembers_layers,
metadata,
raster_effects,
last_view,
transparency_grid,
layers_open,
color_profiles,
export_settings,
extra,
slices,
slices_clip_to_artboard,
print_setup,
assets,
svg_scripts,
puppet,
new_art,
dimensions,
index: _,
} = self;
*version == other.version
&& *title == other.title
&& *template == other.template
&& *units == other.units
&& *ruler_origin == other.ruler_origin
&& *rulers == other.rulers
&& *artboard_ruler_origins == other.artboard_ruler_origins
&& *color_mode == other.color_mode
&& *artboards == other.artboards
&& *swatches == other.swatches
&& *swatch_groups == other.swatch_groups
&& *graphic_styles == other.graphic_styles
&& *char_styles == other.char_styles
&& *para_styles == other.para_styles
&& *text_threads == other.text_threads
&& symbols.len() == other.symbols.len()
&& symbols.iter().zip(&other.symbols).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& graph_designs.len() == other.graph_designs.len()
&& graph_designs.iter().zip(&other.graph_designs).all(|(a, b)| a.name == b.name && Arc::ptr_eq(&a.art, &b.art))
&& *guides == other.guides
&& *views == other.views
&& *saved_selections == other.saved_selections
&& *variables == other.variables
&& *grid == other.grid
&& *raster_effects_ppi == other.raster_effects_ppi
&& *images == other.images
&& patterns.len() == other.patterns.len()
&& patterns.iter().zip(&other.patterns).all(|(a, b)| same_pattern(a, b))
&& match (pattern_edit, &other.pattern_edit) {
(Some(a), Some(b)) => {
a.pattern == b.pattern
&& a.layer == b.layer
&& match (&a.original, &b.original) {
(Some(a), Some(b)) => same_pattern(a, b),
(a, b) => a.is_none() && b.is_none(),
}
}
(a, b) => a.is_none() && b.is_none(),
}
&& *mask_edit == other.mask_edit
&& *unknown == other.unknown
&& *page_isolate == other.page_isolate
&& *page_knockout == other.page_knockout
&& *spot_use_lab == other.spot_use_lab
&& *setup == other.setup
&& *paste_remembers_layers == other.paste_remembers_layers
&& *metadata == other.metadata
&& *raster_effects == other.raster_effects
&& *last_view == other.last_view
&& *transparency_grid == other.transparency_grid
&& *layers_open == other.layers_open
&& *color_profiles == other.color_profiles
&& *export_settings == other.export_settings
&& *extra == other.extra
&& *slices == other.slices
&& *slices_clip_to_artboard == other.slices_clip_to_artboard
&& *print_setup == other.print_setup
&& *assets == other.assets
&& *svg_scripts == other.svg_scripts
&& match (puppet, &other.puppet) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(a, b) => a.is_none() && b.is_none(),
}
&& *new_art == other.new_art
&& *dimensions == other.dimensions
}
/// Document Raster Effects Settings › Resolution in ppi, 1 to 2400 (72 when the stored value
/// isn't a number).
pub fn raster_ppi(&self) -> f64 {
@@ -882,8 +1176,12 @@ impl Document {
id: 1,
name: "Artboard 1".into(),
rect: Rect::new(0.0, 0.0, width, height),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
show_video_safe_areas: false,
video_ruler_pixel_aspect_ratio: 1.0,
color: None,
}],
layers: vec![],
swatches: swatches.into(),
@@ -925,6 +1223,7 @@ impl Document {
assets: vec![],
svg_scripts: vec![],
puppet: None,
new_art: Default::default(),
dimensions: false,
index: Default::default(),
};
@@ -1358,9 +1657,69 @@ impl Document {
pub fn node_count(&self) -> usize {
self.layers.iter().map(|l| l.count()).sum()
}
/// Is the node (and every ancestor) visible and unlocked?
/// Is the node (and every ancestor) visible and unlocked, and not on a locked artboard
/// ([`Document::on_locked_artboard`])?
pub fn is_editable(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked)))
self.is_editable_with(&self.locked_artboards(), id)
}
/// [`Document::is_editable`] with the locked artboards `locks` of this document, measured
/// once for many ids.
pub fn is_editable_with(&self, locks: &LockedArtboards, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible && !n.locked))) && !locks.holds_id(id)
}
/// [`Document::is_editable`] for many ids: the art on the locked artboards is measured once
/// for all of them, a group once for all its members.
pub fn editable(&self) -> impl Fn(NodeId) -> bool + '_ {
let locks = self.locked_artboards();
move |id| self.is_editable_with(&locks, id)
}
/// The locked artboards, for asking which art is on them ([`LockedArtboards::holds`]).
pub fn locked_artboards(&self) -> LockedArtboards<'_> {
self.locked_artboards_except(usize::MAX)
}
/// [`Document::locked_artboards`] leaving out artboard `index`.
pub fn locked_artboards_except(&self, index: usize) -> LockedArtboards<'_> {
// Art flush with an edge from outside can overlap it by a rounding error, as in
// `art_bounds_on` (#1295).
let rects: Vec<Rect> =
self.artboards.iter().enumerate().filter(|(i, a)| a.locked && *i != index).map(|(_, a)| a.rect.inflate(-1e-6, -1e-6)).collect();
// The objects of pattern editing mode's layer, a sublayer's objects included.
fn objects(n: &Node, out: &mut std::collections::HashSet<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.is_layer() {
objects(c, out);
} else {
out.insert(c.id);
}
}
}
let mut pattern_art = std::collections::HashSet::new();
if let Some(layer) = self.pattern_edit.as_ref().filter(|_| !rects.is_empty()).and_then(|e| self.layers.iter().find(|l| l.id == e.layer)) {
objects(layer, &mut pattern_art);
}
LockedArtboards { rects, symbols: self.symbol_boxes(), new_art: self.new_art.get(), pattern_art, held: Default::default() }
}
/// Is `id` on a locked artboard ([`LockedArtboards::holds_id`])? A layer or sublayer is on none.
pub fn on_locked_artboard(&self, id: NodeId) -> bool {
self.locked_artboards().holds_id(id)
}
/// The object of a layer or sublayer that holds node `id`: `id` itself when it is one, else
/// the group or other object around it right inside a layer. None for a layer or sublayer.
pub fn layer_object(&self, id: NodeId) -> Option<&Node> {
self.layer_object_at(&self.index_path(id)?)
}
/// [`Document::layer_object`] for the node at index path `path`.
pub fn layer_object_at(&self, path: &[usize]) -> Option<&Node> {
// Down the path to the first node that isn't a layer.
let mut nodes = &self.layers;
for &i in path {
let n = nodes.get(i)?;
if !n.is_layer() {
return Some(n.as_ref());
}
nodes = n.children()?;
}
None
}
pub fn is_visible(&self, id: NodeId) -> bool {
self.ancestry(id).is_some_and(|a| a.iter().all(|i| self.node(*i).is_some_and(|n| n.visible)))
@@ -1371,9 +1730,16 @@ impl Document {
a.iter().rev().copied().find(|i| self.node(*i).is_some_and(Node::is_layer))
}
/// Every art object the Selection tool can reach (Select All): the visible, unlocked objects
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first).
/// of the visible, unlocked, non-template layers, looking through sublayers (bottom first),
/// except those on a locked artboard.
pub fn selectable_art(&self) -> Vec<NodeId> {
self.layers.iter().filter(|l| l.visible && !l.locked && !l.is_template()).flat_map(|l| l.layer_art(true)).collect()
let locks = self.locked_artboards();
self.layers
.iter()
.filter(|l| l.visible && !l.locked && !l.is_template())
.flat_map(|l| l.layer_art(true))
.filter(|id| locks.is_empty() || !self.node(*id).is_some_and(|n| locks.holds(n)))
.collect()
}
/// Colour of the innermost layer or sublayer containing `id` (selection highlight colour).
pub fn layer_color(&self, id: NodeId) -> [u8; 3] {
@@ -1446,6 +1812,18 @@ impl Document {
}
art
}
/// The art that moves, scales or is cut with artboard `index`: [`Document::art_on_artboard`]
/// without the objects on another locked artboard, which stay unless `locked_and_hidden`, as
/// locked art does.
pub fn art_moving_with(&self, index: usize, locked_and_hidden: bool) -> Vec<NodeId> {
let Some(rect) = self.artboards.get(index).map(|a| a.rect) else { return vec![] };
let mut art = self.art_on_artboard(rect, locked_and_hidden);
let locks = self.locked_artboards_except(index);
if !locked_and_hidden && !locks.is_empty() {
art.retain(|id| !self.node(*id).is_some_and(|n| locks.holds(n)));
}
art
}
/// Where ruler guide `g` runs along its line (the y range of a vertical guide): across its
/// artboard for an artboard guide, None (the whole canvas) for a canvas guide or one whose
/// artboard is gone.
@@ -1571,10 +1949,14 @@ impl Document {
pub fn pattern_mut(&mut self, name: &str) -> Option<&mut PatternDef> {
self.patterns.iter_mut().find(|p| p.name == name)
}
/// Deep-clone `node` with fresh ids for it and all descendants.
/// Deep-clone `node` with fresh ids for it and all descendants, its images' own pixels taken
/// in ([`Self::adopt_pixels`]).
pub fn reid(&mut self, node: &Node) -> Node {
let mut n = node.clone();
n.id = self.alloc_id();
if let NodeKind::Image(im) = &mut n.kind {
self.adopt_image_pixels(im);
}
// A copied annotation must never bind its source ids to unrelated art in a different
// document. Keep the measured geometry; new measurements can be linked after pasting.
if let Some(e) = n.extras_if_any()
@@ -1599,6 +1981,48 @@ impl Document {
}
impl Document {
/// The pixels image `im` shows: its own ([`ImageObject::pixels`]) or this document's.
pub fn image_blob<'a>(&'a self, im: &'a ImageObject) -> Option<&'a ImageBlob> {
im.pixels.as_deref().or_else(|| self.images.get(&im.key))
}
/// Take the pixels the images in `n` (its opacity masks' art included) hold themselves (art
/// made outside a document) into [`Self::images`]: under the image's key, or a free one when
/// the key shows other pixels.
pub fn adopt_pixels(&mut self, n: &mut Node) {
// Only what holds such pixels is copied out of the art it shares.
fn holds(n: &Node) -> bool {
matches!(&n.kind, NodeKind::Image(im) if im.pixels.is_some())
|| n.extras().mask.as_ref().is_some_and(|m| holds(&m.art))
|| n.children().into_iter().flatten().any(|c| holds(c))
}
if let NodeKind::Image(im) = &mut n.kind {
self.adopt_image_pixels(im);
}
if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut())
&& holds(&m.art)
{
self.adopt_pixels(Arc::make_mut(&mut m.art));
}
for c in n.children_mut().into_iter().flatten() {
if holds(c) {
self.adopt_pixels(Arc::make_mut(c));
}
}
}
fn adopt_image_pixels(&mut self, im: &mut ImageObject) {
let Some(blob) = im.pixels.take() else { return };
let mut key = im.key.clone();
let mut i = 1;
while self.images.get(&key).is_some_and(|b| b.bytes != blob.bytes) {
i += 1;
key = format!("{}-{i}", im.key);
}
self.images.entry(key.clone()).or_insert_with(|| (*blob).clone());
im.key = key;
}
/// Saved selections as a file gives them, made safe to list and recall: at most
/// [`SavedSelection::MAX`], each with a clean, unique name and naming objects this document
/// has, each once. (An id it doesn't have would select whatever object takes that id later.)
@@ -1932,6 +2356,101 @@ mod tests {
}
}
/// The art on a locked artboard (each object of a layer whose bounds overlap it, a group as one
/// object) can't be edited, and Select All, a click and a marquee pass over it; art flush with
/// the artboard from outside isn't on it, a layer is on no artboard, and new art isn't on one.
/// Unlocking the artboard gives the art back.
#[test]
fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
use crate::hit::{HitOptions, hit_test, marquee, objects_at};
let (mut d, a, b) = doc_with_rects();
let layer = d.layers[0].id;
d.artboards.push(Artboard {
id: 2,
name: "Artboard 2".into(),
rect: Rect::new(700.0, 0.0, 900.0, 200.0),
locked: false,
show_center_mark: false,
show_cross_hairs: false,
show_video_safe_areas: false,
video_ruler_pixel_aspect_ratio: 1.0,
color: None,
});
let rect = |d: &mut Document, r: Rect, appearance: Appearance| {
let id = d.alloc_id();
d.insert(Some(layer), usize::MAX, Node::path(id, shapes::rectangle(r), appearance)).unwrap();
id
};
let c = rect(&mut d, Rect::new(710.0, 10.0, 730.0, 30.0), Appearance::default_art());
// A group with one member on the artboard and one right of it.
let (g, m1, m2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
let members = [(m1, 750.0), (m2, 950.0)]
.map(|(id, x)| Arc::new(Node::path(id, shapes::rectangle(Rect::new(x, 50.0, x + 10.0, 60.0)), Appearance::default_art())));
d.insert(Some(layer), usize::MAX, Node::group(g, members.to_vec())).unwrap();
// Unstroked and flush with the artboard's left edge, from outside.
let e = rect(&mut d, Rect::new(680.0, 10.0, 700.0, 30.0), Appearance::default());
let opt = HitOptions::default();
let top = |d: &Document, x, y| hit_test(d, Point::new(x, y), opt).map(|h| h.top_object(None));
let all = Rect::new(-10.0, -10.0, 1000.0, 800.0);
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.artboards[1].locked = true;
for id in [c, g, m1, m2] {
assert!(d.on_locked_artboard(id) && !d.is_editable(id), "{id:?} is on the locked artboard");
}
for id in [a, b, e, layer] {
assert!(!d.on_locked_artboard(id) && d.is_editable(id), "{id:?} isn't");
}
assert_eq!(d.selectable_art(), [a, b, e]);
assert_eq!(top(&d, 720.0, 20.0), None, "a click passes over c");
assert_eq!(top(&d, 955.0, 55.0), None, "and over the group's member off the artboard");
let isolated = HitOptions { scope: Some(g), ..opt };
assert!(hit_test(&d, Point::new(755.0, 55.0), isolated).is_none(), "and inside the group isolated before the lock");
assert!(objects_at(&d, Point::new(720.0, 20.0), opt, None).is_empty());
assert_eq!(marquee(&d, all, None, false), [a, b, e]);
assert_eq!(marquee(&d, all, None, true), [a, b, e], "the Direct Selection tool's marquee too");
// Deselected in one pass, with the key object and the anchors.
let mut sel = Selection::default();
sel.set([a, c]);
sel.key = Some(c);
sel.anchors.insert(c, [(0, 0)].into());
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.key, sel.anchors.is_empty()), (vec![a], None, true));
// A targeted layer stays targeted, with its art off the locked artboard selected.
sel.set_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![a, b, e], Some(layer)));
// Shift-clicking the layer's target circle removes its art off the locked artboard, and
// a second Shift-click adds it again.
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!((sel.objects.clone(), sel.target), (vec![], None));
sel.toggle_target(&d, layer);
sel.deselect_on_locked_artboards(&d);
assert_eq!(sel.objects, [a, b, e]);
// A sublayer with art on both artboards: its art off the locked artboard can still be clicked.
let (sub, s1, s2) = (d.alloc_id(), d.alloc_id(), d.alloc_id());
d.insert(Some(layer), usize::MAX, Node::layer(sub, "Sub", LayerColor::Preset(2))).unwrap();
for (id, x, y) in [(s1, 300.0, 300.0), (s2, 800.0, 100.0)] {
d.insert(Some(sub), usize::MAX, Node::path(id, shapes::rectangle(Rect::new(x, y, x + 10.0, y + 10.0)), Appearance::default_art()))
.unwrap();
}
assert_eq!((top(&d, 305.0, 305.0), top(&d, 805.0, 105.0)), (Some(s1), None));
assert!(!d.on_locked_artboard(sub));
let mut both = Selection::default();
both.set([s1, s2]);
both.deselect_on_locked_artboards(&d);
assert_eq!(both.objects, [s1]);
// New art on it is editable and can be clicked while it is new art.
d.new_art.set(Arc::new([c].into()));
assert!(!d.on_locked_artboard(c) && d.is_editable(c), "new art");
assert_eq!(top(&d, 720.0, 20.0), Some(c));
d.new_art.set(Default::default());
d.artboards[1].locked = false;
assert_eq!(d.selectable_art(), [a, b, c, g, e, s1, s2]);
assert_eq!(top(&d, 955.0, 55.0), Some(g));
assert_eq!(marquee(&d, all, None, false), [a, b, c, g, e, s1, s2]);
}
#[test]
fn art_on_artboard_measures_a_symbol_instance_by_its_art() {
let mut d = Document::new(612.0, 792.0);
+1
View File
@@ -377,6 +377,7 @@ mod tests {
xf: Affine::IDENTITY,
link,
placement: Default::default(),
pixels: None,
})),
),
)
+191 -7
View File
@@ -60,12 +60,75 @@ pub enum BlendOrientation {
AlignToPath,
}
/// Distribution of blend steps, as offered by the Blend panel's Type menu.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BlendEaseType {
#[default]
In,
Out,
InOut,
OutIn,
}
impl BlendEaseType {
pub fn id(self) -> &'static str {
match self {
Self::In => "easeIn",
Self::Out => "easeOut",
Self::InOut => "easeInOut",
Self::OutIn => "easeOutIn",
}
}
}
/// Blend panel Type and Ramp. Ramp is a fraction from 0 (even) to 1.
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendEase {
#[serde(default)]
pub kind: BlendEaseType,
#[serde(default, deserialize_with = "blend_ramp")]
pub ramp: f64,
}
impl BlendEase {
pub fn is_linear(&self) -> bool {
self.ramp <= 0.0
}
/// Only the default Type and Ramp can be omitted without losing a chosen Type at Ramp 0.
pub fn is_default(&self) -> bool {
self.kind == BlendEaseType::In && self.ramp == 0.0
}
/// A monotone polynomial distribution, blended with even spacing by Ramp. Public help
/// describes the four distributions, not their numeric curves; these curves are original.
pub fn apply(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let r = if self.ramp.is_finite() { self.ramp.clamp(0.0, 1.0) } else { 0.0 };
let eased = match self.kind {
BlendEaseType::In => u * u,
BlendEaseType::Out => u * (2.0 - u),
BlendEaseType::InOut => u * u * (3.0 - 2.0 * u),
BlendEaseType::OutIn if u < 0.5 => 2.0 * u * (1.0 - u),
BlendEaseType::OutIn => 0.5 + 2.0 * (u - 0.5) * (u - 0.5),
};
u + r * (eased - u)
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct BlendSpec {
#[serde(default)]
pub spacing: BlendSpacing,
#[serde(default)]
pub orientation: BlendOrientation,
/// Acceleration of the steps' shapes and positions along the spine between two keys.
#[serde(default, skip_serializing_if = "BlendEase::is_default")]
pub ease: BlendEase,
/// Independent color acceleration. -100..100, with 0 preserving the even color ramp.
#[serde(default, rename = "colorShift", skip_serializing_if = "is_zero", deserialize_with = "blend_color_shift")]
pub color_shift: f64,
/// Replaced spine. `None` = the straight lines between the key objects' centres.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub spine: Option<PathData>,
@@ -228,12 +291,15 @@ pub struct GradientMesh {
pub points: Vec<MeshPoint>,
}
/// One tessellated piece of a mesh (a small quad with a solid colour).
/// One tessellated piece of a mesh: a small quad, its colour at the centre, and its colour and
/// opacity at each corner (in `pts` order), which a renderer shades between so neighbouring quads
/// meet without a step.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MeshQuad {
pub pts: [Point; 4],
pub color: Color,
pub opacity: f32,
pub corners: [(Color, f32); 4],
}
/// Create Gradient Mesh → Appearance.
@@ -401,11 +467,49 @@ fn cumulative_lengths(pts: &[Point]) -> Vec<f64> {
cum
}
/// Most pieces [`map_nonlinear`] splits one segment into.
const MAX_PIECES: usize = 64;
/// How many pieces of about `piece` a segment of length `len` is split into: one to `most`.
pub fn pieces(len: f64, piece: f64, most: usize) -> usize {
((len / piece).ceil() as usize).clamp(1, most.max(1))
}
/// The piece length to split segments of lengths `lens` with ([`pieces`], at most `most` each) so
/// that the pieces past each segment's first number at most `room`: `piece` when they already do,
/// longer pieces otherwise. A segment of `n` pieces adds `n - 1 ≤ len / piece` points to a path,
/// so pieces of the total length over `room` fit; with no room every segment stays one piece.
pub fn fit_piece(lens: &[f64], piece: f64, most: usize, room: usize) -> f64 {
let added = lens.iter().map(|&len| pieces(len, piece, most) - 1).fold(0, usize::saturating_add);
if added <= room {
return piece;
}
if room == 0 {
return f64::INFINITY;
}
// A NaN length is always one piece.
piece.max(lens.iter().filter(|len| !len.is_nan()).sum::<f64>() / room as f64)
}
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData {
let max_piece = max_piece.max(1e-3);
map_nonlinear_within(path, max_piece, usize::MAX, f)
}
/// [`map_nonlinear`] whose result has at most `max` anchors, or `path`'s count when that is more:
/// where pieces of `max_piece` would make more, the pieces grow until they don't ([`fit_piece`]),
/// a coarser approximation of the image curve. Geometry effects map within their anchor budget.
pub fn map_nonlinear_within(path: &PathData, max_piece: f64, max: usize, f: impl Fn(Point) -> Point) -> PathData {
let mut max_piece = max_piece.max(1e-3);
// Each segment adds one anchor per piece past its first.
let room = max.saturating_sub(path.anchor_count());
let segs = path.subpaths.iter().map(SubPath::segment_count).fold(0, usize::saturating_add);
if segs.saturating_mul(MAX_PIECES - 1) > room {
let lens: Vec<f64> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| poly_len(&seg_cubic(sp, i)))).collect();
max_piece = fit_piece(&lens, max_piece, MAX_PIECES, room);
}
let mut subs = Vec::with_capacity(path.subpaths.len());
for sp in &path.subpaths {
let n = sp.anchors.len();
@@ -421,7 +525,7 @@ pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point
res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind });
if i < segs {
let c = seg_cubic(sp, i);
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
let k = pieces(poly_len(&c), max_piece, MAX_PIECES);
for j in 0..k {
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
if let Some(last) = res.last_mut() {
@@ -769,7 +873,7 @@ impl GradientMesh {
(Color::rgb(rgb[0], rgb[1], rgb[2]), op)
}
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre.
/// Tessellate into `n`×`n` quads per patch, each coloured at its centre and its corners.
pub fn quads(&self, n: usize) -> Vec<MeshQuad> {
if !self.is_valid() {
return vec![];
@@ -777,18 +881,27 @@ impl GradientMesh {
let n = n.clamp(1, 64);
let mut out = Vec::with_capacity(self.rows as usize * self.cols as usize * n * n);
let mut grid = vec![Point::ZERO; (n + 1) * (n + 1)];
let mut colors = vec![(Color::default(), 0.0f32); (n + 1) * (n + 1)];
for r in 0..self.rows as usize {
for c in 0..self.cols as usize {
for j in 0..=n {
for i in 0..=n {
grid[j * (n + 1) + i] = self.eval(r, c, i as f64 / n as f64, j as f64 / n as f64);
let (u, v) = (i as f64 / n as f64, j as f64 / n as f64);
grid[j * (n + 1) + i] = self.eval(r, c, u, v);
colors[j * (n + 1) + i] = self.color_at(r, c, u, v);
}
}
for j in 0..n {
for i in 0..n {
let (color, opacity) = self.color_at(r, c, (i as f64 + 0.5) / n as f64, (j as f64 + 0.5) / n as f64);
let g = |a: usize, b: usize| grid[b * (n + 1) + a];
out.push(MeshQuad { pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)], color, opacity });
let k = |a: usize, b: usize| colors[b * (n + 1) + a];
out.push(MeshQuad {
pts: [g(i, j), g(i + 1, j), g(i + 1, j + 1), g(i, j + 1)],
color,
opacity,
corners: [k(i, j), k(i + 1, j), k(i + 1, j + 1), k(i, j + 1)],
});
}
}
}
@@ -1363,6 +1476,9 @@ pub struct Hooks<'a> {
pub appearance: Outliner<'a>,
/// Distort Pattern Fills: the tiles a pattern fill lays (see [`PatternTiles`]).
pub pattern: Option<PatternTiles<'a>>,
/// The document whose symbols' art counts toward the instance budget of a Repeat of their
/// instances ([`crate::RepeatSpec::expand`]).
pub symbols: Option<&'a crate::Document>,
}
impl<'a> From<Outliner<'a>> for Hooks<'a> {
@@ -1679,7 +1795,7 @@ pub fn expand_live_hooks(n: &Node, hooks: Hooks) -> Vec<Node> {
NodeKind::Blend { children, spec } => blend_expand(children, spec),
NodeKind::Envelope { content, kind, fidelity, options, frame, .. } => expand_envelope(content, kind, *fidelity, *options, **frame, hooks),
NodeKind::Mesh(m) => mesh_quad_nodes(m, 8),
NodeKind::Repeat(r) => r.expand(),
NodeKind::Repeat(r) => r.expand(hooks.symbols),
NodeKind::PlacedDocument(p) => p.art(n),
NodeKind::Intertwine { children } => crate::intertwine::expand(children),
_ => vec![n.clone()],
@@ -1757,10 +1873,78 @@ pub(crate) fn max_outset(nodes: &[Arc<Node>], symbols: Option<&crate::SymbolBoxe
o
}
/// Reading a native file never fails over one bad blend number: out-of-range values clamp,
/// and anything that is not a finite number (NaN, null, text) becomes the default. The
/// commands still refuse such input (`ease_param`).
fn blend_number<'de, D: serde::Deserializer<'de>>(d: D, min: f64, max: f64) -> Result<f64, D::Error> {
let v = serde_json::Value::deserialize(d)?;
Ok(v.as_f64().filter(|n| n.is_finite()).map_or(0.0, |n| n.clamp(min, max)))
}
fn blend_ramp<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, 0.0, 1.0)
}
fn blend_color_shift<'de, D: serde::Deserializer<'de>>(d: D) -> Result<f64, D::Error> {
blend_number(d, -100.0, 100.0)
}
/// Whether an optional stored color shift has its unchanged default.
fn is_zero(v: &f64) -> bool {
*v == 0.0
}
impl BlendSpec {
/// Shift the color transition independently of shape placement. The exponential curve is
/// original; the documented behavior specifies the direction, not numeric sample values.
pub fn color_position(&self, u: f64) -> f64 {
let u = if u.is_finite() { u.clamp(0.0, 1.0) } else { 0.0 };
let shift = if self.color_shift.is_finite() { self.color_shift.clamp(-100.0, 100.0) } else { 0.0 };
if shift == 0.0 { u } else { u.powf(2.0f64.powf(shift / 50.0)) }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A bad Ramp or Shift in a file clamps (non-finite values take the default) instead of
/// failing the whole document.
#[test]
fn blend_easing_numbers_clamp_when_read() {
use serde::de::value::{Error, F64Deserializer};
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"kind":"out","ramp":7.5},"colorShift":-900}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (1.0, -100.0));
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":-3},"colorShift":250}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 100.0));
let spec: BlendSpec = serde_json::from_str(r#"{"ease":{"ramp":null},"colorShift":"NaN"}"#).unwrap();
assert_eq!((spec.ease.ramp, spec.color_shift), (0.0, 0.0));
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
assert_eq!(blend_ramp(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
assert_eq!(blend_color_shift(F64Deserializer::<Error>::new(bad)).unwrap(), 0.0);
}
}
/// #3578: a non-linear map within an anchor budget splits its segments into longer pieces,
/// down to one a segment, and matches the plain map where the budget leaves room.
#[test]
fn map_nonlinear_within_keeps_to_its_anchor_budget() {
let square = vectorcraft_geom::shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0));
let f = |p: Point| Point::new(p.x + (p.y / 10.0).sin(), p.y);
let full = map_nonlinear(&square, 1.0, f);
assert_eq!(full.anchor_count(), 4 * 64);
assert_eq!(map_nonlinear_within(&square, 1.0, 4 * 64, f), full);
// Every side takes the same pieces: of 400 / (max - 4) pt, so 12 a side for 50 anchors.
for (max, want) in [(0, 4), (4, 4), (5, 4), (50, 4 * 12), (255, 4 * 63)] {
assert_eq!(map_nonlinear_within(&square, 1.0, max, f).anchor_count(), want, "{max}");
}
// Lengths that are not numbers stay one piece; with no room every segment is one piece.
assert_eq!(fit_piece(&[f64::NAN, 10.0], 1.0, 64, 5), 2.0);
assert_eq!(fit_piece(&[f64::INFINITY, 10.0], 1.0, 64, 0), f64::INFINITY);
assert_eq!(pieces(f64::INFINITY, f64::INFINITY, 64), 1);
assert_eq!(pieces(10.0, 1.0, 0), 1);
}
/// #1578: Arc bends in the box's real proportions. In half-width units (y scaled by the
/// aspect), the top and bottom edges are concentric circles about the bend's centre, at their
/// real distance from the centre line; an inner edge that would pass the centre stops there.
+26 -15
View File
@@ -3,6 +3,7 @@
//! document title is [`crate::Document::title`]. Every field is defaulted, so documents saved
//! before File Info existed load unchanged.
use chrono::{Datelike, Timelike};
use serde::{Deserialize, Serialize};
/// File Info → Copyright Status.
@@ -118,23 +119,26 @@ pub fn now_unix() -> Option<i64> {
None
}
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (civil-from-days, proleptic
/// Gregorian; years clamped to 0–9999).
/// Unix seconds (UTC) → `[year, month, day, hour, minute, second]` (proleptic Gregorian; years
/// clamped to 0–9999).
pub fn civil(t: i64) -> [i64; 6] {
// Clamp first so every step stays in range for any input.
// Clamp the input first so every input maps to a representable date (0000-01-01 .. 9999-12-31),
// exactly as the old code did. The clamped instant is always inside chrono's range, so the
// conversion below never falls back; the trailing `clamp(0, 9999)` is the old year clamp.
let t = t.clamp(-62_167_219_200, 253_402_300_799);
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
[year.clamp(0, 9999), month, day, secs / 3600, secs / 60 % 60, secs % 60]
let (days, sod) = (t.div_euclid(86_400), t.rem_euclid(86_400));
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, 0));
match (date, time) {
(Some(d), Some(tm)) => {
let year = i128::from(d.year()) + i128::from(cycles) * 400;
let year = i64::try_from(year).unwrap_or(if year < 0 { i64::MIN } else { i64::MAX });
[year.clamp(0, 9999), i64::from(d.month()), i64::from(d.day()), i64::from(tm.hour()), i64::from(tm.minute()), i64::from(tm.second())]
}
_ => [1970, 1, 1, 0, 0, 0], // unreachable: the clamped `t` is always a valid date
}
}
/// Unix seconds → `2026-10-05T14:03:00Z`.
@@ -151,8 +155,15 @@ mod tests {
fn dates_and_statuses() {
assert_eq!(iso8601(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601(1_791_208_980), "2026-10-05T14:03:00Z");
// The input is clamped to 0000-01-01..9999-12-31, then the year is clamped to 0..=9999,
// exactly as the old code did: inputs far past chrono's range land on the clamp bounds,
// never on the epoch.
assert_eq!(civil(i64::MIN)[0], 0);
assert_eq!(civil(i64::MAX)[0], 9999);
assert_eq!(iso8601(i64::MIN), "0000-01-01T00:00:00Z");
assert_eq!(iso8601(i64::MAX), "9999-12-31T23:59:59Z");
assert_eq!(iso8601(10_000_000_000_000), "9999-12-31T23:59:59Z");
assert_eq!(iso8601(-10_000_000_000_000), "0000-01-01T00:00:00Z");
assert_eq!(CopyrightStatus::parse("Public Domain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("publicdomain"), Some(CopyrightStatus::PublicDomain));
assert_eq!(CopyrightStatus::parse("maybe"), None);
+114 -35
View File
@@ -88,6 +88,9 @@ pub enum LiveShape {
/// Corner kinds, in the same order (Live Corners' corner types).
#[serde(default, skip_serializing_if = "all_round")]
kinds: [CornerKind; 4],
/// When true, `radii` are fractions in 0..1 of each corner's largest possible radius.
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
/// Maps the untransformed shape (origin at its top-left) into the document. A rotation
/// or reflection plus a move, unless a shear (or a file saved before transforms folded
/// their scale into `w`/`h`) put more in it.
@@ -112,6 +115,8 @@ pub enum LiveShape {
/// Corner kinds, in the same order (none: round).
#[serde(default, skip_serializing_if = "all_round")]
kinds: Vec<CornerKind>,
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
},
Line {
a: Point,
@@ -129,10 +134,24 @@ pub enum LiveShape {
/// Corner kinds, in the same order (missing: round).
#[serde(default, skip_serializing_if = "all_round")]
kinds: Vec<CornerKind>,
#[serde(default, skip_serializing_if = "is_false")]
radii_relative: bool,
},
}
fn is_false(b: &bool) -> bool {
!*b
}
impl LiveShape {
pub fn radii_relative(&self) -> bool {
match self {
LiveShape::Rectangle { radii_relative, .. } | LiveShape::Polygon { radii_relative, .. } | LiveShape::Path { radii_relative, .. } => {
*radii_relative
}
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => false,
}
}
pub fn label(&self) -> &'static str {
match self {
LiveShape::Rectangle { radii, .. } if radii.iter().any(|r| *r > 0.0) => "Rounded Rectangle",
@@ -146,13 +165,23 @@ impl LiveShape {
/// Regenerate the path.
pub fn to_path(&self) -> PathData {
match self {
LiveShape::Rectangle { w, h, radii, kinds, xf } => {
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), *radii, *kinds).transformed(*xf)
LiveShape::Rectangle { w, h, radii, kinds, radii_relative, xf } => {
let base = shapes::rectangle(Rect::new(0.0, 0.0, *w, *h));
let resolved = crate::corners::resolve_stored_radii(&base, radii, *radii_relative);
let radii = std::array::from_fn(|i| resolved.get(i).copied().unwrap_or(0.0));
shapes::rectangle_with_corners(Rect::new(0.0, 0.0, *w, *h), radii, *kinds).transformed(*xf)
}
LiveShape::Ellipse { w, h, pie, xf } => shapes::ellipse_pie(Rect::new(0.0, 0.0, *w, *h), pie.0, pie.1).transformed(*xf),
LiveShape::Polygon { radii, kinds, .. } => cut_corners(&self.polygon_outline(), radii, kinds).0,
LiveShape::Polygon { radii, kinds, radii_relative, .. } => {
let outline = self.polygon_outline();
let radii = crate::corners::resolve_stored_radii(&outline, radii, *radii_relative);
cut_corners(&outline, &radii, kinds).0
}
LiveShape::Line { a, b } => shapes::line(*a, *b),
LiveShape::Path { base, radii, kinds } => cut_corners(base, radii, kinds).0,
LiveShape::Path { base, radii, kinds, radii_relative } => {
let radii = crate::corners::resolve_stored_radii(base, radii, *radii_relative);
cut_corners(base, &radii, kinds).0
}
}
}
/// A polygon's outline with its corners uncut, in the document (empty for other shapes).
@@ -253,13 +282,13 @@ impl LiveShape {
*xf = a * *xf;
false
}
LiveShape::Polygon { xf, radii, .. } => {
LiveShape::Polygon { xf, radii, radii_relative, .. } => {
*xf = a * *xf;
scale_radii(radii, a)
scale_radii(radii, a, *radii_relative)
}
LiveShape::Path { base, radii, .. } => {
LiveShape::Path { base, radii, radii_relative, .. } => {
base.transform(a);
scale_radii(radii, a)
scale_radii(radii, a, *radii_relative)
}
LiveShape::Line { a: p, b } => {
*p = a * *p;
@@ -274,7 +303,7 @@ impl LiveShape {
/// isn't finite, and for other shapes. Files saved before transforms did this (#291) carry an
/// uneven scale in `xf`: their corners are elliptical until this runs.
pub fn fold_scale(&mut self) -> bool {
let LiveShape::Rectangle { w, h, radii, xf, .. } = self else { return false };
let LiveShape::Rectangle { w, h, radii, radii_relative, xf, .. } = self else { return false };
let [m0, m1, m2, m3, m4, m5] = xf.as_coeffs();
let (sx, sy) = (m0.hypot(m1), m2.hypot(m3));
let orthogonal = sx > 1e-12 && sy > 1e-12 && (m0 * m2 + m1 * m3).abs() <= 1e-9 * sx * sy;
@@ -284,7 +313,9 @@ impl LiveShape {
return false;
}
let k = (sx * sy).sqrt();
radii.iter_mut().for_each(|r| *r *= k);
if !*radii_relative {
radii.iter_mut().for_each(|r| *r *= k);
}
*w *= sx;
*h *= sy;
*xf = Affine::new([m0 / sx, m1 / sx, m2 / sy, m3 / sy, m4, m5]);
@@ -300,12 +331,13 @@ impl LiveShape {
/// Divide live corner radii by `k`, the mean scale of a transform just applied, so the corners
/// keep their size (Scale Corners off). False when nothing changed.
pub fn keep_corners(&mut self, k: f64) -> bool {
let relative = self.radii_relative();
let radii: &mut [f64] = match self {
LiveShape::Rectangle { radii, .. } => radii,
LiveShape::Polygon { radii, .. } | LiveShape::Path { radii, .. } => radii,
LiveShape::Ellipse { .. } | LiveShape::Line { .. } => return false,
};
if !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
if relative || !radii.iter().any(|r| *r > 0.0) || k <= 1e-12 {
return false;
}
radii.iter_mut().for_each(|r| *r /= k);
@@ -343,8 +375,8 @@ fn moves_only(a: Affine) -> bool {
/// Scale corner radii in document units by the mean scale of `a`, applied to their path. True
/// when the path must be cut again: some corner is cut and `a` does more than move it (an
/// uneven scale would otherwise make the cuts elliptical).
fn scale_radii(radii: &mut [f64], a: Affine) -> bool {
if moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
fn scale_radii(radii: &mut [f64], a: Affine, relative: bool) -> bool {
if relative || moves_only(a) || !radii.iter().any(|r| *r > 0.0) {
return false;
}
let k = a.determinant().abs().sqrt();
@@ -401,6 +433,11 @@ pub struct ImageObject {
/// Links panel → Placement Options: how a file read again takes this image's place.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub placement: crate::links::PlacementOptions,
/// The pixels of an image in art made outside a document (type's outlines, a glyph's art),
/// which has no [`crate::Document::images`] to keep them in. A document takes them in
/// ([`crate::Document::adopt_pixels`]): no image in one holds its own.
#[serde(skip)]
pub pixels: Option<Arc<crate::ImageBlob>>,
}
/// Written with its `type` first, then its fields (an internally tagged enum); read by
@@ -491,8 +528,8 @@ pub enum NodeKind {
Mesh(GradientMesh),
/// Live Repeat (radial / grid / mirror) of source art.
Repeat(RepeatSpec),
/// A placed document: an artboard of another VectorCraft file, linked and locked (see
/// [`crate::placed_document`]).
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai`
/// file, linked and locked (see [`crate::placed_document`]).
PlacedDocument(Box<crate::placed_document::PlacedDocument>),
/// Object › Intertwine: the objects keep their own stacking order, except where one of them
/// is drawn over the others: the area [`NodeExtras::intertwine`] of that member (see
@@ -1159,6 +1196,17 @@ impl Node {
pub fn geometric_bounds_with(&self, symbols: &SymbolBoxes) -> Option<Rect> {
self.geometric(Some(symbols))
}
/// Children that contribute to a container's editing bounds and selection highlights. A live trace retains a
/// hidden bitmap for re-tracing/release; only source-image views measure that bitmap.
pub fn editing_children(&self) -> &[Arc<Node>] {
let children = self.children().map(Vec::as_slice).unwrap_or_default();
let trace_source = self.extras().trace.is_some()
&& !self.trace_view().shows_source()
&& children.first().is_some_and(|n| !n.visible && matches!(n.kind, NodeKind::Image(_)));
let skip = usize::from(self.extras().shaper.is_some() || trace_source);
children.get(skip..).unwrap_or_default()
}
/// The box of [`Node::geometric_bounds`], or of [`Node::geometric_bounds_with`] when `symbols`
/// is given.
pub(crate) fn geometric(&self, symbols: Option<&SymbolBoxes>) -> Option<Rect> {
@@ -1168,9 +1216,9 @@ impl Node {
NodeKind::Group { children, clip: true } | NodeKind::Layer { children, clip: true, .. } => {
children.first().and_then(|c| c.geometric(symbols))
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => children
NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. } => self
.editing_children()
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.filter(|c| c.visible || !matches!(self.kind, NodeKind::Layer { .. }))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.geometric(symbols))),
NodeKind::Text(t) => self.projected(t.bounds()),
@@ -1184,7 +1232,7 @@ impl Node {
NodeKind::Mesh(m) => m.bounds(),
// Each copy is measured where the repeat draws it: `RepeatSpec::transforms` lays the
// copies out by the source's bounds measured without the document.
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?),
NodeKind::Repeat(r) => r.copies_bounds(union(&r.source)?, symbols.map(|s| s.document())),
NodeKind::PlacedDocument(p) => Some(p.bounds()),
// The members drawn over the others are drawn where they are.
NodeKind::Intertwine { children } => union(children),
@@ -1223,14 +1271,10 @@ impl Node {
}
}
NodeKind::Intertwine { children } => children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))),
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => {
NodeKind::Layer { .. } | NodeKind::Group { .. } => {
// The container's own strokes paint around its members.
let o = self.appearance.outset();
children
.iter()
.skip(usize::from(self.extras().shaper.is_some()))
.fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)))
.map(|b| b.inflate(o, o))
self.editing_children().iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols))).map(|b| b.inflate(o, o))
}
NodeKind::Blend { children, spec } => {
let b = children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, c.visual(symbols)));
@@ -1262,10 +1306,8 @@ impl Node {
self.projected(Some(b)).map(|b| b.inflate(o, o))
}
NodeKind::Text(t) => {
// The characters' strokes paint around the glyphs, scaled with the type.
let k = Scaling::factor(t.xf).unwrap_or(1.0);
let chars = t.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k;
let o = self.appearance.outset().max(chars);
// The characters' strokes paint around the glyphs.
let o = self.appearance.outset().max(t.char_stroke_outset());
self.geometric(symbols).map(|b| b.inflate(o, o))
}
// An image is measured by its frame, without its own fills and strokes.
@@ -1763,6 +1805,28 @@ pub struct ObjectAttributes {
/// the element's event attributes; nothing in VectorCraft runs them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<(String, String)>,
/// Attributes in other applications' XML namespaces, in order, kept from SVG import to SVG
/// export: what another tool notes on an object (a plotter's pen, a cutter's passes) survives
/// a round trip through VectorCraft. Nothing in VectorCraft reads them. They are the object's,
/// as `data` is, so copies, duplicates and pastes keep them and a deleted object takes them
/// with it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub foreign: Vec<ForeignAttribute>,
}
/// An attribute in another application's XML namespace: `plotter:pen="2"` with `xmlns:plotter=
/// "https://example.org/plotter"` is `{ns: "https://example.org/plotter", prefix: "plotter", name:
/// "pen", value: "2"}`.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForeignAttribute {
/// The namespace's URI, which names it.
pub ns: String,
/// The prefix the file gave the namespace, which export reuses where it can.
pub prefix: String,
/// The attribute's local name.
pub name: String,
/// The attribute's value, as the file had it.
pub value: String,
}
/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event
@@ -2168,14 +2232,28 @@ mod tests {
#[test]
fn rect_live_shape_roundtrip() {
let l = LiveShape::Rectangle { w: 10.0, h: 20.0, radii: [0.0; 4], kinds: Default::default(), xf: Affine::translate((5.0, 5.0)) };
let l = LiveShape::Rectangle {
w: 10.0,
h: 20.0,
radii: [0.0; 4],
kinds: Default::default(),
radii_relative: false,
xf: Affine::translate((5.0, 5.0)),
};
assert_eq!(l.to_path().bounds(), Some(Rect::new(5.0, 5.0, 15.0, 25.0)));
assert_eq!(l.label(), "Rectangle");
}
#[test]
fn corner_kinds_save_only_when_some_corner_isnt_round() {
let mut l = LiveShape::Rectangle { w: 100.0, h: 50.0, radii: [0.0, 10.0, 0.0, 0.0], kinds: Default::default(), xf: Affine::IDENTITY };
let mut l = LiveShape::Rectangle {
w: 100.0,
h: 50.0,
radii: [0.0, 10.0, 0.0, 0.0],
kinds: Default::default(),
radii_relative: false,
xf: Affine::IDENTITY,
};
// Older files read as round.
let json = serde_json::to_value(&l).unwrap();
assert!(json.get("kinds").is_none(), "{json}");
@@ -2189,9 +2267,9 @@ mod tests {
// Polygons saved before Live Corners cut theirs read as sharp; a live path round-trips.
let p: LiveShape =
serde_json::from_value(serde_json::json!({"shape": "polygon", "radius": 10.0, "sides": 5, "xf": [1, 0, 0, 1, 0, 0]})).unwrap();
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![] });
assert_eq!(p, LiveShape::Polygon { radius: 10.0, sides: 5, xf: Affine::IDENTITY, radii: vec![], kinds: vec![], radii_relative: false });
let star = shapes::star(Point::new(0.0, 0.0), 20.0, 10.0, 5, 0.0);
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![] };
let path = LiveShape::Path { base: star, radii: vec![2.0; 10], kinds: vec![], radii_relative: false };
let json = serde_json::to_value(&path).unwrap();
assert_eq!((json["shape"].as_str(), json.get("kinds")), (Some("path"), None));
assert_eq!(serde_json::from_value::<LiveShape>(json).unwrap(), path);
@@ -2221,7 +2299,7 @@ mod tests {
let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
// A hexagon of radius 10 at (50, 50), drawn 2× and turned 30° clockwise on the page.
let xf = Affine::translate((50.0, 50.0)) * Affine::rotate(30f64.to_radians()) * Affine::scale(2.0);
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![] };
let mut p = LiveShape::Polygon { radius: 10.0, sides: 6, xf, radii: vec![], kinds: vec![], radii_relative: false };
assert!(close(p.polygon_radius().unwrap(), 20.0) && close(p.polygon_angle().unwrap(), 330.0) && p.polygon_sides_equal());
p.set_polygon_angle(45.0);
assert!(close(p.polygon_angle().unwrap(), 45.0));
@@ -2246,7 +2324,7 @@ mod tests {
/// A node holding a live rectangle (`w` × `h`, all radii `r`, placed by `xf`) and its path.
fn live_rect(w: f64, h: f64, r: f64, xf: Affine) -> Node {
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), xf };
let live = LiveShape::Rectangle { w, h, radii: [r; 4], kinds: Default::default(), radii_relative: false, xf };
let mut n = Node::path(NodeId(1), live.to_path(), Appearance::default_art());
if let NodeKind::Path { live: slot, .. } = &mut n.kind {
*slot = Some(Box::new(live));
@@ -2470,7 +2548,8 @@ mod tests {
/// A group holding a path, a compound, an image and a nested group, as a file writes it.
fn nested_group() -> Node {
let path = |id| Node::path(NodeId(id), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art());
let image = ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default() };
let image =
ImageObject { key: "k".into(), width: 2, height: 3, xf: Affine::IDENTITY, link: None, placement: Default::default(), pixels: None };
let inner = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(path(6))], clip: true });
let compound = Node::new(NodeId(3), NodeKind::Compound { children: vec![Arc::new(path(4))], rule: FillRule::EvenOdd });
let kids = vec![Arc::new(path(2)), Arc::new(compound), Arc::new(Node::new(NodeId(7), NodeKind::Image(Box::new(image)))), Arc::new(inner)];
+42 -1
View File
@@ -20,7 +20,7 @@ use vectorcraft_geom::{Affine, PathData, Point};
use crate::blend::Spine;
use crate::symbols::SymbolBoxes;
use crate::{Node, NodeId, NodeKind};
use crate::{Document, Node, NodeId, NodeKind};
/// The center of the nine reference points.
pub const CENTER: u8 = 4;
@@ -157,6 +157,47 @@ pub fn attachable_path(n: &Node) -> Option<&PathData> {
}
}
/// What keeps an object from going into an Objects on Path group, as its path or as one of its
/// objects ([`held`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Held {
/// It is inside a compound path, blend, envelope, repeat or Intertwine group, as part of its art.
Live,
/// It is the clipping path of a clip group or of a layer with a clipping mask, or inside one.
Clipping,
}
/// What keeps the object `id` of `d` where it is ([`Held`]), if anything: in an Objects on Path
/// group it would no longer be a part of the compound path, blend, envelope, repeat or Intertwine
/// group it is in, or of the clipping path it is or is in. The innermost reason counts. `None` for
/// an object that is neither a clipping path nor inside one and is in a layer directly or through
/// plain groups and clip groups, and for an id not in `d`.
pub fn held(d: &Document, id: NodeId) -> Option<Held> {
let path = d.index_path(id)?;
let mut chain: Vec<&Node> = Vec::with_capacity(path.len());
let mut nodes = &d.layers;
for &i in &path {
let n: &Node = nodes.get(i)?;
chain.push(n);
if let Some(children) = n.children() {
nodes = children;
}
}
for (depth, n) in chain.iter().enumerate().rev() {
let Some(parent) = depth.checked_sub(1).and_then(|up| chain.get(up)) else { break };
if matches!(n.kind, NodeKind::Path { clipping: true, .. }) || (parent.clips() && path.get(depth) == Some(&0)) {
return Some(Held::Clipping);
}
if matches!(
parent.kind,
NodeKind::Compound { .. } | NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Repeat(_) | NodeKind::Intertwine { .. }
) {
return Some(Held::Live);
}
}
None
}
/// The frame `n` is drawn in when it is attached: the page's axes turned by its rotation
/// ([`Node::bbox_angle`]) less the nearest multiple of 90°, about the center of its bounds. Placed in
/// that frame, an object stands square to the path or across it.
+2 -2
View File
@@ -90,8 +90,8 @@ impl Node {
/// ([`Node::geometric_bounds_with`]).
pub fn bounds_in(&self, symbols: &SymbolBoxes, a: Affine, visual: bool) -> Option<Rect> {
match &self.kind {
NodeKind::Group { children, clip: false } => {
let b = children.iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
NodeKind::Group { clip: false, .. } => {
let b = self.editing_children().iter().fold(None, |acc, c| union_opt(acc, c.bounds_in(symbols, a, visual)));
// The group's own strokes paint around its members (as in `visual_bounds`).
let o = if visual { self.appearance.outset() } else { 0.0 };
b.map(|b| b.inflate(o, o))
+128 -36
View File
@@ -11,12 +11,15 @@
//! **Repeats.** A [`RepeatSpec`] holds source art and a radial / grid / mirror arrangement; the
//! instances are the source under [`repeat_transforms`] (evaluated on demand like blends).
use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use vectorcraft_color::Paint;
use vectorcraft_geom::{Affine, Point, Rect, Vec2};
use crate::Document;
use crate::appearance::AppearanceItem;
use crate::node::{Node, NodeId, NodeKind};
@@ -279,7 +282,7 @@ impl PatternDef {
out
}
/// Offsets of the pattern-editing preview copies (`copies`×`copies` around the tile, the
/// centre `(0, 0)` excluded).
/// centre `(0, 0)` excluded), in row-major order (not paint order).
pub fn preview_offsets(&self) -> Vec<Vec2> {
let k = (self.copies.clamp(1, 15) / 2) as i64;
let mut out = vec![];
@@ -292,6 +295,38 @@ impl PatternDef {
}
out
}
/// Offsets of every pattern-editing preview copy, including the centre tile, in the same paint
/// order [`offsets_covering`] uses for [`Overlap`].
pub fn preview_offsets_in_paint_order(&self) -> Vec<Vec2> {
let k = (self.copies.clamp(1, 15) / 2) as i64;
let (i0, i1, j0, j1) = (-k, k, -k, k);
let mut out = vec![];
let rows: Vec<i64> = if self.overlap.bottom_in_front { (j0..=j1).collect() } else { (j0..=j1).rev().collect() };
for &j in &rows {
let cols: Vec<i64> = if self.overlap.right_in_front { (i0..=i1).collect() } else { (i0..=i1).rev().collect() };
for i in cols {
out.push(self.offset(i, j));
}
}
out
}
/// Preview copies drawn before and after the centre tile in [`Self::preview_offsets_in_paint_order`],
/// for one dimmed composite each (the centre tile is drawn at full strength between them).
pub fn preview_offsets_dimmed_sides(&self) -> (Vec<Vec2>, Vec<Vec2>) {
let mut before = vec![];
let mut after = vec![];
let mut past_center = false;
for o in self.preview_offsets_in_paint_order() {
if o.x == 0.0 && o.y == 0.0 {
past_center = true;
} else if past_center {
after.push(o);
} else {
before.push(o);
}
}
(before, after)
}
/// Instances covering `region` (document space) as plain nodes for a pattern painted with
/// `paint_xf` (pattern space → document), fresh-id-less (id 0). Used by exporters and Distort
/// Pattern Fills; Expand uses [`Self::instances_in_with`].
@@ -487,17 +522,51 @@ pub struct RepeatSpec {
/// many as one 500 × 500 grid, the budget [`PatternDef::offsets_covering`] also keeps.
const MAX_REPEAT_INSTANCES: usize = 250_000;
/// Instances the Repeats in `nodes` make in all (0 when there are none).
fn nested_instances(nodes: &[Arc<Node>]) -> usize {
let mut sum = 0usize;
for n in nodes {
sum = sum.saturating_add(match (&n.kind, n.children()) {
(NodeKind::Repeat(r), _) => r.instances_in_all(),
(_, Some(c)) => nested_instances(c),
_ => 0,
});
/// Counts the instances Repeats make in all, following symbol instances into their symbol's art
/// in `doc`: each symbol's art is counted once, and a missing symbol, or an instance of a symbol
/// inside its own art, counts as none.
///
/// Limitation: symbols that show each other (A's art holds an instance of B and B's holds one of
/// A) are counted along the first path that reaches them. While A is counted, the instance of A
/// in B's art counts as none, and B keeps that count; had B been counted first, A's count would
/// include B's Repeats and B's would not. So within one count, what such symbols add can depend on
/// which of them came first. A symbol whose art reaches no such loop always counts the same.
struct NestedCount<'a> {
doc: Option<&'a Document>,
/// The counts made so far, by symbol name (`None` while that symbol's art is being counted).
symbols: RefCell<HashMap<String, Option<usize>>>,
}
impl<'a> NestedCount<'a> {
fn new(doc: Option<&'a Document>) -> Self {
Self { doc, symbols: RefCell::default() }
}
/// Instances the Repeats in the art of symbol `name` make in all.
fn symbol(&self, name: &str) -> usize {
if let Some(known) = self.symbols.borrow().get(name) {
return known.unwrap_or(0);
}
let Some(sym) = self.doc.and_then(|d| d.symbols.iter().find(|s| s.name == name)) else { return 0 };
self.symbols.borrow_mut().insert(name.to_owned(), None);
let n = self.nodes(std::slice::from_ref(&sym.art));
self.symbols.borrow_mut().insert(name.to_owned(), Some(n));
n
}
/// Instances the Repeats in `nodes` make in all (0 when there are none).
fn nodes(&self, nodes: &[Arc<Node>]) -> usize {
let mut sum = 0usize;
for n in nodes {
sum = sum.saturating_add(match (&n.kind, n.children()) {
(NodeKind::Repeat(r), _) => r.instances_in_all(self),
(NodeKind::SymbolInstance { symbol, .. }, _) => self.symbol(symbol),
(_, Some(c)) => self.nodes(c),
_ => 0,
});
}
sum
}
sum
}
/// Reflection across the line through `c` with direction angle `deg`.
@@ -560,11 +629,13 @@ impl RepeatSpec {
pub fn source_bounds(&self) -> Option<Rect> {
crate::live::nodes_bounds(&self.source)
}
pub fn transforms(&self) -> Vec<Affine> {
/// Where the instances go, counting toward the budget the Repeats in the art of the symbols
/// of `doc` that instances in the source show (none without a document).
pub fn transforms(&self, doc: Option<&Document>) -> Vec<Affine> {
let mut v = repeat_transforms(&self.kind, self.source_bounds());
// Each instance draws the Repeats in the source again, so nested ones multiply (a 500 × 500
// grid of one is 62.5 billion instances): keep the first that fit the budget.
v.truncate(self.kept(nested_instances(&self.source)));
v.truncate(self.kept(NestedCount::new(doc).nodes(&self.source)));
v
}
/// How many of its instances this Repeat keeps when each makes `nested` instances of the
@@ -572,14 +643,21 @@ impl RepeatSpec {
fn kept(&self, nested: usize) -> usize {
self.kind.count().min(MAX_REPEAT_INSTANCES / nested.max(1)).max(1)
}
/// How many instances this Repeat draws ([`Self::transforms`] for `doc`): as many as its
/// options make, fewer when the Repeats in its source would take it past
/// [`MAX_REPEAT_INSTANCES`].
pub fn instances(&self, doc: Option<&Document>) -> usize {
self.kept(NestedCount::new(doc).nodes(&self.source))
}
/// Instances made in all, those of the Repeats nested in the source included.
fn instances_in_all(&self) -> usize {
let nested = nested_instances(&self.source);
fn instances_in_all(&self, count: &NestedCount) -> usize {
let nested = count.nodes(&self.source);
self.kept(nested).saturating_mul(nested.max(1))
}
/// Evaluated instances: one group of the (transformed) source per instance (ids 0).
pub fn expand(&self) -> Vec<Node> {
self.transforms()
/// Evaluated instances: one group of the (transformed) source per instance (ids 0), as many
/// as [`Self::transforms`] keeps for `doc`.
pub fn expand(&self, doc: Option<&Document>) -> Vec<Node> {
self.transforms(doc)
.into_iter()
.map(|m| {
let children = self
@@ -596,14 +674,14 @@ impl RepeatSpec {
})
.collect()
}
/// Geometric bounds of all instances.
pub fn bounds(&self) -> Option<Rect> {
self.copies_bounds(self.source_bounds()?)
/// Geometric bounds of all instances ([`Self::transforms`] for `doc`).
pub fn bounds(&self, doc: Option<&Document>) -> Option<Rect> {
self.copies_bounds(self.source_bounds()?, doc)
}
/// The bounds of all instances of a source measuring `source`, each placed by its transform
/// ([`Self::transforms`]).
pub(crate) fn copies_bounds(&self, source: Rect) -> Option<Rect> {
self.transforms().into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
/// ([`Self::transforms`] for `doc`).
pub(crate) fn copies_bounds(&self, source: Rect, doc: Option<&Document>) -> Option<Rect> {
self.transforms(doc).into_iter().fold(None, |acc, m| vectorcraft_geom::union_opt(acc, Some(m.transform_rect_bbox(source))))
}
/// Apply a document transform (source + arrangement parameters).
pub fn transform(&mut self, a: Affine, scaling: impl Into<crate::node::Scaling>) {
@@ -739,6 +817,20 @@ mod tests {
}
}
#[test]
fn preview_paint_order_respects_overlap() {
let mut def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
def.copies = 3;
assert!(close(*def.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(-10.0, -10.0)));
let mut d2 = def.clone();
d2.overlap = Overlap { right_in_front: true, bottom_in_front: true };
assert!(close(*d2.preview_offsets_in_paint_order().last().unwrap(), Vec2::new(10.0, 10.0)));
assert_eq!(def.preview_offsets_in_paint_order().len(), 9);
let (before, after) = def.preview_offsets_dimmed_sides();
assert_eq!(before.len() + after.len(), 8);
assert!(before.iter().all(|o| *o != Vec2::ZERO) && after.iter().all(|o| *o != Vec2::ZERO));
}
#[test]
fn covering_offsets_and_overlap_order() {
let def = PatternDef::new("p", vec![sq(0.0, 0.0, 10.0)]);
@@ -813,11 +905,11 @@ mod tests {
let r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
let RepeatKind::Radial { center, .. } = r.kind else { panic!() };
assert!((center - Point::new(0.0, -0.0)).hypot() < 1e-9, "{center:?}");
let cs: Vec<Point> = r.transforms().iter().map(|m| *m * Point::new(0.0, -100.0)).collect();
let cs: Vec<Point> = r.transforms(None).iter().map(|m| *m * Point::new(0.0, -100.0)).collect();
assert!((cs[0] - Point::new(0.0, -100.0)).hypot() < 1e-9);
assert!((cs[1] - Point::new(100.0, 0.0)).hypot() < 1e-9, "{cs:?}");
assert!((cs[2] - Point::new(0.0, 100.0)).hypot() < 1e-9);
assert_eq!(r.expand().len(), 4);
assert_eq!(r.expand(None).len(), 4);
}
#[test]
@@ -826,18 +918,18 @@ mod tests {
if let RepeatKind::Radial { radius, .. } = &mut r.kind {
*radius = 50.0;
}
let m = r.transforms()[0];
let m = r.transforms(None)[0];
assert!((m * Point::new(0.0, -100.0) - Point::new(0.0, -50.0)).hypot() < 1e-9);
}
#[test]
fn grid_repeat_transforms() {
let g = RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 5.0, 2.0);
let t = g.transforms();
let t = g.transforms(None);
assert_eq!(t.len(), 9);
assert!((t[1] * Point::ZERO - Point::new(15.0, 0.0)).hypot() < 1e-9);
assert!((t[3] * Point::ZERO - Point::new(0.0, 12.0)).hypot() < 1e-9);
assert_eq!(g.bounds(), Some(Rect::new(0.0, 0.0, 40.0, 34.0)));
assert_eq!(g.bounds(None), Some(Rect::new(0.0, 0.0, 40.0, 34.0)));
}
#[test]
@@ -846,7 +938,7 @@ mod tests {
if let RepeatKind::Grid { flip_cols, .. } = &mut g.kind {
*flip_cols = true;
}
let t = g.transforms();
let t = g.transforms(None);
// Column 1 is mirrored about the source centre, then moved one tile right.
assert!((t[1] * Point::new(0.0, 0.0) - Point::new(20.0, 0.0)).hypot() < 1e-9);
}
@@ -854,10 +946,10 @@ mod tests {
#[test]
fn mirror_transforms() {
let m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
let t = m.transforms();
let t = m.transforms(None);
assert_eq!(t.len(), 2);
assert!((t[1] * Point::new(0.0, 3.0) - Point::new(30.0, 3.0)).hypot() < 1e-9);
let b = m.bounds().unwrap();
let b = m.bounds(None).unwrap();
assert!((b.x1 - 30.0).abs() < 1e-9 && b.x0.abs() < 1e-9 && (b.y1 - 10.0).abs() < 1e-9, "{b:?}");
// Horizontal axis.
let h = reflect_about(Point::new(0.0, 10.0), 0.0);
@@ -867,9 +959,9 @@ mod tests {
#[test]
fn repeat_transform_moves_arrangement() {
let mut r = RepeatSpec::radial(vec![sq(-5.0, -105.0, 10.0)], 4, Some(100.0));
let before = r.bounds().unwrap();
let before = r.bounds(None).unwrap();
r.transform(Affine::translate((50.0, 0.0)), false);
let after = r.bounds().unwrap();
let after = r.bounds(None).unwrap();
assert!((after.x0 - before.x0 - 50.0).abs() < 1e-9);
let mut m = RepeatSpec::mirror(vec![sq(0.0, 0.0, 10.0)], 90.0, 5.0);
m.transform(Affine::rotate(std::f64::consts::FRAC_PI_2), false);
@@ -882,7 +974,7 @@ mod tests {
// Instances made in all, counted from the transforms without drawing any.
fn made(r: &RepeatSpec) -> usize {
let each: usize = r.source.iter().map(|s| if let NodeKind::Repeat(n) = &s.kind { made(n) } else { 1 }).sum();
r.transforms().len().saturating_mul(each)
r.transforms(None).len().saturating_mul(each)
}
let grid = |source: Arc<Node>| {
let mut g = RepeatSpec::grid(vec![source], 0.0, 0.0);
@@ -899,7 +991,7 @@ mod tests {
// A 500 × 500 grid of it made 62.5 billion.
let outer = grid(repeat(inner.clone()));
assert!(made(&outer) <= 250_000, "{} instances", made(&outer));
assert_eq!(outer.expand().len(), outer.transforms().len());
assert_eq!(outer.expand(None).len(), outer.transforms(None).len());
assert!(made(&RepeatSpec::mirror(vec![repeat(inner)], 90.0, 5.0)) <= 250_000);
// Small nests are left as they are.
let small = RepeatSpec::mirror(vec![repeat(RepeatSpec::grid(vec![sq(0.0, 0.0, 10.0)], 1.0, 1.0))], 90.0, 5.0);
+84 -18
View File
@@ -1,4 +1,5 @@
//! Placed documents: another VectorCraft file placed linked ([`PlacedDocument`]).
//! Placed documents: another VectorCraft file, or a page of a PDF or `.ai` file, placed linked
//! ([`PlacedDocument`]).
//!
//! The object keeps the link to the file and the file's bytes, stored like image data in
//! [`Document::images`] (with a small preview a save writes instead, as for linked images), so the
@@ -6,8 +7,9 @@
//! joins the object tree, so editing can't reach it: it is locked by design. Drawing and exporting
//! expand it like a live object ([`crate::live::expanded`]).
//!
//! The file is read by the native format's loader, which lives above this crate and registers
//! itself ([`set_loader`]); the art read is kept in a small cache by content. Before exporting,
//! The file is read by the engine's loader (native documents, PDF and `.ai` pages), which lives
//! above this crate and registers itself ([`set_loader`]); the art read is kept in a small cache
//! by content. Before exporting,
//! every exporter calls [`Document::with_placed_art`], which adds the resources the art uses
//! (images, symbols, patterns) under names of their own ([`RESOURCE_PREFIX`]) to a temporary copy
//! of the document, so they never meet the user's or show in panels. Placed documents inside a
@@ -25,16 +27,21 @@ use vectorcraft_color::Paint;
use crate::{AppearanceItem, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeKind, PatternDef, PlacementOptions, Symbol};
/// A placed document: an artboard of another VectorCraft file, shown as one locked object.
/// A placed document: an artboard of another VectorCraft file, or a page of a PDF or `.ai` file,
/// shown as one locked object.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacedDocument {
/// The file (its `page` is the artboard shown, 1-based).
/// The file (its `page` is the artboard or page shown, 1-based).
pub link: LinkInfo,
/// Key into [`Document::images`]: the file's bytes (see [`key_for`]).
pub key: String,
/// The art's bounds rather than the artboard (File › Place's `crop: "bounding"`).
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub bounding: bool,
/// The box of a PDF or `.ai` page shown when not `bounding` (File › Place's `crop`); a
/// VectorCraft document shows its artboard whatever this says.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub page_box: PageBox,
/// The artboard's size (or the art's) at 100% (pt).
pub width: f64,
pub height: f64,
@@ -45,11 +52,43 @@ pub struct PlacedDocument {
pub placement: PlacementOptions,
}
/// The box of a PDF page a placed PDF or `.ai` file shows (File › Place's `crop`, other than
/// `bounding`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PageBox {
/// The visible page area.
#[default]
Crop,
Art,
Trim,
Bleed,
Media,
}
impl PageBox {
/// The `crop` param value.
pub fn id(self) -> &'static str {
match self {
PageBox::Crop => "crop",
PageBox::Art => "art",
PageBox::Trim => "trim",
PageBox::Bleed => "bleed",
PageBox::Media => "media",
}
}
}
/// The images key of a file's bytes (`hash`, [`crate::links::hash_bytes`]) placed showing
/// artboard `page` (`bounding`: its art's bounds): objects showing the same thing share a blob and
/// its preview.
pub fn key_for(hash: &str, page: u32, bounding: bool) -> String {
format!("{hash}-{page}{}", if bounding { "b" } else { "" })
/// artboard or page `page` (`bounding`: its art's bounds; else the page's box `page_box`):
/// objects showing the same thing share a blob and its preview.
pub fn key_for(hash: &str, page: u32, bounding: bool, page_box: PageBox) -> String {
let shown = match (bounding, page_box) {
(true, _) => "b",
(false, PageBox::Crop) => "",
(false, b) => b.id(),
};
format!("{hash}-{page}{shown}")
}
impl PlacedDocument {
@@ -107,7 +146,7 @@ impl PlacedDocument {
pub fn preview_image(&self, bytes: &[u8]) -> Option<ImageObject> {
let (w, h) = image_size(bytes).filter(|(w, h)| *w > 0 && *h > 0)?;
let px = Affine::scale_non_uniform(self.width / w as f64, self.height / h as f64);
Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default() })
Some(ImageObject { key: self.key.clone(), width: w, height: h, xf: self.xf * px, link: None, placement: Default::default(), pixels: None })
}
}
@@ -249,11 +288,23 @@ pub fn set_file_reader(f: FileReader) {
let _ = FILE_READER.set(f);
}
/// Whether the caller may read the file at a path (`true`: it may). The engine registers one with
/// [`set_read_check`]: the cache below is shared by every caller, and a cached file is handed out
/// only to a caller that may read the file it came from.
pub type ReadCheck = fn(&str) -> bool;
static READ_CHECK: OnceLock<ReadCheck> = OnceLock::new();
/// Register how a caller's right to read a file is checked (the first registration stays).
pub fn set_read_check(f: ReadCheck) {
let _ = READ_CHECK.set(f);
}
/// The most bytes of files read again kept.
const FULL_BYTES: usize = 512 << 20;
/// Files read again, by key, the most recently read last.
type FullFiles = Vec<(String, Arc<Vec<u8>>)>;
/// Files read again, by key, with the path each was read from, the most recently read last.
type FullFiles = Vec<(String, String, Arc<Vec<u8>>)>;
static FULL: Mutex<FullFiles> = Mutex::new(Vec::new());
@@ -268,7 +319,11 @@ pub fn full_bytes_cached(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec
if !blob.is_proxy() {
return Some(blob.bytes.clone());
}
full().iter().rev().find(|(k, _)| *k == p.key).map(|(_, b)| b.clone())
let allowed = |path: &str| READ_CHECK.get().is_none_or(|ok| ok(path));
// Copy the candidates, newest first, so the filesystem checks run without the lock.
let candidates: Vec<(String, Arc<Vec<u8>>)> =
full().iter().rev().filter(|(k, _, _)| *k == p.key).map(|(_, path, b)| (path.clone(), b.clone())).collect();
candidates.into_iter().find(|(path, _)| allowed(path)).map(|(_, b)| b)
}
/// The file's bytes: stored, read again already, or read again now ([`set_file_reader`]). `None`
@@ -279,10 +334,10 @@ pub fn full_bytes(p: &PlacedDocument, blob: &ImageBlob) -> Option<Arc<Vec<u8>>>
}
let b = Arc::new(FILE_READER.get()?(p)?);
let mut c = full();
c.push((p.key.clone(), b.clone()));
let mut total: usize = c.iter().map(|(_, b)| b.len()).sum();
c.push((p.key.clone(), p.link.path.clone(), b.clone()));
let mut total: usize = c.iter().map(|(_, _, b)| b.len()).sum();
while total > FULL_BYTES && c.len() > 1 {
total = total.saturating_sub(c.remove(0).1.len());
total = total.saturating_sub(c.remove(0).2.len());
}
Some(b)
}
@@ -405,7 +460,7 @@ fn read_preview(p: &PlacedDocument, bytes: &Arc<Vec<u8>>) -> Option<Arc<ReadDocu
let mime = if bytes.starts_with(&[0xff, 0xd8]) { "image/jpeg" } else { "image/png" };
doc.images.insert("preview".into(), ImageBlob { mime: mime.into(), bytes: bytes.clone(), proxy: None });
let xf = Affine::scale_non_uniform(frame.width() / w as f64, frame.height() / h as f64);
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default() };
let im = ImageObject { key: "preview".into(), width: w, height: h, xf, link: None, placement: Default::default(), pixels: None };
let art = Node::new(art_id(), NodeKind::Group { children: vec![Arc::new(Node::new(art_id(), NodeKind::Image(Box::new(im))))], clip: false });
let e = Arc::new(ReadDocument { key: p.key.clone(), preview: true, bytes: bytes.clone(), frame, art: Arc::new(art), doc: Arc::new(doc) });
cache().insert(e.clone());
@@ -576,6 +631,7 @@ mod tests {
link: LinkInfo::new("/art/logo.vectorcraft"),
key: key.into(),
bounding: false,
page_box: PageBox::Crop,
width: 80.0,
height: 40.0,
xf: Affine::translate((10.0, 10.0)),
@@ -597,6 +653,7 @@ mod tests {
xf: Affine::IDENTITY,
link: None,
placement: Default::default(),
pixels: None,
})),
);
d.symbols.push(Symbol { name: "Star".into(), art: Arc::new(art) });
@@ -616,9 +673,18 @@ mod tests {
let v = serde_json::to_value(&n).unwrap();
assert_eq!(v["kind"]["type"], "placeddocument");
assert_eq!(v["kind"]["link"]["path"], "/art/logo.vectorcraft");
// The box of a PDF page is written only when it isn't the crop box.
assert!(v["kind"].get("page_box").is_none(), "{v}");
let back: Node = serde_json::from_value(v).unwrap();
assert_eq!(back, n);
assert_eq!(key_for("img00", 2, true), "img00-2b");
assert_eq!(key_for("img00", 2, true, PageBox::Crop), "img00-2b");
assert_eq!(key_for("img00", 2, false, PageBox::Crop), "img00-2");
assert_eq!(key_for("img00", 2, false, PageBox::Trim), "img00-2trim");
let mut trim = placed("doc1-1trim");
trim.page_box = PageBox::Trim;
let v = serde_json::to_value(&trim).unwrap();
assert_eq!(v["page_box"], "trim");
assert_eq!(serde_json::from_value::<PlacedDocument>(v).unwrap(), trim);
}
#[test]
+86
View File
@@ -0,0 +1,86 @@
//! What plug-ins keep with a document: settings that belong to the whole document rather than to
//! one object, such as the pens a plotter holds or a machine's profile.
//!
//! They are the document's foreign data ([`Document::unknown`], under [`PLUGIN_DATA`]), as the
//! perspective grid and the brushes are, so the native file has no section of its own for them
//! and a version from before them keeps them when it saves. The host never reads them: a filter
//! plug-in writes its own and reads only its own back, and an exporter, which is given the whole
//! document, can read them all (`vectorcraft-plugins`, which caps what one plug-in keeps).
use serde_json::{Map, Value};
use crate::Document;
/// `Document::unknown` key: `{"<plug-in id>": <what it keeps>}`.
pub const PLUGIN_DATA: &str = "pluginData";
impl Document {
/// What plug-in `id` keeps with the document, if anything.
pub fn plugin_data(&self, id: &str) -> Option<&Value> {
self.unknown.get(PLUGIN_DATA).and_then(|m| m.get(id))
}
/// Keeps `data` for plug-in `id` in place of what it kept; `None` forgets it, and the key goes
/// once no plug-in keeps anything. A value under the key that isn't a map of plug-in ids (a
/// damaged file) is replaced when a plug-in keeps something.
pub fn set_plugin_data(&mut self, id: &str, data: Option<Value>) {
let Some(data) = data else {
// Nothing to forget: don't copy the foreign data the undo history shares.
if self.plugin_data(id).is_none() {
return;
}
let Some(m) = self.unknown.get_mut(PLUGIN_DATA).and_then(Value::as_object_mut) else { return };
m.remove(id);
if m.is_empty() {
self.unknown.remove(PLUGIN_DATA);
}
return;
};
let kept = self.unknown.entry(PLUGIN_DATA.to_string()).or_insert_with(|| Value::Object(Map::new()));
if !kept.is_object() {
*kept = Value::Object(Map::new());
}
if let Some(m) = kept.as_object_mut() {
m.insert(id.to_string(), data);
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
/// Each plug-in has its own entry, replaced when it keeps something else; forgetting the last
/// one removes the key, and forgetting what isn't kept copies nothing.
#[test]
fn each_plug_in_keeps_its_own_data() {
let mut d = Document::new(10.0, 10.0);
assert_eq!(d.plugin_data("a"), None);
d.set_plugin_data("a", Some(json!({"pens": 2})));
d.set_plugin_data("b", Some(json!([1, 2])));
d.set_plugin_data("a", Some(json!({"pens": 3})));
assert_eq!((d.plugin_data("a"), d.plugin_data("b")), (Some(&json!({"pens": 3})), Some(&json!([1, 2]))));
d.set_plugin_data("a", None);
assert_eq!(d.unknown[PLUGIN_DATA], json!({"b": [1, 2]}));
let before = d.clone();
d.set_plugin_data("a", None);
assert!(crate::Shared::ptr_eq(&before.unknown, &d.unknown), "still shared with the earlier version");
d.set_plugin_data("b", None);
assert!(!d.unknown.contains_key(PLUGIN_DATA));
}
/// A damaged file's value under the key holds nothing for any plug-in, stays while there is
/// nothing to forget, and is replaced when a plug-in keeps something.
#[test]
fn a_damaged_value_holds_nothing() {
let mut d = Document::new(10.0, 10.0);
d.unknown.insert(PLUGIN_DATA.into(), json!(5));
assert_eq!(d.plugin_data("a"), None);
d.set_plugin_data("a", None);
assert_eq!(d.unknown[PLUGIN_DATA], json!(5));
d.set_plugin_data("a", Some(json!(true)));
assert_eq!(d.unknown[PLUGIN_DATA], json!({"a": true}));
}
}
+19 -1
View File
@@ -9,7 +9,12 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
if t.is_empty() {
return Ok(open);
}
t.parse().ok().filter(|n| (1..=count).contains(n)).ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
t.bytes()
.all(|b| b.is_ascii_digit())
.then(|| t.parse().ok())
.flatten()
.filter(|n| (1..=count).contains(n))
.ok_or_else(|| format!("range `{s}`: `{t}` is not a number from 1 to {count}"))
};
let mut out = vec![];
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
@@ -35,3 +40,16 @@ pub fn parse_range(s: &str, count: usize) -> Result<Vec<usize>, String> {
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::parse_range;
#[test]
fn sign_is_not_part_of_a_page_number() {
assert!(parse_range("+2", 5).is_err());
assert!(parse_range("1-+3", 5).is_err());
assert!(parse_range("-+2", 5).is_err());
assert_eq!(parse_range("1-3, 5", 5), Ok(vec![0, 1, 2, 4]));
}
}
+3 -1
View File
@@ -65,7 +65,9 @@ pub fn recolor_node(n: &mut Node, reach: Reach, v: &mut dyn ColorVisitor) -> usi
}
changed += ch as usize;
}
NodeKind::Image(im) if reach.images && im.link.is_none() => {
// An image holding its own pixels is a glyph's (emoji in outlined type): it keeps its
// colours, as the glyph does in type.
NodeKind::Image(im) if reach.images && im.link.is_none() && im.pixels.is_none() => {
if let Some(k) = v.image(&im.key) {
im.key = k;
changed += 1;
+43 -9
View File
@@ -1,6 +1,7 @@
//! Selection state (objects and, for direct selection, anchors and segments; ruler guides).
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -48,6 +49,11 @@ pub struct Selection {
/// with `key`; [`Self::prune`] clears it when no object is selected to align to it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key_guide: Option<usize>,
/// The new art on a locked artboard that this selection keeps editable
/// ([`Document::new_art`]). Undo, Redo and a failed batch restore it with the selection, and
/// the engine then sets the document's from it. Editing state, never saved.
#[serde(skip)]
pub new_art: Arc<HashSet<NodeId>>,
}
impl Selection {
@@ -148,7 +154,11 @@ impl Selection {
if self.anchors.is_empty() {
return None;
}
let mut acc = doc.bounds_of(&self.whole(), visual);
vectorcraft_geom::union_opt(doc.bounds_of(&self.whole(), visual), self.anchors_box(doc))
}
/// The box around the anchors selected with Direct Selection. `None` without any.
pub fn anchors_box(&self, doc: &Document) -> Option<vectorcraft_geom::Rect> {
let mut acc = None;
for (id, set) in &self.anchors {
let Some(NodeKind::Path { path, .. }) = doc.node(*id).map(|n| &n.kind) else { continue };
for a in set.iter().filter_map(|&(si, ai)| path.subpaths.get(si)?.anchors.get(ai)) {
@@ -202,16 +212,36 @@ impl Selection {
/// around them (and so the key object when it is one): hidden or locked art can't stay
/// selected.
pub fn deselect_uneditable(&mut self, doc: &Document) {
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable(*o)).collect();
if uneditable.is_empty() {
let locks = doc.locked_artboards();
let uneditable: HashSet<NodeId> = self.objects.iter().copied().filter(|o| !doc.is_editable_with(&locks, *o)).collect();
self.deselect_all(&uneditable);
}
/// Deselect the objects on a locked artboard ([`crate::LockedArtboards::holds_id`]), whatever
/// selected them or moved them there (and so the key object when it is one): they act as
/// locked art. A targeted layer stays targeted, as it does with locked objects in it.
pub fn deselect_on_locked_artboards(&mut self, doc: &Document) {
let locks = doc.locked_artboards();
if locks.is_empty() {
return;
}
let locked: HashSet<NodeId> = self.objects.iter().copied().filter(|o| locks.holds_id(*o)).collect();
let layer = self.target.filter(|t| doc.node(*t).is_some_and(crate::Node::is_layer));
self.deselect_all(&locked);
if layer.is_some() {
self.target = layer;
}
}
/// Deselect the objects `gone` in one pass, with their anchors, segments and key.
fn deselect_all(&mut self, gone: &HashSet<NodeId>) {
if gone.is_empty() {
return;
}
self.target = None;
self.slices.clear();
self.objects.retain(|o| !uneditable.contains(o));
self.anchors.retain(|id, _| !uneditable.contains(id));
self.segments.retain(|id, _| !uneditable.contains(id));
if self.key.is_some_and(|k| uneditable.contains(&k)) {
self.objects.retain(|o| !gone.contains(o));
self.anchors.retain(|id, _| !gone.contains(id));
self.segments.retain(|id, _| !gone.contains(id));
if self.key.is_some_and(|k| gone.contains(&k)) {
self.key = None;
}
}
@@ -225,12 +255,16 @@ impl Selection {
self.target = Some(id);
}
/// Shift-clicking `id`'s target circle: it (a layer: its art) joins the selection, or leaves it
/// when it is all selected already. Several objects are then selected, none targeted.
/// when it is all selected already. Several objects are then selected, none targeted. Art on a
/// locked artboard is neither counted nor added, since
/// [`Selection::deselect_on_locked_artboards`] deselects it.
pub fn toggle_target(&mut self, doc: &Document, id: NodeId) {
let ids = match doc.node(id) {
let mut ids = match doc.node(id) {
Some(n) if n.is_layer() => n.layer_art(true),
_ => vec![id],
};
let locks = doc.locked_artboards();
ids.retain(|i| !locks.holds_id(*i));
if ids.iter().all(|i| self.contains(*i)) {
ids.iter().for_each(|i| self.remove(*i));
} else {
+7
View File
@@ -154,6 +154,7 @@ pub const LANGUAGES: &[(&str, Quotes)] = &[
("Polish", Quotes { double: ['„', '”'], single: ['‚', '’'] }),
("Czech", Quotes { double: ['„', '“'], single: ['‚', '‘'] }),
("Russian", Quotes { double: ['«', '»'], single: ['„', '“'] }),
("Turkish", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
("Japanese", Quotes { double: ['「', '」'], single: ['『', '』'] }),
("Chinese", Quotes { double: ['“', '”'], single: ['‘', '’'] }),
];
@@ -238,6 +239,11 @@ pub struct DocSetup {
pub export_text: ExportText,
/// Background Contents chosen in New Document.
pub background: Background,
/// Character panel menu › System Layout: every text object advances by whole points. Off by
/// default and for documents from before it, and saved only when on.
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
#[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")]
pub system_layout: bool,
}
impl Default for DocSetup {
@@ -260,6 +266,7 @@ impl Default for DocSetup {
small_caps_size: 70.0,
export_text: ExportText::Editable,
background: Background::Transparent,
system_layout: false,
}
}
}
+1 -1
View File
@@ -189,7 +189,7 @@ fn fx(id: &str, params: Value) -> Effect {
/// A gradient of `(offset, colour, opacity)` stops.
fn gradient(kind: GradientKind, stops: &[(f32, &str, f32)]) -> Gradient {
Gradient { kind, stops: stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect() }
Gradient::new(kind, stops.iter().map(|&(offset, c, opacity)| GradientStop { opacity, ..GradientStop::new(offset, hex(c)) }).collect())
}
/// A linear gradient fitted to each object's box at `angle` degrees (90: upwards).
+1 -1
View File
@@ -297,7 +297,7 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
/// Visit `n` and its subtree, the art of opacity masks included (mask art after the masked
/// object, before its children).
fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
pub fn walk_with_masks<'a>(n: &'a Node, f: &mut dyn FnMut(&'a Node)) {
f(n);
if let Some(mask) = n.extras().mask.as_deref() {
walk_with_masks(&mask.art, f);
+8
View File
@@ -23,6 +23,14 @@ fn every_unit_round_trips_through_its_field_text() {
}
}
#[test]
fn an_explicit_leading_plus_is_accepted() {
assert_eq!(Unit::Points.parse("+5"), Some(5.0));
assert_eq!(Unit::Millimeters.parse("+5mm"), Some(Unit::Millimeters.to_pt(5.0)));
assert_eq!(Unit::Points.parse("+5+3"), Some(8.0));
assert_eq!(Unit::Points.parse("5+-3"), Some(2.0));
}
#[test]
fn typed_units_win_over_the_fields_unit() {
let mm = 72.0 / 25.4;
+417 -3
View File
@@ -104,15 +104,33 @@ pub struct CharStyle {
/// their line line up with it.
#[serde(default, rename = "charAlign", skip_serializing_if = "crate::skip::is_default")]
pub char_align: CharAlign,
/// Character Direction (Character panel menu, Middle Eastern options): these characters are
/// set left to right or right to left inside their paragraph, each stretch of them a Unicode
/// bidi embedding (UAX #9 LRE or RLE); None = Default, the direction the bidi algorithm gives
/// them.
#[serde(default, rename = "charDirection", skip_serializing_if = "Option::is_none")]
pub char_direction: Option<ParaDirection>,
/// Proportional Metrics (Character panel menu, East Asian options): full-width glyphs take the
/// font's own proportional widths, OpenType `palt` (#966); in vertical type upright glyphs take
/// its proportional heights, `vpal`.
#[serde(default, rename = "proportionalMetrics", skip_serializing_if = "crate::skip::is_default")]
pub proportional_metrics: bool,
/// Tate-chu-yoko (Character panel menu, East Asian options): in vertical type these characters
/// stand side by side across one em of the column. Horizontal type keeps the mark and does not
/// move them. Two or three half-width digits do this without the mark.
/// <https://helpx.adobe.com/illustrator/using/formatting-asian-characters.html>
#[serde(default, rename = "tateChuYoko", skip_serializing_if = "crate::skip::is_default")]
pub tate_chu_yoko: bool,
/// Digits (Character panel menu, Middle Eastern options): the numeral system the digits of
/// these characters are drawn in, whatever digits were typed.
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
pub digits: Digits,
/// No Break (Character panel menu): these characters are not broken across lines, including
/// at a space or a hyphenation point inside the run. A run longer than the line stays together
/// and can overflow it.
/// <https://helpx.adobe.com/illustrator/using/hyphenation-line-breaks.html>
#[serde(default, rename = "noBreak", skip_serializing_if = "crate::skip::is_default")]
pub no_break: bool,
}
/// The numeral system digits are drawn in ([`CharStyle::digits`]): as typed, or every digit of
@@ -249,6 +267,9 @@ fn regular() -> String {
fn hundred() -> f64 {
100.0
}
fn yes() -> bool {
true
}
impl Default for CharStyle {
fn default() -> Self {
@@ -282,9 +303,12 @@ impl Default for CharStyle {
stroke_align: StrokeAlign::Center,
position: CharPosition::Normal,
char_align: CharAlign::RomanBaseline,
char_direction: None,
proportional_metrics: false,
tate_chu_yoko: false,
small_caps: None,
digits: Digits::Default,
no_break: false,
}
}
}
@@ -457,6 +481,80 @@ fn default_align_on() -> char {
/// Distance between default tab stops when no explicit stop applies (½ inch).
pub const DEFAULT_TAB_INTERVAL: f64 = 36.0;
/// The bullet of a bulleted list ([`ParaList::bullet`]): the Paragraph panel's Bullets presets.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Bullet {
/// A filled dot (•).
#[default]
Dot,
/// A dash (–).
Dash,
/// A hollow circle (◦).
Circle,
/// A filled square (▪).
Square,
}
impl Bullet {
/// The character the layout draws for the bullet.
pub fn character(self) -> char {
match self {
Bullet::Dot => '\u{2022}',
Bullet::Dash => '\u{2013}',
Bullet::Circle => '\u{25E6}',
Bullet::Square => '\u{25AA}',
}
}
}
/// The list a paragraph is in (Paragraph panel › Bullets). The layout draws the list's marker
/// before the paragraph's first line, in the font, size and color of the paragraph's first
/// character without its baseline shift, rotation, superscript or subscript, tracking, manual
/// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after
/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a
/// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves
/// out as their defaults, and a build ignores the fields it doesn't know.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct ParaList {
/// The bullet each paragraph of the list starts with.
pub bullet: Bullet,
}
impl ParaList {
/// A bulleted list with `bullet`.
pub fn bulleted(bullet: Bullet) -> Self {
Self { bullet }
}
/// The characters of the marker the layout draws before the paragraph.
pub fn marker(&self) -> String {
self.bullet.character().to_string()
}
/// The style the marker is set in: `first` (the style of the paragraph's first character)
/// without its baseline shift, rotation, superscript or subscript, tracking, manual kerning,
/// tate-chu-yoko, underline and strikethrough.
pub fn marker_style(first: &CharStyle) -> CharStyle {
CharStyle {
baseline_shift: 0.0,
rotation: 0.0,
position: CharPosition::Normal,
tracking: 0.0,
kerning: None,
tate_chu_yoko: false,
underline: false,
strikethrough: false,
..first.clone()
}
}
}
/// The left and first-line indents (pt) a list gives a paragraph whose indents are both 0: the
/// marker hangs in the first line's 18 pt before the text.
pub const LIST_HANGING_INDENT: f64 = 18.0;
/// Paragraph composer (Paragraph panel menu): how lines are broken.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -598,6 +696,35 @@ impl Default for JustificationOptions {
}
}
/// Insert Kashidas (Paragraph panel): how long the kashidas are that lengthen the Arabic words of
/// a justified paragraph's lines. A kashida is a tatweel (U+0640) drawn between a word's last pair
/// of joined letters, at most one in each word. Short, Medium and Long kashidas are at most a quarter
/// of an em, half an em and an em long; a Stylistic kashida is a row of whole tatweels as the font
/// draws them, at most an em long.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kashida {
/// No kashidas: justified lines widen their word spaces.
#[default]
None,
Short,
Medium,
Long,
Stylistic,
}
impl Kashida {
/// The longest kashida, in ems of the letter that follows it in the text; 0 for None.
pub fn max_em(self) -> f64 {
match self {
Kashida::None => 0.0,
Kashida::Short => 0.25,
Kashida::Medium => 0.5,
Kashida::Long | Kashida::Stylistic => 1.0,
}
}
}
/// Paragraph attributes (the Paragraph panel).
///
/// Saved through [`ParaStyleFile`], which keeps files with text openable by builds from before
@@ -621,6 +748,9 @@ pub struct ParaStyle {
pub composer: Composer,
/// Tab stops (Tabs panel), sorted by position.
pub tabs: Vec<TabStop>,
/// Bullets (Paragraph panel): the list the paragraph is in; None for a paragraph that is not
/// in a list.
pub list: Option<ParaList>,
/// Paragraph style (Paragraph Styles panel) these attributes come from; None = Normal.
pub style_name: Option<String>,
/// Japanese composition: the spacing of punctuation (Paragraph panel › Mojikumi). Type made
@@ -645,6 +775,9 @@ pub struct ParaStyle {
/// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses,
/// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari.
pub roman_hanging: bool,
/// Insert Kashidas (Paragraph panel): justified lines lengthen their Arabic words with
/// kashidas before they widen their word spaces.
pub kashida: Kashida,
}
impl ParaStyle {
@@ -661,6 +794,19 @@ impl ParaStyle {
t.position = within(t.position, 0.0, 100_000.0);
}
}
/// The leading of characters styled `st` in this paragraph, as the layout sets it: their own
/// leading, or for Auto leading the paragraph's Justification › Auto Leading share of their
/// size (exactly [`CharStyle::effective_leading`] at the default 120%, or when the share is
/// not a finite number).
pub fn effective_leading(&self, st: &CharStyle) -> f64 {
let auto = self.justification.auto_leading;
if st.leading.is_some() || auto == JustificationOptions::default().auto_leading {
return st.effective_leading();
}
let l = self.justification.auto_leading_for(st.size);
if l.is_finite() { l } else { st.effective_leading() }
}
}
/// [`ParaStyle`] as saved. [`Justify::Auto`] is written as the alignment it has in the paragraph
@@ -693,6 +839,8 @@ struct ParaStyleFile {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
tabs: Vec<TabStop>,
#[serde(default, skip_serializing_if = "Option::is_none")]
list: Option<ParaList>,
#[serde(default, skip_serializing_if = "Option::is_none")]
style_name: Option<String>,
#[serde(default, skip_serializing_if = "Mojikumi::is_none")]
mojikumi: Mojikumi,
@@ -706,6 +854,8 @@ struct ParaStyleFile {
kinsoku: Kinsoku,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
roman_hanging: bool,
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
kashida: Kashida,
}
impl From<ParaStyle> for ParaStyleFile {
@@ -727,6 +877,7 @@ impl From<ParaStyle> for ParaStyleFile {
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
@@ -734,6 +885,7 @@ impl From<ParaStyle> for ParaStyleFile {
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = p;
Self {
@@ -749,6 +901,7 @@ impl From<ParaStyle> for ParaStyleFile {
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
@@ -756,6 +909,7 @@ impl From<ParaStyle> for ParaStyleFile {
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
@@ -775,6 +929,7 @@ impl From<ParaStyleFile> for ParaStyle {
hyphenation,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
@@ -782,6 +937,7 @@ impl From<ParaStyleFile> for ParaStyle {
burasagari,
kinsoku,
roman_hanging,
kashida,
..
} = f;
Self {
@@ -796,6 +952,7 @@ impl From<ParaStyleFile> for ParaStyle {
justification,
composer,
tabs,
list,
style_name,
mojikumi,
direction,
@@ -803,11 +960,13 @@ impl From<ParaStyleFile> for ParaStyle {
burasagari,
kinsoku,
roman_hanging,
kashida,
}
}
}
/// A paragraph's base direction ([`ParaStyle::direction`]).
/// A paragraph's base direction ([`ParaStyle::direction`]), or the direction characters are set
/// in ([`CharStyle::char_direction`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ParaDirection {
@@ -1187,6 +1346,10 @@ fn one() -> f64 {
1.0
}
fn fifty() -> f64 {
50.0
}
impl InlineArt {
pub fn new(symbol: impl Into<String>) -> Self {
Self { symbol: symbol.into(), scale: 1.0, baseline_shift: 0.0, bounds: None }
@@ -1252,6 +1415,68 @@ fn reverse_from_end(path: &mut PathData) {
}
}
/// Where ruby (furigana) text sits: above its base text in horizontal type, right of it in
/// vertical type (the Japanese convention), or below (left in vertical type).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RubyPosition {
#[default]
Above,
Below,
}
/// How ruby text lines up over its base text when the two are different widths.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum RubyAlign {
/// Centred over the base text.
#[default]
Center,
/// Starting at the base text's start (left in horizontal type, top in vertical).
Start,
/// Ending at the base text's end (right in horizontal type, bottom in vertical).
End,
}
/// Ruby (furigana, phonetic guide): a reading set over a range of the text.
///
/// `start`/`end` are byte offsets into [`TextObject::plain_text`] (the same space as
/// [`crate::TextObject`] commands' `start`/`end`). The base text itself stays the text;
/// emptying the reading removes the ruby. New ruby is half the base text's size, centred, and
/// allowed to overhang neighbouring characters when the reading is longer than its base.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RubySpan {
/// Byte offset of the first base character in the plain text.
pub start: usize,
/// Byte offset just past the last base character.
pub end: usize,
/// The reading, set in the base characters' font (its size comes from `size_percent`).
pub text: String,
#[serde(default)]
pub position: RubyPosition,
#[serde(default)]
pub align: RubyAlign,
/// Ruby size as a percentage of the base text's size, 1–100 (50 = half, the usual).
#[serde(default = "fifty")]
pub size_percent: f64,
/// Let a reading wider than its base hang over the neighbouring characters instead of being
/// squeezed to the base's width.
#[serde(default = "yes")]
pub overhang: bool,
}
impl RubySpan {
/// Ruby over `start..end` with reading `text`, at the defaults (half size, centred, overhanging).
pub fn new(start: usize, end: usize, text: impl Into<String>) -> Self {
Self { start, end, text: text.into(), position: RubyPosition::Above, align: RubyAlign::Center, size_percent: 50.0, overhang: true }
}
/// `size_percent` made finite and usable: 1–100% (untrusted input).
pub fn safe_size_percent(&self) -> f64 {
if self.size_percent.is_finite() { self.size_percent.clamp(1.0, 100.0) } else { 50.0 }
}
}
/// A text object. `runs` split into paragraphs at `\n`.
///
/// Paragraph attributes: `para` is paragraph 0's (and, while `paras` is empty, every
@@ -1267,6 +1492,10 @@ pub struct TextObject {
/// Maps text space (origin = first baseline start for point type) to the document.
pub xf: Affine,
pub runs: Vec<TextRun>,
/// Ruby (furigana / phonetic guide) over byte ranges of the plain text; empty = none. Kept on
/// the object, so the runs (and their styles) stay the text itself.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub ruby: Vec<RubySpan>,
#[serde(default)]
pub para: ParaStyle,
/// Per-paragraph attributes (see the type's docs); empty = every paragraph uses `para`.
@@ -1291,10 +1520,22 @@ pub struct TextObject {
pub wrap: Vec<WrapShape>,
/// Type › Optical Margin Alignment: in every paragraph, Latin punctuation and the slanted
/// sides of letters such as A and W at a line's start or end hang outside the margin there
/// (point and area type of left-to-right paragraphs; see the layout's `optical_hangs`). Off
/// for new type and for documents from before it, and saved only when on.
/// (point and area type, and type on a path at its brackets; see the layout's `optical_hangs`).
/// Off for new type and for documents from before it, and saved only when on.
#[serde(default, rename = "opticalMarginAlignment", skip_serializing_if = "crate::skip::is_default")]
pub optical_margin: bool,
/// Character panel menu › System Layout, copied from the document: when on, each character
/// that takes space advances by a whole number of points. Off by default, and saved only when
/// on. The document setting is [`crate::DocSetup::system_layout`].
#[serde(default, rename = "systemLayout", skip_serializing_if = "crate::skip::is_default")]
pub system_layout: bool,
/// Character panel menu › Fractional Widths: spacing between characters uses fractions of a
/// point. On for new type and for documents from before it, and saved only when off. Off, each
/// character that takes any space advances by a whole number of points, at least one, so small
/// type does not run together. The setting is the whole type object, not a range of characters.
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/turn-fractional-character-widths-off-or-on.html>
#[serde(default = "yes", rename = "fractionalWidths", skip_serializing_if = "crate::skip::is_true")]
pub fractional_widths: bool,
/// Cached layout bounds in text space, filled in by the layout engine (not serialized).
#[serde(skip)]
pub cached_bounds: Option<Rect>,
@@ -1347,12 +1588,43 @@ impl TextObject {
Some(style)
}
/// Bakes the object's scale ([`style_scale`]) into character attributes and removes that
/// scale from `xf`, so panels show document-point sizes without a separate transform factor.
/// Returns `false` when there is nothing to bake (identity scale or an unrepresentable
/// transform).
pub fn scale_text_attributes(&mut self) -> bool {
let Some(scale) = self.style_scale() else { return false };
if (scale.points - 1.0).abs() <= 1e-12 && (scale.horizontal - 1.0).abs() <= 1e-12 {
return false;
}
let [a, b, c, d, _, _] = self.xf.as_coeffs();
let (sx, sy) = (a.hypot(b), c.hypot(d));
if sx <= 1e-12 || sy <= 1e-12 {
return false;
}
let mut baked = Vec::with_capacity(self.runs.len());
for run in &self.runs {
let Some(eff) = self.effective_char_style(&run.style) else { return false };
baked.push(eff);
}
for (run, style) in self.runs.iter_mut().zip(baked) {
run.style = style;
}
// Character strokes are drawn in text space: widen them by the vertical scale we remove.
self.scale_char_strokes(1.0 / sy);
self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy);
self.cached_bounds = None;
self.cached_baselines.clear();
true
}
pub fn point(origin: Point, text: &str, style: CharStyle) -> Self {
Self {
vertical: false,
kind: TextKind::Point,
xf: Affine::translate(origin.to_vec2()),
runs: vec![TextRun::new(text, style)],
ruby: Vec::new(),
para: ParaStyle::default(),
paras: Vec::new(),
area: AreaOptions::default(),
@@ -1361,6 +1633,8 @@ impl TextObject {
path_spacing: 0.0,
wrap: Vec::new(),
optical_margin: false,
system_layout: false,
fractional_widths: true,
cached_bounds: None,
cached_baselines: Vec::new(),
}
@@ -1368,6 +1642,54 @@ impl TextObject {
pub fn plain_text(&self) -> String {
self.runs.iter().map(|r| r.text.as_str()).collect()
}
/// Clamp the ruby spans to the plain text, drop empty/empty-reading ones and sort them.
///
/// Called after loading and editing text: ranges from a file or an edit are untrusted, and a
/// range past the text or an inverted one would lay out nothing (or panic in a silly place).
pub fn normalize_ruby(&mut self) {
let len: usize = self.runs.iter().map(|r| r.text.len()).sum();
for r in &mut self.ruby {
r.start = r.start.min(len);
r.end = r.end.min(len);
if r.start > r.end {
std::mem::swap(&mut r.start, &mut r.end);
}
r.text = r.text.trim().to_string();
}
self.ruby.retain(|r| r.start < r.end && !r.text.is_empty());
self.ruby.sort_by_key(|r| (r.start, r.end));
}
/// The ruby span covering byte `byte` of the plain text, if any.
pub fn ruby_at(&self, byte: usize) -> Option<&RubySpan> {
self.ruby.iter().find(|r| r.start <= byte && byte < r.end)
}
/// Ruby spans touching the plain-text byte range `start..end`, with their ranges ordered.
pub fn ruby_in(&self, start: usize, end: usize) -> impl Iterator<Item = &RubySpan> {
let (a, b) = (start.min(end), start.max(end));
self.ruby.iter().filter(move |r| r.start < b && a < r.end)
}
/// Keep the ruby spans over the text after replacing plain-text bytes `start..end` with
/// `inserted` bytes of text: spans after the edit move with the text, ones the edit runs into
/// keep their start and end up where the replaced part ended. Call it after every edit of the
/// runs (`vectorcraft_text::edit`), then [`Self::normalize_ruby`] has the last word.
pub fn ruby_after_replace(&mut self, start: usize, end: usize, inserted: usize) {
let (start, end) = (start.min(end), start.max(end));
let delta = inserted as i64 - (end.saturating_sub(start)) as i64;
let shift = |v: usize| -> usize { (v as i64 + delta).max(0) as usize };
for r in &mut self.ruby {
if r.end <= start {
continue;
}
if r.start >= end {
r.start = shift(r.start);
r.end = shift(r.end);
} else {
r.start = r.start.min(start);
r.end = shift(r.end).max(r.start);
}
}
self.normalize_ruby();
}
/// Number of paragraphs (`\n`-separated; empty text has one).
pub fn paragraph_count(&self) -> usize {
1 + self.runs.iter().map(|r| r.text.bytes().filter(|&b| b == b'\n').count()).sum::<usize>()
@@ -1484,6 +1806,21 @@ impl TextObject {
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
}
}
/// How far the characters' strokes paint past their glyphs, in document space: they scale
/// with the type.
pub fn char_stroke_outset(&self) -> f64 {
let k = crate::node::Scaling::factor(self.xf).unwrap_or(1.0);
self.runs.iter().map(|r| r.style.stroke_outset()).fold(0.0, f64::max) * k
}
/// How far the characters' strokes can paint past their glyphs, their miter spikes and square
/// caps included ([`StrokeLayer::reach`]), in document space: what a change of the type must
/// redraw. They stretch with the type, so this is as far as its transform takes them on either
/// axis. The visual bounds take [`Self::char_stroke_outset`].
pub fn char_stroke_reach(&self) -> f64 {
let reach = self.runs.iter().filter(|r| r.style.has_stroke()).map(|r| r.style.stroke_layer().reach()).fold(0.0, f64::max);
let [a, b, c, d, ..] = self.xf.as_coeffs();
reach * (a.abs() + c.abs()).max(b.abs() + d.abs())
}
pub fn transform(&mut self, a: Affine) {
self.xf = a * self.xf;
}
@@ -1658,12 +1995,73 @@ impl crate::Document {
}
}
}
/// Clamps the ruby spans of the type in the layers, symbols, graph designs, pattern swatches
/// and the pattern definition that pattern editing's Cancel puts back (opacity-mask art
/// included) to their text ([`TextObject::normalize_ruby`]). Loaded files and edits carry
/// ranges from outside: a range past the text, an inverted one or an empty reading would lay
/// out nothing. It recurses through the art: call it on a document no deeper than
/// [`crate::depth::MAX_DEPTH`].
pub fn fix_ruby(&mut self) {
let mut fix = |n: &mut crate::Node| {
if let crate::NodeKind::Text(t) = &mut n.kind {
t.normalize_ruby();
}
};
let roots = self
.layers
.iter_mut()
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
for root in roots {
crate::placed_document::each_mut(std::sync::Arc::make_mut(root), &mut fix);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Character Direction is saved only when set, and styles written before it existed read as
/// Default.
#[test]
fn character_direction_is_saved_only_when_set() {
let st = CharStyle::default();
assert_eq!(st.char_direction, None);
assert!(serde_json::to_value(&st).unwrap().get("charDirection").is_none());
for (d, key) in [(ParaDirection::LeftToRight, "leftToRight"), (ParaDirection::RightToLeft, "rightToLeft")] {
let set = CharStyle { char_direction: Some(d), ..CharStyle::default() };
assert_eq!(serde_json::to_value(&set).unwrap()["charDirection"], key);
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&set).unwrap()).unwrap(), set);
}
let old = serde_json::to_value(&st).unwrap();
assert_eq!(serde_json::from_value::<CharStyle>(old).unwrap().char_direction, None);
}
/// Ruby saves with the type and a file saved before it (no `ruby` key) reads with none.
#[test]
fn ruby_is_saved_and_missing_ruby_reads_as_none() {
let mut t = TextObject::point(Point::ZERO, "漢字", CharStyle::default());
t.ruby = vec![RubySpan { size_percent: 40.0, overhang: false, ..RubySpan::new(0, 6, "かんじ") }];
let json = serde_json::to_string(&t).unwrap();
assert!(json.contains("かんじ"));
let back: TextObject = serde_json::from_str(&json).unwrap();
assert_eq!(back.ruby, t.ruby);
assert!((back.ruby[0].size_percent - 40.0).abs() < 1e-9 && !back.ruby[0].overhang);
// Old files have no `ruby` key: none.
let mut old: serde_json::Value = serde_json::from_str(&json).unwrap();
old.as_object_mut().unwrap().remove("ruby");
assert!(serde_json::from_value::<TextObject>(old).unwrap().ruby.is_empty());
// A damaged file's range past the text and its empty reading are clamped away.
let mut damaged = t.clone();
damaged.ruby = vec![RubySpan::new(0, 10_000, "かんじ"), RubySpan::new(3, 2, " "), RubySpan::new(0, 3, "")];
damaged.normalize_ruby();
assert_eq!((damaged.ruby[0].start, damaged.ruby[0].end), (0, 6));
}
/// Digits map every digit of the three sets into the chosen one, leave the rest alone, and
/// are saved only when set.
#[test]
@@ -1687,6 +2085,22 @@ mod tests {
assert_eq!(serde_json::from_value::<CharStyle>(serde_json::to_value(&farsi).unwrap()).unwrap(), farsi);
}
/// Auto leading follows the paragraph's Auto Leading; a set leading doesn't.
#[test]
fn paragraph_effective_leading_follows_auto_leading() {
let st = CharStyle { size: 10.0, leading: None, ..CharStyle::default() };
let mut para = ParaStyle::default();
assert_eq!(para.effective_leading(&st), st.effective_leading(), "default 120%");
para.justification.auto_leading = 200.0;
assert_eq!(para.effective_leading(&st), 20.0);
para.justification.auto_leading = 0.0;
assert_eq!(para.effective_leading(&st), 0.0);
let set = CharStyle { leading: Some(14.0), ..st.clone() };
assert_eq!(para.effective_leading(&set), 14.0);
para.justification.auto_leading = f64::NAN;
assert_eq!(para.effective_leading(&st), st.effective_leading(), "not a number");
}
/// A file can't feed layout justification values `text.setFormat` would refuse: each is
/// clamped to its range, one that isn't finite takes its default, and a Minimum, Desired and
/// Maximum out of order go back to their defaults.

Some files were not shown because too many files have changed in this diff Show More