Merge storytold/main into fix/340-package-relative-links

This commit is contained in:
Brandon Thomas committed 2026-10-19 02:51:22 -04:00
commit 0ac836d6cb
46 files changed
+1213 -146

No files matched your search

+30
View File
@@ -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)"
Generated
+21 -21
View File
@@ -929,7 +929,7 @@ dependencies = [
[[package]]
name = "designcraft"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"arboard",
"designcraft-engine",
@@ -951,7 +951,7 @@ dependencies = [
[[package]]
name = "designcraft-cli"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -967,7 +967,7 @@ dependencies = [
[[package]]
name = "designcraft-color"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-cms",
"kurbo",
@@ -979,7 +979,7 @@ dependencies = [
[[package]]
name = "designcraft-compose"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -999,7 +999,7 @@ dependencies = [
[[package]]
name = "designcraft-doc"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-geom",
@@ -1011,7 +1011,7 @@ dependencies = [
[[package]]
name = "designcraft-engine"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"caseless",
"craft-atomic",
@@ -1050,7 +1050,7 @@ dependencies = [
[[package]]
name = "designcraft-epub"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-doc",
@@ -1059,7 +1059,7 @@ dependencies = [
[[package]]
name = "designcraft-fonts"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"core-text",
"harfrust 0.13.3",
@@ -1072,7 +1072,7 @@ dependencies = [
[[package]]
name = "designcraft-format"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-doc",
"designcraft-images",
@@ -1084,7 +1084,7 @@ dependencies = [
[[package]]
name = "designcraft-geom"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"flo_curves",
"kurbo",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "designcraft-idml"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]]
name = "designcraft-images"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-cms",
"designcraft-color",
@@ -1127,7 +1127,7 @@ dependencies = [
[[package]]
name = "designcraft-mcp"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-engine",
"designcraft-geom",
@@ -1140,7 +1140,7 @@ dependencies = [
[[package]]
name = "designcraft-pdf"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-pdf",
"designcraft-color",
@@ -1162,7 +1162,7 @@ dependencies = [
[[package]]
name = "designcraft-pptx"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1179,7 +1179,7 @@ dependencies = [
[[package]]
name = "designcraft-render"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1197,7 +1197,7 @@ dependencies = [
[[package]]
name = "designcraft-textimport"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-doc",
"quick-xml",
@@ -1206,7 +1206,7 @@ dependencies = [
[[package]]
name = "designcraft-tools"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-color",
"designcraft-compose",
@@ -1218,7 +1218,7 @@ dependencies = [
[[package]]
name = "designcraft-ui-egui"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"craft-ui",
"designcraft-color",
@@ -1245,7 +1245,7 @@ dependencies = [
[[package]]
name = "designcraft-web"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"designcraft-engine",
"designcraft-format",
@@ -5796,7 +5796,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.8.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -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"
+8 -10
View File
@@ -11,11 +11,11 @@
<h1 align="center">DesignCraft</h1>
<p align="center">
<b>Page layout and publishing; an open-source, clean-room reimplementation of Adobe InDesign, rebuilt in pure Rust.</b>
<b>Page layout and publishing; an open-source, modern desktop publishing and page layout studio, built in pure Rust.</b>
</p>
<p align="center">
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.<br>
<i>By the ArtCraft team.</i>
</p>
@@ -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.
<sub>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.</sub>
<br>
<p align="center">
+3 -1
View File
@@ -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
@@ -349,6 +349,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.
- **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change.
- **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change.
- **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change.
- **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `<Image FillColor>` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change.
+5 -1
View File
@@ -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}"))? {
+16
View File
@@ -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() {
+35 -3
View File
@@ -98,9 +98,11 @@ pub fn read(bytes: &[u8]) -> Result<Vec<Swatch>, 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<Color> = 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 {
+4
View File
@@ -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)
+17 -6
View File
@@ -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<Glyph> = 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 {
+18 -1
View File
@@ -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);
+48
View File
@@ -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::<Vec<Line>>()
};
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]
+17
View File
@@ -454,6 +454,11 @@ pub struct CellStyle {
pub inset_overrides: [Option<f64>; 4],
#[serde(skip_serializing_if = "Option::is_none")]
pub vj: Option<crate::item::VerticalJustification>,
/// Cell Options › Text › First Baseline, and its minimum.
#[serde(skip_serializing_if = "Option::is_none")]
pub first_baseline: Option<crate::item::FirstBaseline>,
#[serde(skip_serializing_if = "Option::is_none")]
pub first_baseline_min: Option<f64>,
/// All four edges.
#[serde(skip_serializing_if = "Option::is_none")]
pub stroke: Option<crate::table::CellStroke>,
@@ -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));
}
+31 -2
View File
@@ -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<Cell> = 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] {
+89 -10
View File
@@ -14,7 +14,9 @@
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};
@@ -25,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
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, d.path.as_deref().map(Path::new), &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?))
}),
@@ -262,7 +264,60 @@ 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, document: Option<&Path>, checks: &mut TextChecks) -> Vec<Value> {
/// 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<usize>,
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<Shown<'a>> {
fn walk<'a>(it: &'a Item, xf: Affine, aid: AssetId, page: Option<usize>, out: &mut Vec<Shown<'a>>) {
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<Shown> = 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<Value> {
let texts = d.stories.values().filter_map(|st| {
let l = st.file_link.as_ref()?;
let uses: Vec<Value> = st.frames.iter().map(|id| json!({"id": id.0, "page": d.page_of_item(*id).map(|p| d.page_name(p))})).collect();
@@ -273,25 +328,24 @@ fn list(d: &Document, document: Option<&Path>, checks: &mut TextChecks) -> Vec<V
.values()
.filter(|a| used.contains(&a.id))
.map(|a| {
let uses: Vec<Value> = uses(d, a.id)
let uses: Vec<Value> = 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());
@@ -527,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;
+45 -1
View File
@@ -61,7 +61,9 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
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));
}
}
+7 -2
View File
@@ -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<ItemId> {
p.get(key).and_then(Value::as_u64).map(ItemId)
+32 -7
View File
@@ -297,7 +297,7 @@ pub fn specs() -> Vec<CommandSpec> {
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,7 +309,7 @@ pub fn specs() -> Vec<CommandSpec> {
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;
@@ -1201,11 +1201,10 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result<Value> {
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 +4188,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 +4378,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)]
+73 -4
View File
@@ -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<usize> {
@@ -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::<Vec<_>>(), ["Headline", "Body"]);
}
#[test]
fn word_normal_comes_in_as_its_own_style() {
// Regression (#722): Normal's formatting (over Word's document defaults) was dropped.
+82 -3
View File
@@ -30,7 +30,7 @@ fn style_specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<designcraft_doc::FirstBaseline>, Option<f64>)> {
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<Value> {
const ID: &str = "table.setCell";
let g = target(s, p, ID)?;
@@ -954,6 +970,7 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
.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<Value> {
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();
+18 -5
View File
@@ -125,12 +125,20 @@ pub fn specs() -> Vec<CommandSpec> {
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();
+97
View File
@@ -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<u8>, 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:#?}");
}
+25
View File
@@ -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");
}
}
+26
View File
@@ -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::<Vec<_>>().into()
} else {
features.into()
}
}
fn shape_dir(
face: &FontFace,
text: &str,
@@ -207,6 +221,8 @@ fn shape_dir(
context: ShapeContext<'_>,
) -> Vec<ShapedGlyph> {
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::<Vec<_>>();
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;
+8
View File
@@ -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.
+33 -10
View File
@@ -391,6 +391,12 @@ fn nums(s: &str) -> Vec<f64> {
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<f64> {
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::<f64>().ok()).collect::<Vec<_>>();
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::<f64>().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::<f64>().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::<f64>().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<Vec<f64>> = e.prop("GradientFillStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect());
let start: Option<Vec<f64>> = 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<Vec<f64>> = e.prop("GradientStrokeStart").map(|s| s.split_whitespace().filter_map(|v| v.parse().ok()).collect());
let start: Option<Vec<f64>> = 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::<f64>().ok()).unwrap_or(1.0)
}
fn finite(v: Option<f64>) -> Option<f64> {
v.filter(|v| v.is_finite())
}
+1 -1
View File
@@ -263,7 +263,7 @@ pub fn leading_out(l: Leading) -> (String, &'static str) {
}
}
pub fn leading_in(s: &str) -> Option<Leading> {
if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse().ok().map(Leading::Points) }
if s == "Auto" { Some(Leading::Auto) } else { s.trim().parse::<f64>().ok().filter(|v| v.is_finite()).map(Leading::Points) }
}
pub fn stroke_align_out(a: StrokeAlign) -> &'static str {
+41
View File
@@ -4193,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#"<idPkg:Styles xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<RootCellStyleGroup>
<CellStyle Self="CellStyle/$ID/[None]" Name="$ID/[None]"/>
<CellStyle Self="cs-cap" Name="Cap" FirstBaselineOffset="CapHeight" MinimumFirstBaselineOffset="2" VerticalJustification="CenterAlign"/>
<CellStyle Self="cs-cap-child" Name="CapChild" FillTint="50"><Properties><BasedOn type="object">cs-cap</BasedOn></Properties></CellStyle>
</RootCellStyleGroup>
<RootParagraphStyleGroup/>
<RootTableStyleGroup>
<TableStyle Self="ts" Name="Tbl" BodyRegionCellStyle="cs-cap-child"/>
</RootTableStyleGroup></idPkg:Styles>"#;
const FIRST_BASELINE_STORY: &str = r#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging">
<Story Self="s1"><ParagraphStyleRange><CharacterStyleRange><Table AppliedTableStyle="ts" HeaderRowCount="0">
<Row Name="0" MinimumHeight="20"/>
<Column Name="0" SingleColumnWidth="60"/><Column Name="1" SingleColumnWidth="60"/>
<Cell Name="0:0" AppliedCellStyle="CellStyle/$ID/[None]"><ParagraphStyleRange><CharacterStyleRange><Content>Region</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
<Cell Name="1:0" AppliedCellStyle="CellStyle/$ID/[None]" FirstBaselineOffset="LeadingOffset"><ParagraphStyleRange><CharacterStyleRange><Content>Local</Content></CharacterStyleRange></ParagraphStyleRange></Cell>
</Table></CharacterStyleRange></ParagraphStyleRange></Story></idPkg:Story>"#;
#[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));
}
+10 -1
View File
@@ -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<f64> {
self.prop(k).and_then(|v| v.trim().parse().ok())
self.prop(k).and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite())
}
pub fn boolean(&self, k: &str) -> Option<bool> {
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#"<A W="NaN" X="inf"><Properties><Y type="unit">-inf</Y><Z>2.5</Z></Properties></A>"#;
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)));
}
}
+1 -1
View File
@@ -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()),
}
+20 -5
View File
@@ -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<Vec<u8>> {
/// 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<i64>) -> Option<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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!(
"<rdf:Description rdf:about=\"\" xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\"><pdfxid:GTS_PDFXVersion>PDF/X-4</pdfxid:GTS_PDFXVersion></rdf:Description>",
"<rdf:Description rdf:about=\"\" xmlns:pdf=\"http://ns.adobe.com/pdf/1.3/\"><pdf:Trapped>False</pdf:Trapped></rdf:Description>"
);
let xml = xml.replacen("</rdf:RDF>", &format!("{add}</rdf:RDF>"), 1);
let dates = format!(
"<rdf:Description rdf:about=\"\" xmlns:xmp=\"http://ns.adobe.com/xap/1.0/\" xmlns:xmpMM=\"http://ns.adobe.com/xap/1.0/mm/\">\
<xmp:MetadataDate>{stamp}</xmp:MetadataDate><xmpMM:VersionID>1</xmpMM:VersionID></rdf:Description>"
);
let xml = xml.replacen("</rdf:RDF>", &format!("{add}{dates}</rdf:RDF>"), 1);
let dict = {
let i = dict.find("/Length")?;
let rest = &dict[i + 7..];
@@ -216,6 +225,12 @@ pub fn check_pdfx4(pdf: &[u8]) -> Vec<String> {
if !text.contains("pdfxid:GTS_PDFXVersion>PDF/X-4<") {
issues.push("the XMP metadata doesn't name PDF/X-4".into());
}
if !text.contains("<xmp:MetadataDate>") {
issues.push("the XMP metadata has no xmp:MetadataDate".into());
}
if !text.contains("<xmpMM:VersionID>") {
issues.push("the XMP metadata has no xmpMM:VersionID".into());
}
if text.contains("/Interpolate true") {
issues.push("an image asks for interpolation".into());
}
+14
View File
@@ -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("<xmp:MetadataDate>2023-11-14T22:13:20Z</xmp:MetadataDate>"), "metadata date");
assert!(text.contains("<xmpMM:VersionID>1</xmpMM:VersionID>"), "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");
+24 -2
View File
@@ -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::<f64>().ok()) {
if let Some(sz) = r.val("sz").and_then(|v| v.parse::<f64>().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::<f64>().ok()).map(|v| v / 20.0);
let tw = |v: Option<&str>| v.and_then(|v| v.parse::<f64>().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::<f64>` accepts "NaN"/"inf"; sizes, spacing and indents must ignore them.
#[test]
fn non_finite_sizes_spacing_and_indents_are_ignored() {
let document = format!(
r#"<w:document {W}><w:body>
<w:p><w:pPr><w:spacing w:before="NaN" w:after="inf"/><w:ind w:left="-inf" w:right="NaN" w:firstLine="-inf"/></w:pPr>
<w:r><w:rPr><w:sz w:val="NaN"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:sz w:val="inf"/></w:rPr><w:t>b</w:t></w:r></w:p>
<w:p><w:pPr><w:ind w:hanging="inf"/></w:pPr><w:r><w:t>c</w:t></w:r></w:p>
</w:body></w:document>"#
);
let st = import(&docx(&document, "", "")).unwrap().story;
let fin = |v: Option<f64>| 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.
+16 -1
View File
@@ -216,7 +216,10 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
"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]
+12 -1
View File
@@ -170,7 +170,7 @@ pub fn import(bytes: &[u8]) -> Result<Imported, ImportError> {
}
"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("<ASCII-WIN>\r\n<ParaStyle:Body>A<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!("<ASCII-WIN>\r\n<ParaStyle:Body><cSize:{v}>big<cSize:>");
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());
+40 -3
View File
@@ -35,11 +35,23 @@ fn first_sheet_target(wb: &str, rels: &str) -> Option<String> {
Some(if target.starts_with("xl/") { target } else { format!("xl/{target}") })
}
/// The text of a shared string (`<si>`) or inline string (`<is>`): its own `<t>` and the `<t>` of its
/// `<r>` runs. Phonetic guides (`<rPh>`, `<phoneticPr>`) 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::<usize>().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<Vec<Vec<String>>, Im
let shared: Vec<String> = 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<Imported, ImportError> {
let shared: Vec<String> = 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#"<workbook xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>"#,
),
("xl/_rels/workbook.xml.rels", r#"<Relationships><Relationship Id="rId1" Type="x" Target="worksheets/sheet1.xml"/></Relationships>"#),
(
"xl/sharedStrings.xml",
r#"<sst><si><t>日本</t><rPh sb="0" eb="2"><t>ニホン</t></rPh><phoneticPr fontId="1"/></si><si><r><t>東</t></r><r><t>京</t></r><rPh sb="0" eb="2"><t>トウキョウ</t></rPh></si></sst>"#,
),
(
"xl/worksheets/sheet1.xml",
r#"<worksheet><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c><c r="C1" t="inlineStr"><is><t>大阪</t><rPh sb="0" eb="2"><t>オオサカ</t></rPh><phoneticPr/></is></c></row></sheetData></worksheet>"#,
),
]);
let want = ["日本", "東京", "大阪"].map(String::from).to_vec();
assert_eq!(records(&book, None).unwrap(), vec![want.clone()]);
let imp = import(&book).unwrap();
let t = imp.story.tables.values().next().expect("a table");
let got: Vec<String> = (0..3).map(|c| t.cell(0, c).unwrap().text.text.clone()).collect();
assert_eq!(got, want);
}
fn one_value(cell: &str) -> Vec<u8> {
workbook_with(&format!(
r#"<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Value</t></is></c></row><row r="2">{cell}</row></sheetData></worksheet>"#
+2 -2
View File
@@ -434,8 +434,8 @@ impl Tool for PageTool {
p: d.cur,
text: format!(
"W: {}\nH: {}",
designcraft_geom::format_measure(pg.width, cx.unit),
designcraft_geom::format_measure(pg.height, cx.unit)
designcraft_geom::format_measure(pg.width, cx.doc.settings.horizontal_units),
designcraft_geom::format_measure(pg.height, cx.doc.settings.vertical_units)
),
});
}
+14 -1
View File
@@ -1236,7 +1236,9 @@ pub fn open_document_setup(app: &mut DesignApp) {
}
// The margins as shown: OK applies them to every page only when they were edited.
f["marginsShown"] = json!(SETUP_EDGES.map(|e| f[format!("margin{e}")].clone()));
app.ui.dialog = Some(Dialog::new("documentSetup", f));
let mut d = Dialog::new("documentSetup", f);
d.unit = units;
app.ui.dialog = Some(d);
}
/// The edges of Document Setup's margins, bleed and slug (field suffixes).
@@ -7076,6 +7078,17 @@ mod tests {
assert!((doc.settings.page_width - 612.0).abs() < 1e-6 && doc.settings.bleed[0] == 0.0, "one undo step");
}
#[test]
fn document_setup_reads_a_bare_number_in_the_documents_units() {
let mut app = document_setup_app("millimeters");
setup_fields(&mut app, json!({"bleedTop": "5", "bleedBottom": "5", "bleedInside": "5", "bleedOutside": "5", "width": "210"}));
confirm(&mut app).unwrap();
let mm = designcraft_geom::units::PT_PER_MM;
let settings = &app.session.active().unwrap().doc.settings;
assert!(settings.bleed.iter().all(|b| (b - 5.0 * mm).abs() < 1e-6), "5 mm, not 5 picas: {:?}", settings.bleed);
assert!((settings.page_width - 210.0 * mm).abs() < 1e-6, "{}", settings.page_width);
}
#[test]
fn document_setup_keeps_each_pages_margins_unless_they_are_edited() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
+57 -5
View File
@@ -68,7 +68,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
egui::Panel::right("dock").default_size(253.0).size_range(220.0..=420.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel)).show(
ui,
|ui| {
dock_header(ui, &t, "»");
if dock_header(ui, &t, "»").clicked() {
let _ = app.run("window.collapseToIcons", serde_json::json!({}));
}
// Tab strip (27 pt).
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
@@ -127,7 +129,9 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
// (Essentials Classic) show their names.
let groups = app.ui.icon_groups.clone();
egui::Panel::right("icon_column").exact_size(icon_column_width(app)).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| {
dock_header(ui, &t, "«");
if dock_header(ui, &t, "«").clicked() {
app.ui.dock_expanded = true;
}
ui.add_space(4.0);
match &groups {
Some(groups) => {
@@ -194,12 +198,13 @@ fn icon_clicked(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, response: &egu
crate::panel_docking::legacy_tab(app, ui, id, response);
}
/// 11 pt dock header strip with a collapse chevron.
fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::hover());
/// 11 pt dock header strip with a collapse chevron; clicking it reports a click on the response.
fn dock_header(ui: &mut egui::Ui, t: &Tokens, chevron: &str) -> egui::Response {
let (r, response) = ui.allocate_exact_size(vec2(ui.available_width(), 11.0), Sense::click());
ui.painter().rect_filled(r, 0.0, t.tab_strip);
ui.painter().text(egui::pos2(r.max.x - 8.0, r.center().y), egui::Align2::RIGHT_CENTER, chevron, egui::FontId::proportional(9.0), t.text_dim);
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
response
}
/// A panel opened from the icon column, shown as a floating flyout next to the dock.
@@ -518,3 +523,50 @@ mod visual_regressions {
}
}
}
#[cfg(test)]
mod chevron_tests {
use super::*;
use egui_kittest::Harness;
fn click(harness: &mut Harness<'_, DesignApp>, at: egui::Pos2) {
harness.hover_at(at);
harness.run_steps(1);
harness.drag_at(at);
harness.run_steps(1);
harness.drop_at(at);
harness.run_steps(3);
}
/// The » / « chevron painted at the right end of a panel's 11 pt header.
fn chevron(harness: &Harness<'_, DesignApp>, panel: &str) -> egui::Pos2 {
let r = egui::containers::panel::PanelState::load(&harness.ctx, egui::Id::new(panel)).expect("the panel is laid out").outer_rect;
egui::pos2(r.max.x - 10.0, r.min.y + 5.0)
}
/// Issue #1203: the header chevrons were painted text; clicking them did nothing.
#[test]
fn the_dock_chevron_collapses_and_expands_the_dock() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default());
app.ui.dock_expanded = true;
let mut ready = false;
let mut harness = Harness::builder().with_size(vec2(1280.0, 640.0)).build_ui_state(
move |ui, app: &mut DesignApp| {
if !ready {
crate::theme::install_fonts(ui.ctx(), "en");
ready = true;
return;
}
show(app, ui);
},
app,
);
harness.run_steps(4);
let at = chevron(&harness, "dock");
click(&mut harness, at);
assert!(!harness.state().ui.dock_expanded, "» collapses the dock to icons");
let at = chevron(&harness, "icon_column");
click(&mut harness, at);
assert!(harness.state().ui.dock_expanded, "« expands the dock again");
}
}
+14 -1
View File
@@ -737,7 +737,9 @@ impl DesignApp {
ViewInfo {
zoom: self.view().map(|v| v.zoom).unwrap_or(1.0),
snap: self.ui.snap_view(),
unit: self.ui.units,
// Tool readouts (sizes, smart-guide distances) measure in the document's ruler units,
// as its Control and Properties fields do.
unit: self.session.active().map_or(self.ui.units, |st| st.doc.settings.horizontal_units),
stroke_in_front: self.ui.stroke_front,
}
}
@@ -1843,6 +1845,17 @@ mod tests {
assert!(!accurate.rich_black, "a saved choice is kept");
}
/// Tool readouts measured in picas whatever the document's units (the UI's unit is never set):
/// they follow the active document's ruler units.
#[test]
fn tool_readouts_measure_in_the_documents_units() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
assert_eq!(app.view_info().unit, Unit::Picas, "no document: the UI's default");
app.run("file.new", json!({})).unwrap();
app.run("document.preferences", json!({"horizontalUnits": "inches", "verticalUnits": "millimeters"})).unwrap();
assert_eq!(app.view_info().unit, Unit::Inches);
}
/// An app with one document that has an unsaved edit.
fn app_with_unsaved_document() -> DesignApp {
let mut app = DesignApp::new(designcraft_engine::Session::new(), Services::default());
+13 -6
View File
@@ -360,9 +360,8 @@ fn cards(app: &DesignApp, tab: &str) -> Vec<Card> {
}
}
/// `210 × 297 mm`, `51p0 × 66p0`.
fn size_text(nd: &NewDocument) -> String {
let u = nd.units;
/// `210 × 297 mm`, `51p0 × 66p0`: the size of `nd` in the dialog's units `u`.
fn size_text(nd: &NewDocument, u: Unit) -> String {
let (w, h) = (format_measure(nd.width, u), format_measure(nd.height, u));
let suffix = u.suffix();
if suffix.starts_with(' ')
@@ -391,7 +390,7 @@ fn paint_page(p: &egui::Painter, area: Rect, nd: &NewDocument, custom: bool, col
}
}
fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response {
fn card(ui: &mut Ui, c: &Card, units: Unit, selected: bool) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(CARD, Sense::click());
if selected {
@@ -406,7 +405,7 @@ fn card(ui: &mut Ui, c: &Card, selected: bool) -> egui::Response {
ui.painter(),
pos2(r.center().x, r.top() + 132.0),
Align2::CENTER_CENTER,
&size_text(&c.nd),
&size_text(&c.nd, units),
FontId::proportional(11.0),
t.text_dim,
);
@@ -494,13 +493,14 @@ fn presets(app: &mut DesignApp, ui: &mut Ui, d: &mut Dialog) -> bool {
});
ui.add_space(10.0);
let current = d.s("preset");
let card_units = units(d);
let mut create = false;
let mut delete = None;
let layout = if rtl { Layout::right_to_left(Align::TOP) } else { Layout::left_to_right(Align::TOP) }.with_main_wrap(true);
ui.with_layout(layout, |ui| {
ui.spacing_mut().item_spacing = vec2(10.0, 10.0);
for c in &list {
let resp = card(ui, c, current == c.key);
let resp = card(ui, c, card_units, current == c.key);
if resp.clicked() || resp.double_clicked() {
select(d, c);
}
@@ -1031,6 +1031,13 @@ mod tests {
assert!(!doc.settings.facing_pages);
}
#[test]
fn card_sizes_follow_the_dialog_units() {
let card = NewDocument::from_preset("Business Card").unwrap();
assert_eq!(size_text(&card, Unit::Millimeters), "88.9 × 50.8 mm");
assert_ne!(size_text(&card, Unit::Millimeters), size_text(&card, Unit::Picas));
}
#[test]
fn units_reformat_the_measures_and_linked_edges_follow() {
// InDesign's Units list.
+51
View File
@@ -1864,6 +1864,12 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
super::cell_stroke::panel(app, ui);
return;
}
if super::colors_affect_text(app)
&& let Some(a) = text_attrs(app)
{
text_stroke_panel(app, ui, &a["chars"]);
return;
}
let Some(i) = sel_info(app) else {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select an object."));
return;
@@ -1985,6 +1991,27 @@ pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) {
});
}
/// Stroke panel while text is selected: the text's outline colour, weight and whether it sits behind the fill.
fn text_stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui, c: &Value) {
let swatch = c["stroke"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string();
egui::Grid::new("text_stroke_panel").num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| {
caption(ui, crate::i18n::tr(&app.ui.language, "Weight:"));
if let Some(v) = number(ui, "tsw_panel", c["strokeWeight"].as_f64(), " pt", 60.0, 3) {
let _ = app.run("type.char", json!({"attrs": {"strokeWeight": v}}));
}
ui.end_row();
caption(ui, crate::i18n::tr(&app.ui.language, "Color:"));
super::swatch_picker(app, ui, "tspick", Some(swatch), |app, n| {
let _ = super::apply_swatch(app, true, &n, None);
});
ui.end_row();
});
let mut behind = c["strokeBehindFill"].as_bool().unwrap_or(false);
if ui.checkbox(&mut behind, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stroke Behind Fill"))).changed() {
let _ = app.run("type.char", json!({"attrs": {"strokeBehindFill": behind}}));
}
}
/// A short line ending in arrowhead `a` (pointing left for Start, right for End).
fn arrow_preview(ui: &mut egui::Ui, a: Arrowhead, start: bool, color: egui::Color32) {
use designcraft_geom::kurbo;
@@ -2915,6 +2942,30 @@ mod tests {
h
}
/// With the caret in text, the Stroke panel offers the text's stroke options, not an empty panel.
#[test]
fn stroke_panel_shows_the_text_stroke_options_while_text_is_selected() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap();
let mut ready = false;
let mut h = Harness::builder().with_size(vec2(320.0, 400.0)).build_ui_state(
move |ui, app: &mut DesignApp| {
if !ready {
crate::theme::install_fonts(ui.ctx(), "");
crate::theme::apply(ui.ctx(), &Tokens::for_brightness(Default::default()));
ready = true;
return;
}
stroke_panel(app, ui);
},
app,
);
h.run_steps(3);
assert!(h.query_by_label("Select an object.").is_none());
h.get_by_label("Stroke Behind Fill");
}
/// As InDesign's: Edit Page takes the Page tool to the page in view, and Properties sets that
/// page alone; Back returns to the document.
#[test]
+40 -19
View File
@@ -51,27 +51,26 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
.show(ctx, |ui| {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_top(|ui| {
// Style column.
ui.vertical(|ui| {
ui.set_width(130.0);
for (i, r) in story.para_ranges().iter().enumerate() {
let lines = story.text[r.clone()].len() / 60 + 1;
let style = &story.paras[i].style;
crate::rtl::label(ui, egui::RichText::new(crate::i18n::style_name(&app.ui.language, style)).size(11.0).color(t.text_dim));
for _ in 1..lines {
ui.label(egui::RichText::new(" ").size(11.0));
}
}
});
// Style column: the labels are painted after the text, at the rows the text really wraps to.
let (column, _) = ui.allocate_exact_size(vec2(130.0, 0.0), egui::Sense::hover());
ui.separator();
let mut text = story.text.clone();
let r = ui.add(
egui::TextEdit::multiline(&mut text)
.font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0)))
.desired_width(f32::INFINITY)
.desired_rows(20)
.frame(egui::Frame::NONE),
);
let out = egui::TextEdit::multiline(&mut text)
.font(egui::FontId::proportional(app.ui.story_editor_size.clamp(8.0, 36.0)))
.desired_width(f32::INFINITY)
.desired_rows(20)
.frame(egui::Frame::NONE)
.show(ui);
let r = out.response;
let starts = paragraph_start_offsets(out.galley.rows.iter().map(|row| (row.pos.y, row.ends_with_newline)));
for (i, y) in starts.into_iter().enumerate() {
let Some(para) = story.paras.get(i) else { break };
let rect = egui::Rect::from_min_size(egui::pos2(column.min.x, out.galley_pos.y + y), vec2(130.0, 14.0));
ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| {
let name = crate::i18n::style_name(&app.ui.language, &para.style);
crate::rtl::label(ui, egui::RichText::new(name).size(11.0).color(t.text_dim));
});
}
if r.changed() {
// Minimal diff: common prefix and suffix.
let old = &story.text;
@@ -111,6 +110,21 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) {
}
}
/// Vertical offset of the first laid-out row of each paragraph, from `(row y, row ends with a newline)`
/// in layout order. A paragraph that wraps spans several rows, so counting characters per line
/// drifts from what the editor draws; the rows themselves are the truth.
fn paragraph_start_offsets(rows: impl Iterator<Item = (f32, bool)>) -> Vec<f32> {
let mut starts = Vec::new();
let mut at_start = true;
for (y, ends_with_newline) in rows {
if at_start {
starts.push(y);
}
at_start = ends_with_newline;
}
starts
}
#[cfg(test)]
mod tests {
use egui_kittest::kittest::Queryable;
@@ -123,6 +137,13 @@ mod tests {
st.doc.stories.values().next().unwrap().text.clone()
}
/// A wrapped paragraph takes several rows; the next paragraph's label goes to the row after them.
#[test]
fn labels_follow_wrapped_rows() {
let rows = [(0.0, false), (20.0, false), (40.0, true), (60.0, true), (80.0, false)];
assert_eq!(super::paragraph_start_offsets(rows.into_iter()), vec![0.0, 60.0, 80.0]);
}
/// Story ids are counted per document: typing in the editor after switching documents edited
/// the other document's story with the same id.
#[test]
+1 -1
View File
@@ -26,7 +26,7 @@ of step N's result: N is the 0-based step index (comments and blank lines are no
step) and the path goes through result fields and array indices (`"$1.story"`, `"$3.ids.0"`); `"${N.path}"`
interpolates inside longer strings. Any other string starting with `$` stays as written (`"$1-$2"`, `"$5 off"`), and
`$$` writes a literal `$` anywhere (`"$$1"` passes `$1`, a GREP group). In text parameters `\n` starts a new paragraph and `\u2028` is a forced line
break. Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`.
break (`\u000b`, Word's line break, is read as one too). Most commands ignore parameters they don't know instead of rejecting them, so check names with `describe`.
`designcraft-cli script --help` prints this reference.
```sh
+51 -6
View File
@@ -7,21 +7,64 @@ the result as PNG images. It speaks newline-delimited JSON-RPC 2.0 on stdin/stdo
## Register it
Build the CLI (`cargo build --release -p designcraft-cli`), then:
### From source (development)
With Rust and Cargo installed, run this from the repository root:
```sh
cargo install --path apps/designcraft-cli --locked
```
Cargo copies the executable into its installation root's `bin` directory, normally
`~/.cargo/bin` (Windows `%USERPROFILE%\.cargo\bin`). If you set `CARGO_INSTALL_ROOT`,
Cargo's `install.root` configuration, or `CARGO_HOME`, use that installation's `bin`
directory instead. Unlike a symlink into `target/release`, this copy survives
`cargo clean`. Repeat the install command after source changes to update it.
Ensure that directory is on `PATH`. For a default install in a POSIX shell:
```sh
export PATH="$HOME/.cargo/bin:$PATH"
designcraft-cli --help
```
Persist the PATH setting in your shell configuration (on Windows, add the directory
to your user Path). The **MCP client process** must inherit that PATH too; restart
the client after changing it. A GUI-launched client may not read shell configuration.
If it still reports `ENOENT` or "executable not found", use the absolute path to the
installed binary in its registration instead of the bare name.
### Headless and connected registration
After installation, register the CLI by name:
```sh
# Headless: an in-process engine, no window
claude mcp add designcraft -- /path/to/target/release/designcraft-cli mcp
claude mcp add designcraft -- designcraft-cli mcp
# Drive the running desktop app (start it first with a control port)
# Drive the running desktop app (install/start the desktop app separately)
designcraft --control 7979
claude mcp add designcraft-app -- /path/to/target/release/designcraft-cli mcp --connect 7979
claude mcp add designcraft-app -- designcraft-cli mcp --connect 7979
```
Other MCP clients use the same command line, e.g. in a JSON config:
```json
{"mcpServers": {"designcraft": {"command": "/path/to/designcraft-cli", "args": ["mcp"]}}}
{
"mcpServers": {
"designcraft": {
"command": "designcraft-cli",
"args": ["mcp"]
}
}
}
```
If the client cannot find the CLI on PATH, substitute the absolute installed path
(not a path under `target/`), for example:
```sh
claude mcp add designcraft -- /absolute/install/root/bin/designcraft-cli mcp
```
### From an installed release
@@ -137,7 +180,9 @@ Positions in a story's text are **UTF-8 byte offsets**, not character counts: `a
`start` / `end` of `story.replaceRange` and of `find.find` matches, the `pos` that `text.placeCaret`, `text.insert`
and `text.move` return, a story's `length` in `get_story` and `inspect_document`, and its `overset` position in
`get_story`. A character outside ASCII takes 2 to 4 bytes (é 2, — and は 3), so the story `はただ商店` has length 15
and its second character starts at 3. An offset inside a character snaps back to the start of that character.
and its second character starts at 3. An offset inside a character snaps back to the start of that character, except
in `story.replaceRange`: there an offset inside a character, an `end` past the end of the story or a `start` after `end`
is an error that gives the story length in bytes, and the story is left unchanged.
## Text and image fitting
+2 -2
View File
@@ -377,7 +377,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Right-to-left text (World-Ready basics): right-to-left runs are shaped right to left, lines are reordered by the Unicode Bidirectional Algorithm (matched against the reference implementation), Paragraph Direction (`type.para {direction}`, Paragraph panel, IDML ParagraphDirection, EPUB `dir`). Digits (`digits`: Default, Arabic, Hindi, Farsi, Native by language; Character panel and Character Style Options; IDML DigitsType) draw 0–9 in another script. Kashidas (`kashidas` paragraph attribute, on by default; IDML Kashidas): justified Arabic lines stretch at one join per word (after seen/sad when possible, never inside lam–alef) with a drawn tatweel before word spaces widen. Carets follow the drawing: the caret before a right-to-left glyph sits at its right edge, arrow keys move as drawn (by words in the line's direction), clicks find the nearest drawn caret position and selections highlight the selected glyphs. Binding: Right to Left (Document Setup, `layout.documentSetup {binding}`, IDML PageBinding) makes page 1 a left page and lays spreads out right to left.
- Interface language (Preferences ▸ General ▸ Interface Language, `app.language`; each language listed by its own name): German, French, Spanish, Italian, Japanese, Simplified Chinese and Arabic menu titles, common menu items and panel names (our own translations). Common dialog, panel and tooltip labels are also localized, with English fallback for untranslated strings. Arabic translations cover every key in the shared interface translation table, including menu commands and utility labels. The Type menu's Story Direction, Horizontal/Vertical, Tate-Chu-Yoko, Ruby… and Kenten are translated too. Arabic labels use Unicode bidi run ordering and contextual shaping; welcome and new-document form layouts follow RTL direction independently of document binding. CJK and Arabic glyphs come from [craft-fonts](https://github.com/storytold/craft-fonts) when built with `CRAFT_FONTS_DIR` (all releases): BIZ UDPGothic and Noto Sans CJK SC for the UI (Chinese forms first in the Chinese interface), Noto Sans Arabic, and Mincho for Japanese documents (the web build embeds BIZ UDPGothic only); otherwise from system fonts. Ukrainian covers all 1,430 existing catalog keys, including publishing dialogs and built-in names, with persisted selection, grammatical count captions, English fallback and bundled Cyrillic UI glyph coverage. Polish covers 1,717 catalog keys with the same category-first count captions and English fallback.
- Page transitions (`page.transition/transitions`, Page Transitions panel): twelve transition types with speed and direction per spread, written into exported PDFs as `/Trans` entries through an incremental update.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- PDF/X-4 export (`file.exportPdf {standard: "x4"}`): an output intent with the working CMYK space's profile (by default craft-cms's Coated FOGRA39 profile, identifier FOGRA39; Generic CMYK and Device CMYK embed our Generic CMYK profile, lut16 tables generated from the parametric press model), GTS_PDFXVersion in Info and XMP, `pdf:Trapped` in XMP as in Info, `xmp:MetadataDate` and `xmpMM:VersionID` in XMP, RGB images without a profile tagged sRGB, no image interpolation (none in PDF/A either), and built-in PDF/X-4 checks reported as warnings. qpdf finds no errors in the result.
- EPS place: placed at its (high-resolution) bounding box and shown, printed and exported through its TIFF preview, or a placeholder when it has none. PostScript itself isn't interpreted.
- Preferences: Dictionary (user dictionary; `spelling.setWords/words`), Spelling (Dynamic Spelling: red squiggles on the canvas, also Edit ▸ Spelling), Autocorrect (word list, applied as you type), Notes, Track Changes, Story Editor Display and File Handling (recovery interval) sections.
- Colour management exposed (ICC through the craft-libs `craft-cms` crate): Edit ▸ Color Settings (`color.settings`: RGB/CMYK working spaces, default CMYK Coated FOGRA39 with CMYK swatches shown relative colorimetric with black-point compensation, Generic CMYK and Device CMYK still selectable, intent, black-point compensation; `color.loadProfile` for ICC files; `color.convert` through the working spaces, with gamut checks) and View ▸ Proof Setup / Proof Colors (press, sRGB, legacy Mac RGB, colour-blindness simulations, simulate paper) on the canvas. Placed images are shown through their embedded ICC profiles, and CMYK images through their own profile or the working CMYK space, with the settings' intent and black-point compensation.
@@ -439,7 +439,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
- Gradients: the Gradient Swatch tool (G) drags a gradient's start and end across objects (Shift: 45°); the Gradient panel (InDesign's layout: thumbnail, Type, Location, Angle, Reverse) sets type, angle, reverse and edits stops and midpoints on a ramp (drag, add, drag off to remove, location; drop a swatch from the Swatches panel on a stop or the ramp; stops keep their CMYK), and follows the Fill/Stroke proxy. Edited gradients are unnamed swatches until the Swatches panel's New Swatch names one "New Gradient Swatch" (`swatch.new`, which also duplicates a named swatch); `swatch.create {gradient}` builds one from colour swatches; `object.gradient` (`target: stroke` for a stroke's gradient, drawn across the path's box on screen and in PDF); the vector round-trips through IDML (`GradientFill…`/`GradientStroke…`). A radial gradient stretches with the object's box as InDesign's does (an ellipse reaching the sides of a non-square frame, on screen and in PDF). Text takes a gradient swatch as its fill or stroke, laid across its frame's box as in InDesign (screen and PDF).
- New Document (⌘N) as in InDesign: Recent, Saved, Print, Web and Mobile tabs of page cards (double-click creates) beside the preset's details: document name, size with arrows, units, orientation, pages, start page #, facing pages, primary text frame, columns, margins, bleed and slug per edge (linked or not); Save Document Preset (`file.savePreset`, `file.deletePreset`; saved presets are kept with the preferences, listed by `file.presets` and made by `file.new {preset: name}`); `file.new` takes `intent`, `units`, `startPage` and per-edge `bleed`/`slug`. Preview shows a document with the window's settings in the workspace while it is open; Create keeps it, Close removes it.
- Document Setup (⌥⌘P), laid out like InDesign's: intent, number of pages, start page # (an even start begins with a left page), facing pages, binding, Page Size (presets keeping the orientation, width and height with arrows, orientation), margins for every page and parent page, bleed and slug per edge (each group linked or not), and Adjust Layout (objects follow the new page size and margins), all in one undo step; measures are read in the document's units, and a value that can't be read keeps the dialog open; `layout.documentSetup {}` reports the setup, margins included.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots.
- Stroke panel: cap, join, miter limit, alignment, type, Start/End arrowheads (12 kinds, drawn on screen and in PDF; the path is shortened under pointed heads) and gap colour under dashes and dots; with text selected (or text frames selected with Formatting Affects Text on) it shows the text's stroke weight, colour and Stroke Behind Fill.
- Preferences (⌘K: General, Type, Units & Increments, Grids, Guides & Pasteboard, Display Performance) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document; right-click a ruler to pick its units, the horizontal and vertical rulers each have their own); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands.
- Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu.
- Based on Parent (`layout.parents.options {parent, basedOn}`): a parent shows its base parent's items under its own, on its pages and on the parent itself; chains work, cycles are refused, and items from any parent in the chain can be overridden. IDML carries it as the parent pages' `AppliedMaster`; a cycle or missing base in a file is cleared with a warning on open. The Pages panel has no Parent Options dialog yet.