mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
Merge upstream main into docking-trial; pin craft-libs to main 5bbe9f62
This commit is contained in:
Generated
+47
-34
@@ -954,7 +954,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-atomic"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"rustix 1.1.5",
|
||||
]
|
||||
@@ -962,7 +962,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-cms"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"rayon",
|
||||
]
|
||||
@@ -970,12 +970,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro",
|
||||
@@ -988,7 +988,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -1001,7 +1001,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
@@ -1011,6 +1011,17 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"egui",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
@@ -1139,9 +1150,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-window",
|
||||
"designcraft-engine",
|
||||
"designcraft-ui-egui",
|
||||
"eframe",
|
||||
@@ -1161,7 +1173,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-cli"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1177,7 +1189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-color"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"kurbo",
|
||||
@@ -1189,7 +1201,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-compose"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-doc",
|
||||
@@ -1209,7 +1221,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-doc"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1222,7 +1234,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-engine"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"caseless",
|
||||
"craft-atomic",
|
||||
@@ -1261,7 +1273,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-epub"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1271,7 +1283,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-fonts"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1284,7 +1296,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-format"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"designcraft-images",
|
||||
@@ -1296,7 +1308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-geom"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"flo_curves",
|
||||
"kurbo",
|
||||
@@ -1309,7 +1321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-idml"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1324,7 +1336,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-images"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-cms",
|
||||
"designcraft-color",
|
||||
@@ -1339,7 +1351,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-mcp"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-geom",
|
||||
@@ -1352,7 +1364,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pdf"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-pdf",
|
||||
@@ -1375,7 +1387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-pptx"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"designcraft-color",
|
||||
@@ -1393,7 +1405,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-render"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"designcraft-color",
|
||||
@@ -1412,7 +1424,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-textimport"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-doc",
|
||||
"quick-xml",
|
||||
@@ -1421,7 +1433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-tools"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
@@ -1433,9 +1445,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-ui-egui"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-window",
|
||||
"designcraft-color",
|
||||
"designcraft-compose",
|
||||
"designcraft-doc",
|
||||
@@ -1460,7 +1473,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "designcraft-web"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"designcraft-engine",
|
||||
"designcraft-format",
|
||||
@@ -2458,7 +2471,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro"
|
||||
version = "0.7.1"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"hayro-interpret",
|
||||
@@ -2478,7 +2491,7 @@ checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
|
||||
[[package]]
|
||||
name = "hayro-cmap"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"brotli",
|
||||
"hayro-postscript",
|
||||
@@ -2487,7 +2500,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-interpret"
|
||||
version = "0.7.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"hayro-cmap",
|
||||
@@ -2505,7 +2518,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-jbig2"
|
||||
version = "0.3.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"fearless_simd",
|
||||
"hayro-ccitt",
|
||||
@@ -2523,12 +2536,12 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "hayro-postscript"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
version = "0.7.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-ccitt",
|
||||
@@ -3029,7 +3042,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=d24e4bcce3b420f25b7bca2b2d786cbcc8631f96#d24e4bcce3b420f25b7bca2b2d786cbcc8631f96"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=5bbe9f6254de3bb9f9707ae0740ab0db8170afd5#5bbe9f6254de3bb9f9707ae0740ab0db8170afd5"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -7149,7 +7162,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+16
-14
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -12,7 +12,7 @@ repository = "https://github.com/storytold/designcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Shared reader and writer; publish the immutable revision before this consumer.
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.1.0", default-features = false }
|
||||
# internal
|
||||
designcraft-geom = { path = "crates/geom" }
|
||||
designcraft-color = { path = "crates/color" }
|
||||
@@ -75,18 +75,20 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
indd = { git = "https://github.com/storytold/indd-utils", branch = "main" }
|
||||
|
||||
# Which paths stored in documents may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" }
|
||||
# Atomic file replacement for saves and exports (craft-libs).
|
||||
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96" }
|
||||
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" }
|
||||
# File › Print through the system print spooler (craft-libs).
|
||||
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96" }
|
||||
craft-print = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" }
|
||||
|
||||
# ICC colour management: working spaces, transforms, black point compensation (craft-libs).
|
||||
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96" }
|
||||
craft-cms = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5" }
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", features = ["serde"] }
|
||||
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["serde"] }
|
||||
# The application bar as the window's title bar (craft-libs).
|
||||
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", features = ["egui"] }
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false, features = ["svg"] }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
@@ -122,11 +124,11 @@ debug = 0
|
||||
|
||||
# Opt-in cancelable macOS termination on the pinned winit 0.30 line.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.8.2" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "3d51077fd1a966f84df9ef287839dc84fd5b4ca7" }
|
||||
hayro = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.7.1" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.7.0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.3.0" }
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.1.0" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96", version = "=0.1.0" }
|
||||
hayro = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.7.1" }
|
||||
hayro-interpret = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.7.0" }
|
||||
hayro-syntax = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.7.2" }
|
||||
hayro-jbig2 = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.3.0" }
|
||||
hayro-cmap = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.1.0" }
|
||||
hayro-postscript = { git = "https://github.com/storytold/craft-libs", rev = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5", version = "=0.1.0" }
|
||||
|
||||
+26
-1
@@ -1,9 +1,10 @@
|
||||
# DesignCraft roadmap
|
||||
|
||||
**Stage: pre-alpha** · next: alpha, ~5–7 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
|
||||
- 2026-10-23: placed-artwork layer options survive IDML and native round trips per placement, independently of shared assets. Unsupported visibility overrides are explicit import/preflight/PDF warnings; format 4 prevents older native readers from silently discarding them. Rendering parity is still open; see [graphic-layer-options.md](docs/graphic-layer-options.md).
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-13 · **Change:** minor (File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; File ▸ Open Recent; .indd and .indt open through indd-utils; text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Japanese print, import and table controls localized; Live corners (#186); Optical kerning from outlines (#181); window and menu quit confirmation; failed writes preserve live save and collection state; spring-loaded Zoom navigation; orderly native exit recovery; Pages panel parents; cancelable macOS Dock Quit; text stroke behind fill; Based on Parent; object styles carry effects; table anchors keep their own paragraphs; grouped HTML/EPUB reading order; Links source paths; XLSX cached cell values retain numeric precision and text) · **Target:** Adobe InDesign 2026
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
|
||||
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
|
||||
@@ -11,6 +12,24 @@
|
||||
|
||||
DesignCraft aims at full Adobe InDesign parity — and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web.
|
||||
|
||||
- **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that
|
||||
starts on a floating panel over the bar moves the panel, not the window, and in full screen the
|
||||
bar no longer zooms or moves the window. No readiness percentage change.
|
||||
- **2026-10-24** · Embedded IDML keeps independent placed-PDF layer choices in table-cell and
|
||||
footnote text. The variant rewrite reaches endnote stories too, but IDML endnote structures and
|
||||
artwork remain unsupported. External-only export continues to reject active nested choices.
|
||||
Shared placement ids with different sources or choices fail closed; no readiness percentage change.
|
||||
- **2026-10-24** · PDF/A-2b preparation applies placed-PDF layer choices, including explicit all-on,
|
||||
to anchors inside table-cell text, footnotes and endnotes in an export-only copy. The nested
|
||||
IDML variant write pass remains separate work; no readiness percentage change.
|
||||
- **2026-10-24** · Explicit PDF layer choices, including all-on, are checked with one actual
|
||||
rewrite per distinct source before editing; reset to file defaults bypasses the check. Native
|
||||
format 4 covers both explicit all-on PDF layers and retained graphic-layer options. No readiness percentage change.
|
||||
- **2026-10-23** · Placed PDF layers can explicitly all be shown, separately from Use File Defaults.
|
||||
Legacy commands keep their reset behavior; native saves require a capable reader only for
|
||||
the new all-on state, and PDF/embedded IDML exports preserve the choice. No readiness percentage change.
|
||||
- **2026-10-23** · IDML graphic-layer export rejects parents in already closed subtrees,
|
||||
preserving native-edited layer order through round trips. Synthetic regression added.
|
||||
- **2026-10-23** · PDF layer choices now validate before editing all targets; rejected rewrites
|
||||
leave document/undo state intact. Null trailer entries remain unencrypted, and unsupported
|
||||
all-content crops retain the whole page rather than silently clipping hidden layers.
|
||||
@@ -19,6 +38,10 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
|
||||
regression tests cover metadata, output-intent references, nonzero generations and
|
||||
forward Prev links; repeated cross-reference offsets are rejected explicitly. No readiness percentage change.
|
||||
|
||||
- **2026-10-23** · PDF layer commands reject overrides on layerless files atomically;
|
||||
default layer queries find a placed PDF anywhere in mixed selections. Regression tests cover
|
||||
IDML export after rejection and both selection orders.
|
||||
|
||||
## Status (2026-10-01)
|
||||
|
||||
**Working today**
|
||||
@@ -359,6 +382,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
|
||||
Newest first.
|
||||
|
||||
- **2026-10-24** · Panels dock through the suite's shared docking (craft-libs), and on desktop a floating panel opens in its own window that can leave the main window: keys pressed there run the main window's shortcuts, clipboard and tool keys, and the canvas paints no tool cursor while another of the app's windows has the pointer. The web build keeps floating panels in the page. No readiness percentage change.
|
||||
- **2026-10-24** · Story Options rejects malformed or nonexistent explicit story/frame IDs without changing the selected story, including an invalid frame alongside a valid story. Null targets remain absent; omitted targets still use the selection. No readiness percentage change.
|
||||
- **2026-10-24** · Text Frame Options explains that a custom baseline-grid increment must be a measure greater than zero when zero, a negative value or a non-measure is entered. Validation is unchanged. No readiness percentage change.
|
||||
- **2026-10-24** · Renaming a paragraph or character style, or moving it into a style group, keeps its Export Tagging under the new name, and undo puts it back under the old one. Deleting a tagged style drops its tagging and leaves the replacement style's own tagging as it was. No readiness percentage change.
|
||||
- **2026-10-24** · Quick Apply and the application bar's search leave out the interface commands that failed or reset a setting when chosen there: those that need a workspace, a command and shortcut, a menu item, a panel, a window edge, an app page, a recent file or a swatch they can't give them, a Contextual Task Bar place, and Interface Color Theme, UI Scaling, Reset Shortcuts, the flattener preset, Screen Mode and Zoom To, which without a value switched to the dark theme, set 100%, cleared every custom shortcut or the preset, or went to Normal mode or 100% zoom. Each stays in the menu, panel or dialog that gives it the value. No readiness percentage change.
|
||||
- **2026-10-24** · EPUB Export Options › Split Document, a dialog before File › Export EPUB… saves (`document.epubSplit`, stored with the document): off, a single paragraph style, or based on paragraph style export tags, with Paragraph Style Options › Export Tagging's Split Document flag marking those styles. A reflowable EPUB is one content file unless Split Document is on; the split before each top-level h1 from #791 is gone. A split before a list item or preformatted line no longer leaves an unclosed list or block in a content file. IDML import and export keep the setting and the flag, and Load Styles the flag. No readiness percentage change.
|
||||
|
||||
@@ -239,6 +239,7 @@ pub fn synthetic(spec: &Spec) -> Result<Document, String> {
|
||||
let (nw, nh) = (aw as f64, ah as f64);
|
||||
let s = (r.width() / nw).max(r.height() / nh);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - nw * s) / 2.0, r.y0 + (r.height() - nh * s) / 2.0)) * Affine::scale(s),
|
||||
|
||||
@@ -41,10 +41,12 @@ winsafe = { version = "0.0.29", default-features = false, features = ["user"] }
|
||||
# The same winit as eframe; only the native termination opt-in is used.
|
||||
winit = { version = "0.30", default-features = false }
|
||||
muda = { version = "0.21", default-features = false }
|
||||
# The input context, to drop the IME's marked text when the Type tool ends a composition; the
|
||||
# window's `movable` flag (see `mac_window`). The versions muda uses, through safe methods.
|
||||
# The input context, to drop the IME's marked text when the Type tool ends a composition. The
|
||||
# versions muda uses, through safe methods.
|
||||
objc2 = "0.6"
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSTextInputContext", "NSWindow"] }
|
||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSResponder", "NSTextInputContext"] }
|
||||
# The window's `movable` flag follows the application bar's controls.
|
||||
craft-window = { workspace = true }
|
||||
# Finder double-clicks, Open With, Dock drops and `open -a DesignCraft brochure.idml` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by PhotoCraft,
|
||||
# PdfCraft and WordCraft) registers the handler behind a safe API (Objective-C interop needs unsafe,
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
//! macOS: AppKit moves the window when a drag starts anywhere in the title-bar band, on the
|
||||
//! application bar's controls too. The window is made unmovable while the pointer is on one of
|
||||
//! them (`designcraft_ui_egui::title_bar::window_movable`), so only the bar's empty space moves it.
|
||||
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSWindowStyleMask};
|
||||
|
||||
/// Sets the window's `movable` flag from this frame's title bar. `applied` holds the value last
|
||||
/// set, so AppKit is only called when it changes.
|
||||
pub fn sync_movable(ctx: &egui::Context, applied: &mut Option<bool>) {
|
||||
let movable = designcraft_ui_egui::title_bar::window_movable(ctx);
|
||||
if *applied == Some(movable) {
|
||||
return;
|
||||
}
|
||||
// eframe runs the app on the main thread; anywhere else AppKit is out of reach.
|
||||
let Some(mtm) = MainThreadMarker::new() else {
|
||||
return;
|
||||
};
|
||||
let mut found = false;
|
||||
for window in NSApplication::sharedApplication(mtm).windows().to_vec() {
|
||||
// The main window is the only one with a full-size content view.
|
||||
if window.styleMask().contains(NSWindowStyleMask::FullSizeContentView) {
|
||||
window.setMovable(movable);
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
// Before the window is on screen there is nothing to set yet; try again next frame.
|
||||
if found {
|
||||
*applied = Some(movable);
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,6 @@ mod gpu;
|
||||
mod gpu_limits;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod mac_window;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
mod profile;
|
||||
|
||||
@@ -37,7 +35,7 @@ struct App {
|
||||
/// Documents and quit requests from Finder, the Dock and Open With.
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events: fmv_macos_events::Inbox,
|
||||
/// The window's `movable` flag as last set; see `mac_window`.
|
||||
/// The window's `movable` flag as last set; see `craft_window::title_bar::sync_movable`.
|
||||
#[cfg(target_os = "macos")]
|
||||
movable: Option<bool>,
|
||||
/// Frames begun so far.
|
||||
@@ -91,7 +89,7 @@ impl eframe::App for App {
|
||||
discard_marked_text();
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_window::sync_movable(ui.ctx(), &mut self.movable);
|
||||
craft_window::title_bar::sync_movable(ui.ctx(), &mut self.movable);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
// A normal exit before the third frame is not a crash.
|
||||
|
||||
@@ -68,3 +68,6 @@ sync_github_name = false
|
||||
|
||||
[people.4waymedia]
|
||||
real_name = "Paul Gemignani"
|
||||
|
||||
[people.skylartaylor]
|
||||
display_name = "skylartaylor"
|
||||
|
||||
@@ -577,6 +577,7 @@ fn same_as_clipping_wrap_follows_the_clipping_path() {
|
||||
);
|
||||
let triangle = PathData::single(SubPath::polyline(&[Point::new(75.0, 0.0), Point::new(150.0, 200.0), Point::new(0.0, 200.0)], true));
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (150.0, 200.0),
|
||||
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
|
||||
@@ -725,6 +726,7 @@ fn an_image_contour_stops_at_its_frame() {
|
||||
let mut it = designcraft_doc::Item::new(ItemId(d.alloc()), lid, designcraft_doc::Shape::Polygon, frame);
|
||||
// The image overhangs the frame on every side.
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: designcraft_doc::AssetId(d.alloc()),
|
||||
size: (300.0, 250.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
@@ -769,6 +771,7 @@ fn a_clipping_path_contour_stops_at_its_frame() {
|
||||
true,
|
||||
));
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (150.0, 200.0),
|
||||
xf: designcraft_geom::Affine::translate((150.0, 100.0)),
|
||||
|
||||
@@ -15,6 +15,72 @@ use crate::{Asset, AssetId, Content, Document, Item};
|
||||
/// is treated as using every asset, so nothing it references is merged away or pruned.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
fn item_layer_options(it: &Item, depth: usize, overrides_only: bool) -> bool {
|
||||
depth > MAX_DEPTH
|
||||
|| it.graphic().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|
||||
|| it.children().iter().any(|child| item_layer_options(child, depth + 1, overrides_only))
|
||||
}
|
||||
|
||||
fn story_layer_options(st: &Story, depth: usize, overrides_only: bool) -> bool {
|
||||
depth > MAX_DEPTH
|
||||
|| st.objects.iter().any(|object| item_layer_options(&object.item, depth + 1, overrides_only))
|
||||
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| {
|
||||
cell.graphic.as_ref().is_some_and(|g| g.layer_options.as_ref().is_some_and(|o| !overrides_only || o.has_visibility_overrides()))
|
||||
|| story_layer_options(&cell.text, depth + 1, overrides_only)
|
||||
})
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|note| story_layer_options(¬e.text, depth + 1, overrides_only))
|
||||
}
|
||||
|
||||
fn item_any(it: &Item, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth > MAX_DEPTH || f(it) || it.children().iter().any(|child| item_any(child, depth + 1, f))
|
||||
}
|
||||
|
||||
fn story_any(st: &Story, depth: usize, f: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth > MAX_DEPTH
|
||||
|| st.objects.iter().any(|object| item_any(&object.item, depth + 1, f))
|
||||
|| st.tables.values().flat_map(|table| &table.cells).any(|cell| story_any(&cell.text, depth + 1, f))
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|note| story_any(¬e.text, depth + 1, f))
|
||||
}
|
||||
|
||||
impl Document {
|
||||
/// Whether saving needs a reader that retains per-placement graphic layer settings.
|
||||
/// Exceeding the traversal budget conservatively requires the newer format.
|
||||
pub fn has_graphic_layer_options(&self) -> bool {
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, false))
|
||||
|| self.stories.values().any(|story| story_layer_options(story, 0, false))
|
||||
}
|
||||
|
||||
/// Whether any placed PDF, on document or parent spreads, in groups or anchored, explicitly
|
||||
/// shows every layer (an override with an empty hidden list). Older readers would take that
|
||||
/// for "use the file's defaults". Exceeding the traversal budget conservatively says yes.
|
||||
pub fn has_explicit_all_on_pdf_layers(&self) -> bool {
|
||||
let all_on = |it: &Item| it.pdf_layer_override && it.pdf_hidden_layers.is_empty();
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &all_on))
|
||||
|| self.stories.values().any(|story| story_any(story, 0, &all_on))
|
||||
}
|
||||
|
||||
/// Whether any placed PDF, on document or parent spreads, in groups or anchored, has an
|
||||
/// Object Layer Options choice (hidden layers or explicit all-on). Exceeding the traversal
|
||||
/// budget conservatively says yes.
|
||||
pub fn has_pdf_layer_overrides(&self) -> bool {
|
||||
let chosen = |it: &Item| it.has_pdf_layer_override();
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_any(item, 0, &chosen))
|
||||
|| self.stories.values().any(|story| story_any(story, 0, &chosen))
|
||||
}
|
||||
|
||||
/// Placements outside the ordinary document-spread frame walk used by preflight.
|
||||
pub fn has_unwalked_graphic_layer_visibility_overrides(&self) -> bool {
|
||||
self.parents.iter().flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|
||||
|| self.stories.values().any(|story| story_layer_options(story, 0, true))
|
||||
}
|
||||
|
||||
/// Includes parent items, anchored objects, graphic cells and nested notes.
|
||||
pub fn has_graphic_layer_visibility_overrides(&self) -> bool {
|
||||
self.spreads.iter().chain(&self.parents).flat_map(|spread| &spread.items).any(|item| item_layer_options(item, 0, true))
|
||||
|| self.stories.values().any(|story| story_layer_options(story, 0, true))
|
||||
}
|
||||
}
|
||||
|
||||
impl Asset {
|
||||
/// Whether `other` holds the same file: the same type, PDF page and crop, link and bytes. Files at
|
||||
/// different paths stay apart even with identical bytes, since each can be updated or
|
||||
@@ -356,6 +422,7 @@ mod tests {
|
||||
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
|
||||
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: AssetId(asset),
|
||||
size: (10.0, 10.0),
|
||||
xf: Affine::translate((crop, 0.0)),
|
||||
|
||||
+201
-3
@@ -87,7 +87,9 @@ impl Document {
|
||||
/// The horizontal ruler of spread `r` in pieces `(from x, to x, base)`, spread coordinates,
|
||||
/// covering every x: one piece, or one per page for a page origin.
|
||||
pub fn ruler_pieces(&self, r: SpreadRef) -> Vec<(f64, f64, Point)> {
|
||||
let Some(sp) = self.spread(r) else { return Vec::new() };
|
||||
let Some(sp) = self.spread(r) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if self.settings.ruler_origin != crate::RulerOrigin::Page || sp.pages.len() < 2 {
|
||||
return self.ruler_base(r, 0.0).map(|o| (f64::NEG_INFINITY, f64::INFINITY, o)).into_iter().collect();
|
||||
}
|
||||
@@ -365,7 +367,9 @@ impl Document {
|
||||
let from_empty = fs.is_none_or(|fs| self.story(fs).is_some_and(|s| s.text.is_empty() && s.frames == [from]));
|
||||
let ts = self.frame_story(to)?;
|
||||
let to_first = ts.and_then(|ts| self.story(ts)).is_some_and(|s| s.frames.first() == Some(&to));
|
||||
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else { return self.thread(from, to) };
|
||||
let (Some(ts), true, true) = (ts, from_empty, to_first && from != to) else {
|
||||
return self.thread(from, to);
|
||||
};
|
||||
self.check_thread_links(from, to, true)?;
|
||||
if let Some(fs) = fs {
|
||||
self.stories.remove(&fs);
|
||||
@@ -468,7 +472,9 @@ impl Document {
|
||||
return Err(DocError::Invalid(format!("{id} is locked or on a locked layer")));
|
||||
}
|
||||
}
|
||||
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else { return Err(DocError::NoItem(to)) };
|
||||
let (Some(fl), Some(tl)) = (self.find(from), self.find(to)) else {
|
||||
return Err(DocError::NoItem(to));
|
||||
};
|
||||
let same_place = match (fl.spread, tl.spread) {
|
||||
(SpreadRef::Doc(_), SpreadRef::Doc(_)) => true,
|
||||
(a, b) => a == b,
|
||||
@@ -642,6 +648,45 @@ impl Document {
|
||||
found
|
||||
}
|
||||
|
||||
/// Every item on document and parent spreads, in groups, and among the objects anchored in
|
||||
/// stories, including the stories in table cells and in notes: walks that must cover every
|
||||
/// placement use this, not [`Document::all_items`].
|
||||
pub fn placed_items(&self) -> Vec<&Item> {
|
||||
let mut v = Vec::new();
|
||||
for sp in self.spreads.iter().chain(&self.parents) {
|
||||
for it in &sp.items {
|
||||
placed_item(it, 0, &mut v);
|
||||
}
|
||||
}
|
||||
for st in self.stories.values() {
|
||||
placed_story_items(st, 0, &mut v);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Applies `f` to every placed item `hit` selects, wherever it sits: on document or parent
|
||||
/// spreads, in groups, and anchored in a story (also inside table cells and notes).
|
||||
/// Returns how many items `f` saw.
|
||||
pub fn update_placed_items(&mut self, hit: impl Fn(&Item) -> bool, mut f: impl FnMut(&mut Item)) -> usize {
|
||||
let mut n = 0;
|
||||
for sp in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
|
||||
if !sp.items.iter().any(|it| has_hit(it, &hit)) {
|
||||
continue;
|
||||
}
|
||||
for it in Arc::make_mut(sp).items.iter_mut() {
|
||||
if has_hit(it, &hit) {
|
||||
update_placed_item(Arc::make_mut(it), 0, &hit, &mut f, &mut n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for st in self.stories.values_mut() {
|
||||
if story_has_hit(st, 0, &hit) {
|
||||
update_story_items(Arc::make_mut(st), 0, &hit, &mut f, &mut n);
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Every item id (pre-order) on document spreads.
|
||||
pub fn all_items(&self) -> Vec<ItemId> {
|
||||
let mut v = Vec::new();
|
||||
@@ -736,6 +781,91 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// How deep nested stories (a cell's text, a footnote's text, an endnote's text) are followed
|
||||
/// when every placement is visited. A document nested deeper is left alone.
|
||||
const MAX_PLACEMENT_DEPTH: usize = 64;
|
||||
|
||||
/// The items anchored in `st`, and in the stories nested in its cells and notes, pre-order.
|
||||
fn placed_story_items<'a>(st: &'a Story, depth: usize, v: &mut Vec<&'a Item>) {
|
||||
if depth > MAX_PLACEMENT_DEPTH {
|
||||
return;
|
||||
}
|
||||
for o in &st.objects {
|
||||
placed_item(&o.item, 0, v);
|
||||
}
|
||||
for c in st.tables.values().flat_map(|t| &t.cells) {
|
||||
placed_story_items(&c.text, depth + 1, v);
|
||||
}
|
||||
for n in st.notes.iter().chain(&st.endnotes) {
|
||||
placed_story_items(&n.text, depth + 1, v);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `it` or anything in it is selected by `hit`.
|
||||
fn has_hit(it: &Item, hit: &impl Fn(&Item) -> bool) -> bool {
|
||||
fn check(it: &Item, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool {
|
||||
hit(it) || (depth < MAX_PLACEMENT_DEPTH && it.children().iter().any(|child| check(child, depth + 1, hit)))
|
||||
}
|
||||
check(it, 0, hit)
|
||||
}
|
||||
|
||||
/// [`Document::update_placed_items`] for one item: only the branches holding a hit are copied.
|
||||
fn update_placed_item(it: &mut Item, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) {
|
||||
if hit(it) {
|
||||
f(it);
|
||||
*n += 1;
|
||||
}
|
||||
if depth >= MAX_PLACEMENT_DEPTH {
|
||||
return;
|
||||
}
|
||||
if let Some(children) = it.children_mut() {
|
||||
for c in children.iter_mut().filter(|c| has_hit(c, hit)) {
|
||||
update_placed_item(Arc::make_mut(c), depth + 1, hit, f, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Document::update_placed_items`] for the placements of one story, and of the stories nested
|
||||
/// in its cells and notes.
|
||||
fn update_story_items(st: &mut Story, depth: usize, hit: &impl Fn(&Item) -> bool, f: &mut impl FnMut(&mut Item), n: &mut usize) {
|
||||
if depth > MAX_PLACEMENT_DEPTH {
|
||||
return;
|
||||
}
|
||||
for o in st.objects.iter_mut() {
|
||||
if has_hit(&o.item, hit) {
|
||||
update_placed_item(&mut Arc::make_mut(o).item, 0, hit, f, n);
|
||||
}
|
||||
}
|
||||
for t in st.tables.values_mut().filter(|t| t.cells.iter().any(|c| story_has_hit(&c.text, depth + 1, hit))) {
|
||||
for c in &mut Arc::make_mut(t).cells {
|
||||
if story_has_hit(&c.text, depth + 1, hit) {
|
||||
update_story_items(&mut c.text, depth + 1, hit, f, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for note in st.notes.iter_mut().chain(st.endnotes.iter_mut()) {
|
||||
if story_has_hit(¬e.text, depth + 1, hit) {
|
||||
update_story_items(&mut Arc::make_mut(note).text, depth + 1, hit, f, n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn placed_item<'a>(it: &'a Item, depth: usize, v: &mut Vec<&'a Item>) {
|
||||
v.push(it);
|
||||
if depth < MAX_PLACEMENT_DEPTH {
|
||||
for child in it.children() {
|
||||
placed_item(child, depth + 1, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn story_has_hit(st: &Story, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool {
|
||||
depth <= MAX_PLACEMENT_DEPTH
|
||||
&& (st.objects.iter().any(|o| has_hit(&o.item, hit))
|
||||
|| st.tables.values().flat_map(|t| &t.cells).any(|c| story_has_hit(&c.text, depth + 1, hit))
|
||||
|| st.notes.iter().chain(&st.endnotes).any(|n| story_has_hit(&n.text, depth + 1, hit)))
|
||||
}
|
||||
|
||||
/// Does `p` (in the item's parent space) hit `it`?
|
||||
pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
|
||||
if let Content::Group { items } = &it.content {
|
||||
@@ -780,3 +910,71 @@ pub fn hit_descendant(it: &Item, p: Point, tol: f64, want: &dyn Fn(&Item) -> boo
|
||||
/// How far from its path type on a path is hit (points): about a cap height of 12–18 pt type,
|
||||
/// either side (the type may sit on or under the path).
|
||||
const PATH_TYPE_REACH: f64 = 12.0;
|
||||
#[cfg(test)]
|
||||
mod placed_item_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exhaustive_placements_keep_unselected_branches_shared() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
let layer = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
|
||||
let id = ItemId(d.alloc());
|
||||
let mut item = Item::new(id, layer, Shape::Rectangle, Default::default());
|
||||
item.content = Content::Group { items: vec![Arc::new(Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default()))] };
|
||||
let child = item.children()[0].id;
|
||||
let mut table = crate::Table::new(1, 1, 1, 0, 0, 20.0);
|
||||
table.cells[0].text.insert_object(0, crate::AnchoredObject { item, position: Default::default() });
|
||||
d.story_mut(sid).unwrap().insert_table(0, table);
|
||||
let untouched = StoryId(d.alloc());
|
||||
d.stories.insert(untouched, Arc::new(Story::new(untouched)));
|
||||
let snapshot = d.clone();
|
||||
assert!(d.placed_items().iter().any(|i| i.id == child));
|
||||
assert_eq!(d.update_placed_items(|i| i.id == child, |i| i.hidden = true), 1);
|
||||
assert!(d.placed_items().iter().find(|i| i.id == child).unwrap().hidden);
|
||||
assert!(!snapshot.placed_items().iter().find(|i| i.id == child).unwrap().hidden);
|
||||
assert!(Arc::ptr_eq(&d.stories[&untouched], &snapshot.stories[&untouched]));
|
||||
assert!(Arc::ptr_eq(&d.spreads[0], &snapshot.spreads[0]));
|
||||
let unchanged = d.clone();
|
||||
let mut calls = 0;
|
||||
assert_eq!(d.update_placed_items(|_| false, |_| calls += 1), 0);
|
||||
assert_eq!(calls, 0);
|
||||
assert!(d.spreads.iter().zip(&unchanged.spreads).all(|(a, b)| Arc::ptr_eq(a, b)));
|
||||
assert!(d.parents.iter().zip(&unchanged.parents).all(|(a, b)| Arc::ptr_eq(a, b)));
|
||||
assert!(d.stories.iter().all(|(id, st)| Arc::ptr_eq(st, &unchanged.stories[id])));
|
||||
}
|
||||
#[test]
|
||||
fn exhaustive_placements_bound_nested_stories_and_groups() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
let layer = d.default_layer();
|
||||
let target = ItemId(d.alloc());
|
||||
let mut item = Item::new(target, layer, Shape::Rectangle, Default::default());
|
||||
for _ in 0..MAX_PLACEMENT_DEPTH + 2 {
|
||||
let mut group = Item::new(ItemId(d.alloc()), layer, Shape::Rectangle, Default::default());
|
||||
group.content = Content::Group { items: vec![Arc::new(item)] };
|
||||
item = group;
|
||||
}
|
||||
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
assert!(!d.placed_items().iter().any(|i| i.id == target));
|
||||
let mut calls = 0;
|
||||
assert_eq!(d.update_placed_items(|i| i.id == target, |_| calls += 1), 0);
|
||||
assert_eq!(calls, 0);
|
||||
|
||||
let nested = ItemId(d.alloc());
|
||||
let sid = StoryId(d.alloc());
|
||||
let mut story = Story::new(sid);
|
||||
story.insert_object(
|
||||
0,
|
||||
crate::AnchoredObject { item: Item::new(nested, layer, Shape::Rectangle, Default::default()), position: Default::default() },
|
||||
);
|
||||
for _ in 0..MAX_PLACEMENT_DEPTH + 2 {
|
||||
let mut outer = Story::new(sid);
|
||||
outer.notes.push(Arc::new(crate::Footnote { id: 1, text: story }));
|
||||
story = outer;
|
||||
}
|
||||
d.stories.insert(sid, Arc::new(story));
|
||||
assert!(!d.placed_items().iter().any(|i| i.id == nested));
|
||||
assert_eq!(d.update_placed_items(|i| i.id == nested, |_| calls += 1), 0);
|
||||
assert_eq!(calls, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -680,10 +680,37 @@ pub enum Fitting {
|
||||
CenterContent,
|
||||
}
|
||||
|
||||
/// Retained IDML layer choices for one placement, independent of shared asset bytes.
|
||||
/// Scalar attributes are kept verbatim, including view/export/print state and link-update policy.
|
||||
/// XML Self IDs are regenerated by the exporter and are not stored here.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GraphicLayerOptions {
|
||||
pub attributes: std::collections::BTreeMap<String, String>,
|
||||
pub layers: Vec<std::collections::BTreeMap<String, String>>,
|
||||
/// Parent indices in pre-order; absent entries are top-level layers (older native files).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub parents: Vec<Option<usize>>,
|
||||
}
|
||||
|
||||
pub const GRAPHIC_LAYER_RENDERING_WARNING: &str =
|
||||
"Placed graphic layer visibility overrides are preserved but are not applied to rendering or PDF output";
|
||||
|
||||
impl GraphicLayerOptions {
|
||||
pub fn has_visibility_overrides(&self) -> bool {
|
||||
self.layers.iter().any(|layer| {
|
||||
layer.get("CurrentVisibility").is_some_and(|v| Some(v) != layer.get("OriginalVisibility"))
|
||||
|| ["HasViewState", "HasExportState", "HasPrintState"].iter().any(|key| layer.get(*key).is_some_and(|v| v == "true"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed graphic inside a frame.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Graphic {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layer_options: Option<GraphicLayerOptions>,
|
||||
pub asset: AssetId,
|
||||
/// Natural size of the graphic in points (pixels at its resolution, or PDF page size).
|
||||
pub size: (f64, f64),
|
||||
@@ -1455,6 +1482,10 @@ pub struct Item {
|
||||
/// Object › Object Layer Options: layers of a placed PDF that are hidden.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub pdf_hidden_layers: Vec<String>,
|
||||
/// Explicit placed-PDF visibility, including an all-on override with no hidden names.
|
||||
/// False with an empty hidden list retains the file's own settings (legacy behavior).
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
pub pdf_layer_override: bool,
|
||||
/// Window › Interactive › Media: options of a placed video or sound.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub media: Option<MediaOptions>,
|
||||
@@ -1487,7 +1518,15 @@ fn one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn has_pdf_layer_override(&self) -> bool {
|
||||
self.pdf_layer_override || !self.pdf_hidden_layers.is_empty()
|
||||
}
|
||||
|
||||
pub fn new(id: ItemId, layer: LayerId, shape: Shape, path: PathData) -> Self {
|
||||
Item {
|
||||
id,
|
||||
@@ -1514,6 +1553,7 @@ impl Item {
|
||||
xml_tag: String::new(),
|
||||
button: None,
|
||||
pdf_hidden_layers: Vec::new(),
|
||||
pdf_layer_override: false,
|
||||
live_caption: None,
|
||||
form_field: None,
|
||||
export_options: ExportOptions::default(),
|
||||
@@ -1726,6 +1766,7 @@ mod tests {
|
||||
#[test]
|
||||
fn graphics_without_a_clipping_path_load_and_save_as_before() {
|
||||
let g = Graphic {
|
||||
layer_options: None,
|
||||
asset: AssetId(3),
|
||||
size: (40.0, 30.0),
|
||||
xf: Affine::translate((5.0, 5.0)),
|
||||
@@ -1737,6 +1778,7 @@ mod tests {
|
||||
fill: Default::default(),
|
||||
};
|
||||
let v = serde_json::to_value(&g).unwrap();
|
||||
assert!(v.get("layerOptions").is_none(), "legacy graphics keep their existing representation");
|
||||
assert!(v.get("clip").is_none(), "{v}");
|
||||
assert_eq!(serde_json::from_value::<Graphic>(v.clone()).unwrap(), g);
|
||||
// A clip written with only its type and path gets the default options.
|
||||
|
||||
@@ -369,6 +369,7 @@ fn place_image(
|
||||
};
|
||||
let mode = fitting_of(&options.fitting);
|
||||
let mut g = Graphic {
|
||||
layer_options: None,
|
||||
asset: AssetId(0),
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((inner.x0, inner.y0)),
|
||||
|
||||
+239
-22
@@ -62,7 +62,9 @@ fn print_booklet(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = match str_param(p, "type").unwrap_or("saddleStitch") {
|
||||
"saddleStitch" => designcraft_pdf::BookletKind::SaddleStitch,
|
||||
"twoUpConsecutive" | "twoUp" => designcraft_pdf::BookletKind::TwoUpConsecutive,
|
||||
t => return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)"))),
|
||||
t => {
|
||||
return Err(bad(ID, format!("unknown type `{t}` (saddleStitch, twoUpConsecutive)")));
|
||||
}
|
||||
};
|
||||
let opts = designcraft_pdf::BookletOptions { kind, space_between: p.get("spaceBetween").and_then(Value::as_f64).unwrap_or(0.0), title: None };
|
||||
let st = s.doc()?;
|
||||
@@ -84,9 +86,13 @@ fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Docu
|
||||
// in the document: unused or hidden graphics must not prevent an export.
|
||||
let mut used = std::collections::BTreeSet::new();
|
||||
for block in designcraft_epub::reading_order(d) {
|
||||
let designcraft_epub::Block::Image(id) = block else { continue };
|
||||
let designcraft_epub::Block::Image(id) = block else {
|
||||
continue;
|
||||
};
|
||||
let Some(it) = d.item(id) else { continue };
|
||||
let designcraft_doc::Content::Graphic(g) = &it.content else { continue };
|
||||
let designcraft_doc::Content::Graphic(g) = &it.content else {
|
||||
continue;
|
||||
};
|
||||
used.insert(g.asset);
|
||||
if d.assets.get(&g.asset).is_some_and(|a| designcraft_doc::media_kind(&a.mime).is_some())
|
||||
&& let Some(poster) = it.media.as_ref().and_then(|m| m.poster)
|
||||
@@ -97,7 +103,9 @@ fn eps_previews(d: &designcraft_doc::Document) -> (Cow<'_, designcraft_doc::Docu
|
||||
let mut out = Cow::Borrowed(d);
|
||||
let mut warnings = Vec::new();
|
||||
for id in used {
|
||||
let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else { continue };
|
||||
let Some(a) = d.assets.get(&id).filter(|a| designcraft_images::is_eps(&a.data)) else {
|
||||
continue;
|
||||
};
|
||||
let png = designcraft_images::eps_png(&a.data);
|
||||
if png.is_none() {
|
||||
warnings.push(format!("{}: unusable EPS preview; graphic omitted", a.name));
|
||||
@@ -323,9 +331,13 @@ fn export_epub(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = &st.doc;
|
||||
let mut pages = Vec::new();
|
||||
for i in 0..d.page_count() {
|
||||
let Some((si, pi)) = d.page_loc(i) else { continue };
|
||||
let Some((si, pi)) = d.page_loc(i) else {
|
||||
continue;
|
||||
};
|
||||
let b = d.spreads[si].pages[pi].bounds();
|
||||
let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else { continue };
|
||||
let Some(img) = rr.render_page(d, &s.cache, i, 2.0, false, &ropts) else {
|
||||
continue;
|
||||
};
|
||||
pages.push(designcraft_epub::FixedPage { png: img.to_png(), width: b.width(), height: b.height(), text: page_text(d, &s.cache, i) });
|
||||
}
|
||||
let bytes = designcraft_epub::export_fixed_epub(d, &pages, &opts).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
@@ -471,6 +483,7 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp
|
||||
);
|
||||
let mut it = Item::new(ItemId(out.alloc()), layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (w as f64, h as f64),
|
||||
xf: designcraft_geom::Affine::translate((r.x0, r.y0))
|
||||
@@ -539,7 +552,9 @@ fn flatten(d: &designcraft_doc::Document, cache: &designcraft_compose::Cache, pp
|
||||
);
|
||||
let new = image_item(&mut out, reach, img, it.layer);
|
||||
let osp = std::sync::Arc::make_mut(&mut out.spreads[si]);
|
||||
let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else { continue };
|
||||
let Some(pos) = osp.items.iter().position(|x| x.id == it.id) else {
|
||||
continue;
|
||||
};
|
||||
if threaded(d, it) {
|
||||
// Keep the frame (its story flows on) under the image.
|
||||
osp.items.insert(pos + 1, std::sync::Arc::new(new));
|
||||
@@ -641,6 +656,7 @@ fn rasterize_where(
|
||||
);
|
||||
let mut img_item = Item::new(ItemId(out.alloc()), it.layer, Shape::Rectangle, designcraft_geom::shapes::rectangle(reach));
|
||||
img_item.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (w as f64, h as f64),
|
||||
xf: designcraft_geom::Affine::translate((reach.x0, reach.y0)) * designcraft_geom::Affine::scale(1.0 / k),
|
||||
@@ -706,26 +722,33 @@ fn wrap_stand_in(it: &designcraft_doc::Item, depth: usize) -> designcraft_doc::I
|
||||
}
|
||||
|
||||
/// A copy of `d` where placed PDFs with Object Layer Options hidden layers are 300 ppi images.
|
||||
///
|
||||
/// Parent-page and anchored placements count too, including anchors inside table-cell,
|
||||
/// footnote and endnote text. Only the export copy is rewritten.
|
||||
fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraft_doc::Document>, usize)> {
|
||||
let ids: Vec<designcraft_doc::ItemId> =
|
||||
d.all_items().into_iter().filter(|id| d.item(*id).is_some_and(|it| !it.pdf_hidden_layers.is_empty() && it.graphic().is_some())).collect();
|
||||
if ids.is_empty() {
|
||||
let choices: Vec<_> = d
|
||||
.placed_items()
|
||||
.into_iter()
|
||||
.filter(|it| it.has_pdf_layer_override())
|
||||
.filter_map(|it| {
|
||||
let g = it.graphic()?;
|
||||
let a = d.assets.get(&g.asset).filter(|a| designcraft_render::is_pdf(&a.data))?;
|
||||
Some((it.id, g.clone(), a.clone(), it.pdf_hidden_layers.clone()))
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
return Ok((None, 0));
|
||||
}
|
||||
let mut out = d.clone();
|
||||
let mut images = std::collections::HashMap::new();
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let Some((g, hidden)) = out.item(id).and_then(|it| Some((it.graphic()?.clone(), it.pdf_hidden_layers.clone()))) else { continue };
|
||||
let Some(a) = out.assets.get(&g.asset).cloned() else { continue };
|
||||
if !designcraft_render::is_pdf(&a.data) {
|
||||
continue;
|
||||
}
|
||||
for (id, g, a, hidden) in choices {
|
||||
// The whole page at 300 ppi (the graphic may show only a box of it).
|
||||
let shown = designcraft_render::shown_box(&a);
|
||||
let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
let page_side = (g.size.0 / (r - l)).max(g.size.1 / (b - t));
|
||||
let side = (page_side * 300.0 / 72.0).clamp(64.0, 8000.0) as u32;
|
||||
let png = designcraft_render::pdf_page_png_checked(&a.data, a.page as usize, side, &hidden, shown)
|
||||
let png = designcraft_render::pdf_page_png_options_checked(&a.data, a.page as usize, side, Some(&hidden), shown)
|
||||
.map_err(|e| EngineError::Other(format!("{}: {e}", a.name)))?;
|
||||
let px = designcraft_render::image_size(&png);
|
||||
let aid = designcraft_doc::AssetId(out.alloc());
|
||||
@@ -740,11 +763,19 @@ fn rasterize_layered(d: &designcraft_doc::Document) -> Result<(Option<designcraf
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
if let Some(designcraft_doc::Content::Graphic(gg)) = out.item_mut(id).map(|it| &mut it.content) {
|
||||
gg.asset = aid;
|
||||
}
|
||||
images.insert(id, aid);
|
||||
n += 1;
|
||||
}
|
||||
out.update_placed_items(
|
||||
|it| images.contains_key(&it.id),
|
||||
|it| {
|
||||
if let designcraft_doc::Content::Graphic(gg) = &mut it.content
|
||||
&& let Some(aid) = images.get(&it.id)
|
||||
{
|
||||
gg.asset = *aid;
|
||||
}
|
||||
},
|
||||
);
|
||||
Ok((Some(out), n))
|
||||
}
|
||||
|
||||
@@ -779,6 +810,10 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let doc: &designcraft_doc::Document = &boxed;
|
||||
let r = designcraft_pdf::export_pdf_with_report(doc, &s.cache, &opts).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut r = r;
|
||||
// Flattening and effect rasterization replace placements, so inspect the source too.
|
||||
if st.doc.has_graphic_layer_visibility_overrides() && !r.warnings.iter().any(|w| w == designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING) {
|
||||
r.warnings.push(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into());
|
||||
}
|
||||
// Transparency Blend Space: the page group of pages with transparency.
|
||||
let spreads = designcraft_pdf::sheet_spreads(doc, &opts);
|
||||
let blended: Vec<bool> = spreads.iter().map(|si| doc.spreads.get(*si).is_some_and(|sp| sp.items.iter().any(|it| transparent(it)))).collect();
|
||||
@@ -810,7 +845,9 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"fitPage" => "/Fit",
|
||||
"fitWidth" => "/FitH null",
|
||||
"actual" => "/XYZ null null 1",
|
||||
o => return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)"))),
|
||||
o => {
|
||||
return Err(bad("file.exportPdf", format!("unknown view `{o}` (fitPage, fitWidth, actual)")));
|
||||
}
|
||||
};
|
||||
if let Some(first) = first_page_ref(&r.bytes) {
|
||||
cat.push_str(&format!("/OpenAction[{first} 0 R{dest}]"));
|
||||
@@ -860,6 +897,184 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod placed_layer_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use designcraft_doc::{AnchoredObject, Document, Item, ItemId, SpreadRef, StoryId, Table};
|
||||
use designcraft_geom::Rect;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn two_layer_pdf() -> Vec<u8> {
|
||||
let content = "/OC /a BDC 0 0 1 rg 0 0 50 100 re f EMC /OC /b BDC 1 0 0 rg 50 0 50 100 re f EMC";
|
||||
let objs = [
|
||||
"<</Type/Catalog/Lang(en-GB)/Pages 2 0 R/OCProperties<</OCGs[5 0 R 6 0 R]/D<</Order[5 0 R 6 0 R]/Name(Default)/Locked[5 0 R]>>>>>>"
|
||||
.to_string(),
|
||||
"<</Type/Pages/Kids[3 0 R]/Count 1>>".to_string(),
|
||||
"<</Type/Page/Parent 2 0 R/MediaBox[0 0 100 100]/Resources<</Properties<</a 5 0 R/b 6 0 R>>>>/Contents 4 0 R>>".to_string(),
|
||||
format!("<</Length {}>>\nstream\n{content}\nendstream", content.len()),
|
||||
"<</Type/OCG/Name(Blue)>>".to_string(),
|
||||
"<</Type/OCG/Name(Red)>>".to_string(),
|
||||
];
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
let mut offs = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offs.push(out.len());
|
||||
out.extend_from_slice(format!("{} 0 obj\n{}\nendobj\n", i + 1, o).as_bytes());
|
||||
}
|
||||
let x = out.len();
|
||||
let mut t = format!("xref\n0 {}\n0000000000 65535 f\r\n", objs.len() + 1);
|
||||
for o in offs {
|
||||
t.push_str(&format!("{o:010} 00000 n\r\n"));
|
||||
}
|
||||
t.push_str(&format!("trailer\n<</Size {}/Root 1 0 R>>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1));
|
||||
out.extend_from_slice(t.as_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
fn placement<'a>(d: &'a Document, location: &str, id: ItemId, sid: StoryId) -> &'a Item {
|
||||
match location {
|
||||
"cell" => &d.story(sid).unwrap().tables.values().next().unwrap().cells[0].text.objects[0].item,
|
||||
"footnote" => &d.story(sid).unwrap().notes[0].text.objects[0].item,
|
||||
"endnote" => &d.story(sid).unwrap().endnotes[0].text.objects[0].item,
|
||||
"anchor" => &d.story(sid).unwrap().objects[0].item,
|
||||
_ => d.item(id).unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_layer_fields_on_non_pdf_placements_do_not_block_pdf_a() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let result = s
|
||||
.execute(
|
||||
"file.place",
|
||||
&json!({
|
||||
"base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf",
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = ItemId(result["id"].as_u64().unwrap());
|
||||
s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": ["Blue"]})).unwrap();
|
||||
// Relinking to an ordinary image can leave legacy fields on the placement.
|
||||
// They are not an actionable placed-PDF override and must not fail closed.
|
||||
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let aid = d.item(id).unwrap().graphic().unwrap().asset;
|
||||
let asset = Arc::make_mut(d.assets.get_mut(&aid).unwrap());
|
||||
asset.mime = "image/png".into();
|
||||
asset.data = Arc::new(designcraft_render::Rendered { width: 1, height: 1, pixels: vec![0, 0, 0, 0] }.to_png());
|
||||
let (copy, count) = super::rasterize_layered(d).unwrap();
|
||||
assert_eq!(count, 0);
|
||||
assert!(copy.is_none());
|
||||
s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||||
}
|
||||
|
||||
fn check_placement(location: &str) {
|
||||
for all_on in [false, true] {
|
||||
check_choice(location, all_on);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_choice(location: &str, all_on: bool) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let placed = s
|
||||
.execute(
|
||||
"file.place",
|
||||
&json!({
|
||||
"base64": super::super::file::base64_encode(&two_layer_pdf()), "name": "layers.pdf",
|
||||
"x": 0, "y": 0, "width": 100,
|
||||
"spread": {"kind": if location == "parent" { "parent" } else { "doc" }, "index": 0}
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
let id = ItemId(placed["id"].as_u64().unwrap());
|
||||
let hidden = if all_on { vec![] } else { vec!["Blue"] };
|
||||
s.execute("object.layerOptions", &json!({"ids": [id.0], "hidden": hidden, "useDefaults": false})).unwrap();
|
||||
let sid = {
|
||||
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let layer = d.default_layer();
|
||||
let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
|
||||
if !matches!(location, "doc" | "parent") {
|
||||
let item = (**d.spreads[0].items.iter().find(|it| it.id == id).unwrap()).clone();
|
||||
Arc::make_mut(&mut d.spreads[0]).items.retain(|it| it.id != id);
|
||||
let object = AnchoredObject { item, position: Default::default() };
|
||||
let story = d.story_mut(sid).unwrap();
|
||||
match location {
|
||||
"cell" => {
|
||||
let mut table = Table::new(1, 1, 1, 0, 0, 20.0);
|
||||
table.cells[0].text.insert_object(0, object);
|
||||
story.insert_table(0, table);
|
||||
}
|
||||
"footnote" => {
|
||||
story.insert_note(0, "Note", Default::default()).unwrap();
|
||||
Arc::make_mut(&mut story.notes[0]).text.insert_object(0, object);
|
||||
}
|
||||
"endnote" => {
|
||||
story.insert_endnote(0, "Endnote", Default::default()).unwrap();
|
||||
Arc::make_mut(&mut story.endnotes[0]).text.insert_object(0, object);
|
||||
}
|
||||
"anchor" => story.insert_object(0, object),
|
||||
_ => assert!(matches!(location, "doc" | "parent")),
|
||||
}
|
||||
}
|
||||
sid
|
||||
};
|
||||
// Exercise the real export command for each location, not just the traversal helper.
|
||||
let result = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||||
assert!(
|
||||
result["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("1 placed PDF(s)")),
|
||||
"{location}: {}",
|
||||
result["warnings"]
|
||||
);
|
||||
// Check the rewritten placement, including its pixels. Counting a placement without
|
||||
// updating the nested object would still export the file's default layer visibility.
|
||||
let source = &s.doc().unwrap().doc;
|
||||
let (copy, count) = super::rasterize_layered(source).unwrap();
|
||||
assert_eq!(count, 1, "{location}");
|
||||
let copy = copy.unwrap();
|
||||
let item = placement(©, location, id, sid);
|
||||
let asset = ©.assets[&item.graphic().unwrap().asset];
|
||||
assert_eq!(asset.mime, "image/png", "{location}");
|
||||
let image = designcraft_render::images::decoded(&asset.data, 0);
|
||||
let image = image.unwrap();
|
||||
let pixel = image.data()[usize::from(image.height()) / 2 * usize::from(image.width()) + usize::from(image.width()) / 4];
|
||||
assert_eq!(pixel.a, if all_on { 255 } else { 0 }, "Blue visibility at {location}, all_on={all_on}");
|
||||
// Export preparation must not change the open document's placement or source asset.
|
||||
let original = placement(source, location, id, sid);
|
||||
assert_eq!(original.pdf_hidden_layers, hidden);
|
||||
assert!(original.pdf_layer_override);
|
||||
assert_eq!(source.assets[&original.graphic().unwrap().asset].mime, "application/pdf");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layered_pdf_a_document_placement() {
|
||||
check_placement("doc");
|
||||
}
|
||||
#[test]
|
||||
fn layered_pdf_a_parent_placement() {
|
||||
check_placement("parent");
|
||||
}
|
||||
#[test]
|
||||
fn layered_pdf_a_story_anchor() {
|
||||
check_placement("anchor");
|
||||
}
|
||||
#[test]
|
||||
fn layered_pdf_a_table_cell_anchor() {
|
||||
check_placement("cell");
|
||||
}
|
||||
#[test]
|
||||
fn layered_pdf_a_footnote_anchor() {
|
||||
check_placement("footnote");
|
||||
}
|
||||
#[test]
|
||||
fn layered_pdf_a_endnote_anchor() {
|
||||
check_placement("endnote");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
crate::io::write_file(path, bytes).map_err(EngineError::Other)
|
||||
@@ -1175,7 +1390,9 @@ mod variable_font_tests {
|
||||
|
||||
#[test]
|
||||
fn variable_font_instance_exports_to_pdf() {
|
||||
let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else { return };
|
||||
let Ok(data) = std::fs::read("/System/Library/Fonts/Supplemental/Skia.ttf") else {
|
||||
return;
|
||||
};
|
||||
designcraft_fonts::FontDb::global().add_font(data);
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
|
||||
@@ -723,6 +723,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let xf = Affine::translate((r.x0 + (r.width() - gw) / 2.0, r.y0 + (r.height() - gh) / 2.0)) * Affine::scale(k);
|
||||
validate_placement((bw, bh), r, xf, "file.place")?;
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (bw, bh),
|
||||
xf,
|
||||
@@ -749,6 +750,7 @@ fn file_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
it.effects = e.clone();
|
||||
}
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (bw, bh),
|
||||
xf,
|
||||
@@ -913,6 +915,7 @@ fn place_drop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
validate_placement((nw, nh), frame_rect, xf, "place.drop")?;
|
||||
let r = s.edit(|d, sel| {
|
||||
let graphic = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf,
|
||||
|
||||
@@ -28,7 +28,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let opts = designcraft_idml::ExportOptions { embed_images: p.get("embedImages").and_then(Value::as_bool).unwrap_or(true) };
|
||||
let bytes = designcraft_idml::export_idml_with(&st.doc, &opts);
|
||||
let layer_doc = with_pdf_layer_variants(&st.doc, opts.embed_images)?;
|
||||
let bytes = designcraft_idml::export_idml_with(layer_doc.as_ref().unwrap_or(&st.doc), &opts);
|
||||
match str_param(p, "path") {
|
||||
Some(path) => {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -39,6 +40,99 @@ fn export_idml(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a placement holds an Object Layer Options choice an export can still apply: a graphic
|
||||
/// whose asset is a PDF. A choice left on an item that is no longer a placed PDF (its link was
|
||||
/// replaced with another format) is not one, so the package report does not name it.
|
||||
pub(crate) fn has_applicable_pdf_layer_overrides(doc: &Document) -> bool {
|
||||
doc.placed_items().into_iter().any(|it| {
|
||||
it.has_pdf_layer_override() && it.graphic().and_then(|g| doc.assets.get(&g.asset)).is_some_and(|a| designcraft_render::is_pdf(&a.data))
|
||||
})
|
||||
}
|
||||
|
||||
/// Each placement gets its own embedded variant so two frames sharing a PDF can
|
||||
/// choose different layers. The live document and original linked bytes stay untouched.
|
||||
///
|
||||
/// Parent-page and anchored placements count too, so a page that inherits the placement does not
|
||||
/// quietly fall back to the file's defaults. Read and write walks also reach table-cell and
|
||||
/// note text. Endnote variants are prepared too, but IDML endnote structures/artwork are not
|
||||
/// serialized yet (see `docs/pdf-layer-visibility.md`).
|
||||
fn with_pdf_layer_variants(doc: &Document, embed: bool) -> Result<Option<Document>> {
|
||||
let choices: Vec<_> = doc
|
||||
.placed_items()
|
||||
.into_iter()
|
||||
.filter(|it| it.has_pdf_layer_override())
|
||||
.filter_map(|it| {
|
||||
let asset = it.graphic().and_then(|g| doc.assets.get(&g.asset)).filter(|a| designcraft_render::is_pdf(&a.data))?;
|
||||
Some((it.id, asset, it.pdf_hidden_layers.clone(), it.pdf_layer_override))
|
||||
})
|
||||
.collect();
|
||||
if choices.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !embed {
|
||||
return Err(EngineError::Other("PDF layer choices require embedded images in IDML export".into()));
|
||||
}
|
||||
let mut out = doc.clone();
|
||||
let mut variants = std::collections::HashMap::new();
|
||||
// Duplicated anchors can share an id. Reuse a variant only when source and choice agree.
|
||||
let mut expected = std::collections::HashMap::new();
|
||||
for (id, asset, hidden, overridden) in choices {
|
||||
let signature = (asset.id, hidden.clone(), overridden);
|
||||
if let Some(previous) = expected.get(&id) {
|
||||
if previous != &signature {
|
||||
return Err(EngineError::Other(format!("PDF layer choices: placements sharing id {id} have different sources or layer choices")));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
expected.insert(id, signature);
|
||||
let bytes = designcraft_render::pdf_hide_layers(&asset.data, &hidden)
|
||||
.ok_or_else(|| EngineError::Other(format!("{}: PDF layer choices could not be applied", asset.name)))?;
|
||||
let mut variant = (**asset).clone();
|
||||
variant.id = designcraft_doc::AssetId(out.alloc());
|
||||
variant.data = std::sync::Arc::new(bytes);
|
||||
variant.link = None;
|
||||
let aid = variant.id;
|
||||
out.assets.insert(aid, std::sync::Arc::new(variant));
|
||||
variants.insert(id, aid);
|
||||
}
|
||||
// Anchored objects live in stories, and parent pages outside the document-spread walk, so
|
||||
// the choice is cleared where the placement actually is.
|
||||
// Count distinct ids, allowing identical duplicated anchors, but check every hit against
|
||||
// the original source and choice before rewriting (including placements using defaults).
|
||||
let mut reached = std::collections::HashSet::new();
|
||||
let mut conflicting_id = None;
|
||||
out.update_placed_items(
|
||||
|it| variants.contains_key(&it.id),
|
||||
|it| {
|
||||
reached.insert(it.id);
|
||||
if let Some((asset, hidden, overridden)) = expected.get(&it.id)
|
||||
&& (it.graphic().map(|g| g.asset) != Some(*asset) || &it.pdf_hidden_layers != hidden || it.pdf_layer_override != *overridden)
|
||||
{
|
||||
conflicting_id = Some(it.id);
|
||||
return;
|
||||
}
|
||||
if let designcraft_doc::Content::Graphic(g) = &mut it.content
|
||||
&& let Some(aid) = variants.get(&it.id)
|
||||
{
|
||||
g.asset = *aid;
|
||||
}
|
||||
it.pdf_layer_override = false;
|
||||
it.pdf_hidden_layers.clear();
|
||||
},
|
||||
);
|
||||
if let Some(id) = conflicting_id {
|
||||
return Err(EngineError::Other(format!("PDF layer choices: placements sharing id {id} have different sources or layer choices")));
|
||||
}
|
||||
if reached.len() != variants.len() {
|
||||
return Err(EngineError::Other(format!(
|
||||
"PDF layer choices could not be applied to every placement (reached {} of {})",
|
||||
reached.len(),
|
||||
variants.len()
|
||||
)));
|
||||
}
|
||||
Ok(Some(out))
|
||||
}
|
||||
|
||||
/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`] from
|
||||
/// beside `package` (the package's file, if it has one), so the automation read root applies to
|
||||
/// the paths the package names, and its PDF crops resolved.
|
||||
@@ -558,3 +652,290 @@ mod tests {
|
||||
assert_eq!(without_document_extension("notes.txt"), "notes.txt");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod nested_layer_tests {
|
||||
use std::{io::Read, sync::Arc};
|
||||
|
||||
use designcraft_doc::{AnchoredObject, ItemId, SpreadRef, Story, StoryId, Table};
|
||||
use designcraft_geom::Rect;
|
||||
|
||||
use super::*;
|
||||
|
||||
// One default placement and two nested placements share bytes. The latter use a legacy
|
||||
// nonempty hidden list and explicit all-on, so each needs a different embedded variant.
|
||||
fn fixture(location: &str) -> (Session, ItemId, [ItemId; 2]) {
|
||||
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let mut session = Session::new();
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
let placed = session.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap();
|
||||
let control = ItemId(placed["id"].as_u64().unwrap());
|
||||
let ids = {
|
||||
let doc = Arc::make_mut(&mut session.doc_mut().unwrap().doc);
|
||||
let original = doc.item(control).unwrap().clone();
|
||||
let aid = original.graphic().unwrap().asset;
|
||||
Arc::make_mut(doc.assets.get_mut(&aid).unwrap()).link = Some("/synthetic/layers.pdf".into());
|
||||
let mut hidden = original.clone();
|
||||
hidden.id = ItemId(doc.alloc());
|
||||
hidden.pdf_hidden_layers = vec!["Blue".into()];
|
||||
let mut all_on = original;
|
||||
all_on.id = ItemId(doc.alloc());
|
||||
all_on.pdf_layer_override = true;
|
||||
let ids = [hidden.id, all_on.id];
|
||||
let mut nested = Story::new(StoryId(0));
|
||||
for item in [hidden, all_on] {
|
||||
nested.insert_object(nested.len(), AnchoredObject { item, position: Default::default() });
|
||||
}
|
||||
let layer = doc.default_layer();
|
||||
let (_, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap();
|
||||
let story = doc.story_mut(sid).unwrap();
|
||||
match location {
|
||||
"cell" => {
|
||||
let mut table = Table::new(1, 1, 1, 0, 0, 100.0);
|
||||
table.cells[0].text = nested;
|
||||
story.insert_table(0, table);
|
||||
}
|
||||
"footnote" => {
|
||||
story.insert_note(0, "Note", Default::default()).unwrap();
|
||||
Arc::make_mut(&mut story.notes[0]).text = nested;
|
||||
}
|
||||
_ => {
|
||||
assert_eq!(location, "endnote");
|
||||
story.insert_endnote(0, "Endnote", Default::default()).unwrap();
|
||||
Arc::make_mut(&mut story.endnotes[0]).text = nested;
|
||||
}
|
||||
}
|
||||
ids
|
||||
};
|
||||
(session, control, ids)
|
||||
}
|
||||
|
||||
fn states(blue: bool, red: bool) -> Vec<(String, bool)> {
|
||||
vec![("Blue".into(), blue), ("Red".into(), red)]
|
||||
}
|
||||
|
||||
fn prepared(location: &str) {
|
||||
let (session, control, ids) = fixture(location);
|
||||
let source = &session.doc().unwrap().doc;
|
||||
let original_asset = source.item(control).unwrap().graphic().unwrap().asset;
|
||||
let copy = with_pdf_layer_variants(source, true).unwrap().unwrap();
|
||||
for (id, expected) in ids.into_iter().zip([states(false, true), states(true, true)]) {
|
||||
let item = copy.placed_items().into_iter().find(|i| i.id == id).unwrap();
|
||||
let asset = ©.assets[&item.graphic().unwrap().asset];
|
||||
assert_eq!(designcraft_render::pdf_layers(&asset.data), expected, "{location}: placement {id}");
|
||||
assert_ne!(asset.id, original_asset);
|
||||
assert!(asset.link.is_none());
|
||||
assert!(!item.pdf_layer_override && item.pdf_hidden_layers.is_empty());
|
||||
}
|
||||
assert_eq!(copy.item(control).unwrap().graphic().unwrap().asset, original_asset);
|
||||
assert!(Arc::ptr_eq(©.assets[&original_asset], &source.assets[&original_asset]));
|
||||
assert_eq!(designcraft_render::pdf_layers(&source.assets[&original_asset].data), states(true, false));
|
||||
assert!(source.placed_items().into_iter().filter(|i| ids.contains(&i.id)).all(|i| i.has_pdf_layer_override()));
|
||||
}
|
||||
|
||||
fn embedded(location: &str) {
|
||||
let (mut session, _, _) = fixture(location);
|
||||
let original = session.doc().unwrap().doc.clone();
|
||||
let result = session.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let bytes = base64_decode(result["base64"].as_str().unwrap());
|
||||
let imported = designcraft_idml::import_idml(&bytes).unwrap();
|
||||
let mut roundtrip: Vec<_> =
|
||||
imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).map(|a| designcraft_render::pdf_layers(&a.data)).collect();
|
||||
let mut expected = vec![states(true, false), states(false, true), states(true, true)];
|
||||
roundtrip.sort();
|
||||
expected.sort();
|
||||
assert_eq!(roundtrip, expected, "{location}: reopened IDML keeps each choice");
|
||||
let mut package = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut actual = Vec::new();
|
||||
for index in 0..package.len() {
|
||||
let mut entry = package.by_index(index).unwrap();
|
||||
if !entry.name().ends_with(".xml") {
|
||||
continue;
|
||||
}
|
||||
let mut xml = String::new();
|
||||
entry.read_to_string(&mut xml).unwrap();
|
||||
for cdata in xml.split("<![CDATA[").skip(1) {
|
||||
let data = base64_decode(cdata.split("]]>").next().unwrap());
|
||||
if designcraft_render::is_pdf(&data) {
|
||||
actual.push(designcraft_render::pdf_layers(&data));
|
||||
}
|
||||
}
|
||||
}
|
||||
actual.sort();
|
||||
assert_eq!(actual, expected, "{location}: each placement embeds its own visibility state");
|
||||
assert!(Arc::ptr_eq(&original, &session.doc().unwrap().doc), "export must not edit the live document");
|
||||
}
|
||||
|
||||
fn external(location: &str) {
|
||||
let (mut session, _, ids) = fixture(location);
|
||||
// Each choice alone must reject the export, including a legacy nonempty hidden list.
|
||||
for active in ids {
|
||||
let original = session.doc().unwrap().doc.clone();
|
||||
let mut one = (*original).clone();
|
||||
one.update_placed_items(
|
||||
|i| ids.contains(&i.id) && i.id != active,
|
||||
|i| {
|
||||
i.pdf_layer_override = false;
|
||||
i.pdf_hidden_layers.clear();
|
||||
},
|
||||
);
|
||||
let mut single = Session::new();
|
||||
single.add_document(DocState::new(one, None));
|
||||
let before = single.doc().unwrap().doc.clone();
|
||||
let err = single.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string();
|
||||
assert!(err.contains("embedded images"), "{location}: {err}");
|
||||
assert!(Arc::ptr_eq(&before, &single.doc().unwrap().doc));
|
||||
}
|
||||
Arc::make_mut(&mut session.doc_mut().unwrap().doc).update_placed_items(
|
||||
|i| ids.contains(&i.id),
|
||||
|i| {
|
||||
i.pdf_layer_override = false;
|
||||
i.pdf_hidden_layers.clear();
|
||||
},
|
||||
);
|
||||
session.execute("file.exportIdml", &json!({"embedImages": false})).unwrap();
|
||||
}
|
||||
|
||||
// Duplicating a text frame that holds an anchored placed PDF leaves two placements sharing
|
||||
// one ItemId (renumber gap, tracked separately). Export must still apply the choice to both.
|
||||
#[test]
|
||||
fn duplicated_frame_keeps_its_anchored_pdf_choice_in_idml() {
|
||||
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let placed = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap();
|
||||
let control = ItemId(placed["id"].as_u64().unwrap());
|
||||
let (frame, chosen) = {
|
||||
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let mut item = doc.item(control).unwrap().clone();
|
||||
item.id = ItemId(doc.alloc());
|
||||
item.pdf_hidden_layers = vec!["Blue".into()];
|
||||
let chosen = item.id;
|
||||
let layer = doc.default_layer();
|
||||
let (frame, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap();
|
||||
doc.story_mut(sid).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
|
||||
(frame, chosen)
|
||||
};
|
||||
s.execute("transform.move", &json!({"ids": [frame.0], "dx": 10, "dy": 10, "copy": true})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let with_choice: Vec<_> = doc.placed_items().into_iter().filter(|i| i.has_pdf_layer_override()).map(|i| i.id).collect();
|
||||
assert_eq!(with_choice, [chosen, chosen], "both placements share one id");
|
||||
assert!(doc.check().unwrap_err().to_string().contains(&format!("duplicate item id {chosen}")));
|
||||
let result = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let imported = designcraft_idml::import_idml(&base64_decode(result["base64"].as_str().unwrap())).unwrap();
|
||||
let mut layers: Vec<_> =
|
||||
imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).map(|a| designcraft_render::pdf_layers(&a.data)).collect();
|
||||
layers.sort();
|
||||
assert_eq!(layers, vec![states(false, true), states(true, false)]);
|
||||
}
|
||||
|
||||
// Shared anchored ids are a pre-existing duplication gap. Conflicting choices must not
|
||||
// silently select one variant; this fixture changes only the copied anchor's choice.
|
||||
#[test]
|
||||
fn shared_id_with_divergent_pdf_choices_is_rejected() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let placed = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "layers.pdf"})).unwrap();
|
||||
let control = ItemId(placed["id"].as_u64().unwrap());
|
||||
let frame = {
|
||||
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let mut item = doc.item(control).unwrap().clone();
|
||||
item.id = ItemId(doc.alloc());
|
||||
item.pdf_hidden_layers = vec!["Blue".into()];
|
||||
let layer = doc.default_layer();
|
||||
let (frame, sid) = doc.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 200.0, 200.0), layer, "Body", Default::default()).unwrap();
|
||||
doc.story_mut(sid).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
|
||||
frame
|
||||
};
|
||||
s.execute("transform.move", &json!({"ids": [frame.0], "dx": 10, "dy": 10, "copy": true})).unwrap();
|
||||
// Change ONLY the copy in the newest story to explicit all-on.
|
||||
{
|
||||
let doc = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let (_, st) = doc.stories.iter_mut().filter(|(_, st)| !st.objects.is_empty()).last().unwrap();
|
||||
let o = &mut Arc::make_mut(st).objects[0];
|
||||
let it = &mut Arc::make_mut(o).item;
|
||||
it.pdf_hidden_layers.clear();
|
||||
it.pdf_layer_override = true;
|
||||
}
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let chosen: Vec<_> = before.placed_items().into_iter().filter(|i| i.has_pdf_layer_override()).collect();
|
||||
assert_eq!(chosen.len(), 2);
|
||||
assert_eq!(chosen[0].id, chosen[1].id);
|
||||
assert_eq!(chosen[0].graphic().unwrap().asset, chosen[1].graphic().unwrap().asset);
|
||||
assert_ne!(chosen[0].pdf_hidden_layers, chosen[1].pdf_hidden_layers);
|
||||
let err = s.execute("file.exportIdml", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("placements sharing id") && err.contains("different sources or layer choices"), "{err}");
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
}
|
||||
|
||||
// Cross-document paste: the pasted frame's anchored placement keeps its source id,
|
||||
// which can collide with an unrelated placement in the destination.
|
||||
#[test]
|
||||
fn shared_id_with_different_pdf_assets_is_rejected() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let pdf = designcraft_render::pdf_hide_layers(&super::super::pdflayers::tests::two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let a = s.execute("file.place", &json!({"base64": base64_encode(&pdf), "name": "a.pdf"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("object.layerOptions", &json!({"ids": [a], "hidden": ["Blue"]})).unwrap();
|
||||
let f = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap();
|
||||
let sid = s.doc().unwrap().doc.item(ItemId(f)).unwrap().text_frame().unwrap().story;
|
||||
s.execute("anchored.insert", &json!({"ids": [a], "story": sid.0, "pos": 0})).unwrap();
|
||||
s.execute("selection.set", &json!({"ids": [f]})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
// A different, unrelated PDF placement in the destination (no choice).
|
||||
let b = s
|
||||
.execute("file.place", &json!({"base64": base64_encode(&super::super::pdflayers::tests::two_layer_pdf()), "name": "b.pdf"}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
assert_eq!(a, b, "fresh documents allocate the same first placement id");
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let graphics: Vec<_> = before.placed_items().into_iter().filter(|i| i.graphic().is_some()).collect();
|
||||
assert_eq!(graphics.len(), 2);
|
||||
assert_eq!(graphics[0].id, graphics[1].id);
|
||||
assert_ne!(graphics[0].graphic().unwrap().asset, graphics[1].graphic().unwrap().asset);
|
||||
assert!(!graphics[0].has_pdf_layer_override(), "destination placement uses file defaults");
|
||||
assert!(graphics[1].has_pdf_layer_override());
|
||||
let err = s.execute("file.exportIdml", &json!({})).unwrap_err().to_string();
|
||||
assert!(err.contains("placements sharing id") && err.contains("different sources or layer choices"), "{err}");
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_idml_cell_variants_are_applied() {
|
||||
prepared("cell");
|
||||
}
|
||||
#[test]
|
||||
fn nested_idml_footnote_variants_are_applied() {
|
||||
prepared("footnote");
|
||||
}
|
||||
// Endnote structures/artwork are not serialized by IDML yet. Check preparation without
|
||||
// mistaking a correct export-only variant for endnote-artwork serialization support.
|
||||
#[test]
|
||||
fn nested_idml_endnote_variants_are_prepared() {
|
||||
prepared("endnote");
|
||||
}
|
||||
#[test]
|
||||
fn nested_idml_cell_embedded_choices() {
|
||||
embedded("cell");
|
||||
}
|
||||
#[test]
|
||||
fn nested_idml_footnote_embedded_choices() {
|
||||
embedded("footnote");
|
||||
}
|
||||
#[test]
|
||||
fn nested_idml_cell_external_choices_are_rejected() {
|
||||
external("cell");
|
||||
}
|
||||
#[test]
|
||||
fn nested_idml_footnote_external_choices_are_rejected() {
|
||||
external("footnote");
|
||||
}
|
||||
#[test]
|
||||
fn nested_idml_endnote_external_choices_are_rejected() {
|
||||
external("endnote");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1168,7 @@ mod shared_asset_tests {
|
||||
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
|
||||
);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: sid,
|
||||
size: (4.0, 4.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
@@ -1213,6 +1214,7 @@ mod shared_asset_tests {
|
||||
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
|
||||
);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: missing,
|
||||
size: (4.0, 4.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
@@ -1249,6 +1251,7 @@ mod shared_asset_tests {
|
||||
designcraft_geom::shapes::rectangle(designcraft_geom::Rect::new(0.0, 0.0, 9.0, 9.0)),
|
||||
);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: missing,
|
||||
size: (4.0, 4.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
|
||||
@@ -19,6 +19,7 @@ fn math_item(d: &mut designcraft_doc::Document, latex: &str, size: f64, lid: des
|
||||
let r = Rect::new(at.0, at.1, at.0 + w, at.1 + h);
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (w, h),
|
||||
xf: Affine::translate(at),
|
||||
|
||||
@@ -32,6 +32,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
|
||||
let it = d.item_mut(fid).ok_or(designcraft_doc::DocError::NoItem(fid))?;
|
||||
let r = it.inner_bounds();
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (r.width(), r.height()),
|
||||
xf: Affine::translate((r.x0, r.y0)),
|
||||
@@ -52,6 +53,7 @@ pub(crate) fn place_media(s: &mut Session, p: &Value, name: String, mime: &str,
|
||||
let r = super::finite_rect("file.place", Rect::new(x, y, x + w, y + h))?;
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, shapes::rectangle(r));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (w, h),
|
||||
xf: Affine::translate((x, y)),
|
||||
|
||||
@@ -427,12 +427,19 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|f| format!(" {} {}{}\n", f.family, f.style, copy_font(&root, f, &fonts_dir, &mut copied, &mut taken, &mut files)))
|
||||
.collect();
|
||||
// The IDML copy links the placed files, so a per-placement PDF layer choice cannot travel in
|
||||
// it (an external-only IDML export rejects one for the same reason). Say so in the report
|
||||
// instead of leaving a silent difference between the package's copies.
|
||||
let mut idml_layer_note = "";
|
||||
if p.get("idml").and_then(Value::as_bool).unwrap_or(true) {
|
||||
let path = package_output_path(&dir, &title, "idml")?;
|
||||
// The images are in Links/ beside it: link them there, without embedded copies.
|
||||
let opts = designcraft_idml::ExportOptions { embed_images: false };
|
||||
root.write(&path, &designcraft_idml::export_idml_with(&packed, &opts))?;
|
||||
files.push(path);
|
||||
if super::interchange::has_applicable_pdf_layer_overrides(&packed) {
|
||||
idml_layer_note = "\nPlaced PDF layers\nThe IDML copy links the placed PDFs, so it shows their file defaults. The DesignCraft document keeps each placement's Object Layer Options choice.\n";
|
||||
}
|
||||
}
|
||||
if p.get("pdf").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let path = package_output_path(&dir, &title, "pdf")?;
|
||||
@@ -456,6 +463,7 @@ fn package(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for l in links.as_array().into_iter().flatten() {
|
||||
r += &format!(" {} ({})\n", l["name"].as_str().unwrap_or(""), l["status"].as_str().unwrap_or(""));
|
||||
}
|
||||
r += idml_layer_note;
|
||||
if let Some(issues) = pre.get("issues").and_then(Value::as_array) {
|
||||
r += &format!("\nPreflight: {} issue(s)\n", issues.len());
|
||||
for i in issues {
|
||||
@@ -510,6 +518,32 @@ mod tests {
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A per-placement PDF layer choice cannot travel in an IDML copy that links its files, so the
|
||||
/// report says the copies differ instead of leaving it to be discovered.
|
||||
#[test]
|
||||
fn package_report_names_placed_pdf_layer_choices() {
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("dc-package-pdf-layers-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pdf = super::super::pdflayers::tests::two_layer_pdf();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"title": "LayeredPackage", "width": 100, "height": 100})).unwrap();
|
||||
let id = s
|
||||
.execute("file.place", &json!({"base64": super::super::base64_encode(&pdf), "name": "art.pdf", "x": 0, "y": 0, "width": 100}))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": ["Red"]})).unwrap();
|
||||
let r = s.execute("file.package", &json!({"dir": dir.to_string_lossy(), "idml": true, "pdf": false})).unwrap();
|
||||
let report = r["report"].as_str().unwrap();
|
||||
assert!(report.contains("Placed PDF layers"), "{report}");
|
||||
// The DesignCraft copy keeps the choice; only the linked IDML copy shows file defaults.
|
||||
let packed = super::super::from_bytes(&std::fs::read(dir.join("LayeredPackage.designcraft")).unwrap()).unwrap();
|
||||
let item = packed.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap();
|
||||
assert_eq!(item.pdf_hidden_layers, vec!["Red".to_string()]);
|
||||
assert!(item.has_pdf_layer_override());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// The package's IDML links its images to the files in the package's Links folder and embeds
|
||||
/// no copies of them.
|
||||
#[test]
|
||||
|
||||
@@ -15,13 +15,15 @@ fn placed_pdf(s: &Session, id: designcraft_doc::ItemId) -> Option<std::sync::Arc
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "object.pdfLayers", "Object Layers", [], None, "{id?} → [{name, visible, default}] — the layers of the selected placed PDF", has_selection, |s, p| {
|
||||
let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.first().copied())).ok_or_else(|| bad("object.pdfLayers", "no selection"))?;
|
||||
let id = super::id_param(p, "id").or_else(|| s.active().and_then(|d| d.selection.items.iter().copied().find(|id| placed_pdf(s, *id).is_some()))).ok_or_else(|| bad("object.pdfLayers", "no selection"))?;
|
||||
let data = placed_pdf(s, id).ok_or_else(|| bad("object.pdfLayers", "not a placed PDF"))?;
|
||||
let hidden = s.doc()?.doc.item(id).map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default();
|
||||
let item = s.doc()?.doc.item(id);
|
||||
let hidden = item.map(|i| i.pdf_hidden_layers.clone()).unwrap_or_default();
|
||||
let overridden = item.is_some_and(|i| i.has_pdf_layer_override());
|
||||
Ok(Value::Array(
|
||||
designcraft_render::pdf_layers(&data)
|
||||
.into_iter()
|
||||
.map(|(name, on)| json!({"visible": if hidden.is_empty() { on } else { !hidden.contains(&name) }, "name": name, "default": on}))
|
||||
.map(|(name, on)| json!({"visible": if overridden { !hidden.contains(&name) } else { on }, "name": name, "default": on, "overridden": overridden}))
|
||||
.collect(),
|
||||
))
|
||||
}),
|
||||
@@ -30,30 +32,39 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Object Layer Options…",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?, hidden: [layer name]} — layers of placed PDFs to hide ([] = the file's own settings)",
|
||||
"{ids?, hidden: [layer name], useDefaults?: bool} — hide layers; useDefaults:false makes [] explicitly all on; legacy [] restores file defaults",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let ids = targets(s, p)?;
|
||||
let ids: Vec<_> = targets(s, p)?.into_iter().filter(|id| placed_pdf(s, *id).is_some()).collect();
|
||||
let hidden: Vec<String> = p
|
||||
.get("hidden")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
if ids.iter().all(|id| placed_pdf(s, *id).is_none()) {
|
||||
let use_defaults = match p.get("useDefaults") {
|
||||
None => hidden.is_empty(),
|
||||
Some(v) => v.as_bool().ok_or_else(|| bad("object.layerOptions", "useDefaults must be a boolean"))?,
|
||||
};
|
||||
if use_defaults && !hidden.is_empty() {
|
||||
return Err(bad("object.layerOptions", "useDefaults cannot be combined with hidden layers"));
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(bad("object.layerOptions", "select a placed PDF"));
|
||||
}
|
||||
// Validate every selected PDF before editing any item or recording undo.
|
||||
// Repeated placements of the same source need only one rewrite check.
|
||||
if !hidden.is_empty() {
|
||||
// Validate every selected PDF before editing any item or recording undo, so a
|
||||
// mixed request stays atomic. Any explicit choice, including all-on
|
||||
// (useDefaults:false with no hidden names), needs one actual rewrite per distinct
|
||||
// source: a PDF can list readable layers and still fail the rewrite, and a
|
||||
// layerless PDF cannot be rewritten at all. Resetting to the file defaults needs no
|
||||
// variant and is always allowed.
|
||||
if !use_defaults {
|
||||
let mut checked = std::collections::HashSet::new();
|
||||
for id in &ids {
|
||||
if let Some(data) = placed_pdf(s, *id)
|
||||
&& checked.insert(std::sync::Arc::as_ptr(&data))
|
||||
&& designcraft_render::pdf_hide_layers(&data, &hidden).is_none()
|
||||
{
|
||||
let Some(data) = placed_pdf(s, *id) else { continue };
|
||||
if checked.insert(std::sync::Arc::as_ptr(&data)) && designcraft_render::pdf_hide_layers(&data, &hidden).is_none() {
|
||||
return Err(bad(
|
||||
"object.layerOptions",
|
||||
"the selected PDF layers could not be hidden; use the file defaults or replace the unsupported PDF",
|
||||
"the selected PDF layers could not be changed (no readable optional-content layers, or an unsupported file); use the file defaults or replace the PDF",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -62,9 +73,10 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
for id in &ids {
|
||||
if let Some(it) = d.item_mut(*id) {
|
||||
it.pdf_hidden_layers = hidden.clone();
|
||||
it.pdf_layer_override = !use_defaults;
|
||||
}
|
||||
}
|
||||
Ok(json!({"hidden": hidden}))
|
||||
Ok(json!({"hidden": hidden, "useDefaults": use_defaults}))
|
||||
})
|
||||
}
|
||||
),
|
||||
@@ -243,7 +255,7 @@ pub(crate) mod tests {
|
||||
let redo = s.doc().unwrap().history.redo.len();
|
||||
for ids in [json!([good, bad]), json!([bad, good])] {
|
||||
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": ["Red"]})).unwrap_err();
|
||||
assert!(err.to_string().contains("could not be hidden"));
|
||||
assert!(err.to_string().contains("could not be changed"));
|
||||
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
assert_eq!(s.doc().unwrap().history.redo.len(), redo);
|
||||
@@ -263,6 +275,46 @@ pub(crate) mod tests {
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_all_on_validates_an_actual_rewrite_before_editing() {
|
||||
// 33 xref sections: the layers stay readable, but the visibility rewrite refuses the history.
|
||||
let mut unsupported = two_layer_pdf();
|
||||
for _ in 0..32 {
|
||||
add_pdf_revision(&mut unsupported);
|
||||
}
|
||||
assert_eq!(designcraft_render::pdf_layers(&unsupported).len(), 2);
|
||||
assert!(designcraft_render::pdf_hide_layers(&unsupported, &[]).is_none());
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "pages": 1})).unwrap();
|
||||
let good = place(&mut s, &two_layer_pdf(), 0.0);
|
||||
let bad = place(&mut s, &unsupported, 100.0);
|
||||
let before = s.execute("file.serialize", &json!({})).unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for ids in [json!([good, bad]), json!([bad, good]), json!([bad])] {
|
||||
let err = s.execute("object.layerOptions", &json!({"ids": ids, "hidden": [], "useDefaults": false})).unwrap_err();
|
||||
assert!(err.to_string().contains("could not be changed"), "{err}");
|
||||
assert_eq!(s.execute("file.serialize", &json!({})).unwrap(), before, "rejected all-on must not edit any target");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
assert_eq!(pixel(&s, 175), [255, 0, 0, 255], "the unsupported source keeps showing its file defaults");
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
// The supported source alone still accepts the explicit all-on choice.
|
||||
s.execute("object.layerOptions", &json!({"ids": [good], "hidden": [], "useDefaults": false})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": good})).unwrap()[1]["overridden"], true);
|
||||
// Resetting to file defaults needs no variant, so it bypasses the rewrite check.
|
||||
for reset in [json!({"ids": [good, bad], "hidden": []}), json!({"ids": [good, bad], "useDefaults": true})] {
|
||||
s.execute("object.layerOptions", &reset).unwrap();
|
||||
for id in [&good, &bad] {
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["overridden"], false);
|
||||
}
|
||||
}
|
||||
// A stale explicit all-on choice that outlived its rewritable source still fails closed.
|
||||
let bad_id = serde_json::from_value(bad.clone()).unwrap();
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(bad_id).unwrap().pdf_layer_override = true;
|
||||
assert_eq!(pixel(&s, 175), [255, 255, 255, 255], "unrewritable explicit choice must not draw the unmodified source");
|
||||
assert!(s.execute("file.exportPdf", &json!({})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_non_null_prev_entries_refuse_layer_rewrites() {
|
||||
for prev in ["7 0 R", "-1", "false", "<<>>", "[]", "/Unknown", "(unknown)"] {
|
||||
@@ -382,4 +434,204 @@ pub(crate) mod tests {
|
||||
assert!(image.data()[150 * width + 125].a > 200);
|
||||
assert_eq!(image.data()[150 * width + 175].a, 0);
|
||||
}
|
||||
#[test]
|
||||
fn placed_pdf_all_on_is_distinct_from_defaults_and_survives_exports() {
|
||||
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let id = s
|
||||
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&pdf), "name":"off.pdf", "x":100,"y":100,"width":100}))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let visible = |s: &mut Session| s.execute("object.pdfLayers", &json!({"id": id})).unwrap()[1]["visible"].as_bool().unwrap();
|
||||
let pixel = |s: &Session| {
|
||||
let mut rr = designcraft_render::Renderer::new();
|
||||
rr.threads = 0;
|
||||
rr.render_page(&s.doc().unwrap().doc, &s.cache, 0, 1.0, false, &Default::default()).unwrap().pixel(175, 150)
|
||||
};
|
||||
assert!(!visible(&mut s));
|
||||
assert!(pixel(&s)[1] > 200, "red hidden by default");
|
||||
s.execute("object.layerOptions", &json!({"ids":[id], "hidden":[], "useDefaults":false})).unwrap();
|
||||
assert!(visible(&mut s));
|
||||
assert!(pixel(&s)[0] > 200 && pixel(&s)[1] < 40, "all-on renders red");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!visible(&mut s));
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(visible(&mut s));
|
||||
let bytes = designcraft_format::save(&s.doc().unwrap().doc).unwrap();
|
||||
let roundtrip = designcraft_format::load(&bytes).unwrap();
|
||||
let item = roundtrip.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap();
|
||||
assert!(item.pdf_layer_override && item.pdf_hidden_layers.is_empty());
|
||||
let exported = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
let bytes = super::super::file::base64_decode(exported["base64"].as_str().unwrap());
|
||||
let raster = designcraft_render::render_pdf_page(&bytes, 0, 842).unwrap();
|
||||
let px = raster.data().get(150 * usize::from(raster.width()) + 175).unwrap();
|
||||
assert!(px.r > 200 && px.g < 40, "PDF export retains red: {px:?}");
|
||||
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let bytes = super::super::file::base64_decode(idml["base64"].as_str().unwrap());
|
||||
let imported = designcraft_idml::import_idml(&bytes).unwrap();
|
||||
let pdf_asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap();
|
||||
assert!(designcraft_render::pdf_layers(&pdf_asset.data).iter().all(|(_, on)| *on));
|
||||
assert!(s.execute("file.exportIdml", &json!({"embedImages":false})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.assets.values().find(|a| a.name == "off.pdf").unwrap().data.as_slice(), pdf);
|
||||
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[]})).unwrap();
|
||||
assert!(!visible(&mut s), "legacy empty list still resets defaults");
|
||||
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":false})).unwrap();
|
||||
s.execute("object.layerOptions", &json!({"ids":[id],"useDefaults":true})).unwrap();
|
||||
assert!(!visible(&mut s), "explicit reset restores defaults");
|
||||
}
|
||||
#[test]
|
||||
fn parent_page_choices_reach_idml_variants_and_raster_export() {
|
||||
// Red is hidden by the file itself, so "show every layer" is a visible difference.
|
||||
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let id = s
|
||||
.execute(
|
||||
"file.place",
|
||||
&json!({"base64": super::super::file::base64_encode(&pdf), "name": "on-parent.pdf", "spread": {"kind": "parent", "index": 0}, "x": 0.0, "y": 0.0, "width": 100}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let item = designcraft_doc::ItemId(id.as_u64().unwrap());
|
||||
assert!(
|
||||
matches!(s.doc().unwrap().doc.find(item).unwrap().spread, designcraft_doc::SpreadRef::Parent(0)),
|
||||
"the placement sits on a parent page"
|
||||
);
|
||||
s.execute("object.layerOptions", &json!({"ids": [id], "hidden": [], "useDefaults": false})).unwrap();
|
||||
// Embedded IDML: the parent-page placement gets its own variant, like a document one.
|
||||
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap();
|
||||
let asset = imported.assets.values().find(|a| designcraft_render::is_pdf(&a.data)).unwrap();
|
||||
assert!(designcraft_render::pdf_layers(&asset.data).iter().all(|(_, on)| *on), "the embedded copy shows every layer");
|
||||
// An external-only copy would keep the original file's defaults: the choice is rejected.
|
||||
let err = s.execute("file.exportIdml", &json!({"embedImages": false})).unwrap_err().to_string();
|
||||
assert!(err.contains("embedded images"), "{err}");
|
||||
// PDF/A-2b rasterises a parent-page placement with a choice, warnings included.
|
||||
let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||||
assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}");
|
||||
}
|
||||
|
||||
/// An object anchored in a story is a placement too: the IDML copy gets a variant that shows
|
||||
/// every layer, an external-only copy is refused, and PDF/A-2b rasterises it, rather than each
|
||||
/// quietly falling back to the file's defaults.
|
||||
#[test]
|
||||
fn an_anchored_placement_keeps_its_layer_choice_in_saves_and_exports() {
|
||||
use designcraft_doc::{AnchoredObject, Content, Graphic, Item, Shape, SpreadRef};
|
||||
use designcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
let pdf = designcraft_render::pdf_hide_layers(&two_layer_pdf(), &["Red".into()]).unwrap();
|
||||
let mut d = designcraft_doc::Document::new(&Default::default());
|
||||
let asset = designcraft_doc::AssetId(d.alloc());
|
||||
d.add_asset(designcraft_doc::Asset {
|
||||
id: asset,
|
||||
name: "anchored.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: std::sync::Arc::new(pdf),
|
||||
..Default::default()
|
||||
});
|
||||
let mut item =
|
||||
Item::new(designcraft_doc::ItemId(d.alloc()), d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)));
|
||||
item.pdf_layer_override = true;
|
||||
item.content = Content::Graphic(Graphic {
|
||||
asset,
|
||||
size: (100.0, 100.0),
|
||||
xf: Affine::IDENTITY,
|
||||
clip: None,
|
||||
auto_fit: Default::default(),
|
||||
fit_align: 4,
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
layer_options: None,
|
||||
});
|
||||
let layer = d.default_layer();
|
||||
let (_, story) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default()).unwrap();
|
||||
d.story_mut(story).unwrap().insert_object(0, AnchoredObject { item, position: Default::default() });
|
||||
assert!(d.has_pdf_layer_overrides(), "the anchored placement carries the choice");
|
||||
let mut s = Session::new();
|
||||
s.add_document(crate::DocState::new(d, None));
|
||||
// Embedded IDML carries a variant that shows every layer.
|
||||
let idml = s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
let imported = designcraft_idml::import_idml(&super::super::file::base64_decode(idml["base64"].as_str().unwrap())).unwrap();
|
||||
let copies: Vec<_> = imported.assets.values().filter(|a| designcraft_render::is_pdf(&a.data)).collect();
|
||||
assert!(!copies.is_empty(), "the anchored placement is exported");
|
||||
assert!(
|
||||
copies.iter().all(|a| designcraft_render::pdf_layers(&a.data).iter().all(|(_, on)| *on)),
|
||||
"the embedded copy must not fall back to the file's defaults"
|
||||
);
|
||||
// An external-only copy would keep the original file's defaults.
|
||||
assert!(s.execute("file.exportIdml", &json!({"embedImages": false})).is_err());
|
||||
// PDF/A-2b rasterises a placement with a choice, warnings included.
|
||||
let a2b = s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||||
assert!(a2b["warnings"].as_array().unwrap().iter().any(|w| w.as_str().is_some_and(|w| w.contains("exported as images"))), "{a2b}");
|
||||
}
|
||||
|
||||
/// Relinking a placed PDF to an image leaves the item's layer fields behind. They are no longer a
|
||||
/// placed-PDF choice: the package report asks for a graphic whose asset is a PDF, the same thing
|
||||
/// the export walks ask for, so it does not name the stale field, and exports run unaffected.
|
||||
#[test]
|
||||
fn a_stale_choice_on_a_non_pdf_placement_is_not_a_placed_pdf_choice() {
|
||||
let png = designcraft_render::Rendered { width: 4, height: 3, pixels: [30, 90, 180, 255].repeat(12) }.to_png();
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = s
|
||||
.execute("file.place", &json!({"base64": super::super::file::base64_encode(&png), "name": "photo.png", "x": 0, "y": 0, "width": 100}))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let item = designcraft_doc::ItemId(id.as_u64().unwrap());
|
||||
// The state a relink from a PDF leaves behind: fields set, asset no longer a PDF.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).item_mut(item).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.item(item).unwrap().has_pdf_layer_override(), "the stale field is on the item");
|
||||
assert!(d.has_pdf_layer_overrides(), "the field is there");
|
||||
assert!(!super::super::interchange::has_applicable_pdf_layer_overrides(d), "but it is not a placed-PDF choice");
|
||||
// Nothing is dropped: the field is not a choice any export applies.
|
||||
s.execute("file.exportIdml", &json!({})).unwrap();
|
||||
s.execute("file.exportPdf", &json!({"standard": "a2b"})).unwrap();
|
||||
s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layerless_pdf_rejects_overrides_without_breaking_idml_export() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let pdf = s.execute("file.exportPdf", &json!({})).unwrap()["base64"].clone();
|
||||
let id = s.execute("file.place", &json!({"base64":pdf,"name":"plain.pdf","x":0,"y":0,"width":100})).unwrap()["id"].clone();
|
||||
let layered = s
|
||||
.execute(
|
||||
"file.place",
|
||||
&json!({"base64":super::super::file::base64_encode(&two_layer_pdf()),"name":"layered.pdf","x":120,"y":0,"width":100}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let before = serde_json::to_value(&*s.doc().unwrap().doc).unwrap();
|
||||
assert!(s.execute("object.layerOptions", &json!({"ids":[layered,id],"hidden":[],"useDefaults":false})).is_err());
|
||||
assert_eq!(serde_json::to_value(&*s.doc().unwrap().doc).unwrap(), before);
|
||||
assert!(s.execute("file.exportIdml", &json!({})).is_ok());
|
||||
s.execute("object.layerOptions", &json!({"ids":[id],"hidden":[],"useDefaults":true})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(id.as_u64().unwrap())).unwrap().has_pdf_layer_override());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_options_ignore_non_pdf_targets_in_mixed_selection() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let placed = s
|
||||
.execute(
|
||||
"file.place",
|
||||
&json!({"base64": super::super::file::base64_encode(&two_layer_pdf()), "name":"art.pdf", "x":100,"y":100,"width":100}),
|
||||
)
|
||||
.unwrap();
|
||||
let other = s.execute("frame.create", &json!({"rect":[0,0,20,20]})).unwrap();
|
||||
for ids in [json!([other["id"], placed["id"]]), json!([placed["id"], other["id"]])] {
|
||||
s.execute("selection.set", &json!({"ids":ids})).unwrap();
|
||||
assert_eq!(s.execute("object.pdfLayers", &json!({})).unwrap().as_array().unwrap().len(), 2);
|
||||
}
|
||||
assert!(s.execute("object.pdfLayers", &json!({"id":other["id"]})).is_err(), "explicit non-PDF id remains an error");
|
||||
s.execute("object.layerOptions", &json!({"ids":[placed["id"],other["id"]],"hidden":[],"useDefaults":false})).unwrap();
|
||||
assert!(!s.doc().unwrap().doc.item(designcraft_doc::ItemId(other["id"].as_u64().unwrap())).unwrap().has_pdf_layer_override());
|
||||
assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"hidden":["Red"],"useDefaults":true})).is_err());
|
||||
assert!(s.execute("object.layerOptions", &json!({"ids":[placed["id"]],"useDefaults":"false"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +249,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
let xf = parent * it.xf;
|
||||
pending.extend(it.children().iter().rev().map(|child| (child.as_ref(), xf)));
|
||||
let page = page_of(d, SpreadRef::Doc(si), it, parent);
|
||||
if let Content::Graphic(g) = &it.content
|
||||
&& g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides)
|
||||
{
|
||||
out.push(Issue {
|
||||
severity: "warning",
|
||||
kind: "unsupportedGraphicLayers",
|
||||
message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(),
|
||||
item: Some(it.id.0),
|
||||
page,
|
||||
});
|
||||
}
|
||||
match &it.content {
|
||||
Content::Graphic(g) => match d.assets.get(&g.asset) {
|
||||
None => out.push(Issue { severity: "error", kind: "missingLink", message: "Missing graphic".into(), item: Some(it.id.0), page }),
|
||||
@@ -307,6 +318,17 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec<Issue> {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nested graphic cells and anchored/parent items may not have a page frame
|
||||
// in the walk above. Keep the warning visible after a native save/reopen.
|
||||
if d.has_unwalked_graphic_layer_visibility_overrides() {
|
||||
out.push(Issue {
|
||||
severity: "warning",
|
||||
kind: "unsupportedGraphicLayers",
|
||||
message: designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING.into(),
|
||||
item: None,
|
||||
page: None,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -2017,6 +2017,7 @@ fn place_graphic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?;
|
||||
let cell = t.cell_mut(r, c).ok_or_else(|| bad(ID, format!("no cell {r},{c}")))?;
|
||||
cell.graphic = Some(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (nw, nh),
|
||||
xf,
|
||||
|
||||
@@ -1033,7 +1033,24 @@ fn story_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if on.is_none() && size.is_none() {
|
||||
return Err(bad(CMD, "nothing to set: give opticalMargin or opticalMarginSize"));
|
||||
}
|
||||
let stories = if p.get("story").is_some() || p.get("frame").is_some() {
|
||||
let explicit = |key| p.get(key).filter(|v| !v.is_null());
|
||||
let stories = if explicit("story").is_some() || explicit("frame").is_some() {
|
||||
// Explicit targets must not silently fall back to the current selection.
|
||||
let doc = &s.doc()?.doc;
|
||||
for key in ["story", "frame"] {
|
||||
if let Some(value) = explicit(key) {
|
||||
let id = value.as_u64().ok_or_else(|| bad(CMD, format!("`{key}` must be an unsigned integer")))?;
|
||||
let sid = if key == "story" {
|
||||
StoryId(id)
|
||||
} else {
|
||||
let item = doc.item(ItemId(id)).ok_or_else(|| bad(CMD, format!("no such frame {id}")))?;
|
||||
item.text_frame().ok_or_else(|| bad(CMD, format!("frame {id} holds no text")))?.story
|
||||
};
|
||||
if doc.story(sid).is_none() {
|
||||
return Err(bad(CMD, format!("no such story {}", sid.0)));
|
||||
}
|
||||
}
|
||||
}
|
||||
vec![story_of(s, p).ok_or_else(|| bad(CMD, "no such story"))?]
|
||||
} else {
|
||||
selected_stories(s)?
|
||||
@@ -3359,6 +3376,68 @@ mod bidi_caret_tests {
|
||||
mod story_options_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn malformed_explicit_story_targets_do_not_change_the_selection() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Selected"})).unwrap();
|
||||
let sid = StoryId(r["story"].as_u64().unwrap());
|
||||
for target in ["story", "frame"] {
|
||||
for invalid in [json!("invalid"), json!(-1), json!(1.5), json!(true), json!({}), json!([]), json!(999_999)] {
|
||||
let mut params = json!({"opticalMargin": true});
|
||||
params[target] = invalid;
|
||||
assert!(s.execute("story.options", ¶ms).is_err(), "{params}");
|
||||
assert!(!story(&s, sid).on, "{params} changed the selected story");
|
||||
}
|
||||
}
|
||||
// Selection fallback still works when neither explicit target is supplied.
|
||||
s.execute("story.options", &json!({"opticalMargin": true})).unwrap();
|
||||
assert!(story(&s, sid).on);
|
||||
s.execute("story.options", &json!({"frame": r["id"], "opticalMargin": false})).unwrap();
|
||||
assert!(!story(&s, sid).on);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn story_options_treat_null_targets_as_absent_and_validate_both_explicit_ids() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let a = s.execute("frame.create", &json!({"rect": [0, 0, 300, 200], "content": "text", "text": "Explicit"})).unwrap();
|
||||
let b = s.execute("frame.create", &json!({"rect": [0, 300, 300, 500], "content": "text", "text": "Selected"})).unwrap();
|
||||
let aid = StoryId(a["story"].as_u64().unwrap());
|
||||
let bid = StoryId(b["story"].as_u64().unwrap());
|
||||
for targets in [json!({"story": null}), json!({"frame": null}), json!({"story": null, "frame": null})] {
|
||||
let mut p = targets;
|
||||
p["opticalMargin"] = json!(true);
|
||||
s.execute("story.options", &p).unwrap();
|
||||
assert!(story(&s, bid).on);
|
||||
assert!(!story(&s, aid).on);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!story(&s, bid).on);
|
||||
}
|
||||
for targets in [json!({"story": null, "frame": a["id"]}), json!({"story": a["story"], "frame": null})] {
|
||||
let mut p = targets;
|
||||
p["opticalMargin"] = json!(true);
|
||||
s.execute("story.options", &p).unwrap();
|
||||
assert!(story(&s, aid).on);
|
||||
assert!(!story(&s, bid).on);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// Even a frame ignored by story precedence must be valid when explicitly supplied.
|
||||
let non_text = s.execute("frame.create", &json!({"rect": [400, 0, 500, 100]})).unwrap();
|
||||
for (targets, message) in [
|
||||
(json!({"frame": 999_999}), "no such frame 999999".to_string()),
|
||||
(json!({"frame": non_text["id"]}), format!("frame {} holds no text", non_text["id"])),
|
||||
(json!({"story": 999_999}), "no such story 999999".to_string()),
|
||||
(json!({"story": a["story"], "frame": 999_999}), "no such frame 999999".to_string()),
|
||||
] {
|
||||
let mut p = targets;
|
||||
p["opticalMargin"] = json!(true);
|
||||
assert!(s.execute("story.options", &p).unwrap_err().to_string().contains(&message), "{p}: expected {message}");
|
||||
assert!(!story(&s, aid).on);
|
||||
assert!(!story(&s, bid).on);
|
||||
}
|
||||
}
|
||||
|
||||
fn first_x(s: &Session, sid: StoryId) -> f64 {
|
||||
let cs = s.cache.get(&s.doc().unwrap().doc, sid, None);
|
||||
cs.frames[0].lines[0].glyphs[0].x
|
||||
|
||||
@@ -79,6 +79,7 @@ fn graphic_frame(d: &mut Document, sr: SpreadRef, r: Rect, asset: AssetId, px: (
|
||||
let (nw, nh) = (px.0 as f64, px.1 as f64);
|
||||
let k = (r.width() / nw).max(r.height() / nh);
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (nw, nh),
|
||||
xf: Affine::translate((r.x0 + (r.width() - nw * k) / 2.0, r.y0 + (r.height() - nh * k) / 2.0)) * Affine::scale(k),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
//! Per-placement artwork layer metadata must survive native saves and stay visibly unsupported.
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use designcraft_doc::{Content, GRAPHIC_LAYER_RENDERING_WARNING};
|
||||
use designcraft_engine::{DocState, Session};
|
||||
use serde_json::json;
|
||||
|
||||
fn fixture() -> Vec<u8> {
|
||||
let xml = r#"<Document><Layer Self="L" Name="Synthetic"/><Spread Self="sp"><Page Self="p" GeometricBounds="0 0 100 100"/>
|
||||
<Rectangle Self="r" ItemLayer="L" ContentType="GraphicType"><Properties><PathGeometry><GeometryPathType PathOpen="false"><PathPointArray>
|
||||
<PathPointType Anchor="0 0"/><PathPointType Anchor="0 20"/><PathPointType Anchor="20 20"/><PathPointType Anchor="20 0"/>
|
||||
</PathPointArray></GeometryPathType></PathGeometry></Properties><Image Self="im">
|
||||
<Properties><GraphicBounds Left="0" Top="0" Right="20" Bottom="20"/></Properties>
|
||||
<Link Self="lk" LinkResourceURI="file:/definitely-missing/synthetic.psd"/>
|
||||
<GraphicLayerOption UpdateLinkOption="KeepOverrides">
|
||||
<GraphicLayer Self="g0" Id="0" Name="Group"><GraphicLayer Self="g1" Id="1" Name="Hidden" OriginalVisibility="true" CurrentVisibility="false"/></GraphicLayer>
|
||||
<GraphicLayer Self="g2" Id="2" Name="Shown" OriginalVisibility="false" CurrentVisibility="true"/>
|
||||
</GraphicLayerOption></Image></Rectangle></Spread></Document>"#;
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
z.start_file("designmap.xml", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(xml.as_bytes()).unwrap();
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_layer_options_survive_native_reopen_and_report_unsupported_output() {
|
||||
let imported = designcraft_idml::import_idml_report(&fixture(), &|_| None).unwrap();
|
||||
assert!(imported.warnings.iter().any(|w| w.contains(GRAPHIC_LAYER_RENDERING_WARNING)));
|
||||
let doc = imported.document;
|
||||
let options = |d: &designcraft_doc::Document| {
|
||||
d.spreads[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match &i.content {
|
||||
Content::Graphic(g) => g.layer_options.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let expected = options(&doc);
|
||||
let saved = designcraft_format::save(&doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&meta).unwrap()["version"], 4, "old readers must not silently drop the settings");
|
||||
let reopened = designcraft_format::load(&saved).unwrap();
|
||||
assert_eq!(options(&reopened), expected);
|
||||
let mut s = Session::new();
|
||||
s.add_document(DocState::new(reopened, None));
|
||||
let preflight = s.execute("preflight.run", &json!({})).unwrap();
|
||||
assert!(preflight["issues"].as_array().unwrap().iter().any(|i| i["kind"] == "unsupportedGraphicLayers"));
|
||||
let pdf = s.execute("file.exportPdf", &json!({})).unwrap();
|
||||
assert!(pdf["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_layer_options_in_cells_require_the_new_native_reader() {
|
||||
let mut doc = designcraft_idml::import_idml(&fixture()).unwrap();
|
||||
let graphic = doc.spreads[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match &i.content {
|
||||
Content::Graphic(g) => Some(g.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
// Keep the original spread graphic too: its warning must not hide the cell warning.
|
||||
let layer = doc.default_layer();
|
||||
let (_, story) = doc
|
||||
.add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default())
|
||||
.unwrap();
|
||||
let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0);
|
||||
table.cells[0].graphic = Some(graphic);
|
||||
doc.story_mut(story).unwrap().insert_table(0, table);
|
||||
assert!(doc.has_graphic_layer_options());
|
||||
let reopened = designcraft_format::load(&designcraft_format::save(&doc).unwrap()).unwrap();
|
||||
assert!(reopened.has_graphic_layer_visibility_overrides());
|
||||
let mut s = Session::new();
|
||||
s.add_document(DocState::new(reopened, None));
|
||||
let preflight = s.execute("preflight.run", &json!({})).unwrap();
|
||||
let warnings: Vec<_> = preflight["issues"].as_array().unwrap().iter().filter(|i| i["kind"] == "unsupportedGraphicLayers").collect();
|
||||
assert!(warnings.iter().any(|i| i["item"].is_null()), "the cell needs an independent document-level warning");
|
||||
assert!(warnings.iter().any(|i| !i["item"].is_null()), "the spread retains its item warning");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_graphic_layer_cell_alone_still_requires_the_new_native_reader() {
|
||||
// The cell is the only thing carrying the settings: a save that only looked at spread items
|
||||
// would choose format 3 and drop them.
|
||||
let mut doc = designcraft_idml::import_idml(&fixture()).unwrap();
|
||||
let graphic = doc.spreads[0]
|
||||
.items
|
||||
.iter()
|
||||
.find_map(|i| match &i.content {
|
||||
Content::Graphic(g) => Some(g.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let layer = doc.default_layer();
|
||||
let (_, story) = doc
|
||||
.add_text_frame(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Rect::new(0.0, 0.0, 80.0, 80.0), layer, "Body", Default::default())
|
||||
.unwrap();
|
||||
// Only the cell keeps them: the spread's own placed graphic goes away.
|
||||
std::sync::Arc::make_mut(&mut doc.spreads[0]).items.retain(|i| i.graphic().is_none());
|
||||
let mut table = designcraft_doc::Table::new(1, 1, 1, 0, 0, 20.0);
|
||||
table.cells[0].graphic = Some(graphic);
|
||||
doc.story_mut(story).unwrap().insert_table(0, table);
|
||||
assert!(doc.has_graphic_layer_options(), "the cell alone carries the settings");
|
||||
let saved = designcraft_format::save(&doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&saved)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&meta).unwrap()["version"], 4, "a cell-only save needs the newer reader");
|
||||
assert!(designcraft_format::load(&saved).unwrap().has_graphic_layer_visibility_overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_layer_warning_survives_soft_effects_and_flattening() {
|
||||
for flatten in [false, true] {
|
||||
let doc = designcraft_idml::import_idml(&fixture()).unwrap();
|
||||
let id = doc.spreads[0].items.iter().find(|i| i.graphic().is_some()).unwrap().id.0;
|
||||
let mut session = Session::new();
|
||||
session.add_document(DocState::new(doc, None));
|
||||
session.execute("object.feather", &json!({"ids":[id], "width":2})).unwrap();
|
||||
let out = session.execute("file.exportPdf", &json!({"flatten":flatten})).unwrap();
|
||||
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w == GRAPHIC_LAYER_RENDERING_WARNING), "{out}");
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,7 @@ pub fn document(bytes: Vec<u8>, hidden_sets: &[&[&str]]) -> Document {
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
layer_options: None,
|
||||
});
|
||||
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
}
|
||||
|
||||
@@ -1553,6 +1553,7 @@ mod tests {
|
||||
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
item.alt_text = alt.into();
|
||||
item.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (1.0, 1.0),
|
||||
xf: Affine::IDENTITY,
|
||||
@@ -2090,6 +2091,7 @@ End",
|
||||
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, y, 72.0, y + 36.0)));
|
||||
item.alt_text = alt.into();
|
||||
item.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (1.0, 1.0),
|
||||
xf: Affine::IDENTITY,
|
||||
@@ -2207,6 +2209,7 @@ End",
|
||||
let mut item = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(Rect::new(36.0, 120.0, 72.0, 156.0)));
|
||||
item.alt_text = "Cover \"A\" & B".into();
|
||||
item.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (1.0, 1.0),
|
||||
xf: Affine::IDENTITY,
|
||||
|
||||
@@ -100,6 +100,7 @@ fn nested_and_item_rotations_are_combined_before_measuring_graphics() {
|
||||
let image = ItemId(doc.alloc());
|
||||
let mut item = Item::new(image, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 10.0)));
|
||||
item.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (1.0, 1.0),
|
||||
xf: Affine::IDENTITY,
|
||||
|
||||
+130
-6
@@ -22,8 +22,11 @@ use serde_json::{Value, json};
|
||||
|
||||
pub const MIME: &str = "application/vnd.designcraft+zip";
|
||||
/// Latest reader capability. Version 2 pinned the Keep Lines Together and drop cap defaults;
|
||||
/// version 3 adds rendering of retained EPS source assets.
|
||||
pub const VERSION: u32 = 3;
|
||||
/// version 3 adds rendering of retained EPS source assets; version 4 is one capability covering both
|
||||
/// retained placed-graphic layer options and explicit all-on placed-PDF layer choices, so a version 4
|
||||
/// reader understands both. Any later capability needs version 5. [`required_version`] is the only
|
||||
/// place that picks a version.
|
||||
pub const VERSION: u32 = 4;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FormatError {
|
||||
@@ -67,6 +70,21 @@ pub fn save_with_preview(doc: &Document, preview: Option<&[u8]>) -> Result<Vec<u
|
||||
/// 4 GiB written without them; the margin covers deflate output that is larger than its input.
|
||||
const LARGE_ENTRY: usize = (u32::MAX as usize).saturating_sub(64 << 20);
|
||||
|
||||
/// The oldest reader capability that keeps everything in `doc`. Documents that need nothing newer
|
||||
/// keep their older requirement so existing readers still open them.
|
||||
fn required_version(doc: &Document) -> u32 {
|
||||
// Version 4 covers both of these: older readers ignore the unknown fields, so they would
|
||||
// silently drop retained graphic layer choices, or read an explicit all-on PDF layer choice
|
||||
// (empty hidden list) as "use the file's defaults". A nonempty hidden list stays readable.
|
||||
if doc.has_graphic_layer_options() || doc.has_explicit_all_on_pdf_layers() {
|
||||
return 4;
|
||||
}
|
||||
// Older readers would silently omit EPS artwork. Preview-only legacy assets and documents
|
||||
// without EPS stay at 2 so their existing readers keep working; writing 1 would make a
|
||||
// reader re-run the version 2 paragraph upgrade over current defaults.
|
||||
if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { 3 } else { 2 }
|
||||
}
|
||||
|
||||
fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_entry: usize) -> Result<Vec<u8>, FormatError> {
|
||||
// JSON writes infinity and NaN as null, which no load accepts: the file would not open.
|
||||
if let Some(id) = doc.non_finite_item() {
|
||||
@@ -84,10 +102,7 @@ fn save_with_large_entries_from(doc: &Document, preview: Option<&[u8]>, large_en
|
||||
z.start_file("mimetype", stored).map_err(io)?;
|
||||
z.write_all(MIME.as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
z.start_file("meta.json", deflate).map_err(io)?;
|
||||
// Older readers would silently omit EPS artwork. Preview-only legacy assets and
|
||||
// documents without EPS stay at 2 so their existing readers keep working; writing 1
|
||||
// would make a reader re-run the version 2 paragraph upgrade over current defaults.
|
||||
let version = if doc.assets.values().any(|a| designcraft_images::is_eps(&a.data)) { VERSION } else { 2 };
|
||||
let version = required_version(doc);
|
||||
let meta = json!({"format": "designcraft", "version": version, "generator": concat!("DesignCraft ", env!("CARGO_PKG_VERSION"))});
|
||||
z.write_all(meta.to_string().as_bytes()).map_err(|e| FormatError::Io(e.to_string()))?;
|
||||
if let Some(png) = preview {
|
||||
@@ -404,6 +419,7 @@ mod tests {
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (1.0, 1.0),
|
||||
xf: Affine::translate((crop, crop)),
|
||||
@@ -440,6 +456,95 @@ mod tests {
|
||||
buf.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_all_on_pdf_layers_require_a_capable_reader_only_when_needed() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let aid = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset {
|
||||
id: aid,
|
||||
name: "art.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: Arc::new(b"%PDF-1.7".to_vec()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let id = place(&mut d, aid, 0.0);
|
||||
let version = |d: &Document| {
|
||||
let bytes = save(d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
serde_json::from_str::<Value>(&meta).unwrap()["version"].as_u64().unwrap()
|
||||
};
|
||||
assert_eq!(version(&d), 2);
|
||||
d.item_mut(id).unwrap().pdf_layer_override = true;
|
||||
assert_eq!(version(&d), 4);
|
||||
assert!(load(&save(&d).unwrap()).unwrap().item(id).unwrap().pdf_layer_override);
|
||||
d.item_mut(id).unwrap().pdf_hidden_layers = vec!["Red".into()];
|
||||
assert_eq!(version(&d), 2, "legacy nonempty overrides remain readable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_version_4_reader_keeps_both_explicit_all_on_and_graphic_layer_options() {
|
||||
use designcraft_doc::{Content, GraphicLayerOptions};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let pdf = AssetId(d.alloc());
|
||||
d.assets.insert(
|
||||
pdf,
|
||||
Arc::new(Asset {
|
||||
id: pdf,
|
||||
name: "art.pdf".into(),
|
||||
mime: "application/pdf".into(),
|
||||
data: Arc::new(b"%PDF-1.7".to_vec()),
|
||||
..Default::default()
|
||||
}),
|
||||
);
|
||||
let eps = AssetId(d.alloc());
|
||||
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec();
|
||||
d.assets.insert(
|
||||
eps,
|
||||
Arc::new(Asset { id: eps, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(source), ..Default::default() }),
|
||||
);
|
||||
let all_on = place(&mut d, pdf, 0.0);
|
||||
let layered = place(&mut d, eps, 0.0);
|
||||
assert_eq!(required_version(&d), 3, "neither feature: EPS documents keep version 3");
|
||||
let options = GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() };
|
||||
let set_options = |d: &mut Document, value: Option<GraphicLayerOptions>| {
|
||||
let Some(Content::Graphic(g)) = d.item_mut(layered).map(|it| &mut it.content) else { panic!("placed graphic") };
|
||||
g.layer_options = value;
|
||||
};
|
||||
set_options(&mut d, Some(options.clone()));
|
||||
d.item_mut(all_on).unwrap().pdf_layer_override = true;
|
||||
let bytes = save(&d).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).unwrap();
|
||||
let mut meta = String::new();
|
||||
z.by_name("meta.json").unwrap().read_to_string(&mut meta).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&meta).unwrap()["version"], 4);
|
||||
let reopened = load(&bytes).unwrap();
|
||||
let it = reopened.item(all_on).unwrap();
|
||||
assert!(it.pdf_layer_override && it.pdf_hidden_layers.is_empty(), "explicit all-on survives");
|
||||
assert_eq!(reopened.item(layered).unwrap().graphic().unwrap().layer_options.as_ref(), Some(&options), "graphic layer options survive");
|
||||
assert_eq!(required_version(&reopened), 4);
|
||||
// Either feature alone needs version 4; dropping both restores the older requirement.
|
||||
set_options(&mut d, None);
|
||||
assert_eq!(required_version(&d), 4, "explicit all-on alone");
|
||||
d.item_mut(all_on).unwrap().pdf_layer_override = false;
|
||||
assert_eq!(required_version(&d), 3);
|
||||
set_options(&mut d, Some(options));
|
||||
assert_eq!(required_version(&d), 4, "graphic layer options alone");
|
||||
set_options(&mut d, None);
|
||||
d.assets.remove(&eps);
|
||||
d.remove_item(layered).unwrap();
|
||||
assert_eq!(required_version(&d), 2, "neither feature and no EPS: version 2");
|
||||
// An explicit all-on choice on a parent spread also needs the version 4 reader.
|
||||
let mut parent_item = (*d.remove_item(all_on).unwrap()).clone();
|
||||
parent_item.pdf_layer_override = true;
|
||||
d.insert_item(SpreadRef::Parent(0), parent_item, None).unwrap();
|
||||
assert_eq!(required_version(&d), 4, "parent-spread placements are not skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_source_requires_new_reader_but_preview_documents_keep_the_older_one() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
@@ -473,6 +578,25 @@ mod tests {
|
||||
assert_eq!(version(&d), 2, "legacy EPS previews stay compatible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_place_picks_the_oldest_reader_that_keeps_the_document() {
|
||||
use designcraft_doc::{Content, GraphicLayerOptions};
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
assert_eq!(required_version(&d), 2, "plain documents keep the version 2 reader");
|
||||
let aid = AssetId(d.alloc());
|
||||
let eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 20 10\n%%EOF\n".to_vec();
|
||||
d.assets.insert(
|
||||
aid,
|
||||
Arc::new(Asset { id: aid, name: "logo.eps".into(), mime: "application/postscript".into(), data: Arc::new(eps), ..Default::default() }),
|
||||
);
|
||||
let id = place(&mut d, aid, 0.0);
|
||||
assert_eq!(required_version(&d), 3, "EPS sources alone keep the version 3 reader");
|
||||
let Some(Content::Graphic(g)) = d.item_mut(id).map(|it| &mut it.content) else { panic!("placed graphic") };
|
||||
g.layer_options = Some(GraphicLayerOptions { layers: vec![[("Name".to_string(), "Art".to_string())].into()], ..Default::default() });
|
||||
assert_eq!(required_version(&d), 4, "retained graphic layer options need version 4 even with EPS");
|
||||
assert_eq!(required_version(&load(&save(&d).unwrap()).unwrap()), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_with_assets() {
|
||||
let mut d = Document::new(&NewDocument { pages: 3, ..Default::default() });
|
||||
|
||||
@@ -20,6 +20,15 @@ const TOC_STYLE_ID: &str = "TOCStyle/Table of Contents";
|
||||
const TOC_STYLE_NAME: &str = "Table of Contents";
|
||||
const NO_CHAR_ID: &str = "CharacterStyle/$ID/[No character style]";
|
||||
|
||||
// Retained IDML attributes have unprefixed ASCII names. Native JSON can be
|
||||
// edited independently: never let its map keys become XML syntax or Self IDs.
|
||||
fn graphic_layer_attribute_name(key: &str) -> bool {
|
||||
!key.is_empty()
|
||||
&& key != "Self"
|
||||
&& key != "xmlns"
|
||||
&& key.bytes().enumerate().all(|(i, b)| b.is_ascii_alphabetic() || b == b'_' || (i > 0 && (b.is_ascii_digit() || matches!(b, b'.' | b'-'))))
|
||||
}
|
||||
|
||||
/// Export options.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExportOptions {
|
||||
@@ -2083,6 +2092,53 @@ impl<'a> Ex<'a> {
|
||||
if let Some(c) = &g.clip {
|
||||
el.push(clipping_settings(c));
|
||||
}
|
||||
if let Some(options) = &g.layer_options {
|
||||
let mut option = El::new("GraphicLayerOption");
|
||||
for (key, value) in &options.attributes {
|
||||
if graphic_layer_attribute_name(key) {
|
||||
option.set(key, value);
|
||||
}
|
||||
}
|
||||
// Only an ancestor whose subtree is still open can parent the next layer.
|
||||
// Reopening a closed subtree would reorder native-edited lists on roundtrip.
|
||||
let mut open = Vec::new();
|
||||
let mut parents = Vec::with_capacity(options.layers.len());
|
||||
for i in 0..options.layers.len() {
|
||||
let parent = options.parents.get(i).copied().flatten();
|
||||
if let Some(at) = parent.and_then(|p| open.iter().position(|id| *id == p)).filter(|at| *at < 239) {
|
||||
open.truncate(at + 1);
|
||||
parents.push(parent);
|
||||
} else {
|
||||
open.clear();
|
||||
parents.push(None);
|
||||
}
|
||||
open.push(i);
|
||||
}
|
||||
let mut children: Vec<Vec<El>> = vec![Vec::new(); options.layers.len()];
|
||||
let mut roots = Vec::new();
|
||||
for (i, attributes) in options.layers.iter().enumerate().rev() {
|
||||
let mut layer = El::new("GraphicLayer").attr("Self", self.fresh());
|
||||
for (key, value) in attributes {
|
||||
if graphic_layer_attribute_name(key) {
|
||||
layer.set(key, value);
|
||||
}
|
||||
}
|
||||
if let Some(nested) = children.get_mut(i) {
|
||||
for child in std::mem::take(nested).into_iter().rev() {
|
||||
layer.push(child);
|
||||
}
|
||||
}
|
||||
if let Some(nested) = parents.get(i).copied().flatten().and_then(|parent| children.get_mut(parent)) {
|
||||
nested.push(layer);
|
||||
} else {
|
||||
roots.push(layer);
|
||||
}
|
||||
}
|
||||
for layer in roots.into_iter().rev() {
|
||||
option.push(layer);
|
||||
}
|
||||
el.push(option);
|
||||
}
|
||||
let uri = match &asset.link {
|
||||
Some(l) => path_to_uri(l),
|
||||
None => format!("file:{}", asset.name),
|
||||
|
||||
@@ -3082,6 +3082,26 @@ impl<'r> Importer<'r> {
|
||||
}
|
||||
|
||||
fn graphic(&mut self, g: &El) -> Content {
|
||||
let layer_options = g.find("GraphicLayerOption").map(|options| {
|
||||
let attrs = |e: &El| e.attrs.iter().filter(|(key, _)| key != "Self").cloned().collect();
|
||||
let mut out = designcraft_doc::GraphicLayerOptions { attributes: attrs(options), ..Default::default() };
|
||||
let mut pending: Vec<_> = options.find_all("GraphicLayer").map(|e| (e, None)).collect();
|
||||
pending.reverse();
|
||||
while let Some((layer, parent)) = pending.pop() {
|
||||
let index = out.layers.len();
|
||||
out.layers.push(attrs(layer));
|
||||
out.parents.push(parent);
|
||||
let children: Vec<_> = layer.find_all("GraphicLayer").collect();
|
||||
pending.extend(children.into_iter().rev().map(|e| (e, Some(index))));
|
||||
}
|
||||
if out.parents.iter().all(Option::is_none) {
|
||||
out.parents.clear();
|
||||
}
|
||||
out
|
||||
});
|
||||
if layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) {
|
||||
self.warnings.push(format!("graphic {}: {}", g.get("Self").unwrap_or("(unnamed)"), designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING));
|
||||
}
|
||||
let gxf = parse_affine(g.get("ItemTransform"));
|
||||
let (l, t, r, b) = match g.prop_el("GraphicBounds") {
|
||||
Some(gb) => {
|
||||
@@ -3098,6 +3118,7 @@ impl<'r> Importer<'r> {
|
||||
let uri = link.and_then(|k| k.get("LinkResourceURI")).map(uri_to_path);
|
||||
let graphic = |asset, size| {
|
||||
Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: layer_options.clone(),
|
||||
asset,
|
||||
size,
|
||||
xf: gxf * Affine::translate((l, t)),
|
||||
|
||||
@@ -4507,6 +4507,7 @@ fn exports_graphic_in_table_cell() {
|
||||
});
|
||||
let mut t = designcraft_doc::Table::new(d.alloc(), 2, 2, 0, 0, 200.0);
|
||||
t.cell_mut(1, 0).unwrap().graphic = Some(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: id,
|
||||
size: (24.0, 24.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
|
||||
@@ -70,6 +70,129 @@ fn package(frames: &[String]) -> Vec<u8> {
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_graphic_layers_preserve_hierarchy_on_roundtrip() {
|
||||
let option = r#"<GraphicLayerOption UpdateLinkOption="KeepOverrides">
|
||||
<GraphicLayer Self="a" Id="1" Name="Group"><GraphicLayer Self="b" Id="2" Name="Nested" OriginalVisibility="true" CurrentVisibility="false"/></GraphicLayer>
|
||||
<GraphicLayer Self="c" Id="3" Name="Sibling"/>
|
||||
</GraphicLayerOption>"#;
|
||||
let image = image(1, PHOTO, None).replace("</Image>", &format!("{option}</Image>"));
|
||||
let d = import_idml(&package(&[frame(1, 0.0, 0.0, &image)])).unwrap();
|
||||
let options = |d: &Document| d.spreads[0].items[0].graphic().unwrap().layer_options.clone().unwrap();
|
||||
let expected = options(&d);
|
||||
assert_eq!(expected.layers.len(), 3);
|
||||
assert_eq!(expected.parents, [None, Some(0), None]);
|
||||
assert!(expected.has_visibility_overrides());
|
||||
let reopened = import_idml(&export_idml(&d)).unwrap();
|
||||
assert_eq!(options(&reopened), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_layer_options_survive_for_each_placement_of_a_shared_asset() {
|
||||
let option = |n: usize, visible: &str| {
|
||||
format!(
|
||||
r#"<GraphicLayerOption UpdateLinkOption="KeepOverrides"><GraphicLayer Self="gl{n}" Id="7" Name="Synthetic layer" OriginalVisibility="true" CurrentVisibility="{visible}" Locked="true" HasPrintState="true" PrintState="false"/></GraphicLayerOption>"#
|
||||
)
|
||||
};
|
||||
let frames = ["false", "true"]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, visible)| {
|
||||
let n = i + 1;
|
||||
frame(n, i as f64 * 100.0, 0.0, &image(n, PHOTO, None).replace("</Image>", &format!("{}</Image>", option(n, visible))))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let reads = Cell::new(0);
|
||||
let loaded = import_idml_report_with_previews(
|
||||
&package(&frames),
|
||||
&|_| {
|
||||
reads.set(reads.get() + 1);
|
||||
Some(vec![42; 16])
|
||||
},
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(reads.get(), 1);
|
||||
assert_eq!(loaded.document.assets.len(), 1, "layer choices belong to placements, not shared image bytes");
|
||||
let out = export_idml(&loaded.document);
|
||||
let spreads = zip_text(&out, "Spreads/");
|
||||
assert_eq!(spreads.matches("<GraphicLayer ").count(), 2, "both placements must retain their independent layer choices");
|
||||
assert!(spreads.contains("CurrentVisibility=\"false\""));
|
||||
assert!(spreads.contains("CurrentVisibility=\"true\""));
|
||||
assert_eq!(spreads.matches("UpdateLinkOption=\"KeepOverrides\"").count(), 2);
|
||||
assert_eq!(spreads.matches("PrintState=\"false\"").count(), 2);
|
||||
assert!(loaded.warnings.iter().any(|w| w.contains("layer visibility")));
|
||||
let reopened = import_idml(&out).unwrap();
|
||||
let options = |d: &Document| {
|
||||
d.spreads[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|i| match &i.content {
|
||||
Content::Graphic(g) => g.layer_options.clone(),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(options(&loaded.document), options(&reopened));
|
||||
let twice = zip_text(&export_idml(&reopened), "Spreads/");
|
||||
assert_eq!(twice.matches("<GraphicLayer ").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_native_graphic_layer_parents_cannot_cycle_or_overflow_xml_depth() {
|
||||
let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap();
|
||||
let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else {
|
||||
panic!("graphic")
|
||||
};
|
||||
let mut options = designcraft_doc::GraphicLayerOptions::default();
|
||||
for i in 0..300usize {
|
||||
options.layers.push(std::collections::BTreeMap::from([("Name".into(), format!("Layer {i}"))]));
|
||||
options.parents.push(Some(i.saturating_sub(1)));
|
||||
}
|
||||
options.parents[1] = Some(usize::MAX);
|
||||
g.layer_options = Some(options);
|
||||
let reopened = import_idml(&export_idml(&d)).unwrap();
|
||||
assert_eq!(reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap().layers.len(), 300);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_graphic_layer_subtrees_cannot_reorder_native_layers() {
|
||||
let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap();
|
||||
let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else {
|
||||
panic!("graphic")
|
||||
};
|
||||
g.layer_options = Some(designcraft_doc::GraphicLayerOptions {
|
||||
layers: ["A", "B", "C", "D"].into_iter().map(|name| std::collections::BTreeMap::from([("Name".into(), name.into())])).collect(),
|
||||
parents: vec![None, None, Some(0), Some(2)],
|
||||
..Default::default()
|
||||
});
|
||||
let reopened = import_idml(&export_idml(&d)).unwrap();
|
||||
let options = reopened.spreads[0].items[0].graphic().unwrap().layer_options.as_ref().unwrap();
|
||||
assert_eq!(options.layers.iter().map(|a| a["Name"].as_str()).collect::<Vec<_>>(), ["A", "B", "C", "D"]);
|
||||
assert_eq!(options.parents, [None, None, None, Some(2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphic_layer_attributes_from_native_data_cannot_inject_xml_or_self_ids() {
|
||||
let mut d = import_idml(&package(&[frame(1, 0.0, 0.0, &image(1, PHOTO, None))])).unwrap();
|
||||
let Content::Graphic(g) = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut d.spreads[0]).items[0]).content else {
|
||||
panic!("graphic")
|
||||
};
|
||||
let attributes = std::collections::BTreeMap::from([
|
||||
("Name".into(), "A < B & C".into()),
|
||||
("Self".into(), "injected-id".into()),
|
||||
("xmlns".into(), "urn:injected".into()),
|
||||
("bad\" injected=\"true".into(), "invalid".into()),
|
||||
]);
|
||||
g.layer_options = Some(designcraft_doc::GraphicLayerOptions { attributes: attributes.clone(), layers: vec![attributes], ..Default::default() });
|
||||
let out = export_idml(&d);
|
||||
let xml = zip_text(&out, "Spreads/");
|
||||
assert!(!xml.contains("injected-id") && !xml.contains("injected=") && !xml.contains("urn:injected"));
|
||||
let back = import_idml(&out).unwrap();
|
||||
let Content::Graphic(g) = &back.spreads[0].items[0].content else { panic!("graphic") };
|
||||
assert_eq!(g.layer_options.as_ref().unwrap().layers[0]["Name"], "A < B & C");
|
||||
}
|
||||
|
||||
/// (asset, crop, transform) of each graphic, left to right.
|
||||
fn placements(d: &Document) -> Vec<(AssetId, [f64; 4], designcraft_geom::Affine)> {
|
||||
let mut out: Vec<(f64, AssetId, [f64; 4], designcraft_geom::Affine)> = d.spreads[0]
|
||||
|
||||
@@ -1008,7 +1008,10 @@ impl Exporter<'_> {
|
||||
let g = &if it.media.is_some() { it.drawn_graphic(&self.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
|
||||
if let Some(p) = &path {
|
||||
s.push_clip_path(p, &FillRule::NonZero);
|
||||
self.graphic(s, g, (!it.pdf_hidden_layers.is_empty()).then_some(it.pdf_hidden_layers.as_slice()));
|
||||
// An explicit all-on choice is `Some(&[])`, not the file's defaults: passing
|
||||
// `None` would embed the source's default-hidden layers and skip the rewrite
|
||||
// error that a stale choice must raise.
|
||||
self.graphic(s, g, it.has_pdf_layer_override().then_some(it.pdf_hidden_layers.as_slice()));
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
@@ -1416,7 +1419,11 @@ impl Exporter<'_> {
|
||||
|
||||
/// Draw a placed graphic, inside its clipping path when it has one. `hidden` is the
|
||||
/// placed PDF's layer choice: `None` keeps the file's defaults, `Some(&[])` shows every layer.
|
||||
/// Retained IDML graphic-layer options are not applied when drawing, so they warn here.
|
||||
pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) {
|
||||
if g.layer_options.as_ref().is_some_and(designcraft_doc::GraphicLayerOptions::has_visibility_overrides) {
|
||||
self.warn(designcraft_doc::GRAPHIC_LAYER_RENDERING_WARNING);
|
||||
}
|
||||
match g.clip_region().map(|(region, even_odd)| (to_path(®ion), even_odd)) {
|
||||
Some((Some(clip), even_odd)) => {
|
||||
s.push_clip_path(&clip, if even_odd { &FillRule::EvenOdd } else { &FillRule::NonZero });
|
||||
@@ -2005,6 +2012,7 @@ mod image_content_cache_tests {
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
layer_options: None,
|
||||
};
|
||||
ex.graphic_unclipped(&mut surface, &graphic, hidden.as_deref());
|
||||
assert!(ex.layer_error.is_none());
|
||||
@@ -2289,6 +2297,7 @@ mod eps_tests {
|
||||
designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)),
|
||||
);
|
||||
item.content = Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (2.0, 1.0),
|
||||
xf: Affine::translate((72.0, 72.0)) * Affine::scale(100.0),
|
||||
|
||||
@@ -254,8 +254,10 @@ pub(crate) fn prepare(bytes: &Arc<Vec<u8>>, choice: Option<&[String]>) -> Result
|
||||
if is_hidden {
|
||||
effective.push(id);
|
||||
}
|
||||
// Unmarked groups are visible when registered in the output catalog.
|
||||
if !is_hidden && name.is_some() {
|
||||
// Unmarked groups are visible when registered in the output catalog. An explicit choice
|
||||
// writes every group's state, so the catalog rewrite is attempted (and a file whose
|
||||
// history cannot take one fails closed) even when the choice happens to change nothing.
|
||||
if !is_hidden && name.is_some() && choice.is_none() {
|
||||
continue;
|
||||
}
|
||||
let marker = is_hidden.to_string();
|
||||
|
||||
@@ -496,6 +496,7 @@ mod tests {
|
||||
let r = Rect::new(at.0, at.1, at.0 + 100.0, at.1 + 50.0);
|
||||
let mut it = Item::new(id, lid, Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size,
|
||||
xf: Affine::translate(at) * Affine::scale(100.0 / size.0),
|
||||
|
||||
@@ -17,6 +17,7 @@ fn placed_document(source: Vec<u8>) -> Document {
|
||||
crop: [0.0; 4],
|
||||
wrap: Default::default(),
|
||||
fill: Default::default(),
|
||||
layer_options: None,
|
||||
});
|
||||
doc.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
doc
|
||||
|
||||
@@ -257,6 +257,7 @@ fn place(d: &mut Document, data: Vec<u8>, px: (u32, u32)) {
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(72.0, 72.0, 272.0, 172.0)));
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (px.0 as f64, px.1 as f64),
|
||||
xf: designcraft_geom::Affine::translate((72.0, 72.0)) * designcraft_geom::Affine::scale(50.0),
|
||||
|
||||
@@ -30,6 +30,7 @@ fn place_named(d: &mut Document, data: Vec<u8>, x: f64, name: &str, mime: &str,
|
||||
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(x, 36.0, x + 20.0, 56.0)));
|
||||
item.stroke.weight = 0.0;
|
||||
item.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (20.0, 20.0),
|
||||
xf: Affine::translate((x, 36.0)),
|
||||
|
||||
@@ -212,6 +212,7 @@ fn pictures_are_cropped_to_their_frames() {
|
||||
it.fill = Fill::none();
|
||||
// 200 × 100 image at 1:1, its left quarter cut off by the frame.
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (200.0, 100.0),
|
||||
xf: Affine::translate((50.0, 100.0)),
|
||||
|
||||
+29
-16
@@ -654,13 +654,10 @@ impl Renderer {
|
||||
let g = &if it.media.is_some() { it.drawn_graphic(&f.doc.assets).unwrap_or_else(|| g.clone()) } else { g.clone() };
|
||||
ctx.set_transform(f.view * xf);
|
||||
ctx.push_clip_layer(bp);
|
||||
if !it.pdf_hidden_layers.is_empty() {
|
||||
HIDDEN_LAYERS.with(|h| *h.borrow_mut() = it.pdf_hidden_layers.clone());
|
||||
}
|
||||
let hidden = it.has_pdf_layer_override().then(|| it.pdf_hidden_layers.clone());
|
||||
let previous = HIDDEN_LAYERS.with(|h| h.replace(hidden));
|
||||
self.draw_graphic(ctx, f, g, xf);
|
||||
if !it.pdf_hidden_layers.is_empty() {
|
||||
HIDDEN_LAYERS.with(|h| h.borrow_mut().clear());
|
||||
}
|
||||
HIDDEN_LAYERS.with(|h| h.replace(previous));
|
||||
ctx.pop_layer();
|
||||
}
|
||||
// Items pasted into the frame, clipped by it.
|
||||
@@ -880,12 +877,14 @@ impl Renderer {
|
||||
let on_screen = (f.view * xf * g.xf).determinant().abs().sqrt();
|
||||
let on_screen = if f.opts.quality == DisplayQuality::Typical { on_screen.min(1.0) } else { on_screen };
|
||||
let hidden = HIDDEN_LAYERS.with(|h| h.borrow().clone());
|
||||
let data = if !hidden.is_empty() && is_pdf(&asset.data) {
|
||||
// Stale persisted/relinked choices must not reveal hidden source content.
|
||||
let Some(variant) = pdf_layers::layered(&asset.data, &hidden) else { return };
|
||||
variant
|
||||
} else {
|
||||
asset.data.clone()
|
||||
let data = match hidden.as_deref().filter(|_| is_pdf(&asset.data)) {
|
||||
// An explicit choice (including all-on) that cannot be rewritten fails closed:
|
||||
// stale persisted/relinked choices must not reveal hidden source content.
|
||||
Some(hidden) => {
|
||||
let Some(variant) = pdf_layers::layered(&asset.data, hidden) else { return };
|
||||
variant
|
||||
}
|
||||
None => asset.data.clone(),
|
||||
};
|
||||
// A placed PDF cropped to a box: the whole page, placed so that the box spans graphic space.
|
||||
let [l, t, r, b] = shown_box(asset).unwrap_or([0.0, 0.0, 1.0, 1.0]);
|
||||
@@ -1025,7 +1024,7 @@ fn rect_overlaps(a: Rect, b: Rect) -> bool {
|
||||
|
||||
thread_local! {
|
||||
/// Object Layer Options of the placed PDF being drawn.
|
||||
static HIDDEN_LAYERS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
static HIDDEN_LAYERS: std::cell::RefCell<Option<Vec<String>>> = const { std::cell::RefCell::new(None) };
|
||||
/// Appearance of Black for the render on this thread: 100% K as pure black.
|
||||
static RICH_BLACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Separations Preview plate for the render on this thread.
|
||||
@@ -1296,11 +1295,23 @@ pub fn pdf_page_png(bytes: &[u8], page: usize, max_side: u32, hidden: &[String],
|
||||
/// Rasterize a placed page for export, preserving available interpreter diagnostics.
|
||||
/// This cannot detect backend approximations that do not emit an interpreter warning.
|
||||
pub fn pdf_page_png_checked(bytes: &[u8], page: usize, max_side: u32, hidden: &[String], shown: Option<[f64; 4]>) -> Result<Vec<u8>, PdfRasterError> {
|
||||
pdf_page_png_options_checked(bytes, page, max_side, (!hidden.is_empty()).then_some(hidden), shown)
|
||||
}
|
||||
|
||||
/// Rasterize with an explicit visibility override: `Some(&[])` enables every layer;
|
||||
/// `None` retains file defaults. The legacy entry point keeps its empty-list semantics.
|
||||
pub fn pdf_page_png_options_checked(
|
||||
bytes: &[u8],
|
||||
page: usize,
|
||||
max_side: u32,
|
||||
hidden: Option<&[String]>,
|
||||
shown: Option<[f64; 4]>,
|
||||
) -> Result<Vec<u8>, PdfRasterError> {
|
||||
check_pdf_input_size(bytes.len())?;
|
||||
let data = if hidden.is_empty() {
|
||||
std::borrow::Cow::Borrowed(bytes)
|
||||
} else {
|
||||
let data = if let Some(hidden) = hidden {
|
||||
std::borrow::Cow::Owned(pdf_hide_layers(bytes, hidden).ok_or(PdfRasterError::LayerRewrite)?)
|
||||
} else {
|
||||
std::borrow::Cow::Borrowed(bytes)
|
||||
};
|
||||
let raster = render_pdf_raster(&data, page, max_side)?;
|
||||
if raster.diagnostics.image_decode_failed {
|
||||
@@ -1984,6 +1995,7 @@ mod tests {
|
||||
let mut it =
|
||||
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (100.0, 100.0),
|
||||
xf: Affine::translate((100.0, 100.0)),
|
||||
@@ -2038,6 +2050,7 @@ mod tests {
|
||||
let mut it =
|
||||
Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (100.0, 100.0),
|
||||
xf: Affine::translate((100.0, 100.0)),
|
||||
|
||||
@@ -51,6 +51,7 @@ fn shown(file: Vec<u8>, fill: Fill) -> Vec<[u8; 4]> {
|
||||
it.fill = Fill::swatch(CYAN);
|
||||
it.stroke = Stroke::default();
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (150.0, 50.0),
|
||||
xf: Affine::translate((100.0, 100.0)),
|
||||
|
||||
@@ -78,6 +78,7 @@ fn placed(size: f64, pdf: Vec<u8>) -> Rendered {
|
||||
let id = ItemId(d.alloc());
|
||||
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, size, size)));
|
||||
item.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset,
|
||||
size: (size, size),
|
||||
xf: Affine::IDENTITY,
|
||||
|
||||
@@ -27,6 +27,7 @@ fn color_theme_tool_requests_a_palette_without_editing_the_image() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let id = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
|
||||
d.item_mut(id).unwrap().content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (100.0, 100.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
@@ -1108,6 +1109,7 @@ fn graphic_frame(doc: &mut Document) -> ItemId {
|
||||
let id = ItemId(doc.alloc());
|
||||
let mut item = Item::new(id, doc.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
|
||||
item.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (200.0, 100.0),
|
||||
xf: designcraft_geom::Affine::translate((50.0, 100.0)),
|
||||
@@ -1502,6 +1504,7 @@ fn eyedropper_samples_a_placed_image_and_matches_other_objects() {
|
||||
let photo = ItemId(d.alloc());
|
||||
let mut it = Item::new(photo, lid, Shape::Rectangle, shapes::rectangle(Rect::new(100.0, 100.0, 200.0, 200.0)));
|
||||
it.content = designcraft_doc::Content::Graphic(designcraft_doc::Graphic {
|
||||
layer_options: None,
|
||||
asset: designcraft_doc::AssetId(1),
|
||||
size: (100.0, 100.0),
|
||||
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
|
||||
@@ -1855,6 +1858,7 @@ fn eyedropper_ignores_inactive_states_of_a_multi_state_object() {
|
||||
let raster = {
|
||||
let mut it = Item::new(ItemId(d.alloc()), lid, Shape::Rectangle, shapes::rectangle(area));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: AssetId(1),
|
||||
size: (100.0, 100.0),
|
||||
xf: designcraft_geom::Affine::translate((0.0, 0.0)),
|
||||
|
||||
@@ -11,6 +11,7 @@ workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-ui = { workspace = true, features = ["native-windows"] }
|
||||
craft-window = { workspace = true }
|
||||
designcraft-geom = { workspace = true }
|
||||
designcraft-color = { workspace = true }
|
||||
designcraft-doc = { workspace = true }
|
||||
|
||||
@@ -204,7 +204,7 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
egui::pos2(full.min.x - f32::from(left), full.min.y),
|
||||
egui::pos2(full.max.x + 10.0, full.min.y + MAC_APP_BAR),
|
||||
);
|
||||
crate::title_bar::empty_area(ui, band);
|
||||
let _ = craft_window::title_bar::empty_area(ui, band);
|
||||
}
|
||||
let mut menus_end = full.min.x;
|
||||
let mut tools_start = full.max.x;
|
||||
@@ -2090,7 +2090,7 @@ mod tests {
|
||||
let mut movable_at = |at: egui::Pos2| {
|
||||
h.hover_at(at);
|
||||
h.run_steps(2);
|
||||
crate::title_bar::window_movable(&h.ctx)
|
||||
craft_window::title_bar::window_movable(&h.ctx)
|
||||
};
|
||||
for at in [pos2(bar.center().x, mid), pos2(bar.min.x + MAC_CONTENT_LEFT - 4.0, bar.min.y + 4.0), pos2(bar.center().x, 500.0)] {
|
||||
assert!(movable_at(at), "empty title bar or the page at {at:?}");
|
||||
|
||||
@@ -961,7 +961,11 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result<Val
|
||||
if all || edited(d, "gridIncrement") {
|
||||
g.insert(
|
||||
"increment".into(),
|
||||
json!(d.m("gridIncrement").filter(|v| *v > 0.0).ok_or_else(|| bad("the baseline grid increment is not a measure"))?),
|
||||
json!(
|
||||
d.m("gridIncrement")
|
||||
.filter(|v| *v > 0.0)
|
||||
.ok_or_else(|| bad("the baseline grid increment must be a measure greater than zero"))?
|
||||
),
|
||||
);
|
||||
}
|
||||
if all || edited(d, "gridRelativeTo") {
|
||||
@@ -2547,8 +2551,15 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
|
||||
&& let Some(o) = l.as_object_mut()
|
||||
{
|
||||
o.insert("visible".into(), json!(on));
|
||||
d.fields.insert("useDefaults".into(), json!(false));
|
||||
}
|
||||
}
|
||||
if ui.button(crate::i18n::tr(&app.ui.language, "Use File Defaults")).clicked() {
|
||||
for layer in &mut layers {
|
||||
layer["visible"] = layer["default"].clone();
|
||||
}
|
||||
d.fields.insert("useDefaults".into(), json!(true));
|
||||
}
|
||||
d.fields.insert("layers".into(), json!(layers));
|
||||
}
|
||||
"colorSettings" => {
|
||||
@@ -3424,7 +3435,12 @@ pub fn confirm(app: &mut DesignApp) -> Result<Value, String> {
|
||||
}
|
||||
"layerOptions" => {
|
||||
let hidden: Vec<Value> = d.fields.get("layers").and_then(Value::as_array).map(|a| a.iter().filter(|l| l["visible"] == false).map(|l| l["name"].clone()).collect()).unwrap_or_default();
|
||||
app.run("object.layerOptions", json!({"hidden": hidden}))
|
||||
// A dialog opened without the menu's fields has no `useDefaults` at all: confirming it
|
||||
// with no layer unticked resets file defaults, as it did before all-on existed, rather
|
||||
// than making every layer explicitly visible. Only an explicit `false` means all-on.
|
||||
let use_defaults = d.fields.get("useDefaults").and_then(Value::as_bool).unwrap_or(hidden.is_empty());
|
||||
let hidden = if use_defaults { Vec::new() } else { hidden };
|
||||
app.run("object.layerOptions", json!({"hidden": hidden, "useDefaults": use_defaults}))
|
||||
}
|
||||
"colorSettings" => {
|
||||
let r = app.run("color.settings", json!({"rgb": d.s("rgb"), "cmyk": d.s("cmyk"), "intent": d.s("intent"), "bpc": d.b("bpc")}))?;
|
||||
@@ -7316,6 +7332,29 @@ mod tests {
|
||||
assert_eq!(app.session.doc().unwrap().doc, doc);
|
||||
}
|
||||
|
||||
/// A dialog opened without the menu's fields has no `useDefaults` field at all. Confirming it
|
||||
/// with no layer unticked must keep the file's defaults, not choose every layer explicitly:
|
||||
/// an implicit all-on choice is rejected for a PDF without layers, so the command's error
|
||||
/// tells the two apart.
|
||||
#[test]
|
||||
fn layer_options_confirm_without_the_menu_fields_keeps_file_defaults() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "pages": 1})).unwrap();
|
||||
let pdf = app.run("file.exportPdf", json!({})).unwrap()["base64"].clone();
|
||||
let placed = app.run("file.place", json!({"base64": pdf, "name": "plain.pdf", "x": 0, "y": 0, "width": 100})).unwrap();
|
||||
let id = designcraft_doc::ItemId(placed["id"].as_u64().unwrap());
|
||||
app.run("selection.set", json!({"ids": [placed["id"]]})).unwrap();
|
||||
app.ui.dialog = Some(Dialog::new("layerOptions", json!({})));
|
||||
confirm(&mut app).unwrap();
|
||||
assert!(!app.session.doc().unwrap().doc.item(id).unwrap().has_pdf_layer_override(), "an untouched dialog keeps the file defaults");
|
||||
// The menu's own dialog still asks for an explicit all-on choice when that is the state
|
||||
// it was opened in; a layerless PDF refuses it rather than accepting an empty override.
|
||||
app.ui.dialog = Some(Dialog::new("layerOptions", json!({"useDefaults": false, "layers": []})));
|
||||
let err = confirm(&mut app).unwrap_err();
|
||||
assert!(err.contains("could not be changed"), "{err}");
|
||||
assert!(!app.session.doc().unwrap().doc.item(id).unwrap().has_pdf_layer_override(), "a layerless PDF never takes an explicit choice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_style_settings_compare_with_the_chosen_parent() {
|
||||
let mut styles = designcraft_doc::Styles::default();
|
||||
@@ -8511,6 +8550,24 @@ mod tests {
|
||||
assert_eq!((opts(&app).columns_kind, opts(&app).column_width), (designcraft_doc::ColumnsKind::FixedNumber, 120.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_grid_increment_error_explains_the_positive_measure_rule() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let r = app.run("frame.create", json!({"rect": [36, 36, 336, 236], "content": "text", "text": "Hello"})).unwrap();
|
||||
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
|
||||
for increment in ["0", "-1 pt", "not a measure"] {
|
||||
let mut d = Dialog::new("textFrameOptions", json!({}));
|
||||
seed_text_frame_options(&app, &mut d);
|
||||
d.fields.insert("customGrid".into(), json!(true));
|
||||
d.fields.insert("gridIncrement".into(), json!(increment));
|
||||
app.ui.dialog = Some(d);
|
||||
let error = confirm(&mut app).unwrap_err().to_string();
|
||||
assert!(error.contains("the baseline grid increment must be a measure greater than zero"), "{increment}: {error}");
|
||||
assert!(app.session.doc().unwrap().doc.item(id).unwrap().text_frame().unwrap().options.baseline_grid.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_frame_options_set_a_custom_baseline_grid_relative_to() {
|
||||
use designcraft_doc::{FrameBaselineGrid, FrameGridRelative};
|
||||
|
||||
@@ -32,7 +32,6 @@ mod shortcut_import;
|
||||
pub mod story_editor;
|
||||
pub mod taskbar;
|
||||
pub mod theme;
|
||||
pub mod title_bar;
|
||||
pub mod toolbar;
|
||||
pub mod widgets;
|
||||
|
||||
|
||||
@@ -1645,7 +1645,10 @@ fn run_ui_inner(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value
|
||||
Ok(v) => v,
|
||||
Err(e) => return Some(Err(e.to_string())),
|
||||
};
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("layerOptions", json!({"layers": layers})));
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new(
|
||||
"layerOptions",
|
||||
json!({"useDefaults": !layers.as_array().and_then(|a| a.first()).and_then(|l| l["overridden"].as_bool()).unwrap_or(false), "layers": layers}),
|
||||
));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.rotateSpread" => {
|
||||
|
||||
@@ -3214,6 +3214,7 @@ mod tests {
|
||||
}),
|
||||
);
|
||||
doc.item_mut(id).unwrap().content = Content::Graphic(Graphic {
|
||||
layer_options: None,
|
||||
asset: aid,
|
||||
size: (72.0, 72.0),
|
||||
xf: designcraft_geom::Affine::IDENTITY,
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
//! The application bar as the window's title bar (macOS): an empty part of it moves the window
|
||||
//! when dragged and follows the system's title-bar double-click setting.
|
||||
//!
|
||||
//! The window has a full-size content view, so AppKit hands every click in the title-bar band to
|
||||
//! the app (that is how the bar's own controls work) and does not zoom the window. It does move
|
||||
//! the window when a drag starts anywhere in the band, on the bar's controls too, unless the
|
||||
//! window is not movable. [`window_movable`] tells the macOS shell when the pointer is on a
|
||||
//! control, so it can switch that off before the press.
|
||||
|
||||
use std::sync::{Once, OnceLock};
|
||||
|
||||
/// System Settings › Desktop & Dock › "Double-click a window's title bar to".
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DoubleClick {
|
||||
Zoom,
|
||||
Minimize,
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// The value of the global default `AppleActionOnDoubleClick`, as `defaults read` prints it.
|
||||
/// Anything other than "Minimize" or "None" zooms, the macOS default ("Maximize"; macOS 15 adds
|
||||
/// "Fill", which zooming comes closest to).
|
||||
pub fn parse(defaults_output: &str) -> DoubleClick {
|
||||
match defaults_output.trim() {
|
||||
"Minimize" => DoubleClick::Minimize,
|
||||
"None" => DoubleClick::Nothing,
|
||||
_ => DoubleClick::Zoom,
|
||||
}
|
||||
}
|
||||
|
||||
static ACTION: OnceLock<DoubleClick> = OnceLock::new();
|
||||
static READ: Once = Once::new();
|
||||
|
||||
/// Starts reading the system setting on a background thread, once per run.
|
||||
fn prefetch() {
|
||||
READ.call_once(|| {
|
||||
#[cfg(all(target_os = "macos", not(test)))]
|
||||
{
|
||||
// No thread, no setting: the bar zooms.
|
||||
let _ = std::thread::Builder::new().name("title-bar-setting".into()).spawn(|| {
|
||||
let action = match std::process::Command::new("/usr/bin/defaults").args(["read", "-g", "AppleActionOnDoubleClick"]).output() {
|
||||
Ok(out) if out.status.success() => parse(&String::from_utf8_lossy(&out.stdout)),
|
||||
// Absent (never changed) or unreadable: the default.
|
||||
_ => DoubleClick::Zoom,
|
||||
};
|
||||
let _ = ACTION.set(action);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The system setting; zoom until it has been read.
|
||||
pub fn action() -> DoubleClick {
|
||||
ACTION.get().copied().unwrap_or(DoubleClick::Zoom)
|
||||
}
|
||||
|
||||
/// What a double-click on the empty title bar asks of the window.
|
||||
pub fn double_click_command(action: DoubleClick, maximized: bool) -> Option<egui::ViewportCommand> {
|
||||
match action {
|
||||
DoubleClick::Zoom => Some(egui::ViewportCommand::Maximized(!maximized)),
|
||||
DoubleClick::Minimize => Some(egui::ViewportCommand::Minimized(true)),
|
||||
DoubleClick::Nothing => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes the empty part of `rect` act as a title bar. Call it before drawing the bar's controls:
|
||||
/// they sit on top and take their own clicks.
|
||||
///
|
||||
/// The area senses clicks only. A drag-sensing background would also receive drags that start
|
||||
/// on a click-only control (egui gives clicks and drags to different widgets), so a drag is
|
||||
/// detected here as a press on the empty area that has stopped being a click.
|
||||
pub fn empty_area(ui: &mut egui::Ui, rect: egui::Rect) {
|
||||
prefetch();
|
||||
let resp = ui.interact(rect, ui.id().with("title_bar_empty"), egui::Sense::click());
|
||||
// A control in this layer covers the empty area under the pointer. Another layer on top (a
|
||||
// modal's backdrop, a tooltip) leaves the window movable, as AppKit's own title bar would be.
|
||||
let on_control =
|
||||
ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id())) && !resp.hovered();
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
ui.data_mut(|d| d.insert_temp(movable_id(), (pass, !on_control)));
|
||||
if resp.double_clicked() {
|
||||
let maximized = ui.input(|i| i.viewport().maximized).unwrap_or(false);
|
||||
if let Some(cmd) = double_click_command(action(), maximized) {
|
||||
ui.ctx().send_viewport_cmd(cmd);
|
||||
}
|
||||
}
|
||||
// egui forgets which widget a press landed on once it can no longer be a click, so the press
|
||||
// is remembered (by its start time) while it still can.
|
||||
let armed = resp.id.with("drag_press");
|
||||
let press = ui.input(|i| i.pointer.press_start_time());
|
||||
if resp.is_pointer_button_down_on() {
|
||||
ui.data_mut(|d| d.insert_temp(armed, press));
|
||||
}
|
||||
if press.is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()) && ui.data(|d| d.get_temp::<Option<f64>>(armed)) == Some(press) {
|
||||
// Once per press: AppKit's drag loop takes the mouse-up, so egui may still see the button
|
||||
// down afterwards.
|
||||
ui.data_mut(|d| d.remove::<Option<f64>>(armed));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
}
|
||||
|
||||
fn movable_id() -> egui::Id {
|
||||
egui::Id::new("title_bar_window_movable")
|
||||
}
|
||||
|
||||
/// Whether AppKit may move the window when a drag starts in the title-bar band: false while the
|
||||
/// pointer is on one of the bar's controls, so pressing and dragging it does not move the window.
|
||||
/// True when neither this pass nor the one before drew a title bar.
|
||||
pub fn window_movable(ctx: &egui::Context) -> bool {
|
||||
match ctx.data(|d| d.get_temp::<(u64, bool)>(movable_id())) {
|
||||
Some((pass, movable)) if ctx.cumulative_pass_nr().saturating_sub(pass) <= 1 => movable,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_the_defaults_output() {
|
||||
assert_eq!(parse("Maximize\n"), DoubleClick::Zoom);
|
||||
assert_eq!(parse("Minimize\n"), DoubleClick::Minimize);
|
||||
assert_eq!(parse("None\n"), DoubleClick::Nothing);
|
||||
for junk in ["", "Fill", "minimize", "1", "The domain/default pair of (kCFPreferencesAnyApplication, X) does not exist"] {
|
||||
assert_eq!(parse(junk), DoubleClick::Zoom, "{junk:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_toggles_and_minimize_minimizes() {
|
||||
use egui::ViewportCommand::{Maximized, Minimized};
|
||||
assert_eq!(double_click_command(DoubleClick::Zoom, false), Some(Maximized(true)));
|
||||
assert_eq!(double_click_command(DoubleClick::Zoom, true), Some(Maximized(false)));
|
||||
assert_eq!(double_click_command(DoubleClick::Minimize, false), Some(Minimized(true)));
|
||||
assert_eq!(double_click_command(DoubleClick::Nothing, true), None);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -1,6 +1,6 @@
|
||||
# Where DesignCraft falls short of InDesign
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** minor (gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (placed graphic layer-option preservation and explicit rendering warnings; gap 16: Convert Variable to Text; gap 28 added: placed-PDF font repair remains after visible unembedded font detection, #909; gap 26 shrunk: autoflow fills the following pages before it adds pages at the end; gap 6 shrunk: Type › Reload Fonts reads fonts installed, edited or removed while running (#328); gap 3: Package links are relative, #340; gap 27 closed: the loaded text cursor threads into a frame inside a group (#1075); .indd and .indt open through indd-utils; gap 0 shrunk: text threads by hand; threading gaps added; Optical kerning from outlines (#181); gap 6: macOS lists fonts that font services and managers activate; shared print spooler; image clipping paths from IDML; Tabs panel listed as present (#343); book chapter update safety; gap 16: Based on Parent leftovers; bounded control click/drag input; swatches on placed greyscale and bitmap images) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every known shortfall against InDesign 2026, one entry each, ranked by how much it stops an
|
||||
InDesign professional from doing real work. This is the work list: agents pick from the top.
|
||||
@@ -205,9 +205,18 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format ·
|
||||
- **Estimate:** 20–35 h.
|
||||
|
||||
### 19. Placed graphics depth (FF)
|
||||
- **Have:** per-placement IDML graphic-layer settings survive IDML/native round trips; native format 4 guards older readers. Unsupported layer visibility is reported by import, preflight and PDF export. See [graphic-layer-options.md](graphic-layer-options.md).
|
||||
- **Still missing:** DesignCraft does not apply retained IDML graphic-layer visibility to raster or PDF output; layer-comp choices and the layer-options UI are also missing. Metadata preservation does not establish visual fidelity. Placed-PDF layer choices are applied (see **Layer choices** below).
|
||||
- **Safety:** PDF visibility variants preserve catalog/configuration entries and nonzero-generation
|
||||
references, redefining the existing catalog rather than choosing an arbitrary object number.
|
||||
Encrypted, hybrid-reference and over-budget rewrites are rejected; no print-fidelity claim.
|
||||
- **Layer choices:** placed PDFs distinguish explicit all-on visibility from file defaults;
|
||||
choices survive native saves, raster/PDF output and embedded IDML export for the placements the
|
||||
export walks reach: document and parent spreads, groups, and objects anchored in a story. A
|
||||
placement anchored inside table-cell or footnote/endnote text is also prepared for PDF/A-2b.
|
||||
Embedded IDML applies variants inside table-cell and footnote text; endnote variants are prepared,
|
||||
but IDML endnote structures/artwork are not serialized yet. IDML layer variants are embedded
|
||||
copies; external-only export is rejected when choices would be lost.
|
||||
- **Missing:** EPS is shown through its preview only (no PostScript interpreter); PSD layer
|
||||
comps beyond visibility; the Clipping Path Options dialog (tracing into a clip that keeps the
|
||||
frame, Threshold/Tolerance/Inset Frame preview, picking an alpha channel or a path saved in a
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Placed graphic layer options
|
||||
|
||||
IDML `GraphicLayerOption` / `GraphicLayer` settings are retained on each
|
||||
`Graphic.layer_options`, not on the shared asset. Two placements of one linked
|
||||
file can therefore keep different layer choices while sharing image bytes.
|
||||
Scalar option/layer attributes, layer order and parent-child relationships, layer IDs/names, original/current
|
||||
visibility, link-update policy and view/export/print flags survive import,
|
||||
native save/reopen and IDML export. XML Self IDs are regenerated. Native-edited
|
||||
attribute maps cannot inject XML syntax, declare namespaces or override those Self IDs.
|
||||
Parent indices are optional in native files; missing entries retain the older
|
||||
flat representation. Invalid/cyclic parent indices, parents in closed subtrees and over-budget nesting
|
||||
are emitted as top-level layers, retaining their scalar metadata without
|
||||
unbounded XML recursion.
|
||||
|
||||
This is preservation, **not layer-aware rendering**. The current renderer and
|
||||
PDF exporter use the asset's composite/default appearance and do not apply
|
||||
these per-placement choices. Changed visibility and explicit view/export/print
|
||||
states produce an import warning, a persistent `unsupportedGraphicLayers`
|
||||
preflight issue, and a PDF export warning. Nested graphic cells, anchored
|
||||
objects and parent items remain detectable after native reopening, including
|
||||
when spread items also produce warnings. PDF warnings inspect the source
|
||||
document so effect rasterization and transparency flattening cannot hide them.
|
||||
|
||||
Native files containing these settings require format version 4. Earlier
|
||||
readers reject that version instead of loading and silently discarding the new
|
||||
field. Documents without layer settings retain their existing version 2/3
|
||||
requirements. Older graphics without `layerOptions` still deserialize with
|
||||
`None`, and their JSON representation is unchanged.
|
||||
|
||||
Version 4 is a single reader capability shared with explicit all-on placed-PDF
|
||||
layer choices (#1671): a version 4 reader must understand both, so the two
|
||||
changes ship in the same release. One function in the format crate,
|
||||
`required_version`, chooses the version for every save.
|
||||
|
||||
Synthetic regressions cover independent overrides on two placements sharing
|
||||
one asset, metadata round trips, native reader requirements, nested cells,
|
||||
nested layer hierarchy, preflight/PDF warnings after rasterization and flattening,
|
||||
and invalid native attribute names and parent indices. Private originals
|
||||
and artwork are not repository fixtures; any collection results are local
|
||||
metadata checks, not an independent reference-rendering or fidelity oracle.
|
||||
|
||||
Local collection check (2026-10-23): a five-document pilot passed before
|
||||
expansion to 205 previously converted documents. All 205 completed IDML →
|
||||
native save/reopen → IDML; all 1,113 layer records across 68 documents matched
|
||||
their input scalar attributes and order, ignoring regenerated Self IDs.
|
||||
Persistent preflight warnings appeared in the 57 documents with overrides.
|
||||
The private source files, layer names, document paths and per-file logs remain
|
||||
outside this repository. Original INDD files were not opened by this check.
|
||||
|
||||
Remaining work: apply visibility to PSD layers/PDF optional content, implement
|
||||
layer-comp choices and a layer-options UI, and validate appearance against
|
||||
verified same-save reference PDFs. Gap 19 remains open.
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Shared PDF backend
|
||||
|
||||
DesignCraft pins `craft-pdf`, patched Krilla 0.8.2 and the six Hayro reader crates (with
|
||||
`hayro-cmap` and `hayro-postscript`) to craft-libs revision `d24e4bcce3b420f25b7bca2b2d786cbcc8631f96`. `craft-ui`,
|
||||
`hayro-cmap` and `hayro-postscript`) to craft-libs revision `5bbe9f6254de3bb9f9707ae0740ab0db8170afd5`. `craft-ui`,
|
||||
`craft-print` and the other craft-libs crates are pinned to the same revision. The reader migration is reviewed on top of the separate writer change.
|
||||
|
||||
## Writing
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Placed PDF layer visibility
|
||||
|
||||
Object Layer Options distinguishes a PDF's own default configuration from an explicit visibility choice, including showing every layer. Use File Defaults resets that choice. Only placed PDFs in a mixed selection are changed.
|
||||
|
||||
The engine command `object.layerOptions` accepts `hidden` names and optional `useDefaults`. Existing calls with `hidden: []` retain their reset behavior; pass `useDefaults: false` to explicitly show all layers. `useDefaults: true` resets defaults and cannot be combined with hidden names. Every explicit choice, including all-on (`useDefaults: false` with an empty `hidden` list), is checked with one actual visibility rewrite per distinct source before any target is edited or an undo step is recorded: a PDF can list readable layers yet refuse the rewrite (for example an over-budget cross-reference history), and PDFs without readable optional-content layers cannot be rewritten at all. Resetting to file defaults needs no rewrite and is always allowed. A stored choice whose source can no longer be rewritten still fails closed: the canvas does not draw the unmodified source and PDF export reports an error. With no explicit ID, a query selects the first placed PDF anywhere in the selection; an explicit non-PDF ID remains an error. Queries include `overridden` separately from each layer's `visible` and `default` states.
|
||||
|
||||
Native saves use format 4 only for an explicit all-on override, on document or parent spreads, in groups or anchored: older readers would otherwise silently render file defaults. Format 4 is one reader capability shared with retained placed-graphic layer options (#1647, see [graphic-layer-options.md](graphic-layer-options.md)), so a version 4 reader understands both. Existing nonempty hidden lists retain their older semantics and format requirements. Raster and PDF export honor the choice; PDF export keeps its existing warned rasterization behavior. Embedded IDML export stores a separate PDF variant per placement and leaves original linked bytes untouched. The export walks cover the placements a page shows through its spreads and through its stories: parent-page and anchored ones too, so a page that inherits the placement does not quietly fall back to the file's defaults. PDF/A-2b preparation also follows stories nested in table cells, footnotes and endnotes, applying both hidden-layer and explicit all-on choices to an export-only raster copy. Embedded IDML applies those choices to anchors in table-cell and footnote text too, with independent variants for placements sharing source bytes. If placements share an item id, they must agree on source asset and layer choice or the export fails rather than embedding the wrong variant. Identical duplicated anchors can still export; the shared-id gap in frame duplication remains separate. Variants inside endnote stories are prepared, but IDML does not serialize endnote structures or their artwork; that remains unsupported (gap 19). External-only IDML export rejects active PDF layer choices because the original external file would lose the choice. A package's IDML copy links the placed files, so it cannot carry a choice either; the package report says so for a placement the export walk reaches, and the packaged DesignCraft document keeps it.
|
||||
|
||||
This fixes visibility-state handling, not full InDesign layer/print parity. It does not implement editing the contents of placed PDFs or native INDD placement. Catalog/reference safety fixes landed in #1670.
|
||||
+1
-1
@@ -74,7 +74,7 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
|
||||
/// External crates that constitute a UI toolkit / windowing dependency.
|
||||
/// Entries ending in `*` are prefixes.
|
||||
pub const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
pub const UI_CRATES: &[&str] = &["craft-ui", "craft-window", "egui", "eframe", "winit", "egui_kittest", "rfd", "bevy*"];
|
||||
|
||||
/// First layer allowed to use UI crates.
|
||||
pub const UI_MIN_LAYER: u8 = 6;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const REV: &str = "d24e4bcce3b420f25b7bca2b2d786cbcc8631f96";
|
||||
const REV: &str = "5bbe9f6254de3bb9f9707ae0740ab0db8170afd5";
|
||||
const PACKAGES: &[(&str, &str)] = &[
|
||||
("craft-pdf", "0.1.0"),
|
||||
("krilla", "0.8.2"),
|
||||
|
||||
Reference in New Issue
Block a user