diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 000000000..2c4b8cb1a
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,30 @@
+version: 2
+updates:
+ # Maintain Rust / Cargo dependencies
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ # Consolidate updates into a single PR rather than one PR per crate
+ groups:
+ cargo-dependencies:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "chore(deps)"
+
+ # Maintain GitHub Actions workflows
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ time: "06:00"
+ timezone: "America/New_York"
+ groups:
+ actions:
+ patterns:
+ - "*"
+ commit-message:
+ prefix: "ci(deps)"
diff --git a/Cargo.lock b/Cargo.lock
index a2a1febc3..3db056251 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -824,6 +824,11 @@ dependencies = [
"rayon",
]
+[[package]]
+name = "craft-docpath"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
+
[[package]]
name = "craft-pdf"
version = "0.1.0"
@@ -924,7 +929,7 @@ dependencies = [
[[package]]
name = "designcraft"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -946,7 +951,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -962,7 +967,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -974,7 +979,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -994,7 +999,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -1006,10 +1011,11 @@ dependencies = [
[[package]]
name = "designcraft-engine"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"caseless",
"craft-atomic",
+ "craft-docpath",
"craft-print",
"designcraft-color",
"designcraft-compose",
@@ -1044,7 +1050,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1053,7 +1059,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1066,7 +1072,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1078,7 +1084,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1091,7 +1097,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1106,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1121,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1134,7 +1140,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1156,7 +1162,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1173,7 +1179,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1191,7 +1197,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1200,7 +1206,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1212,7 +1218,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1239,7 +1245,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5790,7 +5796,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.8.0"
+version = "0.9.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index a9aeccd3e..ca5d0baaf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/designcraft", "apps/designcraft-cli", "xtask"]
[workspace.package]
-version = "0.8.0"
+version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -73,6 +73,8 @@ zip = { version = "3", default-features = false, features = ["deflate"] }
# (MIT OR Apache-2.0, no dependencies, builds for wasm).
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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# Atomic file replacement for saves and exports (craft-libs).
craft-atomic = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e9546e191dd62f2bc1d61eca45dbdc2f1b3c1d8e" }
# File › Print through the system print spooler (craft-libs).
diff --git a/README.md b/README.md
index 613f5102b..880433e33 100644
--- a/README.md
+++ b/README.md
@@ -11,11 +11,11 @@
DesignCraft
- Page layout and publishing; an open-source, clean-room reimplementation of Adobe InDesign, rebuilt in pure Rust.
+ Page layout and publishing; an open-source, modern desktop publishing and page layout studio, built in pure Rust.
- A fast, open-source, clean-room take on the Adobe InDesign workflow. It runs natively on macOS,
+ A fast, open-source desktop publishing and page layout workflow. It runs natively on macOS,
Windows and Linux, and in the browser via WebAssembly.
By the ArtCraft team.
@@ -78,9 +78,9 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
## Why DesignCraft
-- **Familiar.** InDesign's layout, tools, menus, panels and shortcuts: spreads and parent pages,
- frames and threaded stories, the Control panel, paragraph and character styles, swatches, text
- wrap and more. If you know InDesign, you already know how to use it.
+- **Familiar.** A familiar layout, tools, menus, panels and shortcuts: spreads and parent pages,
+ master pages, text frames, threaded text, paragraph and character styles, tables, text wrap and
+ more. Everything is right where you expect it.
- **Beautiful type.** A Knuth–Plass paragraph composer (plus single-line), dictionary hyphenation
(the public-domain Moby word list plus our own trained patterns), word, letter and glyph-scaling
justification, keeps, optical margin alignment, columns, baseline grid, tabs, rules and shading.
@@ -88,7 +88,7 @@ by its own renderer. Try it yourself with `File → New → Sample Document`, or
- **Fast.** Multithreaded SIMD rendering (vello_cpu), copy-on-write documents with O(1) undo
snapshots, and cached composition.
- **Open.** A documented native format, IDML import and export, INDD import (through
- [indd-utils](https://github.com/storytold/indd-utils)), editable PowerPoint export, and PNG, PDF
+ [indd-utils](https://github.com/storytold/indd-utils)), editable .pptx export, and PNG, PDF
(PDF/X-4, PDF/A-2b) and EPUB export.
No subscription, no licence server, no telemetry.
@@ -247,10 +247,10 @@ egui frontend is a separate crate, so the UI can be swapped without touching the
| L3 | `render` (vello_cpu) |
| L4 | `tools` (pointer events → commands + overlays) |
| L5 | `engine` (session, history, command registry) |
-| L6 | `ui-egui` (InDesign-style UI, control channel) |
+| L6 | `ui-egui` (publishing UI, control channel) |
| L7 | `apps/designcraft`, `apps/designcraft-cli`, `apps/designcraft-web` |
-- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); how close to InDesign: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md)
+- **Status and milestones:** [ROADMAP.md](ROADMAP.md) (stage: pre-alpha); feature assessment: [`docs/target-app-parity.md`](docs/target-app-parity.md); known gaps: [`docs/gaps.md`](docs/gaps.md)
- **Contributor and agent rules** (clean-room, asset policy, quality gates): [`AGENTS.md`](AGENTS.md)
- **Bundled assets:** every one is listed with its licence in [`ASSETS.md`](ASSETS.md)
- **App icon and colour:** a calico cat in a polka-dot scarf on DesignCraft green `#7bb51c`; see [`assets/app-icon/`](assets/app-icon/README.md)
@@ -373,8 +373,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and DesignCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
-Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DesignCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.
-
diff --git a/ROADMAP.md b/ROADMAP.md
index cb633c50f..e939f29bc 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -186,7 +186,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
## How far from full parity (estimate, 2026-10-04)
**Breadth: ~99% weighted** (P0 core 99%, P1 100%, P2 97%) over the 275 features of the InDesign catalogue, scored
-row by row in [docs/parity.md](docs/parity.md) (`cargo xtask parity` recomputes it). Many features scored done still
+row by row in [docs/parity-checklist.md](docs/parity-checklist.md) (`cargo xtask parity` recomputes it). Many features scored done still
lack some of InDesign's options or dialog details, so **overall parity including depth is about 83%**.
**Remaining work: about 170 wall-clock hours of a single Claude Opus 5.5 agent** (±30%), or roughly 60–85 hours with
diff --git a/apps/designcraft-cli/src/main.rs b/apps/designcraft-cli/src/main.rs
index 20e8509e7..71e05a480 100644
--- a/apps/designcraft-cli/src/main.rs
+++ b/apps/designcraft-cli/src/main.rs
@@ -62,6 +62,7 @@ References to earlier results (in string parameters):
"$$" a literal `$` anywhere: "$$1" passes `$1` (a GREP group), "$${0.id}" passes `${0.id}`.
Text parameters: "\n" starts a new paragraph, "\u2028" is a forced line break, "\t" a tab.
+"\u000b" (Word's line break, Chr(11)) is read as a forced line break too.
Most commands ignore parameters they do not know instead of rejecting them: check names
with `designcraft-cli describe COMMAND`.
@@ -330,7 +331,10 @@ fn run(args: &[String]) -> Result<(), String> {
}
}
"--page" => page = val()?.parse().map_err(|_| "bad --page")?,
- "--scale" => scale = val()?.parse().map_err(|_| "bad --scale")?,
+ "--scale" => {
+ // 0, a negative value and NaN made a 1×1 image; infinity, "no such page".
+ scale = val()?.parse().ok().filter(|s: &f64| s.is_finite() && *s > 0.0).ok_or("bad --scale: expected a positive number")?;
+ }
"--pdf-options" => {
// The export adds its path to them.
pdf_opts = match serde_json::from_str(&val()?).map_err(|e| format!("--pdf-options: {e}"))? {
diff --git a/apps/designcraft-cli/tests/cli.rs b/apps/designcraft-cli/tests/cli.rs
index 0cb9be859..173a605f4 100644
--- a/apps/designcraft-cli/tests/cli.rs
+++ b/apps/designcraft-cli/tests/cli.rs
@@ -252,6 +252,22 @@ fn run_export_options_come_before_their_export() {
let _ = std::fs::remove_dir_all(&dir);
}
+/// `--scale 0`, a negative value or `nan` wrote a 1×1 PNG and exited 0; `inf` said "no such page".
+#[test]
+fn run_scale_that_is_not_finite_and_positive_is_an_error() {
+ let dir = std::env::temp_dir().join(format!("dc-cli-scale-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let png = dir.join("a.png");
+ for scale in ["nan", "0", "-2", "inf", "-inf"] {
+ let out = cli().args(["run", "--sample", "--scale", scale, "--export", png.to_str().unwrap()]).output().unwrap();
+ let err = String::from_utf8_lossy(&out.stderr);
+ assert!(!out.status.success(), "--scale {scale}: {err}");
+ assert!(err.contains("bad --scale"), "--scale {scale}: {err}");
+ }
+ assert!(!png.exists(), "nothing is exported when the scale is wrong");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
/// `--pdf-options` must be a JSON object; any other JSON value used to panic when the PDF path was set on it.
#[test]
fn pdf_options_that_are_not_an_object_are_an_error_not_a_panic() {
diff --git a/crates/color/src/ase.rs b/crates/color/src/ase.rs
index c20c249ec..6cfd72034 100644
--- a/crates/color/src/ase.rs
+++ b/crates/color/src/ase.rs
@@ -98,9 +98,11 @@ pub fn read(bytes: &[u8]) -> Result, AseError> {
}
}
let color = match &model[..] {
- b"CMYK" => Color::Cmyk { c: b.f32()?, m: b.f32()?, y: b.f32()?, k: b.f32()? },
- b"RGB " => Color::Rgb { r: b.f32()?, g: b.f32()?, b: b.f32()? },
- b"Gray" => Color::Gray { k: 1.0 - b.f32()? },
+ b"CMYK" => {
+ Color::Cmyk { c: b.f32()?.clamp(0.0, 1.0), m: b.f32()?.clamp(0.0, 1.0), y: b.f32()?.clamp(0.0, 1.0), k: b.f32()?.clamp(0.0, 1.0) }
+ }
+ b"RGB " => Color::Rgb { r: b.f32()?.clamp(0.0, 1.0), g: b.f32()?.clamp(0.0, 1.0), b: b.f32()?.clamp(0.0, 1.0) },
+ b"Gray" => Color::Gray { k: (1.0 - b.f32()?).clamp(0.0, 1.0) },
b"LAB " => {
let (l, a, bb) = (b.f32()?, b.f32()?, b.f32()?);
let [r, g, bl] = cms::lab::lab_to_srgb(cms::Lab { l: l * 100.0, a, b: bb });
@@ -231,6 +233,36 @@ mod tests {
assert_eq!(read(&write(&sw[1..])).map(|w| w.len()), Ok(MAX_COLORS), "up to the limit");
}
+ #[test]
+ fn out_of_range_components_are_clamped() {
+ let mut f = Vec::new();
+ f.extend(b"ASEF\x00\x01\x00\x00");
+ f.extend(3u32.to_be_bytes());
+ let cases: [(&[u8; 4], &[f32]); 3] = [(b"RGB ", &[5.0, -2.0, 0.5]), (b"CMYK", &[2.0, -1.0, 0.5, 9.0]), (b"Gray", &[-1.0])];
+ for (model, vals) in cases {
+ let mut c = Vec::new();
+ c.extend(2u16.to_be_bytes());
+ c.extend([0, b'X', 0, 0]);
+ c.extend(model);
+ for v in vals {
+ c.extend(v.to_bits().to_be_bytes());
+ }
+ c.extend(2u16.to_be_bytes());
+ f.extend(COLOR.to_be_bytes());
+ f.extend((c.len() as u32).to_be_bytes());
+ f.extend(&c);
+ }
+ let colors: Vec = read(&f)
+ .unwrap()
+ .into_iter()
+ .filter_map(|s| match s.value {
+ SwatchValue::Color { color, .. } => Some(color),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(colors, [Color::Rgb { r: 1.0, g: 0.0, b: 0.5 }, Color::Cmyk { c: 1.0, m: 0.0, y: 0.5, k: 1.0 }, Color::Gray { k: 1.0 },]);
+ }
+
#[test]
fn non_finite_components_are_skipped() {
let sw = |name: &str, color: Color| Swatch {
diff --git a/crates/compose/src/breaker.rs b/crates/compose/src/breaker.rs
index 61c69e304..0dff34702 100644
--- a/crates/compose/src/breaker.rs
+++ b/crates/compose/src/breaker.rs
@@ -563,7 +563,11 @@ fn overlong_words(items: &[Item], ig: &[usize], glyphs: &[Glyph], measure: f64)
let marks = out.get_or_insert_with(|| vec![false; glyphs.len()]);
for i in a..b {
let (Some(g), Some(next)) = (glyphs.get(i), glyphs.get(i + 1)) else { continue };
+ // Glyphs of one source character (an automatic page number's digits, a
+ // multi-glyph cluster) stay together, as InDesign keeps them.
+ let one_char = next.byte == g.byte && (next.len == 0 || next.len == g.len);
let ok = !g.no_break
+ && !one_char
&& g.break_after != Some(false)
&& next.ch != SOFT_HYPHEN
&& !is_forced(next.ch)
diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index d736de9c9..4a2c66397 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -1716,7 +1716,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
drop_box = Some(DropCapBox {
end: dc.end,
baseline: baseline + dc.drop,
- rect: Rect::new(at, baseline - asc, at + dc.width, baseline + dc.drop + desc),
+ // From its line's top, or its own top when it rises above that line.
+ rect: Rect::new(at, (baseline - asc).min(baseline + dc.drop - dc.cap), at + dc.width, baseline + dc.drop + desc),
});
}
ruby::annotate(db, &mut styles_tab, &story.text, &mut placed, doc.settings.glyph_fallback, f.vertical);
@@ -3764,11 +3765,15 @@ struct DropCap {
/// Its baseline below the first line's: `lines − 1` leadings of the text after it (grid steps
/// on a baseline grid), wherever line `lines` is set.
drop: f64,
+ /// Its cap height as set (how far its top sits above its baseline).
+ cap: f64,
}
/// Take the drop cap (the glyphs before byte `end`) off the front of a paragraph's glyphs and scale
/// it so its cap height reaches from the first line's cap height down to line `lines`' baseline,
-/// measured in the leading and cap height of the text after it. A one-line drop cap keeps its own
+/// measured in the leading and cap height of the text after it: the size of a cap set at that text's
+/// size. As in InDesign, a cap set larger (or smaller) is scaled by its size over that text's,
+/// keeping its baseline, so it rises above the first line (or falls short of it). A one-line drop cap keeps its own
/// size when that is smaller. On a baseline grid of increment `grid`, lines are that many grid
/// steps apart. With `align_left` its ink starts at the indent (see [`DropCap::lsb`]). Tabs inside
/// it advance to the stops of `tabs` (stops and the start-side indent; in right-to-left paragraphs
@@ -3796,14 +3801,19 @@ fn split_drop_cap(
let mut after: Vec = glyphs.drain(..spaces).collect();
let end = after.last().map_or(end, |g| g.byte.saturating_add(g.len).max(end));
let body = glyphs.iter().find(|g| g.len > 0 && g.cap > 0.0);
- let (lead, cap) = body.map_or((base_leading, base_cap), |g| (g.leading, g.cap));
+ let (lead, cap, body_size) = body.map_or((base_leading, base_cap, None), |g| (g.leading, g.cap, Some(g.size)));
let lead = match grid.map(|inc| (lead / inc - 1e-6).ceil().max(1.0) * inc) {
Some(pitch) if pitch.is_finite() && pitch > 0.0 => pitch,
_ => lead,
};
let drop = (lines - 1) as f64 * lead;
- let own = dc.iter().map(|g| g.cap).fold(0.0, f64::max);
- let k = if own > 0.0 { (drop + cap) / own } else { 1.0 };
+ // The cap's tallest glyph sets its height; its point size over the body's scales the fit.
+ let (own, own_size) = dc.iter().fold((0.0, 0.0), |(c, s), g| if g.cap > c { (g.cap, g.size) } else { (c, s) });
+ let ratio = match body_size {
+ Some(b) if b > 0.0 && own_size > 0.0 && (own_size / b).is_finite() => own_size / b,
+ _ => 1.0,
+ };
+ let k = if own > 0.0 { (drop + cap) / own * ratio } else { 1.0 };
let k = if lines == 1 { k.min(1.0) } else { k };
let k = if k.is_finite() && k > 0.0 { k.min(1000.0) } else { 1.0 };
for g in dc.iter_mut().chain(after.iter_mut()) {
@@ -3844,7 +3854,8 @@ fn split_drop_cap(
}
let width = dc.iter().map(|g| g.adv).sum();
dc.append(&mut after);
- Some(DropCap { glyphs: dc, end, lines, width, drop, lsb })
+ let cap = dc.iter().map(|g| g.cap).fold(0.0, f64::max);
+ Some(DropCap { glyphs: dc, end, lines, width, drop, lsb, cap })
}
impl DropCap {
diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs
index d70ff074a..940e0aa77 100644
--- a/crates/compose/src/table.rs
+++ b/crates/compose/src/table.rs
@@ -125,7 +125,7 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, left
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
- opts: TextFrameOptions::default(),
+ opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() },
vertical: false,
exclusions: vec![],
outline: None,
@@ -835,6 +835,23 @@ mod height_tests {
AnchoredObject::new(designcraft_doc::Item::new(ItemId(0), LayerId(0), designcraft_doc::Shape::Rectangle, path), position)
}
+ #[test]
+ fn cell_first_baseline_option_sets_the_first_line_and_the_row_height() {
+ // Cell Options › Text › First Baseline: Cap Height sets the first line by the cap height,
+ // not the typographic ascent, so a row sized for one line of caps keeps its height.
+ let ascent = text_cell("Hd", 4.0);
+ let mut cap = ascent.clone();
+ cap.first_baseline = designcraft_doc::FirstBaseline::CapHeight;
+ let (a, c) = (measure(&ascent, 92.0), measure(&cap, 92.0));
+ let (la, lc) = (&a.text.frames[0].lines[0], &c.text.frames[0].lines[0]);
+ assert!(lc.baseline < la.baseline, "cap height {} should sit above ascent {}", lc.baseline, la.baseline);
+ assert!(c.content_h < a.content_h);
+ let mut fixed = ascent.clone();
+ fixed.first_baseline = designcraft_doc::FirstBaseline::Fixed;
+ fixed.first_baseline_min = 6.0;
+ assert!((measure(&fixed, 92.0).text.frames[0].lines[0].baseline - 6.0).abs() < 1e-6);
+ }
+
#[test]
fn cell_bottom_inset_contains_descenders_without_growing_every_row() {
let cell = text_cell("gyp", 8.5);
diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs
index 9d8f6be70..ed4c7c476 100644
--- a/crates/compose/src/tests.rs
+++ b/crates/compose/src/tests.rs
@@ -474,6 +474,23 @@ fn jump_object_keeps_grid_aligned_lines_on_the_grid() {
assert!((l.baseline - want).abs() < 1e-6, "{} vs {want}", l.baseline);
}
+/// An automatic page number wider than its frame stays on one line and hangs out, as in InDesign
+/// ("136" in a frame made for two digits); breaking it put "13" over "4".
+#[test]
+fn page_number_wider_than_its_frame_stays_whole() {
+ let text = designcraft_doc::story::PAGE_NUMBER.to_string();
+ let (d, sid, _) = doc_with(&text, Rect::new(0.0, 0.0, 10.0, 40.0), ParaAttrs::default());
+ let mut specs = frame_specs(&d, sid);
+ specs[0].page_name = Some("136".into());
+ let cs = compose(&d, d.story(sid).unwrap(), &specs, &ComposeOptions::default());
+ let lines = all_lines(&cs);
+ assert_eq!(lines.len(), 1, "{lines:?}");
+ assert!(!cs.is_overset());
+ // A word of ordinary letters that doesn't fit still breaks where it must.
+ let (d, sid, _) = doc_with("Wordwiderthanframe", Rect::new(0.0, 0.0, 30.0, 200.0), ParaAttrs::default());
+ assert!(all_lines(&compose_story(&d, sid, &ComposeOptions::default())).len() > 1);
+}
+
#[test]
fn hostile_wrap_geometry_terminates() {
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &[]);
@@ -3646,6 +3663,37 @@ fn right_to_left_drop_cap_sets_its_tabs_from_the_right_edge() {
assert!(right <= 260.0 + 1e-6 && right > 255.0, "text ends at {right}");
}
+/// The cap's point size scales it, as in InDesign: at the body's size (10 pt) it fills its lines
+/// exactly; set larger or smaller it is scaled by its size over the body's, keeping its baseline,
+/// so it rises above the first line (or falls short of it). The body's lines don't move, and the
+/// cap's box (highlight, caret) reaches the top of a cap that rises above its line.
+#[test]
+fn drop_cap_scales_with_its_point_size() {
+ let set = |size: f64| {
+ let (mut d, sid, _) = drop_doc(&[LOREM; 2].join(" "), drop_cap(3, 1));
+ d.story_mut(sid).unwrap().format_chars(0..1, |f| f.over.size = Some(size));
+ let cs = compose_story(&d, sid, &ComposeOptions::default());
+ all_lines(&cs).into_iter().cloned().collect::>()
+ };
+ let base = set(10.0);
+ let (b0, b2) = (base[0].baseline, base[2].baseline);
+ let body = base[0].glyphs.iter().find(|g| g.byte == 1).unwrap();
+ let fitted = cap_of(&base[0].glyphs[0]);
+ assert!((b2 - fitted - (b0 - cap_of(body))).abs() < 0.01, "at the body's size the cap fills its lines");
+ for (size, scale) in [(15.0, 1.5), (7.5, 0.75)] {
+ let l = set(size);
+ let dc = &l[0].glyphs[0];
+ for (a, b) in l.iter().zip(&base) {
+ assert!((a.baseline - b.baseline).abs() < 1e-6, "{size} pt: the lines stay where they were");
+ }
+ assert!((l[0].baseline + dc.y - b2).abs() < 1e-6, "{size} pt: the cap sits on line 3's baseline");
+ assert!((cap_of(dc) - fitted * scale).abs() < 0.01, "{size} pt: cap height {} vs {}", cap_of(dc), fitted * scale);
+ let top = (l[0].baseline - l[0].ascent).min(b2 - cap_of(dc));
+ assert!((l[0].drop_cap.unwrap().rect.y0 - top).abs() < 1e-6, "{size} pt: the box starts at the line's or the cap's top");
+ }
+ assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line");
+}
+
/// A rule in Text Color takes the colour of the paragraph's text: the first character's for the
/// rule above, the last character's for the rule below. A swatch colour is used as is.
#[test]
diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs
index db61e5869..f8ae0ec19 100644
--- a/crates/doc/src/build.rs
+++ b/crates/doc/src/build.rs
@@ -2,7 +2,7 @@
//! (insert/delete/move pages, repagination into spreads, parents).
use std::collections::BTreeMap;
-use std::sync::Arc;
+use std::{collections::HashSet, sync::Arc};
use designcraft_geom::{Affine, Unit, units};
use serde::{Deserialize, Serialize};
@@ -258,6 +258,53 @@ impl Document {
id
}
+ /// Delete a parent spread and unapply it wherever it is used. Local overrides survive as
+ /// ordinary items; the deleted parent's own items leave their text threads normally.
+ pub fn delete_parent(&mut self, id: SpreadId) -> Result<()> {
+ let index = self.parent_index(id).ok_or_else(|| DocError::Invalid(format!("no parent {id}")))?;
+ let parent = self.parents.get(index).ok_or_else(|| DocError::Invalid(format!("no parent {id}")))?;
+ let roots: Vec<_> = parent.items.iter().map(|item| item.id).collect();
+ let mut deleted = HashSet::new();
+ for item in &parent.items {
+ item.walk(&mut |item| {
+ deleted.insert(item.id);
+ });
+ }
+ for item in roots {
+ self.remove_item(item)?;
+ }
+ self.parents.remove(index);
+
+ let mut detached = Vec::new();
+ for spread in self.spreads.iter_mut().chain(self.parents.iter_mut()) {
+ let spread = Arc::make_mut(spread);
+ if let Some(parent) = &mut spread.parent
+ && parent.based_on == Some(id)
+ {
+ parent.based_on = None;
+ }
+ for page in &mut spread.pages {
+ if page.parent == Some(id) {
+ page.parent = None;
+ }
+ page.overridden.retain(|item| !deleted.contains(item));
+ }
+ for item in &spread.items {
+ item.walk(&mut |item| {
+ if item.overrides.is_some_and(|source| deleted.contains(&source)) {
+ detached.push(item.id);
+ }
+ });
+ }
+ }
+ for item in detached {
+ if let Some(item) = self.item_mut(item) {
+ item.overrides = None;
+ }
+ }
+ Ok(())
+ }
+
/// List the pages of each facing parent spread in binding order: left then right, or right
/// then left when bound right to left. Positions stay, so the parent items keep their pages.
pub fn order_parent_pages(&mut self) {
diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs
index 89465448e..862a1cda9 100644
--- a/crates/doc/src/page.rs
+++ b/crates/doc/src/page.rs
@@ -301,17 +301,27 @@ impl Spread {
pub fn bounds(&self) -> Rect {
self.pages.iter().map(Page::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO)
}
- /// The bleed guide: one rectangle around the spread. `bleed` is the document's top, bottom,
+ /// The bleed guide: one rectangle around the spread. None without pages or without any bleed.
+ pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option {
+ self.outset_rect(bleed)
+ }
+ /// The slug guide. Slug offsets are measured from the page edges, like the bleed, so a slug
+ /// narrower than the bleed sits inside it, as in InDesign. None without pages or without any
+ /// slug.
+ pub fn slug_rect(&self, slug: [f64; 4]) -> Option {
+ self.outset_rect(slug)
+ }
+ /// The spread's bounds grown by a document outset (the bleed or the slug): top, bottom,
/// inside, outside; the inside value applies only at an outer edge on the spine side (a lone
/// left page's right edge). The outer edges are the leftmost and rightmost pages, whichever
- /// binding ordered the list. None without pages or without any bleed.
- pub fn bleed_rect(&self, bleed: [f64; 4]) -> Option {
+ /// binding ordered the list.
+ pub fn outset_rect(&self, outset: [f64; 4]) -> Option {
let (l, r) = self.outer_page_indices()?;
let (first, last) = (self.pages.get(l)?, self.pages.get(r)?);
- if !bleed.iter().any(|v| *v > 0.0) {
+ if !outset.iter().any(|v| *v > 0.0) {
return None;
}
- let [top, bottom, inside, outside] = bleed;
+ let [top, bottom, inside, outside] = outset;
let l = if first.side == PageSide::Left { outside } else { inside };
let r = if matches!(last.side, PageSide::Right | PageSide::Single) { outside } else { inside };
let b = self.bounds();
@@ -464,6 +474,9 @@ mod tests {
let s = Spread { id: SpreadId(1), pages, items: vec![], parent: None, allow_shuffle: true };
assert_eq!(s.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 1228.0, 794.0)));
assert_eq!(s.bleed_rect([0.0; 4]), None);
+ // The slug is measured the same way, from the page edges.
+ assert_eq!(s.slug_rect(bleed), s.bleed_rect(bleed));
+ assert_eq!(s.slug_rect([0.0; 4]), None);
}
let lone_left = Spread { id: SpreadId(1), pages: vec![page(PageSide::Left, 0.0)], items: vec![], parent: None, allow_shuffle: true };
assert_eq!(lone_left.bleed_rect(bleed), Some(Rect::new(-4.0, -1.0, 615.0, 794.0)));
diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs
index 72590d47c..5c747fdbf 100644
--- a/crates/doc/src/styles.rs
+++ b/crates/doc/src/styles.rs
@@ -454,6 +454,11 @@ pub struct CellStyle {
pub inset_overrides: [Option; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub vj: Option,
+ /// Cell Options › Text › First Baseline, and its minimum.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub first_baseline: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub first_baseline_min: Option,
/// All four edges.
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option,
@@ -529,6 +534,12 @@ impl CellStyle {
if let Some(v) = self.vj {
cell.vj = v;
}
+ if let Some(v) = self.first_baseline {
+ cell.first_baseline = v;
+ }
+ if let Some(v) = self.first_baseline_min {
+ cell.first_baseline_min = v;
+ }
if let Some(v) = &self.stroke {
cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()];
cell.border_overrides = [true; 4];
@@ -698,6 +709,12 @@ pub fn resolve_cell_style(cells: &[CellStyle], name: &str) -> CellStyle {
if style.vj.is_some() {
out.vj = style.vj;
}
+ if style.first_baseline.is_some() {
+ out.first_baseline = style.first_baseline;
+ }
+ if style.first_baseline_min.is_some() {
+ out.first_baseline_min = style.first_baseline_min;
+ }
if let Some(stroke) = &style.stroke {
out.strokes = std::array::from_fn(|_| CellStrokeAttrs::from_stroke(stroke));
}
diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs
index 14e02a7cb..e96aac4b7 100644
--- a/crates/doc/src/table.rs
+++ b/crates/doc/src/table.rs
@@ -13,7 +13,7 @@ use std::sync::Arc;
use serde::{Deserialize, Serialize};
use crate::ids::StoryId;
-use crate::item::{StrokeType, VerticalJustification};
+use crate::item::{FirstBaseline, StrokeType, VerticalJustification};
use crate::story::{CharFormat, ParaFormat, Story, TABLE_ANCHOR};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -159,6 +159,12 @@ fn full() -> f32 {
fn no_fill() -> String {
designcraft_color::swatch::NONE.into()
}
+fn is_ascent(f: &FirstBaseline) -> bool {
+ *f == FirstBaseline::Ascent
+}
+fn is_zero_min(v: &f64) -> bool {
+ *v == 0.0
+}
fn default_insets() -> [f64; 4] {
[4.0; 4]
}
@@ -181,6 +187,12 @@ pub struct Cell {
pub insets: [f64; 4],
#[serde(default)]
pub vj: VerticalJustification,
+ /// Cell Options › Text › First Baseline: how far the first line's baseline sits below the
+ /// top inset (IDML `FirstBaselineOffset`), and its minimum (`MinimumFirstBaselineOffset`).
+ #[serde(default, skip_serializing_if = "is_ascent")]
+ pub first_baseline: FirstBaseline,
+ #[serde(default, skip_serializing_if = "is_zero_min")]
+ pub first_baseline_min: f64,
/// Text rotation in degrees (only 0 is composed for now).
#[serde(default)]
pub rotation: f64,
@@ -216,6 +228,8 @@ impl Default for Cell {
fill_tint: 1.0,
insets: default_insets(),
vj: VerticalJustification::Top,
+ first_baseline: FirstBaseline::Ascent,
+ first_baseline_min: 0.0,
rotation: 0.0,
strokes: Default::default(),
border_overrides: [false; 4],
@@ -897,7 +911,8 @@ impl Table {
}
/// Move row `from` to position `to` (indices before the move). Refused when merged cells span
- /// rows (the move would cut them).
+ /// rows (the move would cut them) or when the move would take a row across a header/footer
+ /// boundary (`from` and `to` must be the same kind of row).
pub fn move_row(&mut self, from: usize, to: usize) -> Result<(), String> {
let (nr, nc) = (self.nrows(), self.ncols());
if from >= nr || to >= nr {
@@ -906,6 +921,9 @@ impl Table {
if self.regions().iter().any(|r| r.2 > 1) {
return Err("unmerge cells that span rows first".into());
}
+ if self.rows[from].kind != self.rows[to].kind {
+ return Err("header and footer rows must stay first and last".into());
+ }
let row = self.rows.remove(from);
self.rows.insert(to, row);
let cells: Vec = self.cells.drain(from * nc..(from + 1) * nc).collect();
@@ -1255,6 +1273,17 @@ mod tests {
Story::with_text(StoryId(1), t, ParaFormat::default())
}
+ #[test]
+ fn move_row_keeps_header_and_footer_rows_in_place() {
+ let mut t = Table::new(1, 3, 2, 1, 1, 120.0);
+ for (from, to) in [(0, 2), (0, 4), (2, 0), (2, 4), (4, 2), (4, 0)] {
+ assert!(t.move_row(from, to).is_err(), "move_row({from}, {to}) should be refused");
+ t.check().unwrap();
+ }
+ t.move_row(1, 3).unwrap();
+ t.check().unwrap();
+ }
+
#[test]
fn selected_owners_include_covered_cells_without_expanding_range() {
for column in [true, false] {
diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml
index 8982ddfda..69e0e8d50 100644
--- a/crates/engine/Cargo.toml
+++ b/crates/engine/Cargo.toml
@@ -10,6 +10,7 @@ workspace = true
[dependencies]
craft-print = { workspace = true }
+craft-docpath = { workspace = true }
designcraft-images = { workspace = true }
designcraft-geom = { workspace = true }
designcraft-color = { workspace = true }
diff --git a/crates/engine/src/cmd/book.rs b/crates/engine/src/cmd/book.rs
index 34a385abd..4a65137cf 100644
--- a/crates/engine/src/cmd/book.rs
+++ b/crates/engine/src/cmd/book.rs
@@ -36,6 +36,19 @@ fn save(_: &Book) -> Result<()> {
Err(EngineError::Other("books need a file system".into()))
}
+/// A chapter's file, resolved against the book's file. A chapter on another computer (other than
+/// the book's own network share) or a device is not opened ([`crate::io::document_path`]).
+fn chapter(b: &Book, path: &str) -> Result {
+ crate::io::document_path(path, Some(std::path::Path::new(&b.path)))
+ .map(|p| p.to_string_lossy().into_owned())
+ .map_err(|why| EngineError::Other(format!("{path}: {why}")))
+}
+
+/// Every chapter's file ([`chapter`]), in order.
+fn chapters(b: &Book) -> Result> {
+ b.documents.iter().map(|path| chapter(b, path)).collect()
+}
+
fn load(path: &str) -> Result {
#[cfg(not(target_arch = "wasm32"))]
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
@@ -45,7 +58,8 @@ fn load(path: &str) -> Result {
vec![]
};
if path.to_lowercase().ends_with(".idml") {
- return super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")));
+ return super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(path)))
+ .map_err(|e| EngineError::Other(format!("{path}: {e}")));
}
super::file::from_bytes(&bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -144,7 +158,7 @@ fn paginate(s: &Session) -> Result {
let mut next = 1u32;
let mut chapter = 0u32;
let mut chapters = Vec::with_capacity(b.documents.len());
- for path in &b.documents {
+ for path in &self::chapters(b)? {
writable_chapter(s, path, "book.paginate")?;
let mut d = load(path)?;
start_at(&mut d, next);
@@ -167,13 +181,14 @@ fn paginate(s: &Session) -> Result {
fn sync_styles(s: &Session) -> Result {
let b = s.book.as_ref().ok_or_else(no_book)?;
let mut batch = ChapterBatch::new(b.documents.len(), "book.syncStyles")?;
- let src_path = b.documents.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
+ let paths = chapters(b)?;
+ let src_path = paths.get(b.style_source).ok_or_else(|| bad("book.syncStyles", "the book is empty"))?;
let src = load(src_path)?;
let styles = src.styles.clone();
let swatches = src.swatches.clone();
drop(src);
let mut n = 0;
- for (i, path) in b.documents.iter().enumerate() {
+ for (i, path) in paths.iter().enumerate() {
if i == b.style_source {
continue;
}
@@ -304,7 +319,7 @@ pub fn specs() -> Vec {
let mut first = 1usize;
let mut docs = Vec::new();
for p in &b.documents {
- let pages = load(p).map(|d| d.page_count()).unwrap_or(0);
+ let pages = chapter(b, p).and_then(|p| load(&p)).map(|d| d.page_count()).unwrap_or(0);
docs.push(json!({"path": p, "pages": pages, "firstPage": first}));
first = first.saturating_add(pages);
}
@@ -319,7 +334,7 @@ pub fn specs() -> Vec {
cmd!(noundo "book.exportPdf", "Export Book to PDF…", [], None, "{path?} — every document in order as one PDF → {path, bytes, pages} (no path: {base64, …})", has_book, |s, p| {
let b = s.book.clone().ok_or_else(no_book)?;
let mut parts = Vec::new();
- for path in &b.documents {
+ for path in &chapters(&b)? {
let mut d = load(path)?;
// The chapter's own fonts, for this export (skipped font files are logged).
let _fonts = super::file::load_document_fonts(&mut d, path).0;
diff --git a/crates/engine/src/cmd/datamerge/fill.rs b/crates/engine/src/cmd/datamerge/fill.rs
index 963f4189f..b0165d9d3 100644
--- a/crates/engine/src/cmd/datamerge/fill.rs
+++ b/crates/engine/src/cmd/datamerge/fill.rs
@@ -497,3 +497,21 @@ mod link_tests {
}
}
}
+
+#[cfg(all(test, unix))]
+mod image_path_tests {
+ use std::path::Path;
+
+ use super::image_candidates;
+
+ #[test]
+ fn image_cells_resolve_beside_the_data_and_never_to_other_computers() {
+ let (data, doc) = (Path::new("/work/data"), Path::new("/work/doc"));
+ for cell in [r"\\192.0.2.1\share\x.png", "file://192.0.2.1/s/x.png", "//192.0.2.1/s/x.png", "https://example.com/x.png"] {
+ assert!(image_candidates(cell, Some(data), Some(doc)).is_empty(), "{cell}");
+ }
+ let found = image_candidates("img/x.png", Some(data), Some(doc));
+ assert_eq!(found, [data.join("img").join("x.png"), doc.join("img").join("x.png")]);
+ assert!(image_candidates("img/x.png", None, None).is_empty(), "not beside the working directory");
+ }
+}
diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs
index 38ec18321..b29ae3b3c 100644
--- a/crates/engine/src/cmd/datamerge/mod.rs
+++ b/crates/engine/src/cmd/datamerge/mod.rs
@@ -98,11 +98,20 @@ fn source_select(s: &mut Session, p: &Value) -> Result {
})
}
+/// Where a data source's file is, resolved against the document's file (`document`); `None` when
+/// it has none or the path may not be followed ([`crate::io::document_path`]).
+fn source_file(src: &DataSource, document: Option<&Path>) -> Option {
+ crate::io::document_path(src.path.as_deref()?, document).ok()
+}
+
fn source_update(s: &mut Session, _p: &Value) -> Result {
let (path, name, delimiter, sheet) = {
- let src = s.doc()?.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?;
+ let st = s.doc()?;
+ let src = st.doc.data_merge.sources.first().ok_or_else(|| bad("data.source.update", "select a data source"))?;
let path = src.path.clone().ok_or_else(|| bad("data.source.update", "this source has no file to update"))?;
- (path, src.name.clone(), src.delimiter, src.sheet.clone())
+ let at =
+ crate::io::document_path(&path, st.path.as_deref().map(Path::new)).map_err(|why| bad("data.source.update", format!("{path}: {why}")))?;
+ (at.to_string_lossy().into_owned(), src.name.clone(), src.delimiter, src.sheet.clone())
};
let bytes = read_path(&path).map_err(|e| bad("data.source.update", e))?;
let table = parse_file(&bytes, &name, Some(delimiter), sheet.as_deref()).map_err(|e| bad("data.source.update", e))?;
@@ -199,7 +208,7 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result {
let options = doc.data_merge.options.clone();
let maps = identity_map(&doc);
let row = source.rows.get((record as usize).saturating_sub(1)).cloned().unwrap_or_default();
- let data_dir = source.path.as_deref().and_then(parent_dir);
+ let data_dir = source_file(&source, st.path.as_deref().map(Path::new)).as_deref().and_then(Path::parent).map(Path::to_path_buf);
let doc_dir = st.path.as_deref().and_then(parent_dir);
fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref());
st.doc = Arc::new(doc);
@@ -218,7 +227,7 @@ fn merge_cmd(s: &mut Session, p: &Value) -> Result {
let (table, dir) = load_inline(p).map_err(|e| bad("data.merge", e))?;
(table, Vec::new(), dir, None)
} else {
- let (table, warnings, dir, status) = linked_table(&st.doc).map_err(|e| bad("data.merge", e))?;
+ let (table, warnings, dir, status) = linked_table(&st.doc, st.path.as_deref().map(Path::new)).map_err(|e| bad("data.merge", e))?;
(table, warnings, dir, Some(status))
};
let mut options = st.doc.data_merge.options.clone();
@@ -624,28 +633,24 @@ pub(crate) fn refresh_relative_paths(doc: &mut Document, doc_path: &Path) {
}
/// On open, compare the absolute path with its fingerprint. A missing absolute path is tried
-/// again as `relative_path` beside the document.
+/// again as `relative_path` beside the document. Paths that may not be followed ([`crate::io::document_path`]) are missing.
pub(crate) fn resolve_sources_on_open(doc: &mut Document, doc_path: Option<&Path>) {
for src in &mut doc.data_merge.sources {
- let abs_exists = src.path.as_deref().is_some_and(crate::io::is_file);
- if abs_exists {
- if let Some(path) = src.path.clone() {
- src.status = status_of(Path::new(&path), src.fingerprint);
- }
+ if let Some(path) = source_file(src, doc_path).filter(|p| crate::io::is_file(p)) {
+ src.status = status_of(&path, src.fingerprint);
continue;
}
if let (Some(doc_path), Some(rel)) = (doc_path, src.relative_path.clone())
&& let Some(dir) = doc_path.parent()
+ && let Ok(candidate) = crate::io::document_path(&rel, Some(doc_path))
+ && crate::io::is_file(&candidate)
{
- let candidate = dir.join(&rel);
- if crate::io::is_file(&candidate) {
- let saved = candidate.to_string_lossy().into_owned();
- src.fingerprint = fingerprint_of(Path::new(&saved));
- src.status = SourceStatus::Ok;
- src.relative_path = Some(relative_between(dir, Path::new(&saved)));
- src.path = Some(saved);
- continue;
- }
+ let saved = candidate.to_string_lossy().into_owned();
+ src.fingerprint = fingerprint_of(Path::new(&saved));
+ src.status = SourceStatus::Ok;
+ src.relative_path = Some(relative_between(dir, Path::new(&saved)));
+ src.path = Some(saved);
+ continue;
}
if src.path.is_some() {
src.status = SourceStatus::Missing;
@@ -680,11 +685,17 @@ fn read_source(p: &Value) -> std::result::Result<(DataSource, Value), String> {
Ok((source, report))
}
-fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec, Option, SourceStatus), String> {
+fn linked_table(doc: &Document, document: Option<&Path>) -> std::result::Result<(Table, Vec, Option, SourceStatus), String> {
let src = doc.data_merge.sources.first().ok_or_else(|| "select a data source or pass csv or rows".to_string())?;
let mut warnings = Vec::new();
let status = if let Some(path) = &src.path {
- let status = status_of(Path::new(path), src.fingerprint);
+ let status = match crate::io::document_path(path, document) {
+ Ok(path) => status_of(&path, src.fingerprint),
+ Err(why) => {
+ warnings.push(format!("The data file is not read ({why}). This merge used the cached rows."));
+ return Ok((cached_table(src), warnings, None, SourceStatus::Missing));
+ }
+ };
match status {
SourceStatus::Modified => warnings.push("The data file changed since it was read. This merge used the cached rows.".into()),
SourceStatus::Missing => warnings.push("The data file is missing. This merge used the cached rows.".into()),
@@ -694,9 +705,12 @@ fn linked_table(doc: &Document) -> std::result::Result<(Table, Vec, Opti
} else {
src.status
};
- let data_dir = src.path.as_deref().and_then(parent_dir);
- let table = Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter };
- Ok((table, warnings, data_dir, status))
+ let data_dir = source_file(src, document).as_deref().and_then(Path::parent).map(Path::to_path_buf);
+ Ok((cached_table(src), warnings, data_dir, status))
+}
+
+fn cached_table(src: &DataSource) -> Table {
+ Table { fields: src.fields.clone(), rows: src.rows.clone(), warnings: src.warnings.clone(), delimiter: src.delimiter }
}
fn set_source_status(doc: &mut Document, status: SourceStatus) -> bool {
diff --git a/crates/engine/src/cmd/datamerge/parse.rs b/crates/engine/src/cmd/datamerge/parse.rs
index e7c9be6e3..61ad6224e 100644
--- a/crates/engine/src/cmd/datamerge/parse.rs
+++ b/crates/engine/src/cmd/datamerge/parse.rs
@@ -277,32 +277,23 @@ fn next_slot(col: i32, row: i32, rows_first: bool, fits_col: &impl Fn(i32) -> bo
(0, 0, true)
}
-/// Files to try for an image cell. `http` and `https` are not candidates. Relative paths are
-/// not resolved against the process working directory.
+/// Files to try for an image cell: an absolute path as it is, a relative one beside the data
+/// file, then beside the document. Relative paths are not resolved against the process working
+/// directory, and paths that may not be followed ([`crate::io::document_path`]: URLs, other
+/// computers, devices) are not candidates.
pub fn image_candidates(cell: &str, data_dir: Option<&Path>, doc_dir: Option<&Path>) -> Vec {
let cell = cell.trim();
if cell.is_empty() {
return Vec::new();
}
- let lower = cell.to_ascii_lowercase();
- if lower.starts_with("http://") || lower.starts_with("https://") {
- return Vec::new();
- }
- let path = Path::new(cell);
- let mut out = Vec::new();
- let mut push = |p: PathBuf| {
- if !out.iter().any(|e| e == &p) {
+ let found = std::iter::once(crate::io::document_path(cell, None))
+ .chain(data_dir.map(|dir| crate::io::folder_path(cell, dir)))
+ .chain(doc_dir.map(|dir| crate::io::folder_path(cell, dir)));
+ let mut out: Vec = Vec::new();
+ for p in found.flatten() {
+ if !out.contains(&p) {
out.push(p);
}
- };
- if path.is_absolute() {
- push(path.to_path_buf());
- }
- if let Some(dir) = data_dir {
- push(dir.join(cell));
- }
- if let Some(dir) = doc_dir {
- push(dir.join(cell));
}
out
}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 0c80ffedf..973262937 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -478,7 +478,7 @@ fn open_path(s: &mut Session, p: &Value) -> Result {
return Err(bad("file.open", format!("{path}: not a DesignCraft or IDML file")));
}
let (mut d, mut warnings) = load_bytes(&bytes)?;
- super::interchange::resolve_packaged_links(&mut d, std::path::Path::new(path).parent());
+ super::interchange::resolve_packaged_links(&mut d, Some(std::path::Path::new(path)));
super::datamerge::resolve_sources_on_open(&mut d, Some(std::path::Path::new(path)));
let (fonts, faces, font_warnings) = load_document_fonts(&mut d, path);
warnings.extend(font_warnings);
@@ -948,7 +948,7 @@ fn snippet_export(s: &mut Session, p: &Value) -> Result {
fn place_layout_page(s: &mut Session, p: &Value, name: &str, bytes: &[u8]) -> Result {
const ID: &str = "file.place";
let src = if name.to_lowercase().ends_with(".idml") {
- super::interchange::import_idml_confined(bytes).map_err(|e| bad(ID, e.to_string()))?
+ super::interchange::import_idml_confined(bytes, str_param(p, "path").map(std::path::Path::new)).map_err(|e| bad(ID, e.to_string()))?
} else {
from_bytes(bytes)?
};
@@ -1073,7 +1073,7 @@ fn file_revert(s: &mut Session, _: &Value) -> Result {
{
let mut d = if path.to_ascii_lowercase().ends_with(".idml") {
let bytes = crate::io::read_file(&path).map_err(EngineError::Other)?;
- super::interchange::import_idml_confined(&bytes).map_err(|e| EngineError::Other(e.to_string()))?
+ super::interchange::import_idml_confined(&bytes, Some(std::path::Path::new(&path))).map_err(|e| EngineError::Other(e.to_string()))?
} else {
from_bytes(&crate::io::read_file(&path).map_err(EngineError::Other)?)?
};
diff --git a/crates/engine/src/cmd/fonts.rs b/crates/engine/src/cmd/fonts.rs
index 55bf4231b..b1577400c 100644
--- a/crates/engine/src/cmd/fonts.rs
+++ b/crates/engine/src/cmd/fonts.rs
@@ -1,5 +1,6 @@
//! Type › Find/Replace Font: the fonts a document uses (missing ones flagged) and replacing one
-//! font with another everywhere — local formatting and style definitions.
+//! font with another everywhere — local formatting and style definitions. Type › Reload Fonts:
+//! the fonts installed, edited or removed since they were read.
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -8,7 +9,7 @@ use designcraft_doc::{CharAttrs, Document, Story};
use designcraft_fonts::FontSource;
use serde_json::{Value, json};
-use super::{CommandSpec, bad, cmd, has_doc, str_param};
+use super::{CommandSpec, always, bad, cmd, has_doc, str_param};
use crate::{Result, Session};
pub fn specs() -> Vec {
@@ -25,9 +26,30 @@ pub fn specs() -> Vec {
has_doc,
replace
),
+ cmd!(
+ noundo "font.reload",
+ "Reload Fonts",
+ ["Type"],
+ None,
+ "{} — scans the font folders again and reads again the installed font files changed or removed since they were read, then recomposes the open documents → {cataloged, changed: [path]}",
+ always,
+ reload
+ ),
]
}
+/// Fonts installed, edited or removed while the app runs (#328, #547): the font database reads
+/// them again, then every open document is composed again with what is installed now.
+fn reload(s: &mut Session, _: &Value) -> Result {
+ let reloaded = designcraft_fonts::FontDb::global().reload_fonts();
+ // A new cache: render jobs still holding the old one can't bring back the old fonts' layout.
+ s.cache = Arc::new(designcraft_compose::Cache::new());
+ for st in &mut s.docs {
+ st.bump_revision();
+ }
+ Ok(json!({"cataloged": reloaded.cataloged, "changed": reloaded.changed}))
+}
+
/// Every story's text, including table cells and footnotes.
pub(super) fn for_each_story(d: &Document, f: &mut dyn FnMut(&Story)) {
let mut pending: Vec<&Story> = d.stories.values().map(AsRef::as_ref).collect();
@@ -233,6 +255,22 @@ fn replace(s: &mut Session, p: &Value) -> Result {
mod tests {
use super::*;
+ #[test]
+ fn reload_fonts_recomposes_open_documents_without_editing_them() {
+ let mut s = Session::new();
+ let r = s.execute("font.reload", &json!({})).unwrap();
+ assert!(r["cataloged"].is_u64() && r["changed"].is_array(), "{r}");
+ s.execute("file.new", &json!({})).unwrap();
+ s.execute("frame.create", &json!({"rect": [72, 72, 400, 300], "content": "text", "text": "Some text here."})).unwrap();
+ let (revision, undo) = (s.doc().unwrap().revision, s.doc().unwrap().history.undo.len());
+ let cache = Arc::clone(&s.cache);
+ s.execute("font.reload", &json!({})).unwrap();
+ assert!(!Arc::ptr_eq(&cache, &s.cache), "the layout composed with the old fonts is let go");
+ let st = s.doc().unwrap();
+ assert_ne!(st.revision, revision, "the canvas draws the document again");
+ assert_eq!(st.history.undo.len(), undo, "reloading fonts isn't an edit");
+ }
+
#[test]
fn replacement_resolves_inherited_family_and_style_and_rejects_missing_targets() {
let mut s = Session::new();
diff --git a/crates/engine/src/cmd/interchange.rs b/crates/engine/src/cmd/interchange.rs
index cf127f343..bf0e011a0 100644
--- a/crates/engine/src/cmd/interchange.rs
+++ b/crates/engine/src/cmd/interchange.rs
@@ -1,5 +1,11 @@
//! Interchange formats: IDML (InDesign Markup Language) import and export, and INDD/INDT
//! import (converted to IDML in memory by indd-utils).
+//!
+//! A package's linked files are read from where the link points on this computer, then from the
+//! link's trailing names beside the package (where a package moved from its author's computer
+//! keeps them). Links to other computers and devices are not followed
+//! ([`crate::io::document_path`]). Only regular files that hold an image or a graphic format are
+//! read, and only up to [`MAX_LINKED_FILE_BYTES`].
use designcraft_doc::Document;
use serde_json::{Value, json};
@@ -33,28 +39,40 @@ fn export_idml(s: &mut Session, p: &Value) -> Result {
}
}
-/// [`designcraft_idml::import_idml`] with the linked images read through [`crate::io`], so the
-/// automation read root applies to the paths the package names, and its PDF crops resolved.
-pub(crate) fn import_idml_confined(bytes: &[u8]) -> designcraft_idml::Result {
- let mut d = designcraft_idml::import_idml_with(bytes, &|path| crate::io::read_file(path).ok())?;
+/// [`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.
+pub(crate) fn import_idml_confined(bytes: &[u8], package: Option<&std::path::Path>) -> designcraft_idml::Result {
+ let read = |link: &str| -> Option> {
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ read_packaged_link(link, package).map(|(_, data)| data)
+ }
+ #[cfg(target_arch = "wasm32")]
+ {
+ let _ = (link, package);
+ None
+ }
+ };
+ let mut d = designcraft_idml::import_idml_with(bytes, &read)?;
resolve_pdf_crops(&mut d);
Ok(d)
}
-/// Import IDML bytes; `dir` is the folder the package came from (for relative link lookup).
-pub fn import(bytes: &[u8], dir: Option<&std::path::Path>) -> Result {
- import_report(bytes, dir).map(|(document, _)| document)
+/// Import IDML bytes; `package` is the package's file (its folder is where links are looked for).
+pub fn import(bytes: &[u8], package: Option<&std::path::Path>) -> Result {
+ import_report(bytes, package).map(|(document, _)| document)
}
/// [`import`], with the problems that did not stop it (parts missing from the package, …).
-pub fn import_report(bytes: &[u8], dir: Option<&std::path::Path>) -> Result<(Document, Vec)> {
- import_report_with_previews(bytes, dir, &std::collections::HashMap::new())
+pub fn import_report(bytes: &[u8], package: Option<&std::path::Path>) -> Result<(Document, Vec)> {
+ import_report_with_previews(bytes, package, &std::collections::HashMap::new())
}
/// [`import_report`], showing `previews` (graphic `Self` → image) for linked files that can't be read.
fn import_report_with_previews(
bytes: &[u8],
- dir: Option<&std::path::Path>,
+ package: Option<&std::path::Path>,
previews: &std::collections::HashMap>>,
) -> Result<(Document, Vec)> {
#[cfg(not(target_arch = "wasm32"))]
@@ -62,13 +80,13 @@ fn import_report_with_previews(
let read = |link: &str| -> Option> {
#[cfg(not(target_arch = "wasm32"))]
{
- let (path, data) = read_packaged_link(link, dir)?;
+ let (path, data) = read_packaged_link(link, package)?;
resolved.borrow_mut().insert(link.to_string(), path);
Some(data)
}
#[cfg(target_arch = "wasm32")]
{
- let _ = (link, &dir);
+ let _ = (link, &package);
None
}
};
@@ -84,7 +102,7 @@ fn import_report_with_previews(
std::sync::Arc::make_mut(asset).link = Some(path);
}
}
- resolve_packaged_links(&mut document, dir);
+ resolve_packaged_links(&mut document, package);
document
};
Ok((document, warnings))
@@ -173,77 +191,107 @@ const MAX_LINK_PATH_BYTES: usize = 128 * 1024;
#[cfg(not(target_arch = "wasm32"))]
const MAX_LINK_SUFFIX_PARTS: usize = 8;
+/// Largest linked file read when a package is opened (bytes); bigger files stay missing.
#[cfg(not(target_arch = "wasm32"))]
-fn packaged_link_candidates(link: &str, dir: Option<&std::path::Path>) -> Vec {
+pub(crate) const MAX_LINKED_FILE_BYTES: u64 = 256 * 1024 * 1024;
+
+/// The files to try for `link` in the package at `package`: the link itself, then its trailing
+/// names beside the package (without a package file, only an absolute link). Each is a path
+/// [`crate::io::document_path`] allows.
+#[cfg(not(target_arch = "wasm32"))]
+fn packaged_link_candidates(link: &str, package: Option<&std::path::Path>) -> Vec {
// Enough for a maximum-length Windows path even with four-byte UTF-8 characters.
// Bound splitting/allocation for link strings supplied by imported documents.
if link.len() > MAX_LINK_PATH_BYTES {
return Vec::new();
}
- let mut candidates = vec![std::path::PathBuf::from(link)];
- if let Some(dir) = dir {
- // IDML may retain Windows paths even when the package is opened on another platform.
- let parts: Vec<&str> = link.split(['/', '\\']).filter(|part| !part.is_empty()).collect();
- let safe_part = |part: &&str| *part != "." && *part != ".." && !part.contains(':');
- if !link.starts_with(['/', '\\']) && parts.iter().all(safe_part) {
- candidates.push(dir.join(parts.iter().collect::()));
+ let mut candidates: Vec = crate::io::document_path(link, package).into_iter().collect();
+ let Some(package) = package else { return candidates };
+ // Names relative to the package, resolved beside it.
+ let mut beside = |names: &[&str]| {
+ if let Ok(path) = crate::io::document_path(&names.join("/"), Some(package))
+ && !candidates.contains(&path)
+ {
+ candidates.push(path);
}
- // Packagers can keep subfolders under Links (for example, Links/illustrations/logo.ai).
- // Preserve that suffix instead of flattening every resource to its basename.
- if let Some(index) = parts.iter().rposition(|part| part.eq_ignore_ascii_case("Links")) {
- let suffix = &parts[index + 1..];
- if !suffix.is_empty() && suffix.iter().all(safe_part) {
- candidates.push(dir.join("Links").join(suffix.iter().collect::()));
- }
- }
- // A package moved off its author's machine keeps its own subfolders (`/images/logo.png`
- // beside the .idml as `images/logo.png`): try the link's trailing folders, longest first.
- let first = parts.len().saturating_sub(MAX_LINK_SUFFIX_PARTS);
- for start in first..parts.len().saturating_sub(1) {
- if let Some(suffix) = parts.get(start..).filter(|s| s.iter().all(safe_part)) {
- candidates.push(dir.join(suffix.iter().collect::()));
- }
- }
- if let Some(name) = parts.last().filter(|part| safe_part(part)) {
- candidates.push(dir.join(name));
- candidates.push(dir.join("Links").join(name));
+ };
+ // IDML may retain Windows paths even when the package is opened on another platform.
+ let parts: Vec<&str> = link.split(['/', '\\']).filter(|part| !part.is_empty()).collect();
+ let safe_part = |part: &&str| *part != "." && *part != ".." && !part.contains(':');
+ // Packagers can keep subfolders under Links (for example, Links/illustrations/logo.ai).
+ // Preserve that suffix instead of flattening every resource to its basename.
+ if let Some(index) = parts.iter().rposition(|part| part.eq_ignore_ascii_case("Links")) {
+ let suffix = parts.get(index + 1..).unwrap_or_default();
+ if !suffix.is_empty() && suffix.iter().all(safe_part) {
+ beside(&[&["Links"][..], suffix].concat());
}
}
+ // A package moved off its author's machine keeps its own subfolders (`/images/logo.png`
+ // beside the .idml as `images/logo.png`): try the link's trailing folders, longest first.
+ let first = parts.len().saturating_sub(MAX_LINK_SUFFIX_PARTS);
+ for start in first..parts.len().saturating_sub(1) {
+ if let Some(suffix) = parts.get(start..).filter(|s| s.iter().all(safe_part)) {
+ beside(suffix);
+ }
+ }
+ if let Some(name) = parts.last().filter(|part| safe_part(part)) {
+ beside(&[name]);
+ beside(&["Links", name]);
+ }
candidates
}
+/// Bytes an image or a graphic can be placed from (raster formats, PDF, SVG, PSD, EPS).
+#[cfg(not(target_arch = "wasm32"))]
+fn is_graphic(bytes: &[u8]) -> bool {
+ designcraft_images::mime(bytes) != "application/octet-stream" || designcraft_images::is_eps(bytes)
+}
+
/// Embedded images do not call the IDML resource loader. Rebind their missing links
/// beside the opened document too, without replacing their embedded image bytes.
#[cfg(not(target_arch = "wasm32"))]
-pub(crate) fn resolve_packaged_links(document: &mut Document, dir: Option<&std::path::Path>) {
- let Some(dir) = dir else { return };
+pub(crate) fn resolve_packaged_links(document: &mut Document, package: Option<&std::path::Path>) {
+ let Some(package) = package else { return };
for asset in document.assets.values_mut() {
let Some(link) = asset.link.as_deref() else { continue };
- if link.len() > MAX_LINK_PATH_BYTES || crate::io::is_file(link) {
+ if link.len() > MAX_LINK_PATH_BYTES {
continue;
}
- let Some(path) = packaged_link_candidates(link, Some(dir)).into_iter().find(|p| crate::io::is_file(p)) else { continue };
- // Preserve ordinary Windows paths rather than canonicalizing to a verbatim URI.
- let path = std::path::absolute(&path).unwrap_or(path);
+ if let Ok(path) = crate::io::document_path(link, Some(package))
+ && crate::io::is_file(&path)
+ {
+ // A link relative to the document (what File › Package writes, `Links/logo.png`) is
+ // held as the file it names, as every other link is, so preflight and updates find it.
+ if std::path::Path::new(link).is_relative() {
+ std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
+ }
+ continue;
+ }
+ let Some(path) = packaged_link_candidates(link, Some(package)).into_iter().find(|p| crate::io::is_file(p)) else { continue };
std::sync::Arc::make_mut(asset).link = Some(path.to_string_lossy().into_owned());
}
}
+/// The first candidate for `link` ([`packaged_link_candidates`]) that is a regular file no larger
+/// than [`MAX_LINKED_FILE_BYTES`] holding an image or a graphic, with its path.
#[cfg(not(target_arch = "wasm32"))]
-fn read_packaged_link(link: &str, dir: Option<&std::path::Path>) -> Option<(String, Vec)> {
- for path in packaged_link_candidates(link, dir) {
- if let Ok(bytes) = crate::io::read_file(&path) {
- // Keep ordinary Windows drive/UNC syntax for IDML URI export; canonicalize
- // would introduce a verbatim prefix that is not an IDML file URI.
- let path = std::path::absolute(&path).unwrap_or(path);
- return Some((path.to_string_lossy().into_owned(), bytes));
+fn read_packaged_link(link: &str, package: Option<&std::path::Path>) -> Option<(String, Vec)> {
+ for path in packaged_link_candidates(link, package) {
+ if !crate::io::is_file(&path) || !crate::io::metadata(&path).is_ok_and(|m| m.len() <= MAX_LINKED_FILE_BYTES) {
+ continue;
}
+ // At most one byte over the limit, in case the file grew since.
+ let Ok(bytes) = crate::io::read_capped(&path, MAX_LINKED_FILE_BYTES) else { continue };
+ if bytes.len() as u64 > MAX_LINKED_FILE_BYTES || !is_graphic(&bytes) {
+ continue;
+ }
+ return Some((path.to_string_lossy().into_owned(), bytes));
}
None
}
pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result {
- let (bytes, dir, file, title) = if let Some(b) = str_param(p, "base64") {
+ let (bytes, package, file, title) = if let Some(b) = str_param(p, "base64") {
let name = str_param(p, "name").unwrap_or_default();
(base64_decode(b), None, name.to_string(), without_document_extension(name).to_string())
} else if let Some(path) = str_param(p, "path") {
@@ -253,7 +301,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result {
let b: Vec = Vec::new();
let pp = std::path::Path::new(path);
let file = pp.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string());
- (b, pp.parent().map(|d| d.to_path_buf()), file, pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
+ (b, Some(pp.to_path_buf()), file, pp.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default())
} else {
return Err(bad("file.openIdml", "missing `path` or `base64`"));
};
@@ -280,7 +328,7 @@ pub(crate) fn open_idml(s: &mut Session, p: &Value) -> Result {
} else {
bytes
};
- let (mut d, import_warnings) = import_report_with_previews(&bytes, dir.as_deref(), &previews)?;
+ let (mut d, import_warnings) = import_report_with_previews(&bytes, package.as_deref(), &previews)?;
warnings.extend(import_warnings);
if !title.is_empty() {
d.title = title;
@@ -346,12 +394,12 @@ mod tests {
std::fs::write(&path, &png).unwrap();
std::fs::write(dir.join("logo.png"), b"a different file with the same basename").unwrap();
- let doc = import(&idml, Some(&dir)).unwrap();
+ let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(*asset.data, png, "the packaged graphic must render even when its author-machine path no longer exists");
let resolved = std::path::absolute(&path).unwrap();
assert_eq!(asset.link.as_deref(), resolved.to_str(), "remember the resolved link so update and preflight use the packaged file");
- assert_eq!(super::super::links::status(asset), "ok");
+ assert_eq!(super::super::links::status(asset, None), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -364,11 +412,11 @@ mod tests {
let (idml, png) = linked_idml("/images/logo.png", false).unwrap();
std::fs::write(&path, &png).unwrap();
- let doc = import(&idml, Some(&dir)).unwrap();
+ let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(*asset.data, png);
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
- assert_eq!(super::super::links::status(asset), "ok");
+ assert_eq!(super::super::links::status(asset, None), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -380,11 +428,11 @@ mod tests {
let (idml, png) = linked_idml("/original-computer/project/logo.png", false).unwrap();
std::fs::write(&path, &png).unwrap();
- let doc = import(&idml, Some(&dir)).unwrap();
+ let doc = import(&idml, Some(&dir.join("x.idml"))).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(*asset.data, png);
assert_eq!(asset.link.as_deref(), std::path::absolute(&path).unwrap().to_str());
- assert_eq!(super::super::links::status(asset), "ok");
+ assert_eq!(super::super::links::status(asset, None), "ok");
std::fs::remove_dir_all(dir).unwrap();
}
@@ -400,39 +448,90 @@ mod tests {
let (idml, png) = linked_idml(original.to_str().unwrap(), true).unwrap();
std::fs::write(&original, b"modified original").unwrap();
std::fs::write(©, &png).unwrap();
- let doc = import(&idml, Some(&package)).unwrap();
+ let doc = import(&idml, Some(&package.join("x.idml"))).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "an existing original stays authoritative");
assert_eq!(*asset.data, png);
- assert_eq!(super::super::links::status(asset), "modified");
+ assert_eq!(super::super::links::status(asset, None), "modified");
std::fs::remove_file(&original).unwrap();
std::fs::write(©, b"modified packaged copy").unwrap();
- let doc = import(&idml, Some(&package)).unwrap();
+ let doc = import(&idml, Some(&package.join("x.idml"))).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(asset.link.as_deref(), copy.to_str());
assert_eq!(*asset.data, png, "rebinding must not replace embedded pixels");
- assert_eq!(super::super::links::status(asset), "modified");
+ assert_eq!(super::super::links::status(asset, None), "modified");
std::fs::remove_file(©).unwrap();
- for folder in [Some(package.as_path()), None] {
+ let packaged = package.join("x.idml");
+ for folder in [Some(packaged.as_path()), None] {
let doc = import(&idml, folder).unwrap();
let asset = doc.assets.values().next().unwrap();
assert_eq!(std::path::Path::new(asset.link.as_deref().unwrap()), original.as_path(), "keep a missing link when there is no candidate");
assert_eq!(*asset.data, png);
- assert_eq!(super::super::links::status(asset), "missing");
+ assert_eq!(super::super::links::status(asset, None), "missing");
}
let oversized = "a/".repeat(MAX_LINK_PATH_BYTES);
- assert!(packaged_link_candidates(&oversized, Some(&package)).is_empty());
+ assert!(packaged_link_candidates(&oversized, Some(&packaged)).is_empty());
let mut doc = import(&idml, None).unwrap();
Arc::make_mut(doc.assets.values_mut().next().unwrap()).link = Some(oversized.clone());
- resolve_packaged_links(&mut doc, Some(&package));
+ resolve_packaged_links(&mut doc, Some(&packaged));
let asset = doc.assets.values().next().unwrap();
assert_eq!(asset.link.as_deref(), Some(oversized.as_str()));
assert_eq!(*asset.data, png);
std::fs::remove_dir_all(dir).unwrap();
}
+ #[test]
+ fn only_local_graphic_files_are_read_from_links() {
+ let base = std::env::temp_dir().join(format!("dc-idml-link-reach-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&base);
+ let (dir, outside) = (base.join("package"), base.join("outside"));
+ std::fs::create_dir_all(dir.join("Links")).unwrap();
+ std::fs::create_dir_all(&outside).unwrap();
+ let package = dir.join("x.idml");
+ let png = designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png();
+ std::fs::write(dir.join("Links").join("notes.png"), b"not an image").unwrap();
+ std::fs::write(outside.join("notes.txt"), b"not an image").unwrap();
+ #[cfg(unix)]
+ {
+ std::os::unix::fs::symlink("/dev/zero", dir.join("Links").join("zero.png")).unwrap();
+ std::os::unix::fs::symlink("/dev/zero", outside.join("zero.png")).unwrap();
+ }
+ let (text, zero) = (outside.join("notes.txt"), outside.join("zero.png"));
+ let links = [
+ r"\\192.0.2.1\share\x.png",
+ "file://192.0.2.1/s/x.png",
+ text.to_str().unwrap(),
+ zero.to_str().unwrap(),
+ "/dev/zero",
+ "Links/zero.png",
+ "Links/notes.png",
+ ];
+ for link in links {
+ let (idml, _) = linked_idml(link, false).unwrap();
+ let (doc, seen) = crate::io::touched(|| import(&idml, Some(&package)).unwrap());
+ // Beside the package, and the link itself where it is on this computer.
+ assert!(seen.iter().all(|p| p.starts_with(&dir) || p == std::path::Path::new(link)), "{link}: {seen:?}");
+ let asset = doc.assets.values().next().unwrap();
+ assert!(asset.data.is_empty(), "{link}");
+ assert_eq!(super::super::links::status(asset, None), "missing", "{link}");
+ }
+ // An image elsewhere on this computer is read, and so is one beside the package.
+ let away = outside.join("logo.png");
+ std::fs::write(&away, &png).unwrap();
+ let (idml, _) = linked_idml(away.to_str().unwrap(), false).unwrap();
+ let doc = import(&idml, Some(&package)).unwrap();
+ let asset = doc.assets.values().next().unwrap();
+ assert_eq!((&*asset.data, asset.link.as_deref()), (&png, away.to_str()));
+ assert_eq!(super::super::links::status(asset, None), "ok");
+ std::fs::write(dir.join("Links").join("logo.png"), &png).unwrap();
+ let (idml, _) = linked_idml("Links/logo.png", false).unwrap();
+ let doc = import(&idml, Some(&package)).unwrap();
+ assert_eq!(*doc.assets.values().next().unwrap().data, png);
+ std::fs::remove_dir_all(base).unwrap();
+ }
+
/// INDD routing without an InDesign file: bytes with the INDD signature (or an .indd/.indt
/// name) go to the converter, and what it can't read is an open error that adds no document.
#[test]
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
index 6a4e7f054..615e7c1df 100644
--- a/crates/engine/src/cmd/layout.rs
+++ b/crates/engine/src/cmd/layout.rs
@@ -257,6 +257,27 @@ pub fn specs() -> Vec {
Ok(json!({"id": id.0, "prefix": prefix}))
})
}),
+ cmd!(
+ "layout.parents.delete",
+ "Delete Parent",
+ [],
+ None,
+ "{id: parent spread id} — unapply the parent and keep local overrides as ordinary items",
+ has_doc,
+ |s, p| {
+ let id = p.get("id").and_then(Value::as_u64).map(SpreadId).ok_or_else(|| bad("layout.parents.delete", "missing parent id"))?;
+ s.edit(|d, sel| {
+ d.delete_parent(id)?;
+ *sel = Default::default();
+ ok()
+ })?;
+ let st = s.doc_mut()?;
+ if st.doc.parents.is_empty() {
+ st.editing_parents = false;
+ }
+ ok()
+ }
+ ),
cmd!(
noundo "layout.parents.edit",
"Edit Parents",
@@ -2030,3 +2051,7 @@ mod parent_base_tests {
assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(a));
}
}
+
+#[cfg(test)]
+#[path = "parent_delete_tests.rs"]
+mod parent_delete_tests;
diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs
index eca813b88..c6e9e7566 100644
--- a/crates/engine/src/cmd/links.rs
+++ b/crates/engine/src/cmd/links.rs
@@ -7,10 +7,16 @@
//!
//! A linked text file (File › Place with Create Links) remembers its file's size and hash on the
//! story instead; updating it imports the file again into the story.
+//!
+//! A link is followed only where [`crate::io::document_path`] allows it, resolved against the
+//! document's own file; a refused link is missing, with the reason in `links.list`.
+use std::path::Path;
use std::sync::Arc;
-use designcraft_doc::{AssetId, Content, Document, Fingerprint, ItemId, Selection, Story, StoryId, TextFileLink};
+use designcraft_compose::Cache;
+use designcraft_doc::{AssetId, Content, Document, Fingerprint, Item, ItemId, Selection, Story, StoryId, TextFileLink};
+use designcraft_geom::Affine;
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc, str_param};
@@ -18,10 +24,10 @@ use crate::{EngineError, Result, Session};
pub fn specs() -> Vec {
vec![
- cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
+ cmd!(query "links.list", "Links", [], None, "{} → [{asset, name, path, status: ok|modified|missing|embedded, refused?: why a missing link isn't followed (a path on another computer, a device), pixels, pdfCrop (placed PDFs: the box shown), uses: [{id, page, ppi}]}], then linked text files: [{story, name, path, status (also tooLarge: unchanged size, too big to check), edited (the story was changed in the layout), uses: [{id, page}]}]", has_doc, |s, _| {
// Taken out for the call so the document needn't be cloned on every poll.
let mut checks = std::mem::take(&mut s.text_link_checks);
- let links = s.doc().map(|d| list(&d.doc, &mut checks));
+ let links = s.doc().map(|d| list(&d.doc, &s.cache, d.path.as_deref().map(Path::new), &mut checks));
s.text_link_checks = checks;
Ok(Value::Array(links?))
}),
@@ -126,31 +132,47 @@ fn uses(d: &Document, aid: AssetId) -> Vec {
out
}
+/// Why a link stored in the document at `document` isn't followed, if it isn't.
+pub fn refused(link: &str, document: Option<&Path>) -> Option {
+ crate::io::document_path(link, document).err()
+}
+
/// Whether an asset's linked file is gone ([`status`] "missing" without reading the file). An
-/// asset can hold data while its file is missing: the document's preview of it.
-pub fn link_missing(a: &designcraft_doc::Asset) -> bool {
- let Some(path) = &a.link else { return false };
+/// asset can hold data while its file is missing: the document's preview of it. `document` is
+/// the document's file, which relative links are resolved against.
+pub fn link_missing(a: &designcraft_doc::Asset, document: Option<&Path>) -> bool {
+ let Some(link) = &a.link else { return false };
#[cfg(not(target_arch = "wasm32"))]
{
- !crate::io::exists(path)
+ crate::io::document_path(link, document).map_or(true, |path| !crate::io::exists(path))
}
#[cfg(target_arch = "wasm32")]
{
- let _ = path;
+ let _ = (link, document);
false
}
}
-/// Link status of an asset.
-pub fn status(a: &designcraft_doc::Asset) -> &'static str {
- let Some(path) = &a.link else { return "embedded" };
+/// Link status of an asset in the document at `document`. Only a regular file is compared, so a
+/// link to a device (`/dev/zero`) or a FIFO is missing rather than read.
+pub fn status(a: &designcraft_doc::Asset, document: Option<&Path>) -> &'static str {
+ let Some(link) = &a.link else { return "embedded" };
+ // Nothing was read from the file (a package's link that wasn't followed): missing, as in
+ // Preflight.
+ if a.data.is_empty() {
+ return "missing";
+ }
#[cfg(not(target_arch = "wasm32"))]
{
- match crate::io::metadata(path) {
+ let Ok(path) = crate::io::document_path(link, document) else { return "missing" };
+ if !crate::io::is_file(&path) {
+ return "missing";
+ }
+ match crate::io::metadata(&path) {
Err(_) => "missing",
Ok(m) if m.len() as usize != a.data.len() => "modified",
// Same size: compare the bytes (cheap for the sizes involved, and exact).
- Ok(_) => match crate::io::read_file(path) {
+ Ok(_) => match crate::io::read_capped(&path, a.data.len() as u64) {
Ok(b) if b == *a.data => "ok",
Ok(_) => "modified",
Err(_) => "missing",
@@ -159,7 +181,7 @@ pub fn status(a: &designcraft_doc::Asset) -> &'static str {
}
#[cfg(target_arch = "wasm32")]
{
- let _ = path;
+ let _ = (link, document);
"ok"
}
}
@@ -196,14 +218,15 @@ pub(crate) type TextChecks = std::collections::HashMap &'static str {
+/// or synced again without changes stays OK. `document` is the document's file.
+pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks, document: Option<&Path>) -> &'static str {
#[cfg(not(target_arch = "wasm32"))]
{
- if !crate::io::is_file(&l.path) {
+ let Ok(path) = crate::io::document_path(&l.path, document) else { return "missing" };
+ if !crate::io::is_file(&path) {
return "missing";
}
- let now = match crate::io::metadata(&l.path) {
+ let now = match crate::io::metadata(&path) {
Err(_) => return "missing",
Ok(m) => super::datamerge::fingerprint_from(&m),
};
@@ -216,7 +239,7 @@ pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static
let hash = match checks.get(&l.path) {
Some((at, hash)) if *at == now => hash.clone(),
_ if now.size > MAX_TEXT_LINK_BYTES => return "tooLarge",
- _ => match read_text(&l.path) {
+ _ => match read_text(&path) {
Ok((b, at)) => {
let hash = fnv_hex(&b);
// The time from before the read: a change during it is seen next time.
@@ -232,7 +255,7 @@ pub(crate) fn text_status(l: &TextFileLink, checks: &mut TextChecks) -> &'static
}
#[cfg(target_arch = "wasm32")]
{
- let _ = (l, checks);
+ let _ = (l, checks, document);
"ok"
}
}
@@ -241,40 +264,93 @@ fn file_name(path: &str) -> String {
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
}
-fn list(d: &Document, checks: &mut TextChecks) -> Vec {
+/// A graphic showing an asset: the item, its page, and the transform from its parent's space to
+/// the spread.
+struct Shown<'a> {
+ item: &'a Item,
+ page: Option,
+ parent_xf: Affine,
+}
+
+/// Where an asset shows: frames on spreads (see [`uses`]) and anchored or inline objects, whose
+/// page and position come from the frame their story lays them out in.
+fn shown<'a>(d: &'a Document, cache: &Cache, aid: AssetId) -> Vec> {
+ fn walk<'a>(it: &'a Item, xf: Affine, aid: AssetId, page: Option, out: &mut Vec>) {
+ if let Content::Graphic(g) = &it.content
+ && g.asset == aid
+ {
+ out.push(Shown { item: it, page, parent_xf: xf });
+ }
+ for c in it.children() {
+ walk(c, xf * it.xf, aid, page, out);
+ }
+ }
+ let mut out: Vec = uses(d, aid)
+ .into_iter()
+ .filter_map(|id| {
+ let loc = d.find(id)?;
+ Some(Shown { item: d.item_at(&loc)?, page: d.page_of_item(id), parent_xf: d.parent_xf(&loc) })
+ })
+ .collect();
+ for st in d.stories.values() {
+ let mut composed = None;
+ for (k, o) in st.objects.iter().enumerate() {
+ let mut found = false;
+ o.item.walk(&mut |i| found |= matches!(&i.content, Content::Graphic(g) if g.asset == aid));
+ if !found {
+ continue;
+ }
+ let cs = composed.get_or_insert_with(|| cache.get(d, st.id, None));
+ let placed = cs.frames.iter().find_map(|ft| ft.objects.iter().find(|p| p.index == k).map(|p| (ft.frame, p.origin)));
+ // Overset: no page; the resolution of the object as it would show.
+ let (page, xf) = match placed {
+ Some((fid, origin)) => {
+ let frame_xf =
+ d.find(fid).map_or(Affine::IDENTITY, |loc| d.parent_xf(&loc)) * d.item(fid).map_or(Affine::IDENTITY, |i| d.text_xf(i));
+ (d.page_of_item(fid), frame_xf * Affine::translate(origin.to_vec2()))
+ }
+ None => (None, Affine::IDENTITY),
+ };
+ walk(&o.item, xf, aid, page, &mut out);
+ }
+ }
+ out
+}
+
+fn list(d: &Document, cache: &Cache, document: Option<&Path>, checks: &mut TextChecks) -> Vec {
let texts = d.stories.values().filter_map(|st| {
let l = st.file_link.as_ref()?;
let uses: Vec = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
- Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks), "edited": story_hash(st) != l.story_hash, "uses": uses}))
+ Some(json!({"story": st.id.0, "name": file_name(&l.path), "path": l.path, "status": text_status(l, checks, document), "refused": refused(&l.path, document).map(|r| r.to_string()), "edited": story_hash(st) != l.story_hash, "uses": uses}))
});
let used = d.used_assets();
d.assets
.values()
.filter(|a| used.contains(&a.id))
.map(|a| {
- let uses: Vec = uses(d, a.id)
+ let uses: Vec = shown(d, cache, a.id)
.into_iter()
- .map(|id| {
+ .map(|u| {
+ let it = u.item;
// Effective resolution: image pixels over the size it is shown at.
let ppi = (|| {
- let loc = d.find(id)?;
- let it = d.item_at(&loc)?;
let Content::Graphic(g) = &it.content else { return None };
// Vector (placed PDF or SVG): no effective resolution.
if a.mime == "application/pdf" || a.mime == "image/svg+xml" {
return None;
}
let (pw, _) = a.pixels?;
- let xf = d.parent_xf(&loc) * it.xf * g.xf;
+ let xf = u.parent_xf * it.xf * g.xf;
let shown_w = xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1)).width();
(shown_w > 0.0).then(|| (pw as f64 / (shown_w / 72.0)).round())
})();
- let page = d.page_of_item(id).map(|p| d.page_name(p));
- json!({"id": id.0, "page": page, "ppi": ppi})
+ let page = u.page.map(|p| d.page_name(p));
+ json!({"id": it.id.0, "page": page, "ppi": ppi})
})
.collect();
let pdf_crop = (a.mime == "application/pdf").then(|| a.pdf_crop.name());
- json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a), "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
+ let why = a.link.as_deref().and_then(|l| refused(l, document)).map(|r| r.to_string());
+ json!({"asset": a.id.0, "name": a.name, "path": a.link, "status": status(a, document), "refused": why, "pixels": a.pixels, "pdfCrop": pdf_crop, "uses": uses})
})
.chain(texts)
.collect()
@@ -282,15 +358,16 @@ fn list(d: &Document, checks: &mut TextChecks) -> Vec {
/// A linked text file's bytes, with its size and time from before they were read.
#[cfg(not(target_arch = "wasm32"))]
-fn read_text(path: &str) -> Result<(Vec, Fingerprint)> {
- let err = |e: std::io::Error| EngineError::Other(format!("{path}: {e}"));
+fn read_text(path: impl AsRef) -> Result<(Vec, Fingerprint)> {
+ let path = path.as_ref();
+ let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
// The roots' own message when they refuse the path.
crate::file_access::check_read(path).map_err(EngineError::Other)?;
let m = crate::io::metadata(path).map_err(err)?;
if !crate::io::is_file(path) {
- return Err(EngineError::Other(format!("{path}: not a file")));
+ return Err(EngineError::Other(format!("{}: not a file", path.display())));
}
- let too_large = || EngineError::Other(format!("{path}: larger than {} MiB", MAX_TEXT_LINK_BYTES / (1024 * 1024)));
+ let too_large = || EngineError::Other(format!("{}: larger than {} MiB", path.display(), MAX_TEXT_LINK_BYTES / (1024 * 1024)));
if m.len() > MAX_TEXT_LINK_BYTES {
return Err(too_large());
}
@@ -303,8 +380,8 @@ fn read_text(path: &str) -> Result<(Vec, Fingerprint)> {
}
#[cfg(target_arch = "wasm32")]
-fn read_text(path: &str) -> Result<(Vec, Fingerprint)> {
- Err(EngineError::Other(format!("{path}: files can't be read on the web")))
+fn read_text(path: impl AsRef) -> Result<(Vec, Fingerprint)> {
+ Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display())))
}
/// Import a linked text file again; a warning says when edits made in the layout were replaced.
@@ -318,24 +395,28 @@ fn reimport(d: &mut Document, sid: StoryId, path: &str, bytes: &[u8], before: Fi
}
#[cfg(not(target_arch = "wasm32"))]
-fn reload(path: &str) -> Result<(Vec, (u32, u32))> {
+fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> {
+ let path = path.as_ref();
let b = crate::io::read_file(path).map_err(EngineError::Other)?;
- let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{path}: unsupported or corrupt image")))?;
+ let px = designcraft_render::image_size(&b).ok_or_else(|| bad("links", format!("{}: unsupported or corrupt image", path.display())))?;
Ok((b, px))
}
#[cfg(target_arch = "wasm32")]
-fn reload(path: &str) -> Result<(Vec, (u32, u32))> {
- Err(EngineError::Other(format!("{path}: files can't be read on the web")))
+fn reload(path: impl AsRef) -> Result<(Vec, (u32, u32))> {
+ Err(EngineError::Other(format!("{}: files can't be read on the web", path.as_ref().display())))
}
-fn relinked_asset(a: &designcraft_doc::Asset, path: &str) -> Result> {
- let (bytes, px) = reload(path)?;
+/// The replacement for `a`: bytes read from `read`, with `link` recorded as the stored link.
+/// Relink passes the file the user chose as both. Update passes the path the stored link resolved
+/// to and the stored link itself, so refreshing a link never rewrites what the document holds.
+fn relinked_asset(a: &designcraft_doc::Asset, read: &Path, link: &str) -> Result> {
+ let (bytes, px) = reload(read)?;
let page = if designcraft_render::is_pdf(&bytes) { a.page } else { 0 };
let px = if page > 0 {
designcraft_render::pdf_page_size(&bytes, page as usize)
.map(|(w, h)| (w.round().max(1.0) as u32, h.round().max(1.0) as u32))
- .ok_or_else(|| bad("links", format!("{path}: can't read PDF page {}", u64::from(page) + 1)))?
+ .ok_or_else(|| bad("links", format!("{}: can't read PDF page {}", read.display(), u64::from(page) + 1)))?
} else {
px
};
@@ -344,8 +425,8 @@ fn relinked_asset(a: &designcraft_doc::Asset, path: &str) -> Result Result {
let aid = asset_param(p, "links.relink")?;
let a = s.doc()?.doc.assets.get(&aid).ok_or_else(|| bad("links.relink", "no such graphic"))?;
// Build the replacement before the edit: a file that cannot be read leaves the document as it was.
- let fresh = relinked_asset(a, &path)?;
+ let fresh = relinked_asset(a, Path::new(&path), &path)?;
s.edit(|d, _| {
let name = fresh.name.clone();
d.assets.insert(aid, fresh);
@@ -387,11 +468,12 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result {
}
let missing_only = p.get("missingOnly").and_then(Value::as_bool).unwrap_or(dir.is_some() && ext.is_none());
let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect());
- let d = &s.doc()?.doc;
+ let st = s.doc()?;
+ let (d, document) = (&st.doc, st.path.as_deref().map(Path::new));
let mut todo: Vec<(AssetId, std::path::PathBuf)> = Vec::new();
let mut not_found = Vec::new();
for a in d.assets.values() {
- if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a) != "missing") {
+ if only.as_ref().is_some_and(|o| !o.contains(&a.id.0)) || (missing_only && status(a, document) != "missing") {
continue;
}
let old = std::path::PathBuf::from(a.link.clone().unwrap_or_else(|| a.name.clone()));
@@ -399,9 +481,15 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result {
if let Some(e) = &ext {
name = std::path::Path::new(&name).with_extension(e).into_os_string();
}
- let folder = dir.clone().or_else(|| old.parent().map(|p| p.to_path_buf())).unwrap_or_default();
- let cand = folder.join(&name);
- if crate::io::exists(&cand) {
+ // The link's own folder only where the link may be followed; the name only as a plain
+ // file name (not a device).
+ let folder = match &dir {
+ Some(dir) => Some(dir.clone()),
+ None => a.link.as_deref().and_then(|l| crate::io::document_path(l, document).ok()).and_then(|p| p.parent().map(Path::to_path_buf)),
+ };
+ let plain_name = name.to_str().is_some_and(|n| !n.contains(['/', '\\']) && craft_docpath::is_local(n));
+ let cand = folder.filter(|_| plain_name).map(|f| f.join(&name));
+ if let Some(cand) = cand.filter(|c| crate::io::exists(c)) {
todo.push((a.id, cand));
} else {
not_found.push(name.to_string_lossy().to_string());
@@ -412,7 +500,7 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result {
let mut fresh = Vec::new();
for (aid, path) in todo {
let a = d.assets.get(&aid).ok_or_else(|| bad("links.relinkFolder", "no such graphic"))?;
- fresh.push((aid, relinked_asset(a, &path.to_string_lossy())?));
+ fresh.push((aid, relinked_asset(a, &path, &path.to_string_lossy())?));
}
let n = fresh.len();
if n > 0 {
@@ -427,25 +515,36 @@ fn relink_folder(s: &mut Session, p: &Value) -> Result {
}
fn update(s: &mut Session, p: &Value) -> Result {
- let d = s.doc()?.doc.clone();
+ let (d, document) = {
+ let st = s.doc()?;
+ (st.doc.clone(), st.path.clone())
+ };
+ let document = document.as_deref().map(Path::new);
let checks = &mut s.text_link_checks;
let (only_asset, only_story) = (p.get("asset").and_then(Value::as_u64), story_param(p));
let all = only_asset.is_none() && only_story.is_none();
- let texts: Vec<(StoryId, String)> = d
+ // A link craft-docpath refuses is missing, so it is never read or refreshed.
+ let resolved = |link: &str| crate::io::document_path(link, document).ok();
+ let texts: Vec<(StoryId, String, std::path::PathBuf)> = d
.stories
.values()
.filter(|st| all || only_story == Some(st.id))
- .filter_map(|st| st.file_link.as_ref().filter(|l| text_status(l, checks) == "modified").map(|l| (st.id, l.path.clone())))
+ .filter_map(|st| {
+ let l = st.file_link.as_ref().filter(|l| text_status(l, checks, document) == "modified")?;
+ Some((st.id, l.path.clone(), resolved(&l.path)?))
+ })
.collect();
let mut fresh = Vec::new();
- for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a) == "modified") {
- if let Some(path) = &a.link {
- fresh.push((a.id, relinked_asset(a, path)?));
+ for a in d.assets.values().filter(|a| all || only_asset == Some(a.id.0)).filter(|a| status(a, document) == "modified") {
+ // The bytes come from where the stored link resolves; the link itself stays as the
+ // document stored it, so refreshing never rewrites a relative link to an absolute one.
+ if let Some((link, at)) = a.link.as_deref().and_then(|l| resolved(l).map(|at| (l, at))) {
+ fresh.push((a.id, relinked_asset(a, &at, link)?));
}
}
let mut fresh_texts = Vec::new();
- for (sid, path) in texts {
- let (bytes, before) = read_text(&path)?;
+ for (sid, path, at) in texts {
+ let (bytes, before) = read_text(&at)?;
fresh_texts.push((sid, path, bytes, before));
}
let n = fresh.len() + fresh_texts.len();
@@ -482,6 +581,31 @@ mod tests {
super::super::file::base64_decode(result["base64"].as_str().unwrap())
}
+ #[test]
+ fn anchored_image_lists_its_page_and_resolution() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 2, "facingPages": false})).unwrap();
+ let r = s
+ .execute("frame.create", &json!({"spread": 1, "rect": [72, 72, 400, 400], "content": "text", "text": "Before after and more words."}))
+ .unwrap();
+ let sid = s.doc().unwrap().doc.item(ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
+ let placed = s
+ .execute(
+ "file.place",
+ &json!({"base64": super::super::file::base64_encode(&png(10, 10)), "name": "dot.png", "x": 450, "y": 450, "width": 36}),
+ )
+ .unwrap();
+ let id = placed["id"].as_u64().unwrap();
+ let before = s.execute("links.list", &json!({})).unwrap();
+ assert_eq!(before[0]["uses"][0]["ppi"], 20.0);
+ s.execute("anchored.insert", &json!({"ids": [id], "story": sid.0, "pos": 7})).unwrap();
+ let list = s.execute("links.list", &json!({})).unwrap();
+ let uses = list[0]["uses"].as_array().unwrap();
+ assert_eq!(uses.len(), 1, "anchored image must have one use: {list}");
+ assert_eq!(uses[0]["page"], "2");
+ assert_eq!(uses[0]["ppi"], 20.0);
+ }
+
#[test]
fn eps_web_exports_convert_tiff_and_skip_bad_used_assets() {
use std::io::Read;
@@ -571,7 +695,8 @@ mod tests {
assert_eq!(s.execute("links.list", &json!({})).unwrap()[0]["status"], "embedded");
std::fs::remove_file(&replacement).unwrap();
let embedded = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
- assert_eq!(status(&embedded.assets[&aid]), "embedded");
+ // The asset is embedded, so no link is followed and the document is irrelevant.
+ assert_eq!(status(&embedded.assets[&aid], None), "embedded");
assert_eq!(embedded.assets[&aid].data.as_slice(), changed);
assert!(designcraft_render::decode_pixmap(&embedded.assets[&aid].data).is_some());
let _ = std::fs::remove_dir_all(dir);
@@ -1205,7 +1330,7 @@ mod text_link_tests {
// Saved and opened again: the link and its options are kept.
let back = designcraft_format::load(&designcraft_format::save(&s.doc().unwrap().doc).unwrap()).unwrap();
assert_eq!(back.story(sid).unwrap().file_link, story(&s, sid).file_link);
- assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default()), "ok");
+ assert_eq!(super::text_status(back.story(sid).unwrap().file_link.as_ref().unwrap(), &mut Default::default(), None), "ok");
// The file moves: Missing; Relink to it imports it into the story.
let moved = dir.join("moved").join("chapter.docx");
@@ -1344,7 +1469,7 @@ mod text_link_read_tests {
fn status_of(path: &std::path::Path, size: u64) -> &'static str {
let l = TextFileLink { path: path.to_string_lossy().to_string(), size, hash: "0".into(), ..Default::default() };
- promptly(move || text_status(&l, &mut Default::default()))
+ promptly(move || text_status(&l, &mut Default::default(), None))
}
fn read(path: &std::path::Path) -> Result {
diff --git a/crates/engine/src/cmd/mathexpr.rs b/crates/engine/src/cmd/mathexpr.rs
index e0ec7a56b..b684f2457 100644
--- a/crates/engine/src/cmd/mathexpr.rs
+++ b/crates/engine/src/cmd/mathexpr.rs
@@ -61,7 +61,9 @@ fn insert(s: &mut Session, p: &Value) -> Result {
if let Some(id) = super::id_param(p, "id") {
return s.edit(|d, _| {
let old = d.item(id).ok_or_else(|| bad("math.insert", "no such object"))?;
- let at = old.bounds();
+ // The path and graphic live in the item's inner space, which `xf` maps to the spread:
+ // build the new ones at the old inner corner and keep `xf`.
+ let at = old.path.bounds().ok_or_else(|| bad("math.insert", "equation has no geometry"))?;
let it = math_item(d, &latex, size, lid, (at.x0, at.y0));
let target = d.item_mut(id).ok_or_else(|| bad("math.insert", "no such object"))?;
target.path = it.path;
@@ -129,4 +131,46 @@ mod tests {
let st = &s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(f["story"].as_u64().unwrap())];
assert_eq!(st.objects.len(), 1);
}
+
+ /// The item's top-left corner in spread space (inner path corner through its transform).
+ fn origin(s: &Session, id: designcraft_doc::ItemId) -> designcraft_geom::Point {
+ let d = &s.doc().unwrap().doc;
+ let it = d.item(id).unwrap();
+ let r = it.path.bounds().unwrap();
+ it.xf * designcraft_geom::Point::new(r.x0, r.y0)
+ }
+
+ #[test]
+ fn editing_a_transformed_equation_keeps_its_place() {
+ for step in ["move", "rotate", "scale"] {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("math.insert", &json!({"latex": r"\frac{a}{b}", "x": 100, "y": 100})).unwrap();
+ let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
+ match step {
+ "move" => s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})),
+ "rotate" => s.execute("transform.rotate", &json!({"ids": [id.0], "angle": 30})),
+ _ => s.execute("transform.scale", &json!({"ids": [id.0], "sx": 2.0, "sy": 1.5})),
+ }
+ .unwrap();
+ let before = origin(&s, id);
+ s.execute("math.insert", &json!({"id": id.0, "latex": r"\frac{a}{b}"})).unwrap();
+ let after = origin(&s, id);
+ assert!((before.x - after.x).abs() < 1e-6 && (before.y - after.y).abs() < 1e-6, "{step}: {before:?} -> {after:?}");
+ }
+ }
+
+ #[test]
+ fn editing_a_moved_equation_keeps_its_bounds() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("math.insert", &json!({"latex": "x", "x": 100, "y": 100})).unwrap();
+ let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
+ s.execute("transform.move", &json!({"ids": [id.0], "dx": 50, "dy": 40})).unwrap();
+ let before = s.doc().unwrap().doc.item(id).unwrap().bounds();
+ s.execute("math.insert", &json!({"id": id.0, "latex": "x"})).unwrap();
+ let after = s.doc().unwrap().doc.item(id).unwrap().bounds();
+ assert_eq!((before.x0, before.y0), (after.x0, after.y0));
+ assert_eq!((after.x0, after.y0), (150.0, 140.0));
+ }
}
diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs
index 124c74826..3b16b9cfc 100644
--- a/crates/engine/src/cmd/mod.rs
+++ b/crates/engine/src/cmd/mod.rs
@@ -259,9 +259,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
}
/// Plain text from a parameter, with CR LF and lone CR (classic Mac and InDesign line ends) as
/// `\n`, the paragraph separator, as edit.paste and text import already do. Kept as they were,
-/// the CRs joined the paragraphs into one.
+/// the CRs joined the paragraphs into one. A vertical tab (Word's and PowerPoint's Shift+Enter,
+/// Chr(11)) is a forced line break: kept, it was drawn as a missing-glyph box.
pub(crate) fn text_param(p: &Value, key: &str) -> String {
- str_param(p, key).unwrap_or("").replace("\r\n", "\n").replace('\r', "\n")
+ str_param(p, key)
+ .unwrap_or("")
+ .replace("\r\n", "\n")
+ .replace('\r', "\n")
+ .replace('\u{B}', designcraft_doc::story::FORCED_LINE_BREAK.encode_utf8(&mut [0; 4]))
}
pub(crate) fn id_param(p: &Value, key: &str) -> Option {
p.get(key).and_then(Value::as_u64).map(ItemId)
diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs
index 2d5817600..82f33a6d8 100644
--- a/crates/engine/src/cmd/object.rs
+++ b/crates/engine/src/cmd/object.rs
@@ -259,9 +259,28 @@ pub fn specs() -> Vec {
"Stroke",
[],
None,
- "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind:…}, cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
+ "{swatch?, tint?, weight?, align?: center|inside|outside, type?: {kind: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed|stripes|style, pattern?: [dash, gap, …] (dashed: required, points), bands?: [[start, width], …] (stripes: required, fractions of stroke weight), name?: string (style: required, document stroke style name)} (other kinds take no extra fields; unknown type fields are errors), cap?: butt|round|projecting, join?: miter|round|bevel, miterLimit?, start?, end?: none|simple|simpleWide|triangle|triangleWide|barbed|curved|circle|circleSolid|square|squareSolid|bar, gapSwatch?, gapTint?, gapOverprint?: bool, ids?}",
has_doc,
|s, p| {
+ let kind: Option = p
+ .get("type")
+ .map(|v| {
+ if let Some(fields) = v.as_object() {
+ let field = match v.get("kind").and_then(Value::as_str) {
+ Some("dashed") => Some("pattern"),
+ Some("stripes") => Some("bands"),
+ Some("style") => Some("name"),
+ _ => None,
+ };
+ for key in fields.keys() {
+ if key != "kind" && Some(key.as_str()) != field {
+ return Err(bad("object.stroke", format!("type: unknown field `{key}`")));
+ }
+ }
+ }
+ serde_json::from_value(v.clone()).map_err(|e| bad("object.stroke", format!("type: {e}")))
+ })
+ .transpose()?;
for key in ["swatch", "gapSwatch"] {
if let Some(name) = p.get(key).and_then(Value::as_str) {
known_swatch(&s.doc()?.doc, name, "object.stroke", key)?;
@@ -297,7 +316,7 @@ pub fn specs() -> Vec {
st.swatch = v.into();
}
if let Some(v) = p2.get("tint").and_then(Value::as_f64) {
- st.tint = v as f32;
+ st.tint = v.clamp(0.0, 1.0) as f32;
}
if let Some(v) = p2.get("weight").and_then(Value::as_f64) {
st.weight = v.max(0.0);
@@ -309,16 +328,18 @@ pub fn specs() -> Vec {
st.gap_swatch = v.into();
}
if let Some(v) = p2.get("gapTint").and_then(Value::as_f64) {
- st.gap_tint = v as f32;
+ st.gap_tint = v.clamp(0.0, 1.0) as f32;
}
if let Some(v) = p2.get("gapOverprint").and_then(Value::as_bool) {
st.gap_overprint = v;
}
- for (k, apply) in [("align", 0), ("type", 1), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] {
+ if let Some(kind) = &kind {
+ st.kind = kind.clone();
+ }
+ for (k, apply) in [("align", 0), ("cap", 2), ("join", 3), ("start", 4), ("end", 5)] {
if let Some(v) = p2.get(k).cloned() {
let _ = match apply {
0 => serde_json::from_value(v).map(|x| st.align = x),
- 1 => serde_json::from_value(v).map(|x| st.kind = x),
2 => serde_json::from_value(v).map(|x| st.cap = x),
3 => serde_json::from_value(v).map(|x| st.join = x),
4 => serde_json::from_value(v).map(|x| st.start = x),
@@ -1201,11 +1222,10 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result {
let to = rect_param(p, "to").ok_or_else(|| bad("transform.resize", "missing to"))?;
let scale_content = bool_or(p, "content", false);
let distribute = bool_or(p, "distribute", false);
- if from.width().abs() < 1e-9 || from.height().abs() < 1e-9 {
- return Err(bad("transform.resize", "degenerate source rect"));
- }
- let sx = to.width() / from.width();
- let sy = to.height() / from.height();
+ // A flat source (a horizontal or vertical line) has no size to scale on its flat axis: that axis only moves.
+ let ratio = |new: f64, old: f64| if old.abs() < 1e-9 { 1.0 } else { new / old };
+ let sx = ratio(to.width(), from.width());
+ let sy = ratio(to.height(), from.height());
let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx };
let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy };
let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0));
@@ -4189,6 +4209,16 @@ mod fitting_tests {
assert!(String::from_utf8_lossy(&bytes).contains("/Subtype/Image"));
}
+ #[test]
+ fn a_flat_line_resizes_along_its_length() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let id = s.execute("line.create", &json!({"a": [100, 100], "b": [200, 100]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("transform.resize", &json!({"ids": [id], "from": [100, 100, 200, 100], "to": [100, 100, 300, 100]})).unwrap();
+ let r = s.doc().unwrap().doc.item(ItemId(id)).unwrap().bounds();
+ assert!((r.x0 - 100.0).abs() < 1e-9 && (r.x1 - 300.0).abs() < 1e-9 && (r.y0 - 100.0).abs() < 1e-9 && r.height().abs() < 1e-9, "{r:?}");
+ }
+
#[test]
fn live_distribute_spreads_objects_without_resizing() {
let mut s = Session::new();
@@ -4369,6 +4399,22 @@ mod line_end_tests {
s.execute("note.edit", &json!({"story": n["story"], "id": n["id"], "text": "Three\r\nFour"})).unwrap();
assert_eq!(note(&mut s), "Three\nFour");
}
+
+ /// "One\u{B}Two" kept the vertical tab, drawn as a missing-glyph box, where Word and
+ /// PowerPoint text (Chr(11)) means a forced line break.
+ #[test]
+ fn vertical_tabs_are_forced_line_breaks() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 300], "content": "text", "text": "One\u{B}Two"})).unwrap();
+ let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap());
+ let story = |s: &Session| s.doc().unwrap().doc.story(sid).unwrap().clone();
+ assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two", 1));
+ s.execute("text.insert", &json!({"text": "\u{B}Three", "raw": true})).unwrap();
+ assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("One\u{2028}Two\u{2028}Three", 1));
+ s.execute("story.setText", &json!({"story": sid.0, "text": "A\u{B}B\rC"})).unwrap();
+ assert_eq!((story(&s).text.as_str(), story(&s).paras.len()), ("A\u{2028}B\nC", 2));
+ }
}
#[cfg(test)]
@@ -4799,6 +4845,64 @@ mod group_tests {
}
}
+#[cfg(test)]
+mod stroke_type_tests {
+ use serde_json::json;
+
+ #[test]
+ fn malformed_stroke_types_return_bad_params_without_editing() {
+ let mut s = crate::Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].clone();
+ for (kind, hint) in [
+ (json!({"kind": "dashed", "dash": [8, 2.5]}), "dash"),
+ (json!({"kind": "zigzag"}), "zigzag"),
+ (json!({"kind": "solid", "dash": [8, 2.5]}), "dash"),
+ (json!({"kind": "dashed", "pattern": [8, 2.5], "dash": [1, 2]}), "dash"),
+ (json!({"kind": "dashed"}), "pattern"),
+ (json!({"kind": "dashed", "pattern": "bad"}), "type"),
+ (json!({"kind": "stripes", "bands": [[0, "bad"]]}), "type"),
+ (json!({"kind": "style"}), "name"),
+ (json!({}), "kind"),
+ (json!("solid"), "type"),
+ (json!(null), "type"),
+ ] {
+ let before = s.doc().unwrap().doc.clone();
+ let error = s.execute("object.stroke", &json!({"ids": [id], "weight": 5, "type": kind})).unwrap_err();
+ assert!(matches!(error, crate::EngineError::BadParams { .. }), "{error}");
+ assert!(error.to_string().contains(hint), "{kind}: {error}");
+ assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc), "{kind}: nothing changed");
+ }
+ }
+
+ #[test]
+ fn stroke_types_apply_all_supported_variants() {
+ let mut s = crate::Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let id = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap();
+ for kind in [
+ json!({"kind": "solid"}),
+ json!({"kind": "dashed", "pattern": [8, 2.5]}),
+ json!({"kind": "dotted"}),
+ json!({"kind": "thickThin"}),
+ json!({"kind": "thinThick"}),
+ json!({"kind": "thinThin"}),
+ json!({"kind": "thickThick"}),
+ json!({"kind": "thinThickThin"}),
+ json!({"kind": "thickThinThick"}),
+ json!({"kind": "wavy"}),
+ json!({"kind": "hashed"}),
+ json!({"kind": "stripes", "bands": [[0, 0.3], [0.7, 0.3]]}),
+ json!({"kind": "style", "name": "Rails"}),
+ ] {
+ let result = s.execute("object.stroke", &json!({"ids": [id], "type": kind})).unwrap();
+ assert_eq!(result["changed"], 1);
+ let item = s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap();
+ assert_eq!(item.stroke.kind, serde_json::from_value::(kind).unwrap());
+ }
+ }
+}
+
#[cfg(test)]
mod swatch_name_tests {
use serde_json::json;
diff --git a/crates/engine/src/cmd/package.rs b/crates/engine/src/cmd/package.rs
index b953fdc75..f2d33ccfa 100644
--- a/crates/engine/src/cmd/package.rs
+++ b/crates/engine/src/cmd/package.rs
@@ -18,6 +18,9 @@ use serde_json::{Value, json};
const MAX_OUTPUT_COMPONENT_BYTES: usize = 200;
+/// The folder of a package that holds the files its document places.
+const LINKS_FOLDER: &str = "Links";
+
fn path_error(path: &Path, message: impl std::fmt::Display) -> EngineError {
EngineError::Other(format!("{}: {message}", path.display()))
}
@@ -282,7 +285,10 @@ fn unique_name(base: String, taken: &mut Vec) -> String {
}
/// Write each asset's bytes into `dir`; returns the relinked document and the paths written.
-fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>) -> Result<(Document, Vec)> {
+/// `relative_to` names the folder `dir` is, beside the saved document: the links then read
+/// `Links/logo.png`, so a package holds no path from the machine that made it (#340). `None` links
+/// the copies by their full paths (Links › Copy Link(s) To, which relinks the open document).
+fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[AssetId]>, relative_to: Option<&str>) -> Result<(Document, Vec)> {
root.validate_directory(dir)?;
let mut out = d.clone();
let mut taken = Vec::new();
@@ -303,7 +309,10 @@ fn write_links(d: &Document, root: &OutputRoot, dir: &Path, only: Option<&[Asset
let path = dir.join(&name);
root.write(&path, a.data.as_slice())?;
let mut na = (**a).clone();
- na.link = Some(path.to_string_lossy().to_string());
+ na.link = Some(match relative_to {
+ Some(folder) => format!("{folder}/{name}"),
+ None => path.to_string_lossy().to_string(),
+ });
out.assets.insert(id, Arc::new(na));
written.push(path);
}
@@ -387,8 +396,8 @@ fn package(s: &mut Session, p: &Value) -> Result {
let links = s.execute("links.list", &json!({}))?;
let pre = s.execute("preflight.run", &json!({})).unwrap_or(Value::Null);
let d = s.doc()?.doc.clone();
- let links_dir = root.child("Links")?;
- let (packed, mut files) = write_links(&d, &root, &links_dir, None)?;
+ let links_dir = root.child(LINKS_FOLDER)?;
+ let (packed, mut files) = write_links(&d, &root, &links_dir, None, Some(LINKS_FOLDER))?;
let doc_path = package_output_path(&dir, &title, "designcraft")?;
root.write(&doc_path, &super::to_bytes(&packed)?)?;
files.insert(0, doc_path);
@@ -463,7 +472,7 @@ fn copy_links(s: &mut Session, p: &Value) -> Result {
let only: Option> = p.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(AssetId).collect());
let d = s.doc()?.doc.clone();
let root = OutputRoot::new(dir.clone())?;
- let (relinked, written) = write_links(&d, &root, &dir, only.as_deref())?;
+ let (relinked, written) = write_links(&d, &root, &dir, only.as_deref(), None)?;
s.edit(|doc, _| {
doc.assets = relinked.assets;
Ok(())
@@ -520,14 +529,17 @@ mod tests {
std::io::Read::read_to_string(&mut f, &mut spreads).unwrap();
}
}
- let linked = dir.join("Links").join("my photo.png");
- let uri = format!("file:{}", linked.to_string_lossy().replace(' ', "%20"));
+ // Relative to the package: no path from the machine that made it (#340).
+ let uri = "file:Links/my%20photo.png";
+ let folder = dir.file_name().unwrap().to_string_lossy().into_owned();
+ assert!(!spreads.contains(&folder), "the package's own folder is not written: {spreads}");
assert!(spreads.contains(&format!(r#"LinkResourceURI="{uri}""#)), "{spreads}");
assert!(!spreads.contains(") -> ItemId {
+ let mut p = json!({"spread": {"kind": "parent", "index": parent}, "rect": [36, 36, 200, 100]});
+ if let Some(text) = text {
+ p["content"] = json!("text");
+ p["text"] = json!(text);
+ }
+ ItemId(s.execute("frame.create", &p).unwrap()["id"].as_u64().unwrap())
+}
+
+#[test]
+fn parent_delete_rejects_invalid_ids_without_editing_or_adding_history() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ s.execute("layout.parents.edit", &json!({"on": true})).unwrap();
+ let before = s.doc().unwrap().doc.clone();
+ let history = s.doc().unwrap().history.undo.len();
+ for params in [json!({}), json!({"id": null}), json!({"id": "A"}), json!({"id": -1}), json!({"id": 999999})] {
+ assert!(s.execute("layout.parents.delete", ¶ms).is_err(), "{params}");
+ let st = s.doc().unwrap();
+ assert!(Arc::ptr_eq(&st.doc, &before));
+ assert_eq!(st.history.undo.len(), history);
+ assert!(st.editing_parents);
+ }
+}
+
+#[test]
+fn parent_delete_preserves_local_copies_cleans_references_and_roundtrips_undo() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 3, "facingPages": false})).unwrap();
+ let a = s.doc().unwrap().doc.parents[0].id;
+ let text = frame(&mut s, 0, Some("Parent folio"));
+ let story = s.doc().unwrap().doc.item(text).unwrap().text_frame().unwrap().story;
+ let shape = frame(&mut s, 0, None);
+ let group = ItemId(s.execute("object.group", &json!({"ids": [text.0, shape.0]})).unwrap()["id"].as_u64().unwrap());
+ let local = ItemId(s.execute("layout.overrideParentItems", &json!({"page": 0})).unwrap()["ids"][0].as_u64().unwrap());
+ let local_text = s.doc().unwrap().doc.item(local).unwrap().children().iter().find(|i| i.text_frame().is_some()).unwrap().id;
+ let local_story = s.doc().unwrap().doc.item(local_text).unwrap().text_frame().unwrap().story;
+ assert_ne!(story, local_story);
+
+ let b = SpreadId(s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap()["id"].as_u64().unwrap());
+ let c = SpreadId(s.execute("layout.parents.new", &json!({"prefix": "C"})).unwrap()["id"].as_u64().unwrap());
+ let inherited = frame(&mut s, 1, Some("Local parent edit"));
+ s.edit(|d, _| {
+ // Imported parents can base themselves on another parent and carry local overrides.
+ let bp = Arc::make_mut(&mut d.parents[1]);
+ bp.parent.as_mut().unwrap().based_on = Some(a);
+ bp.pages[0].parent = Some(a);
+ bp.pages[0].overridden.push(text);
+ Arc::make_mut(&mut d.parents[2]).parent.as_mut().unwrap().based_on = Some(b);
+ d.item_mut(inherited).unwrap().overrides = Some(text);
+ Ok(())
+ })
+ .unwrap();
+ s.execute("layout.pages.applyParent", &json!({"pages": [1], "parent": "B"})).unwrap();
+ let other_local = ItemId(s.execute("layout.overrideParentItems", &json!({"page": 1, "ids": [inherited.0]})).unwrap()["ids"][0].as_u64().unwrap());
+ s.execute("layout.parents.edit", &json!({"on": true})).unwrap();
+ s.doc_mut().unwrap().selection = Selection::text(TextSel::caret(story, 0));
+ let before = s.doc().unwrap().doc.clone();
+ let history = s.doc().unwrap().history.undo.len();
+ before.check().unwrap();
+
+ s.execute("layout.parents.delete", &json!({"id": a.0})).unwrap();
+ let st = s.doc().unwrap();
+ assert_eq!(st.history.undo.len(), history + 1);
+ assert_eq!(st.selection, Selection::default());
+ assert!(st.editing_parents, "other parents remain editable");
+ let d = &st.doc;
+ assert_eq!(d.parents.iter().map(|p| p.id).collect::>(), [b, c]);
+ assert_eq!(d.page(0).unwrap().parent, None);
+ assert_eq!(d.page(1).unwrap().parent, Some(b));
+ assert_eq!(d.page(2).unwrap().parent, None);
+ assert_eq!(d.parents[0].parent.as_ref().unwrap().based_on, None);
+ assert_eq!(d.parents[1].parent.as_ref().unwrap().based_on, Some(b));
+ assert_eq!(d.parents[0].pages[0].parent, None);
+ assert!(d.parents[0].pages[0].overridden.is_empty());
+ assert!([group, text, shape].iter().all(|id| d.item(*id).is_none()));
+ assert!(d.story(story).is_none(), "nested text frames leave no dangling story");
+ assert_eq!(d.item(local).unwrap().overrides, None);
+ assert_eq!(d.story(local_story).unwrap().text, "Parent folio");
+ assert!(d.page(0).unwrap().overridden.is_empty());
+ assert_eq!(d.item(inherited).unwrap().overrides, None);
+ assert_eq!(d.item(other_local).unwrap().overrides, Some(inherited));
+ assert_eq!(d.page(1).unwrap().overridden, [inherited]);
+ d.check().unwrap();
+ let after = d.clone();
+
+ s.execute("edit.undo", &Value::Null).unwrap();
+ assert_eq!(s.doc().unwrap().doc, before);
+ s.doc().unwrap().doc.check().unwrap();
+ s.execute("edit.redo", &Value::Null).unwrap();
+ assert_eq!(s.doc().unwrap().doc, after);
+ s.doc().unwrap().doc.check().unwrap();
+}
+
+#[test]
+fn parent_delete_last_parent_returns_to_document_pages() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"pages": 2})).unwrap();
+ let id = s.doc().unwrap().doc.parents[0].id;
+ s.execute("layout.parents.edit", &json!({"on": true})).unwrap();
+ frame(&mut s, 0, Some("Last parent"));
+ let selection_before = s.doc().unwrap().selection.clone();
+ assert!(selection_before.text.is_some(), "creating the text frame leaves a caret");
+ s.execute("layout.parents.delete", &json!({"id": id.0})).unwrap();
+ let st = s.doc().unwrap();
+ assert!(st.doc.parents.is_empty());
+ assert!(!st.editing_parents);
+ assert_eq!(st.selection, Selection::default());
+ assert_eq!(st.doc.page_count(), 2);
+ assert!(st.doc.spreads.iter().flat_map(|sp| &sp.pages).all(|page| page.parent.is_none()));
+ s.execute("edit.undo", &Value::Null).unwrap();
+ assert_eq!(s.doc().unwrap().doc.parents[0].id, id);
+ assert_eq!(s.doc().unwrap().selection, selection_before);
+ s.execute("edit.redo", &Value::Null).unwrap();
+ assert!(s.doc().unwrap().doc.parents.is_empty());
+ assert!(!s.doc().unwrap().editing_parents);
+ assert_eq!(s.doc().unwrap().selection, Selection::default());
+ // A parent-free document still supports creating another parent.
+ s.execute("layout.parents.new", &json!({"prefix": "B"})).unwrap();
+ assert_eq!(s.doc().unwrap().doc.parents.len(), 1);
+ s.doc().unwrap().doc.check().unwrap();
+}
diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs
index 5343f54f4..fa93d4e40 100644
--- a/crates/engine/src/cmd/place_text.rs
+++ b/crates/engine/src/cmd/place_text.rs
@@ -236,7 +236,8 @@ fn same_rect(a: Rect, b: Rect) -> bool {
/// on its page (when it sits in one), then one frame per column on each page it adds. With
/// `primary` (Smart Text Reflow of the primary text frame), each added page gets one frame on the
/// live area with the page's columns instead. Empty frames it made at the end of the thread are
-/// removed again, except the first on a page. Returns pages added.
+/// removed again, except the first on a page it didn't add; a page it added that holds only such
+/// a frame goes with it. Returns pages added.
/// A story on a parent spread doesn't autoflow: its frames thread only on that parent. Errors
/// name `cmd`, the command that autoflows.
pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &str, primary: bool) -> Result {
@@ -249,6 +250,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let vertical = story_vertical || primary;
let mut added = 0;
let mut made = Vec::new();
+ let mut new_pages = Vec::new();
let mut first = true;
loop {
let (overset, held, full) = fit(d, sid);
@@ -278,7 +280,7 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
let per_page = if vertical { 1 } else { d.page(page).map_or(1, |p| p.columns.count.max(1) as usize) };
let pages = want.div_ceil(per_page).clamp(1, max_pages - added);
let parent = d.page_loc(page).and_then(|(si, pi)| d.spreads[si].pages[pi].parent);
- d.insert_pages(Some(page), pages, parent)?;
+ new_pages.extend(d.insert_pages(Some(page), pages, parent)?);
for pg in page + 1..=page + pages {
let (si, pi) = d.page_loc(pg).ok_or_else(|| bad(cmd, "page insert failed"))?;
let columns = d.spreads[si].pages[pi].columns.clone();
@@ -297,10 +299,24 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s
while let Some(&f) = made.last() {
let frames = d.story(sid).map_or(&[][..], |s| s.frames.as_slice());
let [.., before, tail] = frames else { break };
- if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) || d.page_of_item(*before) != d.page_of_item(f) {
+ if *tail != f || cs.frame(f).is_some_and(|ft| !ft.range.is_empty()) {
break;
}
- d.remove_item(f)?;
+ let page = d.page_of_item(f);
+ if d.page_of_item(*before) != page {
+ // The first frame on its page: the estimate added more pages than the text needs.
+ // Drop the page too when autoflow added it and the frame is all it holds.
+ let Some(pg) = page.filter(|pg| d.page(*pg).is_some_and(|p| new_pages.contains(&p.id))) else { break };
+ let alone = d.spreads.iter().flat_map(|sp| sp.items.iter()).all(|it| it.id == f || d.page_of_item(it.id) != Some(pg));
+ if !alone {
+ break;
+ }
+ d.remove_item(f)?;
+ d.delete_pages(&[pg])?;
+ added = added.saturating_sub(1);
+ } else {
+ d.remove_item(f)?;
+ }
made.pop();
}
Ok(added)
@@ -366,7 +382,14 @@ pub(super) fn place_text(
let story = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
story.delete(r.clone());
+ // An insertion point in an empty paragraph takes the first imported paragraph's style
+ // (insert_story would keep the empty paragraph's own, losing the styleMap result).
+ let p0 = story.para_at(r.start);
+ let blank = story.para_ranges().get(p0).is_some_and(|x| x.is_empty());
let end = story.insert_story(r.start, &imp.story);
+ if blank && let (Some(first), Some(p)) = (imp.story.paras.first(), story.paras.get_mut(p0)) {
+ *p = ParaFormat { table: p.table, ..first.clone() };
+ }
sel.text = Some(TextSel { anchor: end, focus: end, ..t });
if link.is_some() {
warnings.push("not linked: a linked text file fills a whole story, not an insertion point".into());
@@ -686,6 +709,38 @@ mod reflow_tests {
s.execute("text.delete", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.page_count(), 1);
}
+
+ /// Placing text into the primary frame adds only the pages the text needs, also when a wrap
+ /// leaves page 1 little room or the text ends at a page boundary (#1165).
+ #[test]
+ fn smart_text_reflow_place_adds_no_empty_pages() {
+ let dir = std::env::temp_dir().join(format!("dc-reflow-place-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ for (n, wrap, want) in [(280, true, 5), (250, false, 3)] {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({"preset": "Letter", "pages": 1, "primaryTextFrame": true})).unwrap();
+ if wrap {
+ let id = s.execute("frame.create", &json!({"rect": [36, 36, 576, 600]})).unwrap()["id"].as_u64().unwrap();
+ s.execute("object.textWrap", &json!({"ids": [id], "mode": "boundingBox"})).unwrap();
+ }
+ let sid = s.doc().unwrap().doc.settings.primary_story.expect("primary story");
+ s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
+ let path = dir.join(format!("words-{n}.txt"));
+ std::fs::write(&path, "Words keep coming and the page fills up quickly with them. ".repeat(n)).unwrap();
+ s.execute("file.place", &json!({"path": path.to_string_lossy()})).unwrap();
+ let d = s.doc().unwrap().doc.clone();
+ assert_eq!(d.page_count(), want, "{n} lines");
+ let frames = &d.story(sid).unwrap().frames;
+ assert_eq!(frames.len(), d.page_count(), "{n} lines");
+ let cs = designcraft_compose::compose_story(&d, sid, &Default::default());
+ assert!(cs.overset_at.is_none(), "{n} lines");
+ for f in frames {
+ assert!(cs.frame(*f).is_some_and(|ft| !ft.range.is_empty()), "{n} lines: empty frame {f:?}");
+ }
+ d.check().unwrap();
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+ }
}
#[cfg(test)]
@@ -767,6 +822,20 @@ mod pdf_page_tests {
assert_eq!(s.doc().unwrap().doc.styles.para("Body").unwrap().chars.size, Some(10.0));
}
+ #[test]
+ fn word_first_paragraph_into_empty_caret_keeps_mapped_style() {
+ // Regression (#1208): placing into a new text frame (which leaves its caret selected) left the first paragraph unstyled.
+ let b64 = crate::cmd::base64_encode(&word_file());
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ s.execute("style.paragraph.create", &json!({"name": "Headline"})).unwrap();
+ let fr = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text"})).unwrap();
+ let sid = fr["story"].as_u64().unwrap();
+ s.execute("file.place", &json!({"base64": b64, "name": "a.docx", "frame": fr["id"], "styleMap": {"Title": "Headline"}})).unwrap();
+ let st = s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().clone();
+ assert_eq!(st.paras.iter().map(|p| p.style.as_str()).collect::>(), ["Headline", "Body"]);
+ }
+
#[test]
fn word_normal_comes_in_as_its_own_style() {
// Regression (#722): Normal's formatting (over Word's document defaults) was dropped.
diff --git a/crates/engine/src/cmd/preflight.rs b/crates/engine/src/cmd/preflight.rs
index 7ae80a1bb..19d64c1fc 100644
--- a/crates/engine/src/cmd/preflight.rs
+++ b/crates/engine/src/cmd/preflight.rs
@@ -116,6 +116,7 @@ fn overset_location(s: &Session, target: designcraft_doc::ItemId) -> Option<(des
pub fn check(s: &Session, min_ppi: f64) -> Vec {
let Some(st) = s.active() else { return vec![] };
let d = &st.doc;
+ let document = st.path.as_deref().map(std::path::Path::new);
let mut out = Vec::new();
// Stories: overset and fonts.
let mut missing_fonts: Vec = Vec::new();
@@ -251,10 +252,13 @@ pub fn check(s: &Session, min_ppi: f64) -> Vec {
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 }),
- Some(a) if a.data.is_empty() || super::links::link_missing(a) => out.push(Issue {
+ Some(a) if a.data.is_empty() || super::links::link_missing(a, document) => out.push(Issue {
severity: "error",
kind: "missingLink",
- message: format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
+ message: match a.link.as_deref().and_then(|l| super::links::refused(l, document)) {
+ Some(why) => format!("Missing link: {} ({why})", a.link.as_deref().unwrap_or(&a.name)),
+ None => format!("Missing link: {}", a.link.as_deref().unwrap_or(&a.name)),
+ },
item: Some(it.id.0),
page,
}),
diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs
index af3221054..7049c2f39 100644
--- a/crates/engine/src/cmd/table.rs
+++ b/crates/engine/src/cmd/table.rs
@@ -30,7 +30,7 @@ fn style_specs() -> Vec {
"New Cell Style…",
[],
None,
- "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
+ "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, stroke?: {weight?, color?, tint?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?, basedOn?: cell style name, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} → {name}",
has_doc,
|s, p| cell_style_create(s, p)
),
@@ -297,7 +297,7 @@ fn table_specs() -> Vec {
"Cell Options",
[],
None,
- "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
+ "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj? | verticalJustification?: top|center|bottom|justify, firstBaseline?: ascent|capHeight|leading|xHeight|fixed|emboxHeight, firstBaselineMin?: pt, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}, story?, table?, rows?: [a, b] | row?, cols?: [a, b] | col? (the target cells; default: the selection)} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns",
in_table,
set_cell
),
@@ -932,6 +932,22 @@ fn drawn_side(t: &Table, owners: &[(usize, usize)], (r, c): (usize, usize), side
drawn.or_else(|| t.cell(r, c).and_then(|cell| cell.strokes.get(side).cloned()))
}
+/// Cell Options › Text › First Baseline: `firstBaseline` (offset method) and `firstBaselineMin` (pt).
+fn first_baseline_params(p: &Value, id: &str) -> Result<(Option, Option)> {
+ let fb = match p.get("firstBaseline").filter(|v| !v.is_null()) {
+ Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(id, format!("firstBaseline: {e}")))?),
+ None => None,
+ };
+ let min = match p.get("firstBaselineMin").filter(|v| !v.is_null()) {
+ Some(v) => match v.as_f64().filter(|m| m.is_finite() && (0.0..=8640.0).contains(m)) {
+ Some(m) => Some(m),
+ None => return Err(bad(id, "firstBaselineMin: a number of points from 0 to 8640")),
+ },
+ None => None,
+ };
+ Ok((fb, min))
+}
+
fn set_cell(s: &mut Session, p: &Value) -> Result {
const ID: &str = "table.setCell";
let g = target(s, p, ID)?;
@@ -954,6 +970,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result {
Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))),
None => None,
};
+ let (first_baseline, first_baseline_min) = first_baseline_params(p, ID)?;
let text = str_param(p, "text").map(|_| super::text_param(p, "text"));
let stroke = match p.get("stroke").filter(|v| !v.is_null()) {
Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)),
@@ -998,6 +1015,12 @@ fn set_cell(s: &mut Session, p: &Value) -> Result {
if let Some(v) = vj {
cell.vj = v;
}
+ if let Some(v) = first_baseline {
+ cell.first_baseline = v;
+ }
+ if let Some(v) = first_baseline_min {
+ cell.first_baseline_min = v;
+ }
if let Some(tx) = &text {
let len = cell.text.len();
cell.text.replace(0..len, tx);
@@ -1525,7 +1548,7 @@ fn get(s: &mut Session, p: &Value) -> Result {
.filter_map(|(r, c)| {
let cell = t.cell(r, c)?;
Some(json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill,
- "insets": cell.insets, "vj": cell.vj, "style": cell.style}))
+ "insets": cell.insets, "vj": cell.vj, "firstBaseline": cell.first_baseline, "firstBaselineMin": cell.first_baseline_min, "style": cell.style}))
})
.collect();
Ok(json!({
@@ -1572,6 +1595,13 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Opt
if let Some(v) = p.get("vj") {
cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?);
}
+ let (first_baseline, first_baseline_min) = first_baseline_params(p, cmd)?;
+ if first_baseline.is_some() {
+ cs.first_baseline = first_baseline;
+ }
+ if first_baseline_min.is_some() {
+ cs.first_baseline_min = first_baseline_min;
+ }
if let Some(st) = stroke {
cs.stroke = Some(st);
cs.strokes = Default::default();
@@ -1600,6 +1630,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result {
cs.fill_tint = Some(cell.fill_tint);
cs.insets = Some(cell.insets);
cs.vj = Some(cell.vj);
+ cs.first_baseline = Some(cell.first_baseline);
+ cs.first_baseline_min = Some(cell.first_baseline_min);
cs.stroke = Some(cell.strokes[0].clone());
cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone());
}
@@ -1787,6 +1819,28 @@ mod style_tests {
use crate::Session;
+ #[test]
+ fn cell_first_baseline_is_a_cell_option_and_a_cell_style_attribute() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
+ let sid = r["story"].as_u64().unwrap();
+ s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
+ s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap();
+ s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "capHeight", "firstBaselineMin": 3})).unwrap();
+ let got = s.execute("table.get", &json!({})).unwrap();
+ let cell = got["cells"].as_array().unwrap().iter().find(|c| c["row"] == 0 && c["col"] == 0).unwrap().clone();
+ assert_eq!((cell["firstBaseline"].as_str(), cell["firstBaselineMin"].as_f64()), (Some("capHeight"), Some(3.0)));
+ assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaseline": "middle"})).is_err());
+ assert!(s.execute("table.setCell", &json!({"row": 0, "col": 0, "firstBaselineMin": -1})).is_err());
+ s.execute("style.cell.create", &json!({"name": "Leading", "firstBaseline": "leading"})).unwrap();
+ s.execute("style.cell.apply", &json!({"name": "Leading", "row": 1, "col": 1})).unwrap();
+ let d = &s.doc().unwrap().doc;
+ let t = d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap();
+ assert_eq!(t.cell(1, 1).unwrap().first_baseline, designcraft_doc::FirstBaseline::Leading);
+ assert_eq!(t.cell(1, 0).unwrap().first_baseline, designcraft_doc::FirstBaseline::Ascent);
+ }
+
#[test]
fn cell_and_table_styles_apply_and_follow_edits() {
let mut s = Session::new();
@@ -2191,6 +2245,31 @@ mod sort_tests {
assert_eq!((cell(&s, 2, 0), cell(&s, 2, 1)), ("x".to_string(), "a".to_string()));
}
+ #[test]
+ fn move_row_cannot_move_a_header_row_out_of_place() {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap();
+ s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap();
+ s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap();
+ assert!(s.execute("table.moveRow", &json!({"from": 0, "to": 2})).is_err());
+ s.doc().unwrap().doc.check().unwrap();
+ // Dragging the selected header row onto the last body row is refused too.
+ let d = s.doc().unwrap().doc.clone();
+ let (sid, tid) = d.stories.values().find_map(|st| st.tables.keys().next().map(|k| (st.id, *k))).unwrap();
+ let fid = d.stories[&sid].frames[0];
+ let cs = s.cache.get(&d, sid, None);
+ let cells = &cs.frames[0].tables[0].cells;
+ let centre = |r: usize| {
+ let c = cells.iter().find(|c| c.row == r && c.col == 0).unwrap().rect.center();
+ d.text_xf(d.item(fid).unwrap()) * c
+ };
+ let (from, to) = (centre(0), centre(2));
+ s.execute("table.select", &json!({"story": sid.0, "table": tid, "rows": [0, 0], "what": "row"})).unwrap();
+ assert!(s.execute("table.dropCells", &json!({"frame": fid.0, "from": [from.x, from.y], "to": [to.x, to.y]})).is_err());
+ s.doc().unwrap().doc.check().unwrap();
+ }
+
#[test]
fn table_options_limits_header_and_footer_rows() {
let mut s = Session::new();
diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs
index 24ff0384a..12bd540b7 100644
--- a/crates/engine/src/cmd/text.rs
+++ b/crates/engine/src/cmd/text.rs
@@ -125,12 +125,20 @@ pub fn specs() -> Vec {
has_doc,
|s, p| {
let sid = StoryId(p.get("story").and_then(Value::as_u64).ok_or_else(|| bad("story.replaceRange", "missing story"))?);
- let a = p.get("start").and_then(Value::as_u64).unwrap_or(0) as usize;
- let b = p.get("end").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a);
+ let offset = |v: u64| usize::try_from(v).map_err(|_| bad("story.replaceRange", format!("offset {v} is out of range")));
+ let a = offset(p.get("start").and_then(Value::as_u64).unwrap_or(0))?;
+ let b = match p.get("end").and_then(Value::as_u64) {
+ Some(v) => offset(v)?,
+ None => a,
+ };
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
- let (a, b) = (a.min(st.len()), b.min(st.len()).max(a.min(st.len())));
+ let n = st.len();
+ if a > b || b > n || !st.text.is_char_boundary(a) || !st.text.is_char_boundary(b) {
+ let msg = format!("range {a}..{b} must be UTF-8 byte offsets on character boundaries, start <= end; the story is {n} bytes");
+ return Err(bad("story.replaceRange", msg));
+ }
st.replace(a..b, &text);
let len = st.len();
if let Some(t) = sel.text.as_mut().filter(|t| t.story == sid) {
@@ -2292,9 +2300,14 @@ mod open_type_tests {
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "é漢🙂x"})).unwrap();
let sid = r["story"].clone();
- // Offsets inside 漢 (2..5) and 🙂 (5..9) snap back to the character starts: 漢 is replaced.
- s.execute("story.replaceRange", &json!({"story": sid, "start": 3, "end": 6, "text": "字"})).unwrap();
let story = designcraft_doc::StoryId(sid.as_u64().unwrap());
+ // Offsets inside 漢 (2..5) and 🙂 (5..9), past the end, or reversed are refused and change nothing.
+ for (start, end) in [(3, 6), (2, 4), (0, 99), (5, 2)] {
+ let r = s.execute("story.replaceRange", &json!({"story": sid, "start": start, "end": end, "text": "字"}));
+ assert!(r.is_err_and(|e| e.to_string().contains("10 bytes")), "{start}..{end}");
+ assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é漢🙂x");
+ }
+ s.execute("story.replaceRange", &json!({"story": sid, "start": 2, "end": 5, "text": "字"})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(story).unwrap().text, "é字🙂x");
// A caret before 🙂 (byte 5) would land inside it once é (2 bytes) is gone.
s.execute("text.select", &json!({"story": sid, "anchor": 5})).unwrap();
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index 9eb8850ad..5378a411e 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -8,12 +8,58 @@
//! it is ([`is_file`], [`is_dir`], [`exists`], [`metadata`]), and as the roots' own message where
//! it asks for the bytes.
+pub use craft_docpath::Refused;
pub use std::fs::Metadata;
-use std::path::Path;
+use std::path::{Path, PathBuf};
+
+/// The path to open for a path stored in a document (a link, a data source, a book chapter),
+/// resolved against `document`, the path of the file it came from, when there is one.
+///
+/// Paths to other computers (other than the document's own network share), devices and URLs are
+/// refused, and so is a result that isn't absolute: without a document, a relative path would
+/// depend on the working directory, and a Windows path names no file on other platforms. A
+/// refused path is never handed to the file system; callers treat it as a missing file.
+pub fn document_path(stored: &str, document: Option<&Path>) -> Result {
+ let document = document.map(|d| std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()));
+ let path = craft_docpath::resolve(stored, document.as_deref())?;
+ if path.is_absolute() { Ok(path) } else { Err(Refused::Invalid) }
+}
+
+/// [`document_path`] for a path stored in a file inside `folder` (a data file's image names).
+pub fn folder_path(stored: &str, folder: &Path) -> Result {
+ // Resolved against a document in the folder: only the document's folder is used.
+ document_path(stored, Some(&folder.join("-")))
+}
+
+#[cfg(test)]
+thread_local! {
+ static TOUCHED: std::cell::RefCell>> = const { std::cell::RefCell::new(None) };
+}
+
+/// Run `f` and list every path it handed to this module, in order (on this thread).
+#[cfg(test)]
+pub(crate) fn touched(f: impl FnOnce() -> T) -> (T, Vec) {
+ let previous = TOUCHED.with(|t| t.replace(Some(Vec::new())));
+ let out = f();
+ let seen = TOUCHED.with(|t| t.replace(previous)).unwrap_or_default();
+ (out, seen)
+}
+
+fn touch(path: &Path) {
+ #[cfg(test)]
+ TOUCHED.with(|t| {
+ if let Some(seen) = t.borrow_mut().as_mut() {
+ seen.push(path.to_path_buf());
+ }
+ });
+ #[cfg(not(test))]
+ let _ = path;
+}
/// The bytes of the file at `path`, inside the automation read root when there is one.
pub fn read_file(path: impl AsRef) -> Result, String> {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path)?;
std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))
}
@@ -22,6 +68,7 @@ pub fn read_file(path: impl AsRef) -> Result, String> {
/// failures. A refused path is a `PermissionDenied` error carrying the roots' message.
pub fn read(path: impl AsRef) -> std::io::Result> {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::read(path)
}
@@ -31,6 +78,7 @@ pub fn read(path: impl AsRef) -> std::io::Result> {
pub fn read_capped(path: impl AsRef, cap: u64) -> std::io::Result> {
use std::io::Read;
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let mut b = Vec::new();
std::fs::File::open(path)?.take(cap.saturating_add(1)).read_to_end(&mut b)?;
@@ -42,6 +90,7 @@ pub fn read_capped(path: impl AsRef, cap: u64) -> std::io::Result>
/// roots' message.
pub fn open(path: impl AsRef) -> std::io::Result {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::File::open(path)
}
@@ -49,6 +98,7 @@ pub fn open(path: impl AsRef) -> std::io::Result {
/// The text of the file at `path`, inside the automation read root when there is one.
pub fn read_to_string(path: impl AsRef) -> Result {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path)?;
std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))
}
@@ -56,6 +106,7 @@ pub fn read_to_string(path: impl AsRef) -> Result {
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
pub fn write_file(path: impl AsRef, bytes: &[u8]) -> Result<(), String> {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_write(path)?;
// Replaced atomically where there is a real file system (see `atomic`).
#[cfg(not(target_arch = "wasm32"))]
@@ -71,6 +122,7 @@ pub fn write_file(path: impl AsRef, bytes: &[u8]) -> Result<(), String> {
#[cfg(not(target_arch = "wasm32"))]
pub fn replace_with(path: impl AsRef, staged: impl AsRef) -> std::io::Result<()> {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
let mut input = std::fs::File::open(staged)?;
crate::atomic::write_atomic_with(path, |file| std::io::copy(&mut input, file).map(|_| ()))
@@ -80,6 +132,7 @@ pub fn replace_with(path: impl AsRef, staged: impl AsRef) -> std::io
/// write root when there is one.
pub fn writable(path: impl AsRef) -> std::io::Result<()> {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_write(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::OpenOptions::new().write(true).open(path).map(drop)
}
@@ -88,6 +141,7 @@ pub fn writable(path: impl AsRef) -> std::io::Result<()> {
/// one.
pub fn canonicalize(path: impl AsRef) -> std::io::Result {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::canonicalize(path)
}
@@ -96,6 +150,7 @@ pub fn canonicalize(path: impl AsRef) -> std::io::Result) -> std::io::Result {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::metadata(path)
}
@@ -118,6 +173,7 @@ pub fn exists(path: impl AsRef) -> bool {
/// The entries of the folder at `path`, inside the automation read root when there is one.
pub fn read_dir(path: impl AsRef) -> std::io::Result {
let path = path.as_ref();
+ touch(path);
crate::file_access::check_read(path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
std::fs::read_dir(path)
}
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
index 95e822fdd..183b81c5c 100644
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -744,6 +744,8 @@ mod tests_idml;
#[cfg(test)]
mod tests_ime;
#[cfg(test)]
+mod tests_linked_paths;
+#[cfg(test)]
mod tests_params;
#[cfg(test)]
mod tests_roots;
diff --git a/crates/engine/src/tests_idml.rs b/crates/engine/src/tests_idml.rs
index 0701052d0..3a49168be 100644
--- a/crates/engine/src/tests_idml.rs
+++ b/crates/engine/src/tests_idml.rs
@@ -575,3 +575,100 @@ fn table_of_contents_character_styles_and_sort_round_trip_through_idml() {
let e = &after.entries[1];
assert_eq!((e.page_number_style.as_deref(), e.separator_style.as_deref(), e.sort), (None, None, false));
}
+
+/// `text` with the value after every `open` (up to the next `close`) set to `value`, and how
+/// many values were replaced.
+fn replace_values(text: &str, open: &str, close: char, value: &str) -> (String, usize) {
+ let mut rest = text;
+ let mut new = String::new();
+ let mut count = 0;
+ while let Some(at) = rest.find(open) {
+ let start = at + open.len();
+ new.push_str(&rest[..start]);
+ new.push_str(value);
+ let end = rest[start..].find(close).map_or(rest.len(), |e| start + e);
+ rest = &rest[end..];
+ count += 1;
+ }
+ new.push_str(rest);
+ (new, count)
+}
+
+/// `package` with every `{name}="…"` attribute and `<{name}>…{name}>` / `<{name} type="…">…`
+/// property element in its XML parts set to `value`, and how many values were replaced.
+fn with_attr_set(package: &[u8], name: &str, value: &str) -> (Vec, usize) {
+ let mut zip = zip::ZipArchive::new(std::io::Cursor::new(package)).unwrap();
+ let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
+ let mut count = 0;
+ for i in 0..zip.len() {
+ let mut f = zip.by_index(i).unwrap();
+ let mut body = Vec::new();
+ std::io::Read::read_to_end(&mut f, &mut body).unwrap();
+ if f.name().ends_with(".xml")
+ && let Ok(text) = String::from_utf8(body.clone())
+ {
+ let (text, a) = replace_values(&text, &format!(" {name}=\""), '"', value);
+ let (text, b) = replace_values(&text, &format!("<{name}>"), '<', value);
+ let (text, c) = replace_values(&text, &format!("<{name} type=\"unit\">"), '<', value);
+ let (text, d) = replace_values(&text, &format!("<{name} type=\"double\">"), '<', value);
+ count += a + b + c + d;
+ body = text.into_bytes();
+ }
+ out.start_file(f.name(), zip::write::SimpleFileOptions::default()).unwrap();
+ std::io::Write::write_all(&mut out, &body).unwrap();
+ }
+ (out.finish().unwrap().into_inner(), count)
+}
+
+/// Non-finite numbers in an IDML package never reach the model: the imported document saves
+/// and reopens. Every listed name occurs in the exported sample, so each case really rewrites
+/// values; names the sample lacks (feather `Width`, gradient start/length) go through `El::num`,
+/// covered in `xml.rs`.
+#[test]
+fn idml_non_finite_numbers_import_to_a_document_that_reopens() {
+ let package = designcraft_idml::export_idml(&crate::sample::magazine());
+ let attrs = [
+ "StrokeWeight",
+ "MiterLimit",
+ "Angle",
+ "FirstBaselineOffset",
+ "MinimumFirstBaselineOffset",
+ "SpaceBefore",
+ "SpaceAfter",
+ "LeftIndent",
+ "RightIndent",
+ "FirstLineIndent",
+ "PointSize",
+ "Tracking",
+ "Leading",
+ "FillTint",
+ "StrokeTint",
+ "GapTint",
+ "Position",
+ "BaselineShift",
+ "Skew",
+ ];
+ let mut failures = Vec::new();
+ for attr in attrs {
+ for value in ["NaN", "inf", "-inf"] {
+ let (bytes, replaced) = with_attr_set(&package, attr, value);
+ if replaced == 0 {
+ failures.push(format!("{attr}: not present in the exported package"));
+ continue;
+ }
+ let doc = match designcraft_idml::import_idml(&bytes) {
+ Ok(doc) => doc,
+ Err(e) => {
+ failures.push(format!("{attr}={value}: import failed: {e}"));
+ continue;
+ }
+ };
+ match designcraft_format::save(&doc).map(|saved| designcraft_format::load(&saved)) {
+ Ok(Ok(_)) => {}
+ Ok(Err(e)) => failures.push(format!("{attr}={value}: saved but load failed: {e}")),
+ Err(e) => failures.push(format!("{attr}={value}: save failed: {e}")),
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{failures:#?}");
+}
diff --git a/crates/engine/src/tests_linked_paths.rs b/crates/engine/src/tests_linked_paths.rs
new file mode 100644
index 000000000..eb4c953ec
--- /dev/null
+++ b/crates/engine/src/tests_linked_paths.rs
@@ -0,0 +1,107 @@
+//! Paths stored in documents (links, data sources, book chapters) are followed only where
+//! [`crate::io::document_path`] allows: never to another computer, and resolved beside the
+//! document. Every path the engine hands the file system is recorded by [`crate::io::touched`].
+
+use std::path::{Path, PathBuf};
+
+use serde_json::{Value, json};
+
+use crate::Session;
+
+const UNC: &str = r"\\192.0.2.1\share\x.png";
+const FILE_URL: &str = "file://192.0.2.1/s/x.png";
+
+fn png() -> Vec {
+ designcraft_render::Rendered { width: 2, height: 2, pixels: vec![255; 16] }.to_png()
+}
+
+fn folder(name: &str) -> PathBuf {
+ let dir = std::env::temp_dir().join(format!("dc-linked-paths-{name}-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ dir
+}
+
+/// A document saved as `doc` with one placed graphic per link, linked to it.
+fn document_with_links(doc: &Path, links: &[&str]) -> Session {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ for (i, link) in links.iter().enumerate() {
+ s.execute("file.place", &json!({"base64": crate::cmd::base64_encode(&png()), "name": format!("{i}.png"), "x": 72 * i, "y": 72})).unwrap();
+ let link = link.to_string();
+ s.edit(move |d, _| {
+ let a = d.assets.values_mut().find(|a| a.link.is_none() && a.name == format!("{i}.png")).unwrap();
+ std::sync::Arc::make_mut(a).link = Some(link);
+ Ok(Value::Null)
+ })
+ .unwrap();
+ }
+ s.doc_mut().unwrap().path = Some(doc.to_string_lossy().into_owned());
+ s
+}
+
+#[test]
+fn links_to_other_computers_never_reach_the_file_system() {
+ let dir = folder("remote");
+ let doc = dir.join("doc.designcraft");
+ let mut s = document_with_links(&doc, &[UNC, FILE_URL]);
+ let (rows, seen) = crate::io::touched(|| {
+ let rows = s.execute("links.list", &json!({})).unwrap();
+ let issues = s.execute("preflight.run", &json!({})).unwrap();
+ s.execute("links.relinkFolder", &json!({"extension": "jpg"})).unwrap();
+ s.execute("links.update", &json!({})).unwrap();
+ (rows, issues)
+ });
+ assert!(seen.is_empty(), "{seen:?}");
+ let (rows, issues) = rows;
+ for row in rows.as_array().unwrap() {
+ assert_eq!(row["status"], "missing", "{row}");
+ assert_eq!(row["refused"], "the path is on another computer", "{row}");
+ }
+ assert_eq!(issues.to_string().matches("Missing link").count(), 2, "{issues}");
+
+ // Data sources and book chapters stored the same way.
+ let mut d = s.doc().unwrap().doc.as_ref().clone();
+ d.data_merge.sources.push(designcraft_doc::DataSource {
+ id: 1,
+ path: Some(UNC.replace("x.png", "data.csv")),
+ relative_path: Some(FILE_URL.replace("x.png", "data.csv")),
+ name: "data.csv".into(),
+ delimiter: designcraft_doc::Delimiter::Comma,
+ sheet: None,
+ fields: vec![],
+ rows: vec![],
+ fingerprint: None,
+ status: designcraft_doc::SourceStatus::Ok,
+ warnings: vec![],
+ });
+ std::fs::write(&doc, designcraft_format::save(&d).unwrap()).unwrap();
+ let mut opened = Session::new();
+ let ((), seen) = crate::io::touched(|| {
+ opened.execute("file.open", &json!({"path": doc.to_string_lossy()})).unwrap();
+ opened.execute("links.list", &json!({})).unwrap();
+ });
+ // The document itself and the folders beside it, nothing else.
+ assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
+ assert_eq!(opened.doc().unwrap().doc.data_merge.sources[0].status, designcraft_doc::SourceStatus::Missing);
+ let book = dir.join("b.dcbook");
+ std::fs::write(&book, json!({"documents": [UNC.replace("x.png", "c.designcraft"), FILE_URL.replace("x.png", "c.designcraft")]}).to_string())
+ .unwrap();
+ s.execute("book.open", &json!({"path": book.to_string_lossy()})).unwrap();
+ let (list, seen) = crate::io::touched(|| s.execute("book.list", &json!({})).unwrap());
+ assert!(seen.is_empty(), "{seen:?}");
+ assert_eq!(list["documents"][0]["pages"], 0);
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn a_relative_link_resolves_beside_the_document() {
+ let dir = folder("relative");
+ std::fs::write(dir.join("logo.png"), png()).unwrap();
+ let mut s = document_with_links(&dir.join("doc.designcraft"), &["logo.png"]);
+ let (rows, seen) = crate::io::touched(|| s.execute("links.list", &json!({})).unwrap());
+ assert_eq!(rows[0]["status"], "ok", "{rows}");
+ assert!(rows[0]["refused"].is_null());
+ assert!(seen.iter().all(|p| p.starts_with(&dir)), "{seen:?}");
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/engine/src/tests_roots.rs b/crates/engine/src/tests_roots.rs
index a8720cd4c..39ab5e57d 100644
--- a/crates/engine/src/tests_roots.rs
+++ b/crates/engine/src/tests_roots.rs
@@ -71,9 +71,10 @@ fn idml_links_are_read_through_the_roots() {
// Linked, not embedded: opening the package has to read the file its link names.
let idml = base64_idml(&mut s);
let linked = |doc: &designcraft_doc::Document| doc.assets.values().filter(|a| !a.data.is_empty()).count();
- let unconfined = cmd::interchange::import_idml_confined(&idml).unwrap();
+ let package = base.join("outside/x.idml");
+ let unconfined = cmd::interchange::import_idml_confined(&idml, Some(&package)).unwrap();
assert_eq!(linked(&unconfined), 1, "without roots the link is followed");
- let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml)).unwrap();
+ let confined = confine(Some(&roots), || cmd::interchange::import_idml_confined(&idml, Some(&package))).unwrap();
assert_eq!(linked(&confined), 0, "with roots a link outside the read root is not read");
// The same through the commands that open or place a package.
let b64 = cmd::base64_encode(&idml);
diff --git a/crates/engine/tests/stroke_tint_range.rs b/crates/engine/tests/stroke_tint_range.rs
new file mode 100644
index 000000000..40aca49cc
--- /dev/null
+++ b/crates/engine/tests/stroke_tint_range.rs
@@ -0,0 +1,25 @@
+//! `object.stroke` keeps `tint` and `gapTint` within 0..1, so a saved document always reloads.
+use designcraft_doc::ItemId;
+use designcraft_engine::Session;
+use serde_json::json;
+
+fn stroked(tint: f64, gap_tint: f64) -> (Session, ItemId) {
+ let mut s = Session::new();
+ s.execute("file.new", &json!({})).unwrap();
+ let created = s.execute("frame.create", &json!({"rect": [36, 36, 200, 200]})).unwrap();
+ let id = ItemId(created["id"].as_u64().unwrap());
+ s.execute("object.stroke", &json!({"ids": [id.0], "swatch": "[Black]", "tint": tint, "gapTint": gap_tint})).unwrap();
+ (s, id)
+}
+
+#[test]
+fn out_of_range_stroke_tints_are_clamped_and_the_saved_file_reloads() {
+ for (tint, gap, want_tint, want_gap) in [(1e300, 1e300, 1.0, 1.0), (2.0, -1.0, 1.0, 0.0), (-1e300, 0.5, 0.0, 0.5)] {
+ let (s, id) = stroked(tint, gap);
+ let doc = &s.doc().unwrap().doc;
+ let st = &doc.item(id).unwrap().stroke;
+ assert_eq!((st.tint, st.gap_tint), (want_tint, want_gap), "tint {tint}, gapTint {gap}");
+ let bytes = designcraft_format::save(doc).unwrap();
+ designcraft_format::load(&bytes).expect("reload what we just saved");
+ }
+}
diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
index f65dd700e..352e0952b 100644
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -370,6 +370,24 @@ impl FontFace {
}
}
+/// A font file's length and modification time.
+#[cfg(not(target_arch = "wasm32"))]
+type FileStamp = (u64, Option);
+
+#[cfg(not(target_arch = "wasm32"))]
+fn file_stamp(path: &std::path::Path) -> Option {
+ std::fs::metadata(path).ok().map(|m| (m.len(), m.modified().ok()))
+}
+
+/// What [`FontDb::reload_fonts`] did: the faces cataloged by the rescan, and the installed font
+/// files that changed or were removed since they were read (their faces are read again, from the
+/// new file, the next time they are looked up).
+#[derive(Debug, Default, PartialEq)]
+pub struct ReloadedFonts {
+ pub cataloged: usize,
+ pub changed: Vec,
+}
+
#[cfg(not(target_arch = "wasm32"))]
#[derive(Clone, Debug)]
struct CatalogEntry {
@@ -412,6 +430,10 @@ pub struct FontDb {
outlines: Mutex>>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock>,
+ /// Each installed font file read into the shared fonts, with its (length, modification time)
+ /// when it was read: [`FontDb::reload_fonts`] reads again the files that changed since.
+ #[cfg(not(target_arch = "wasm32"))]
+ installed_files: Mutex>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
font_dirs: Vec,
@@ -1067,6 +1089,8 @@ impl FontDb {
outlines: Mutex::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
+ #[cfg(not(target_arch = "wasm32"))]
+ installed_files: Mutex::new(HashMap::new()),
font_dirs,
font_files: Vec::new,
#[cfg(not(target_arch = "wasm32"))]
@@ -1221,6 +1245,56 @@ impl FontDb {
count
}
+ /// Fonts installed, changed or removed since they were read (#328): scan the font folders
+ /// again (see [`FontDb::load_system_fonts`]), then let go of the shared faces whose installed
+ /// file changed (length or modification time) or is gone, so the next lookup reads the file as
+ /// it is now, or finds the font missing. What was composed with the old faces must be composed
+ /// again. Faces are never freed (see [`FaceRef`]): each reload of a changed file keeps the old
+ /// copy. Bundled, added and document fonts are left alone. Nothing to do on wasm.
+ pub fn reload_fonts(&self) -> ReloadedFonts {
+ #[cfg(not(target_arch = "wasm32"))]
+ {
+ // Rescan first: a lookup after the faces are dropped finds the new files.
+ let cataloged = self.load_system_fonts();
+ let changed: Vec = {
+ let mut files = self.installed_files.lock().unwrap_or_else(|e| e.into_inner());
+ let mut changed: Vec<_> = files.iter().filter(|(p, stamp)| file_stamp(p).as_ref() != Some(*stamp)).map(|(p, _)| p.clone()).collect();
+ changed.sort();
+ for p in &changed {
+ files.remove(p);
+ }
+ changed
+ };
+ if !changed.is_empty() {
+ let stale = |f: &FontFace| matches!(&f.source, FontSource::Installed(p) if changed.contains(p));
+ let mut dropped = std::collections::HashSet::new();
+ self.faces.write().unwrap_or_else(|e| e.into_inner()).retain(|f| {
+ let keep = !stale(f);
+ if !keep {
+ dropped.insert(f.id);
+ }
+ keep
+ });
+ self.instances.write().unwrap_or_else(|e| e.into_inner()).retain(|(base, _), f| !dropped.contains(base) && !stale(f));
+ self.outlines.lock().unwrap_or_else(|e| e.into_inner()).retain(|(face, _), _| !dropped.contains(face));
+ log::debug!("reloading {} changed font files ({} faces)", changed.len(), dropped.len());
+ }
+ ReloadedFonts { cataloged, changed }
+ }
+ #[cfg(target_arch = "wasm32")]
+ ReloadedFonts::default()
+ }
+
+ /// Read an installed font file, noting its length and modification time as they were before
+ /// the read (a change during it is then found by the next reload).
+ #[cfg(not(target_arch = "wasm32"))]
+ fn read_installed(&self, path: &std::path::Path) -> Option> {
+ let stamp = file_stamp(path)?;
+ let data = std::fs::read(path).ok()?;
+ self.installed_files.lock().unwrap_or_else(|e| e.into_inner()).insert(path.to_path_buf(), stamp);
+ Some(data)
+ }
+
#[cfg(not(target_arch = "wasm32"))]
fn scan_font_dirs(&self) -> usize {
let mut found = Vec::new();
@@ -1294,7 +1368,7 @@ impl FontDb {
// The whole family arrives at once (see `insert_faces`).
let mut faces = Vec::new();
for p in paths {
- if let Ok(data) = std::fs::read(&p) {
+ if let Some(data) = self.read_installed(&p) {
faces.extend(self.new_faces(data, FontSource::Installed(p)));
}
}
@@ -1499,7 +1573,7 @@ impl FontDb {
if std::fs::metadata(&p).map(|m| m.len() > 40 << 20).unwrap_or(true) {
continue;
}
- let Ok(data) = std::fs::read(&p) else { continue };
+ let Some(data) = self.read_installed(&p) else { continue };
let hit =
enumerate_faces(&data).iter().any(|(i, _, _, _)| skrifa::FontRef::from_index(&data, *i).is_ok_and(|f| f.charmap().map(c).is_some()));
if hit && self.add_faces(data, FontSource::Installed(p)) > 0 && covered(self) {
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
index 861e846a8..aca293d12 100644
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -20,7 +20,7 @@ mod vertical;
pub use features::{FontFeatures, font_features};
pub use fontdb::{
DOCUMENT_FONTS_FOLDER, DocumentFonts, FALLBACK_FAMILY, FaceRef, FontDb, FontFace, FontSource, MAX_DOCUMENT_FONT_BYTES, MAX_DOCUMENT_FONT_FILES,
- MAX_DOCUMENT_FONTS_TOTAL, ScopedFonts, base_style, bundled, same_style, system_font_dirs,
+ MAX_DOCUMENT_FONTS_TOTAL, ReloadedFonts, ScopedFonts, base_style, bundled, same_style, system_font_dirs,
};
pub use group::{FamilyInfo, FontGroup, sort_for_menu};
pub use harfrust::Feature;
@@ -197,6 +197,20 @@ pub fn shape_with_context(
out
}
+/// `features` as a run in `script` takes them. Contextual alternates are part of how Arabic joins
+/// (connecting strokes, tatweel and letter variants): InDesign keeps them in Arabic text with
+/// Contextual Alternates off, so `calt` off is dropped there.
+fn script_features(features: &[Feature], script: unicode_script::Script) -> std::borrow::Cow<'_, [Feature]> {
+ use unicode_script::Script;
+ let calt = Tag::new(b"calt");
+ let off = |f: &Feature| f.tag == calt && f.value == 0;
+ if script == Script::Arabic && features.iter().any(off) {
+ features.iter().filter(|f| !off(f)).copied().collect::>().into()
+ } else {
+ features.into()
+ }
+}
+
fn shape_dir(
face: &FontFace,
text: &str,
@@ -207,6 +221,8 @@ fn shape_dir(
context: ShapeContext<'_>,
) -> Vec {
let mut out = Vec::with_capacity(text.len());
+ let features = script_features(features, script);
+ let features = &features[..];
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
let mut buf = UnicodeBuffer::new();
@@ -334,6 +350,16 @@ pub fn first_glyph(face: &FontFace, chars: &[char]) -> u32 {
mod tests {
use super::*;
+ #[test]
+ fn arabic_keeps_contextual_alternates() {
+ use unicode_script::Script;
+ let off = feature("-calt").unwrap();
+ let liga = feature("-liga").unwrap();
+ let tags = |s: Script| script_features(&[off, liga], s).iter().map(|f| (f.tag, f.value)).collect::>();
+ assert_eq!(tags(Script::Arabic), vec![(liga.tag, 0)]);
+ assert_eq!(tags(Script::Latin), vec![(off.tag, 0), (liga.tag, 0)]);
+ }
+
#[test]
fn arabic_script_runs_do_not_inherit_the_hebrew_shaper() {
use unicode_script::Script;
diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs
index bf01ad79a..39bf0767e 100644
--- a/crates/fonts/src/tests_sysfonts.rs
+++ b/crates/fonts/src/tests_sysfonts.rs
@@ -389,3 +389,53 @@ fn installed_and_loaded_fonts_are_found_by_postscript_name() {
assert_eq!(db.postscript_face("Source Serif 4"), None);
assert_eq!(db.postscript_face("NoSuchFont-Regular"), None);
}
+
+/// Write `font` to `path` with a modification time `secs` from now, so a rewrite of the same
+/// length still reads as changed however coarse the file system's clock.
+fn write_font(path: &Path, font: &[u8], secs: u64) {
+ std::fs::write(path, font).unwrap();
+ let file = std::fs::File::options().write(true).open(path).unwrap();
+ file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(secs)).unwrap();
+}
+
+#[test]
+fn reloading_reads_changed_font_files_again_and_keeps_the_others() {
+ use crate::testing::font_with_glyph;
+ let dir = font_dir("reload");
+ let family = "DC Test Reloaded";
+ let path = dir.join("Reloaded.ttf");
+ write_font(&path, &font_with_glyph(family, &['a'], 'W').unwrap(), 10);
+ let db = FontDb::with_font_dirs(vec![dir.clone()]);
+ let before = db.face(family, "Regular");
+ let wide = before.advance(before.glyph_for('a'));
+ let kept = db.face(FAMILY, "Regular");
+ // The font is edited and installed again under the same name (#328).
+ write_font(&path, &font_with_glyph(family, &['a'], 'i').unwrap(), 20);
+ assert_eq!(db.face(family, "Regular").id(), before.id(), "a lookup alone doesn't read the file again");
+ let reloaded = db.reload_fonts();
+ assert_eq!(reloaded.changed, std::slice::from_ref(&path));
+ assert_eq!(reloaded.cataloged, 3);
+ let after = db.face(family, "Regular");
+ assert_ne!(after.id(), before.id());
+ let narrow = after.advance(after.glyph_for('a'));
+ assert!(narrow < wide, "the edited font sets 'a' as its i: {narrow} vs {wide}");
+ assert_eq!(db.face(FAMILY, "Regular").id(), kept.id(), "unchanged files aren't read again");
+ assert_eq!(db.reload_fonts().changed, Vec::::new(), "nothing changed since");
+ let _ = std::fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn reloading_lets_go_of_removed_fonts_and_finds_new_ones() {
+ let dir = font_dir("reload-removed");
+ let removed = dir.join("Removed.ttf");
+ write_font(&removed, &crate::testing::font_with("DC Test Removed", &['a']).unwrap(), 10);
+ let db = FontDb::with_font_dirs(vec![dir.clone()]);
+ assert_eq!(db.face("DC Test Removed", "Regular").family, "DC Test Removed");
+ std::fs::remove_file(&removed).unwrap();
+ write_font(&dir.join("Added.ttf"), &crate::testing::font_with("DC Test Added Later", &['a']).unwrap(), 10);
+ assert_eq!(db.reload_fonts().changed, [removed]);
+ assert!(!db.has_family("DC Test Removed"));
+ assert_eq!(db.face("DC Test Removed", "Regular").family, FALLBACK_FAMILY);
+ assert!(db.has_family("DC Test Added Later"));
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs
index a773ec9e5..86fef8e46 100644
--- a/crates/idml/src/export.rs
+++ b/crates/idml/src/export.rs
@@ -923,6 +923,12 @@ impl<'a> Ex<'a> {
if let Some(vj) = cs.vj {
el.set("VerticalJustification", names::vj_out(vj));
}
+ if let Some(fb) = cs.first_baseline {
+ el.set("FirstBaselineOffset", names::first_baseline_out(fb));
+ }
+ if let Some(min) = cs.first_baseline_min {
+ el.set("MinimumFirstBaselineOffset", num(min));
+ }
if let Some(st) = &cs.stroke {
for side in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"] {
self.cell_stroke_attrs(&mut el, side, st);
@@ -2770,6 +2776,8 @@ impl<'a> Ex<'a> {
.attr("BottomInset", num(cell.insets[2]))
.attr("RightInset", num(cell.insets[3]))
.attr("VerticalJustification", vj)
+ .attr("FirstBaselineOffset", names::first_baseline_out(cell.first_baseline))
+ .attr("MinimumFirstBaselineOffset", num(cell.first_baseline_min))
.attr("RotationAngle", num(cell.rotation));
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
// Do not turn absent native defaults into explicit shared-edge candidates.
diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs
index 5cd3746a9..d770aef79 100644
--- a/crates/idml/src/import.rs
+++ b/crates/idml/src/import.rs
@@ -391,6 +391,12 @@ fn nums(s: &str) -> Vec {
s.split_whitespace().filter_map(|v| v.parse().ok()).collect()
}
+/// Like [`nums`], but empty when any number is NaN or infinite.
+fn finite_nums(s: &str) -> Vec {
+ let v = nums(s);
+ if v.iter().all(|x| x.is_finite()) { v } else { Vec::new() }
+}
+
/// The viewBox size of an SVG whose root has a viewBox but no width/height (percentages count as
/// none): InDesign places such a file at one viewBox unit to the point.
fn svg_viewbox_size(data: &[u8]) -> Option<(f64, f64)> {
@@ -936,12 +942,16 @@ impl<'r> Importer<'r> {
// Custom stroke styles.
for e in top.iter().filter(|e| matches!(e.local(), "StripedStrokeStyle" | "DashedStrokeStyle" | "DottedStrokeStyle")) {
let (Some(id), Some(name)) = (e.get("Self"), e.get("Name")) else { continue };
- let nums = |k: &str| e.get(k).unwrap_or("").split_whitespace().filter_map(|x| x.parse::().ok()).collect::>();
let kind = match e.local() {
"StripedStrokeStyle" => designcraft_doc::StrokeType::Stripes {
- bands: nums("StripeArray").as_chunks::<2>().0.iter().map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0)).collect(),
+ bands: finite_nums(e.get("StripeArray").unwrap_or(""))
+ .as_chunks::<2>()
+ .0
+ .iter()
+ .map(|c| (c[0] / 100.0, (c[1] - c[0]) / 100.0))
+ .collect(),
},
- "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: nums("DashArray") },
+ "DashedStrokeStyle" => designcraft_doc::StrokeType::Dashed { pattern: finite_nums(e.get("DashArray").unwrap_or("")) },
_ => designcraft_doc::StrokeType::Dotted,
};
let _ = id;
@@ -1189,6 +1199,8 @@ impl<'r> Importer<'r> {
cs.inset_overrides = [None; 4];
}
cs.vj = e.get("VerticalJustification").map(names::vj_in);
+ cs.first_baseline = e.get("FirstBaselineOffset").map(names::first_baseline_in);
+ cs.first_baseline_min = e.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()).map(|v| v.clamp(0.0, 8640.0));
cs.strokes = ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].map(|side| self.cell_stroke_attrs(e, side));
if let Some(stroke) = uniform_cell_stroke(&cs.strokes) {
cs.stroke = Some(stroke);
@@ -1830,7 +1842,7 @@ impl<'r> Importer<'r> {
for li in tl.find_all("ListItem") {
let get = |k: &str| li.find(k).map(|x| x.text_content());
tabs.push(TabStop {
- position: get("Position").and_then(|v| v.trim().parse().ok()).unwrap_or(0.0),
+ position: get("Position").and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).unwrap_or(0.0),
align: names::tab_align_in(get("Alignment").unwrap_or_default().trim()),
leader: get("Leader").unwrap_or_default(),
align_on: get("AlignmentCharacter").filter(|c| c != ".").unwrap_or_default(),
@@ -2189,8 +2201,8 @@ impl<'r> Importer<'r> {
if r >= nr || c >= nc {
continue;
}
- let rs = (ce.num("RowSpan").unwrap_or(1.0).max(1.0) as usize).min(nr - r);
- let cs = (ce.num("ColumnSpan").unwrap_or(1.0).max(1.0) as usize).min(nc - c);
+ let rs = (span(ce, "RowSpan").max(1.0) as usize).min(nr - r);
+ let cs = (span(ce, "ColumnSpan").max(1.0) as usize).min(nc - c);
let mut cell = t.cell(r, c).cloned().unwrap_or_default();
let cell_style = ce.get("AppliedCellStyle").map(|r| self.cell_style_ref(r)).unwrap_or_else(|| designcraft_doc::NO_CELL_STYLE.into());
if cell_style != designcraft_doc::NO_CELL_STYLE {
@@ -2213,6 +2225,12 @@ impl<'r> Importer<'r> {
if let Some(vj) = ce.get("VerticalJustification") {
cell.vj = names::vj_in(vj);
}
+ if let Some(fb) = ce.get("FirstBaselineOffset") {
+ cell.first_baseline = names::first_baseline_in(fb);
+ }
+ if let Some(min) = ce.num("MinimumFirstBaselineOffset").filter(|v| v.is_finite()) {
+ cell.first_baseline_min = min.clamp(0.0, 8640.0);
+ }
cell.rotation = ce.num("RotationAngle").unwrap_or(cell.rotation);
for (i, side) in ["TopEdge", "LeftEdge", "BottomEdge", "RightEdge"].iter().enumerate() {
let attrs = self.cell_stroke_attrs(ce, side);
@@ -2745,7 +2763,7 @@ impl<'r> Importer<'r> {
if let Some(c) = color {
s.swatch = self.swatch_ref(&c);
}
- if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse().ok()) {
+ if let Some(w) = g(self, "StrokeWeight").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) {
s.weight = w;
}
if let Some(t) = g(self, "StrokeTint").and_then(|v| v.parse().ok()) {
@@ -2763,7 +2781,7 @@ impl<'r> Importer<'r> {
if let Some(v) = g(self, "EndJoin") {
s.join = names::join_in(&v);
}
- if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse().ok()) {
+ if let Some(v) = g(self, "MiterLimit").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) {
s.miter_limit = v;
}
if let Some(v) = g(self, "LeftLineEnd") {
@@ -2829,7 +2847,7 @@ impl<'r> Importer<'r> {
{
it.fill.gradient_angle = Some(a);
}
- let start: Option> = e.prop("GradientFillStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect());
+ let start: Option> = e.prop("GradientFillStart").map(|s| finite_nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientFillLength"))
&& len > 0.0
{
@@ -2847,7 +2865,7 @@ impl<'r> Importer<'r> {
{
it.stroke.gradient_angle = Some(a);
}
- let start: Option> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect());
+ let start: Option> = e.prop("GradientStrokeStart").map(|s| finite_nums(&s));
if let (Some([x, y]), Some(len)) = (start.as_deref().and_then(|v| <[f64; 2]>::try_from(v).ok()), e.num("GradientStrokeLength"))
&& len > 0.0
{
@@ -3301,6 +3319,11 @@ fn overlay(base: &mut El, top: &El, depth: usize) {
}
}
+/// A cell span count; an infinite span is kept so it clamps to the table edge.
+fn span(e: &El, k: &str) -> f64 {
+ e.prop(k).and_then(|v| v.trim().parse::().ok()).unwrap_or(1.0)
+}
+
fn finite(v: Option) -> Option {
v.filter(|v| v.is_finite())
}
@@ -3711,9 +3734,23 @@ fn wrap_path_of(pg: &El) -> std::result::Result {
Ok(PathData::new(subs))
}
-/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`).
+/// IDML link URI → file system path (`file:/a%20b` → `/a b`, `file:///C:/x` → `C:/x`). A URI
+/// naming another computer keeps it as a network path (`file://host/s/x` → `//host/s/x`).
pub(crate) fn uri_to_path(uri: &str) -> String {
- let rest = uri.strip_prefix("file://").or_else(|| uri.strip_prefix("file:")).unwrap_or(uri);
+ let rest = match uri.strip_prefix("file://") {
+ Some(rest) => {
+ let host_end = rest.find(['/', '\\']).unwrap_or(rest.len());
+ let host = rest.get(..host_end).unwrap_or_default();
+ if host.is_empty() || host.eq_ignore_ascii_case("localhost") {
+ rest.get(host_end..).unwrap_or(rest)
+ } else if matches!(host.as_bytes(), [letter, b':'] if letter.is_ascii_alphabetic()) {
+ rest
+ } else {
+ return format!("//{}", percent_decode(rest));
+ }
+ }
+ None => uri.strip_prefix("file:").unwrap_or(uri),
+ };
// `file:///C:/…` → `/C:/…` → `C:/…`
let rest = match rest.as_bytes() {
[b'/', _, b':', _, ..] => rest.get(1..).unwrap_or(rest),
diff --git a/crates/idml/src/names.rs b/crates/idml/src/names.rs
index 70d02e7c0..569244470 100644
--- a/crates/idml/src/names.rs
+++ b/crates/idml/src/names.rs
@@ -263,7 +263,7 @@ pub fn leading_out(l: Leading) -> (String, &'static str) {
}
}
pub fn leading_in(s: &str) -> Option {
- if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse().ok().map(Leading::Points) }
+ if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse::().ok().filter(|v| v.is_finite()).map(Leading::Points) }
}
pub fn stroke_align_out(a: StrokeAlign) -> &'static str {
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 3f5b7977d..035333da4 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -1022,6 +1022,10 @@ fn round_trips_overridden_parent_items() {
fn uri_and_base64_helpers() {
assert_eq!(import::uri_to_path("file:/a%20b/c.png"), "/a b/c.png");
assert_eq!(import::uri_to_path("file:///C:/x/y.jpg"), "C:/x/y.jpg");
+ assert_eq!(import::uri_to_path("file://localhost/a/b.png"), "/a/b.png");
+ assert_eq!(import::uri_to_path("file://C:/x/y.jpg"), "C:/x/y.jpg");
+ // Another computer stays one: a network path, not a folder named after it.
+ assert_eq!(import::uri_to_path("file://192.0.2.1/s/x%20y.png"), "//192.0.2.1/s/x y.png");
assert_eq!(export::path_to_uri("/a b/c.png"), "file:/a%20b/c.png");
let data: Vec = (0..=255u8).cycle().take(1000).collect();
assert_eq!(base64_decode(&base64_encode(&data)), data);
@@ -4189,3 +4193,44 @@ mod liquid_page_rules {
}
}
}
+
+/// Cell Options › Text › First Baseline, set by a cell style that a table style applies to its body
+/// region (cells with `[None]` take it from there) and overridden on one cell.
+const FIRST_BASELINE_STYLES: &str = r#"
+
+
+
+ cs-cap
+
+
+
+
+ "#;
+
+const FIRST_BASELINE_STORY: &str = r#"
+ "#;
+
+#[test]
+fn cell_first_baseline_comes_from_cell_styles_table_regions_and_cells_and_round_trips() {
+ use designcraft_doc::FirstBaseline;
+ let d = import_idml(&fixture_with_table_styles(FIRST_BASELINE_STYLES, FIRST_BASELINE_STORY)).unwrap();
+ d.check().unwrap();
+ let style = d.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
+ assert_eq!((style.first_baseline, style.first_baseline_min), (Some(FirstBaseline::CapHeight), Some(2.0)));
+ let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
+ let region = table.cell(0, 0).unwrap();
+ assert_eq!((region.first_baseline, region.first_baseline_min), (FirstBaseline::CapHeight, 2.0), "inherited through BasedOn and the body region");
+ assert_eq!(table.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading, "a cell's own attribute wins");
+ let back = import_idml(&export_idml(&d)).unwrap();
+ let t2 = back.stories.values().flat_map(|s| s.tables.values()).next().unwrap();
+ assert_eq!(t2.cell(0, 0).unwrap().first_baseline, FirstBaseline::CapHeight);
+ assert_eq!(t2.cell(0, 0).unwrap().first_baseline_min, 2.0);
+ assert_eq!(t2.cell(0, 1).unwrap().first_baseline, FirstBaseline::Leading);
+ let s2 = back.styles.cell.iter().find(|c| c.name == "Cap").unwrap();
+ assert_eq!(s2.first_baseline, Some(FirstBaseline::CapHeight));
+}
diff --git a/crates/idml/src/xml.rs b/crates/idml/src/xml.rs
index b3f1ef0d0..374e70c7f 100644
--- a/crates/idml/src/xml.rs
+++ b/crates/idml/src/xml.rs
@@ -93,8 +93,9 @@ impl El {
pub fn prop_el(&self, k: &str) -> Option<&El> {
self.find("Properties").and_then(|p| p.find(k))
}
+ /// A finite numeric property; `NaN` and infinities count as absent.
pub fn num(&self, k: &str) -> Option {
- self.prop(k).and_then(|v| v.trim().parse().ok())
+ self.prop(k).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())
}
pub fn boolean(&self, k: &str) -> Option {
match self.prop(k)?.trim() {
@@ -337,4 +338,12 @@ mod tests {
assert_eq!(e.num("Leading"), Some(14.0));
assert_eq!(e.prop("Missing"), None);
}
+
+ /// `NaN` and infinities parse as `f64` but are not usable numbers.
+ #[test]
+ fn non_finite_numbers_count_as_absent() {
+ let x = br#"-inf 2.5 "#;
+ let e = parse(x).unwrap();
+ assert_eq!((e.num("W"), e.num("X"), e.num("Y"), e.num("Z")), (None, None, None, Some(2.5)));
+ }
}
diff --git a/crates/images/src/lib.rs b/crates/images/src/lib.rs
index a64ebace1..61b9aff7d 100644
--- a/crates/images/src/lib.rs
+++ b/crates/images/src/lib.rs
@@ -75,7 +75,11 @@ fn svg_options() -> &'static usvg::Options<'static> {
db.set_sans_serif_family("Source Sans 3");
db.set_monospace_family("JetBrains Mono");
let font_resolver = usvg::FontResolver { select_font: select_font(), select_fallback: select_fallback() };
- usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, ..Default::default() }
+ // Images inside an SVG come only from `data:` URLs: an `href` naming a file is not read
+ // (it is left out, as usvg leaves out an image it can't load).
+ let image_href_resolver =
+ usvg::ImageHrefResolver { resolve_data: usvg::ImageHrefResolver::default_data_resolver(), resolve_string: Box::new(|_, _| None) };
+ usvg::Options { fontdb: Arc::new(db), font_family: "Source Sans 3".into(), font_resolver, image_href_resolver, ..Default::default() }
})
}
@@ -513,6 +517,39 @@ mod tests {
assert!(!is_svg(b"\x89PNG"));
}
+ #[test]
+ fn svg_images_come_only_from_data_urls() {
+ let dir = std::env::temp_dir().join(format!("dc-svg-href-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let file = dir.join("red.png");
+ image::RgbaImage::from_pixel(4, 4, image::Rgba([255, 0, 0, 255])).save(&file).unwrap();
+ let blue = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10'%3E%3Crect width='10' height='10' fill='blue'/%3E%3C/svg%3E";
+ let svg = format!(
+ r#"
+
+
+
+ "#,
+ file.display()
+ );
+ // The image nodes usvg kept: only the data URL's (PDF export embeds every one).
+ fn images(g: &usvg::Group) -> usize {
+ g.children()
+ .iter()
+ .map(|n| match n {
+ usvg::Node::Group(g) => images(g),
+ usvg::Node::Image(_) => 1,
+ _ => 0,
+ })
+ .sum()
+ }
+ assert_eq!(images(svg_tree(svg.as_bytes()).unwrap().root()), 1, "files the SVG names are not read");
+ let (px, w, _) = render_svg(svg.as_bytes(), 30).unwrap();
+ let at = |x: usize, y: usize| px[(y * w as usize + x) * 4..][..4].to_vec();
+ assert_eq!(at(15, 5), [0, 0, 255, 255], "a data URL is drawn");
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
/// The family of the font that sets each text glyph of `svg`, and the glyph's id.
fn glyph_families(svg: &str) -> Vec<(String, u16)> {
fn walk(g: &usvg::Group, tree: &usvg::Tree, out: &mut Vec<(String, u16)>) {
diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs
index 3afe22015..4e7627e9e 100644
--- a/crates/pdf/src/export.rs
+++ b/crates/pdf/src/export.rs
@@ -254,7 +254,7 @@ fn export_pdf_with_report_inner(doc: &Document, cache: &Cache, opts: &PdfOptions
}
if opts.standard == Standard::PdfX4 {
// Output intent and PDF/X identification, then check the result.
- match crate::pdfx::make_pdfx4(&bytes, &title) {
+ match crate::pdfx::make_pdfx4(&bytes, &title, created) {
Some(b) => bytes = b,
None => warnings.push("PDF/X-4: the output intent couldn't be added".into()),
}
diff --git a/crates/pdf/src/pdfx.rs b/crates/pdf/src/pdfx.rs
index 75d661cf8..534886f1d 100644
--- a/crates/pdf/src/pdfx.rs
+++ b/crates/pdf/src/pdfx.rs
@@ -23,9 +23,11 @@ fn lit(s: &str) -> String {
o
}
-/// Turn krilla's output into PDF/X-4. `None` when the file isn't the simple kind this
-/// understands (a classic cross-reference table and plain dictionaries).
-pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> {
+/// Turn krilla's output into PDF/X-4. `date` (Unix seconds, UTC) is the XMP metadata date; `None`
+/// (no clock, as on wasm) writes the epoch, because PDF/X-4 requires the entry. `None` back when
+/// the file isn't the simple kind this understands (a classic cross-reference table and plain
+/// dictionaries).
+pub fn make_pdfx4(pdf: &[u8], title: &str, date: Option) -> Option> {
let t_at = rfind(pdf, b"trailer")?;
let trailer = String::from_utf8_lossy(&pdf[t_at..]).to_string();
let size = int_after(&trailer, "/Size")?;
@@ -84,7 +86,8 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> {
i
}
};
- // XMP: the PDF/X identification schema, and the trapping state that Info also states.
+ // XMP: the PDF/X identification schema, the trapping state that Info also states, and the
+ // metadata date and version ID that PDF/X-4 requires.
if let Some(meta) = int_after(&cat, "/Metadata") {
let r = object(pdf, meta)?;
let body = &pdf[r];
@@ -96,11 +99,17 @@ pub fn make_pdfx4(pdf: &[u8], title: &str) -> Option> {
if dict.contains("/Filter") {
return None;
}
+ let (y, mo, d, h, mi, se) = crate::export::civil(date.unwrap_or(0));
+ let stamp = format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z");
let add = concat!(
"PDF/X-4 ",
"False "
);
- let xml = xml.replacen("", &format!("{add}"), 1);
+ let dates = format!(
+ "\
+ {stamp} 1 "
+ );
+ let xml = xml.replacen("", &format!("{add}{dates}"), 1);
let dict = {
let i = dict.find("/Length")?;
let rest = &dict[i + 7..];
@@ -216,6 +225,12 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec {
if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") {
issues.push("the XMP metadata doesn't name PDF/X-4".into());
}
+ if !text.contains("") {
+ issues.push("the XMP metadata has no xmp:MetadataDate".into());
+ }
+ if !text.contains("") {
+ issues.push("the XMP metadata has no xmpMM:VersionID".into());
+ }
if text.contains("/Interpolate true") {
issues.push("an image asks for interpolation".into());
}
diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs
index ed6a1ac1f..ee02e2190 100644
--- a/crates/pdf/src/tests.rs
+++ b/crates/pdf/src/tests.rs
@@ -163,6 +163,20 @@ fn spreads_and_ranges() {
assert_eq!(export_pdf(&d, &Cache::new(), &PdfOptions { pages: Some(vec![9]), ..Default::default() }), Err(PdfError::BadPage(10)));
}
+#[test]
+fn pdfx4_xmp_has_metadata_date_and_version_id() {
+ let d = doc_with_text("Print me");
+ let opts = PdfOptions { standard: Standard::PdfX4, created: Some(1_700_000_000), ..Default::default() };
+ let r = export_pdf_with_report(&d, &Cache::new(), &opts).unwrap();
+ let text = String::from_utf8_lossy(&r.bytes).to_string();
+ assert!(text.contains("2023-11-14T22:13:20Z "), "metadata date");
+ assert!(text.contains("1 "), "version id");
+ assert!(crate::check_pdfx4(&r.bytes).is_empty());
+ // Without the two entries the check says so.
+ let stripped = text.replace("xmp:MetadataDate", "xmp:Other").replace("xmpMM:VersionID", "xmpMM:Other");
+ assert_eq!(crate::check_pdfx4(stripped.as_bytes()).iter().filter(|i| i.contains("xmp")).count(), 2);
+}
+
#[test]
fn standards() {
let d = doc_with_text("Archive me");
diff --git a/crates/textimport/src/docx.rs b/crates/textimport/src/docx.rs
index c4375669f..783b010c6 100644
--- a/crates/textimport/src/docx.rs
+++ b/crates/textimport/src/docx.rs
@@ -180,7 +180,7 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
if let Some(s) = r.on("strike") {
a.strikethrough = Some(s);
}
- if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()) {
+ if let Some(sz) = r.val("sz").and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()) {
a.size = Some(sz / 2.0);
}
if let Some(f) = r.child("rFonts").and_then(|f| f.attr("ascii").or(f.attr("hAnsi"))) {
@@ -203,7 +203,7 @@ fn run_attrs(rpr: Option<&El>) -> CharAttrs {
fn para_attrs(ppr: Option<&El>) -> ParaAttrs {
let mut a = ParaAttrs::default();
let Some(p) = ppr else { return a };
- let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).map(|v| v / 20.0);
+ let tw = |v: Option<&str>| v.and_then(|v| v.parse::().ok()).filter(|v| v.is_finite()).map(|v| v / 20.0);
if let Some(sp) = p.child("spacing") {
a.space_before = tw(sp.attr("before"));
a.space_after = tw(sp.attr("after"));
@@ -680,6 +680,28 @@ mod tests {
assert_eq!(st.text, "Date: 1 May 2026.\nLink");
}
+ /// `str::parse::` accepts "NaN"/"inf"; sizes, spacing and indents must ignore them.
+ #[test]
+ fn non_finite_sizes_spacing_and_indents_are_ignored() {
+ let document = format!(
+ r#"
+
+ a b
+ c
+ "#
+ );
+ let st = import(&docx(&document, "", "")).unwrap().story;
+ let fin = |v: Option| v.is_none_or(f64::is_finite);
+ for p in &st.paras {
+ let p = &p.para;
+ assert!(fin(p.space_before) && fin(p.space_after), "{p:?}");
+ assert!(fin(p.left_indent) && fin(p.right_indent) && fin(p.first_line_indent), "{p:?}");
+ }
+ for at in [0, 1] {
+ assert!(fin(st.format_after(at).over.size), "{:?}", st.format_after(at).over.size);
+ }
+ }
+
#[test]
fn tracked_changes_import_as_accepted_including_moves() {
// Regression (#256): text in a tracked move destination (`w:moveTo`) vanished on import.
diff --git a/crates/textimport/src/rtf.rs b/crates/textimport/src/rtf.rs
index f336b7f36..baf065c30 100644
--- a/crates/textimport/src/rtf.rs
+++ b/crates/textimport/src/rtf.rs
@@ -216,7 +216,10 @@ pub fn import(bytes: &[u8]) -> Result {
"ul" => restyle(&|s| s.underline = on),
"ulnone" => restyle(&|s| s.underline = false),
"strike" => restyle(&|s| s.strike = on),
- "fs" => restyle(&|s| s.size = param.map(|p| p as f64 / 2.0)),
+ // Half-points; sizes outside the engine's 0.1–1296 pt range fall back to the default.
+ "fs" => restyle(&|s| {
+ s.size = param.map(|p| p as f64 / 2.0).filter(|pt| (0.1..=1296.0).contains(pt));
+ }),
"plain" => restyle(&|s| {
s.bold = false;
s.italic = false;
@@ -297,6 +300,18 @@ Caf\'e9 \u8364? price\tab tab\line next\par
assert!(!st.text.contains("Times"), "font table skipped");
}
+ /// `\fs` sizes outside the engine's 0.1–1296 pt character size range are not stored.
+ #[test]
+ fn font_size_out_of_range_is_not_stored() {
+ let i = import(br"{\rtf1\ansi\fs0 A\fs-8 B\fs999999999 C\fs24 D}").unwrap();
+ let st = &i.story;
+ for ch in ["A", "B", "C"] {
+ let size = st.format_after(st.text.find(ch).unwrap()).over.size;
+ assert!(size.is_none_or(|s| (0.1..=1296.0).contains(&s)), "{ch}: {size:?}");
+ }
+ assert_eq!(st.format_after(st.text.find('D').unwrap()).over.size, Some(12.0));
+ }
+
/// A '-' right after a control word is text unless a digit follows it (then it is a negative
/// parameter). It used to be swallowed.
#[test]
diff --git a/crates/textimport/src/tagged.rs b/crates/textimport/src/tagged.rs
index b84577eae..9bc84b766 100644
--- a/crates/textimport/src/tagged.rs
+++ b/crates/textimport/src/tagged.rs
@@ -170,7 +170,7 @@ pub fn import(bytes: &[u8]) -> Result {
}
"cFont" => fmt.over.font_family = (!value.is_empty()).then(|| value.to_string()),
"cTypeface" => fmt.over.font_style = (!value.is_empty()).then(|| value.to_string()),
- "cSize" => fmt.over.size = value.trim().parse().ok(),
+ "cSize" => fmt.over.size = value.trim().parse().ok().filter(|s: &f64| s.is_finite()),
"DefineParaStyle" | "DefineCharStyle" => {
let nm = value.split('=').next().unwrap_or("").trim().to_string();
let list = if name == "DefineParaStyle" { &mut para_styles } else { &mut char_styles };
@@ -245,6 +245,17 @@ mod tests {
assert_eq!(import("\r\nA<0xD83D>B<0xDE00>C".as_bytes()).unwrap().story.text, "ABC");
}
+ /// `f64::from_str` accepts "NaN" and "inf"; a non-finite size must not reach the story.
+ #[test]
+ fn non_finite_char_size_is_ignored() {
+ for v in ["NaN", "inf", "infinity", "-inf"] {
+ let src = format!("\r\nbig");
+ let i = import(src.as_bytes()).unwrap();
+ let size = i.story.char_format_at(i.story.text.find("big").unwrap() + 1).over.size;
+ assert!(size.is_none_or(f64::is_finite), "{v}: size {size:?}");
+ }
+ }
+
#[test]
fn tagged_text_keeps_a_leading_empty_paragraph() {
let doc = Document::new(&designcraft_doc::build::NewDocument::default());
diff --git a/crates/textimport/src/xlsx.rs b/crates/textimport/src/xlsx.rs
index d07956998..3f20f3dee 100644
--- a/crates/textimport/src/xlsx.rs
+++ b/crates/textimport/src/xlsx.rs
@@ -35,11 +35,23 @@ fn first_sheet_target(wb: &str, rels: &str) -> Option {
Some(if target.starts_with("xl/") { target } else { format!("xl/{target}") })
}
+/// The text of a shared string (``) or inline string (``): its own `` and the `` of its
+/// `` runs. Phonetic guides (``, ``) are the reading, not the cell text.
+fn string_item(si: &El) -> String {
+ si.els()
+ .map(|e| match e.name.as_str() {
+ "t" => e.text(),
+ "r" => e.els().filter(|t| t.name == "t").map(El::text).collect(),
+ _ => String::new(),
+ })
+ .collect()
+}
+
fn value(c: &El, shared: &[String]) -> String {
let v = || c.child("v").map(El::text).unwrap_or_default();
match c.attr("t") {
Some("s") => v().trim().parse::().ok().and_then(|i| shared.get(i).cloned()).unwrap_or_default(),
- Some("inlineStr") => c.child("is").map(El::text).unwrap_or_default(),
+ Some("inlineStr") => c.child("is").map(string_item).unwrap_or_default(),
// A cached formula string is text even when it looks like a number.
Some("str") => v(),
Some("b") => {
@@ -90,7 +102,7 @@ pub fn records(bytes: &[u8], sheet: Option<&str>) -> Result>, Im
let shared: Vec = part(&mut zip, "xl/sharedStrings.xml")?
.map(|x| parse(&x))
.transpose()?
- .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect())
+ .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect())
.unwrap_or_default();
let mut cells: Vec<(usize, usize, String)> = Vec::new();
if let Some(data) = sheet_xml.child("sheetData") {
@@ -198,7 +210,7 @@ pub fn import(bytes: &[u8]) -> Result {
let shared: Vec = part(&mut zip, "xl/sharedStrings.xml")?
.map(|x| parse(&x))
.transpose()?
- .map(|sst| sst.els().filter(|e| e.name == "si").map(El::text).collect())
+ .map(|sst| sst.els().filter(|e| e.name == "si").map(string_item).collect())
.unwrap_or_default();
let mut cells: Vec<((usize, usize), String)> = Vec::new();
if let Some(data) = sheet.child("sheetData") {
@@ -355,6 +367,31 @@ mod tests {
assert!(records(&book, Some("Missing")).is_err());
}
+ #[test]
+ fn phonetic_guide_text_is_not_cell_text() {
+ let book = sheets(&[
+ (
+ "xl/workbook.xml",
+ r#" "#,
+ ),
+ ("xl/_rels/workbook.xml.rels", r#" "#),
+ (
+ "xl/sharedStrings.xml",
+ r#"日本 |