mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-10 18:40:27 -06:00
# Conflicts: # ROADMAP.md
This commit is contained in:
@@ -131,6 +131,8 @@ Newest first.
|
||||
- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change.
|
||||
- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change.
|
||||
- **2026-10-13** · Placed PDFs are drawn through pdfcraft's patched hayro crates: calculator functions whose `idiv` divides by zero or overflows, chains of stitching functions a hundred thousand deep, and mesh shadings with points far off the page, ten thousand overlapping triangles or forty thousand patches no longer panic, overflow the stack, run for over a minute or use more than 2 GiB of memory. A page with a `/UserUnit`, and its trim, bleed and art boxes, are sized in points. No readiness percentage change.
|
||||
- **2026-10-13** · Object › Fitting has InDesign's shortcuts: Fill Frame Proportionally ⌘⌥⇧C, Fit Content Proportionally ⌘⌥⇧E, Fit Frame to Content ⌘⌥C, Fit Content to Frame ⌘⌥E, Center Content ⌘⇧E. No readiness percentage change.
|
||||
- **2026-10-13** · Gradients between process colours blend as inks, as InDesign shows them: magenta to cyan passes through C 50 M 50 (a light lilac) on screen instead of the darker RGB mix, and a midpoint between CMYK or grey stops no longer turns the PDF shading RGB. No readiness percentage change.
|
||||
- **2026-10-13** · Commands refuse parameter keys their params description doesn't name (#776): the control channel, MCP (`-32602`, every `batch` step checked before the first runs) and `designcraft-cli --cmd` answer with an “unknown parameter” error naming the nearest or the accepted keys instead of running with defaults; journals still replay. Descriptions of the table, anchored-object, data-merge and placing commands now name every key they read. No readiness percentage change.
|
||||
- **2026-10-13** · File ▸ Open Recent lists the documents last opened or saved (newest first, no duplicates; Save a Copy and exports don't count), with Clear Recent Files; the start screen shows the last eight, below the community card, as cards with a picture of the first page: saving puts one in the file (`preview.png`), so the start screen reads it without opening the document. A file that is gone is dimmed and says so when chosen, and stays listed. Preferences ▸ File Handling sets how many are kept (default 20, 0–100). `file.openRecent {index}`, `file.recentFiles`, `file.clearRecent` (#706). No readiness percentage change.
|
||||
- **2026-10-13** · Dropping an `.ase` file on the window loads its swatches, as Load Swatches does (the drop failed with "Can't Place the File"). No readiness percentage change.
|
||||
|
||||
@@ -103,6 +103,26 @@ impl Gradient {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// Stops for a renderer that blends between them in RGB: the expanded stops, plus samples
|
||||
/// through each span that isn't RGB at both ends, so process colours keep blending as inks.
|
||||
pub fn display_stops(&self) -> Vec<(f32, Color, f32)> {
|
||||
const STEPS: usize = 16;
|
||||
let stops = self.expanded_stops();
|
||||
let mut out: Vec<(f32, Color, f32)> = Vec::with_capacity(stops.len() * STEPS);
|
||||
for (i, s) in stops.iter().enumerate() {
|
||||
out.push(*s);
|
||||
let Some(n) = stops.get(i + 1) else { continue };
|
||||
if matches!((s.1, n.1), (Color::Rgb { .. }, Color::Rgb { .. })) || n.0 - s.0 <= 1e-4 {
|
||||
continue;
|
||||
}
|
||||
for j in 1..STEPS {
|
||||
let t = s.0 + (n.0 - s.0) * j as f32 / STEPS as f32;
|
||||
let (c, a) = self.sample(t);
|
||||
out.push((t, c, a));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn reverse(&mut self) {
|
||||
// A midpoint belongs to the segment after its stop, so it moves with that segment and mirrors.
|
||||
let mids: Vec<_> = self.stops.iter().rev().skip(1).map(|s| 1.0 - s.midpoint).collect();
|
||||
@@ -199,6 +219,25 @@ mod tests {
|
||||
assert!((mid - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_stops_blend_as_inks() {
|
||||
// LYH Studio episode 6: magenta to cyan is C 50 M 50 halfway (a light lilac), not the RGB mix.
|
||||
let mut g = Gradient::default();
|
||||
g.stops[0].color = Color::cmyk(0.0, 1.0, 0.0, 0.0);
|
||||
g.stops[1].color = Color::cmyk(1.0, 0.0, 0.0, 0.0);
|
||||
assert_eq!(g.sample(0.5).0, Color::cmyk(0.5, 0.5, 0.0, 0.0));
|
||||
let d = g.display_stops();
|
||||
assert_eq!(d.len(), 17, "sampled through the span");
|
||||
assert!(d.iter().any(|(o, c, _)| (*o - 0.5).abs() < 1e-6 && *c == Color::cmyk(0.5, 0.5, 0.0, 0.0)));
|
||||
// A midpoint stays an ink too; RGB stops aren't sampled.
|
||||
g.stops[0].midpoint = 0.25;
|
||||
assert!(matches!(g.expanded_stops()[1].1, Color::Cmyk { .. }));
|
||||
assert_eq!(Gradient::default().display_stops().len(), 2);
|
||||
// Grey and CMYK meet as inks; RGB with anything else blends in RGB.
|
||||
assert_eq!(Color::gray(1.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::cmyk(0.5, 0.0, 0.0, 0.5));
|
||||
assert!(matches!(Color::rgb(1.0, 0.0, 0.0).lerp(&Color::cmyk(1.0, 0.0, 0.0, 0.0), 0.5), Color::Rgb { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn midpoint_shifts() {
|
||||
let mut g = Gradient::default();
|
||||
|
||||
+10
-1
@@ -173,8 +173,17 @@ impl Color {
|
||||
let [r, g, b] = self.to_rgb();
|
||||
Color::rgb(1.0 - r, 1.0 - g, 1.0 - b)
|
||||
}
|
||||
/// Linear interpolation in display RGB.
|
||||
/// The colour `t` of the way to `other`. Process inks blend as inks (CMYK with CMYK or grey,
|
||||
/// grey with grey), as InDesign blends a gradient's stops; anything else blends in RGB.
|
||||
pub fn lerp(&self, other: &Color, t: f32) -> Color {
|
||||
let l = |a: f32, b: f32| a + (b - a) * t;
|
||||
match (*self, *other) {
|
||||
(Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c2, m: m2, y: y2, k: k2 }) => return Color::cmyk(l(c, c2), l(m, m2), l(y, y2), l(k, k2)),
|
||||
(Color::Gray { k }, Color::Gray { k: k2 }) => return Color::gray(l(k, k2)),
|
||||
(Color::Gray { k }, Color::Cmyk { .. }) => return Color::cmyk(0.0, 0.0, 0.0, k).lerp(other, t),
|
||||
(Color::Cmyk { .. }, Color::Gray { k }) => return self.lerp(&Color::cmyk(0.0, 0.0, 0.0, k), t),
|
||||
_ => {}
|
||||
}
|
||||
let a = self.to_rgb();
|
||||
let b = other.to_rgb();
|
||||
Color::rgb(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! shown); each placement's frame data (transform, crop, fitting) lives on its [`Graphic`].
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::hash::BuildHasher;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::story::Story;
|
||||
@@ -246,10 +247,19 @@ impl Document {
|
||||
if !self.asset_refs(&mut |_| {}) {
|
||||
return 0;
|
||||
}
|
||||
let mut reps: HashMap<(usize, &str, u32), Vec<&Arc<Asset>>> = HashMap::new();
|
||||
let fingerprint = std::collections::hash_map::RandomState::new();
|
||||
let mut hashes = HashMap::new();
|
||||
let mut reps: HashMap<_, Vec<&Arc<Asset>>> = HashMap::new();
|
||||
let mut map = HashMap::new();
|
||||
for a in self.assets.values() {
|
||||
let bucket = reps.entry((a.data.len(), a.mime.as_str(), a.page)).or_default();
|
||||
// Empty, unlinked assets never compare equal. Don't build a quadratic bucket
|
||||
// of them, and hash a shared immutable buffer only once per compaction.
|
||||
if a.data.is_empty() && a.link.is_none() {
|
||||
continue;
|
||||
}
|
||||
let hash = *hashes.entry(Arc::as_ptr(&a.data)).or_insert_with(|| fingerprint.hash_one(a.data.as_slice()));
|
||||
let bucket = reps.entry((hash, a.mime.as_str(), a.page, a.link.as_deref())).or_default();
|
||||
// A fingerprint only selects candidates; equality still checks the actual file.
|
||||
match bucket.iter().find(|r| r.same_file(a)) {
|
||||
Some(r) => {
|
||||
map.insert(a.id, r.id);
|
||||
@@ -280,6 +290,56 @@ mod tests {
|
||||
Asset { id: AssetId(id), name: "p.png".into(), mime: "image/png".into(), data: Arc::new(data.to_vec()), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_size_assets_keep_distinct_contents_and_merge_exact_copies() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
for id in 1..=256u64 {
|
||||
let mut bytes = vec![42; 1024];
|
||||
bytes[1016..].copy_from_slice(&id.to_le_bytes());
|
||||
d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes)));
|
||||
d.assets.insert(AssetId(id + 256), Arc::new(asset(id + 256, &bytes)));
|
||||
}
|
||||
assert_eq!(d.merge_duplicate_assets(), 256);
|
||||
assert_eq!(d.assets.len(), 256);
|
||||
for id in 1..=256u64 {
|
||||
assert_eq!(d.assets[&AssetId(id)].data[1016..], id.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compaction_preserves_asset_identity_and_missing_data_rules() {
|
||||
let mut d = Document::new(&Default::default());
|
||||
let mut cases = vec![asset(1, b"same"), asset(2, b"same"), asset(3, b"same"), asset(4, b"same")];
|
||||
cases[1].mime = "application/pdf".into();
|
||||
cases[2].page = 1;
|
||||
cases[3].link = Some("/linked.png".into());
|
||||
cases.extend([asset(5, b""), asset(6, b""), asset(7, b""), asset(8, b"")]);
|
||||
cases[6].link = Some("/missing.png".into());
|
||||
cases[7].link = Some("/missing.png".into());
|
||||
for a in cases {
|
||||
d.assets.insert(a.id, Arc::new(a));
|
||||
}
|
||||
assert_eq!(d.merge_duplicate_assets(), 1);
|
||||
assert_eq!(d.assets.keys().copied().collect::<Vec<_>>(), (1..=7).map(AssetId).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[ignore = "manual bounded timing comparison; no timing assertion"]
|
||||
fn equal_size_assets_timing() {
|
||||
for count in [500, 1000, 2000] {
|
||||
let mut d = Document::new(&Default::default());
|
||||
for id in 1..=count as u64 {
|
||||
let mut bytes = vec![42; 8192];
|
||||
bytes[8184..].copy_from_slice(&id.to_le_bytes());
|
||||
d.assets.insert(AssetId(id), Arc::new(asset(id, &bytes)));
|
||||
}
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(d.merge_duplicate_assets(), 0);
|
||||
eprintln!("{count} distinct 8 KiB assets: {:?}", start.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
fn graphic_item(id: u64, asset: u64, crop: f64) -> Item {
|
||||
let mut it = Item::new(ItemId(id), LayerId(1), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)));
|
||||
it.content = Content::Graphic(Graphic {
|
||||
|
||||
@@ -463,6 +463,25 @@ impl Table {
|
||||
v
|
||||
}
|
||||
|
||||
/// Owning cells touched by the range, once each in row-major order. Bounds are clipped
|
||||
/// to the grid; resolving covered positions does not expand the selection rectangle.
|
||||
pub fn selected_owners(&self, range: CellRange) -> Vec<(usize, usize)> {
|
||||
let (nr, nc) = (self.nrows(), self.ncols());
|
||||
if range.r0 >= nr || range.c0 >= nc || range.r0 > range.r1 || range.c0 > range.c1 {
|
||||
return Vec::new();
|
||||
}
|
||||
let owners = self.owners();
|
||||
let mut selected = std::collections::BTreeSet::new();
|
||||
for r in range.r0..=range.r1.min(nr - 1) {
|
||||
for c in range.c0..=range.c1.min(nc - 1) {
|
||||
if let Some(&owner) = owners.get(r * nc + c) {
|
||||
selected.insert(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
selected.into_iter().collect()
|
||||
}
|
||||
|
||||
/// The owning (top-left) cell address of grid position (r, c).
|
||||
pub fn owner(&self, r: usize, c: usize) -> (usize, usize) {
|
||||
self.owners().get(r * self.ncols() + c).copied().unwrap_or((r, c))
|
||||
@@ -1195,6 +1214,35 @@ mod tests {
|
||||
Story::with_text(StoryId(1), t, ParaFormat::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_owners_include_covered_cells_without_expanding_range() {
|
||||
for column in [true, false] {
|
||||
let mut t = Table::new(1, 2, 2, 0, 0, 120.0);
|
||||
let range = if column { CellRange::new(0, 1, 1, 1) } else { CellRange::new(1, 0, 1, 1) };
|
||||
assert_eq!(t.selected_owners(range), if column { vec![(0, 1), (1, 1)] } else { vec![(1, 0), (1, 1)] });
|
||||
t.merge(if column { CellRange::new(0, 0, 0, 1) } else { CellRange::new(0, 0, 1, 0) }).unwrap();
|
||||
assert_eq!(t.selected_owners(range), vec![(0, 0), (1, 1)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_owners_are_bounded_sorted_and_unique() {
|
||||
let mut t = Table::new(1, 3, 3, 0, 0, 180.0);
|
||||
t.merge(CellRange::new(0, 0, 1, 1)).unwrap();
|
||||
assert_eq!(t.selected_owners(CellRange::new(1, 1, usize::MAX, usize::MAX)), vec![(0, 0), (1, 2), (2, 1), (2, 2)]);
|
||||
assert_eq!(t.selected_owners(CellRange::new(0, 0, 1, 1)), vec![(0, 0)], "covered positions do not duplicate an owner");
|
||||
assert!(t.selected_owners(CellRange::new(3, 0, usize::MAX, usize::MAX)).is_empty());
|
||||
assert!(t.selected_owners(CellRange::new(0, 3, usize::MAX, usize::MAX)).is_empty());
|
||||
assert!(t.selected_owners(CellRange { r0: 1, c0: 0, r1: 0, c1: 1 }).is_empty());
|
||||
t.rows.clear();
|
||||
t.cells.clear();
|
||||
assert!(t.selected_owners(CellRange::cell(0, 0)).is_empty());
|
||||
|
||||
let mut right = Table::new(2, 2, 2, 0, 0, 120.0);
|
||||
right.merge(CellRange::new(0, 1, 1, 1)).unwrap();
|
||||
assert_eq!(right.selected_owners(CellRange::new(1, 0, 1, 1)), vec![(0, 1), (1, 0)], "owner order, not encounter order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_table_gets_own_paragraph() {
|
||||
let mut s = story("before after");
|
||||
|
||||
@@ -188,6 +188,10 @@ fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.anchor = t.anchor.min(len);
|
||||
t.focus = t.focus.min(len);
|
||||
}
|
||||
// `first` is the first match of the whole search, not of each story.
|
||||
if first_only && count > 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(json!({"count": count}))
|
||||
})
|
||||
@@ -251,6 +255,21 @@ mod tests {
|
||||
assert!(s.doc().unwrap().selection.text.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_first_changes_one_match_in_the_whole_document() {
|
||||
for grep in [false, true] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
for y in [36, 336] {
|
||||
s.execute("frame.create", &json!({"rect": [36, y, 300, y + 200], "content": "text", "text": "red and red"})).unwrap();
|
||||
}
|
||||
let r = s.execute("find.change", &json!({"find": "red", "change": "blue", "first": true, "grep": grep})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
assert_eq!(s.execute("find.find", &json!({"find": "blue"})).unwrap().as_array().unwrap().len(), 1);
|
||||
assert_eq!(s.execute("find.find", &json!({"find": "red"})).unwrap().as_array().unwrap().len(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_in_the_documents_dialect() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! whole table).
|
||||
|
||||
use designcraft_compose as compose;
|
||||
use designcraft_doc::story::floor_char_boundary;
|
||||
use designcraft_doc::{
|
||||
AltFills, CellAddr, CellRange, CellStroke, Document, RowHeightMode, Selection, StoryId, StrokeType, Table, TableSel, TextSel,
|
||||
VerticalJustification,
|
||||
@@ -228,11 +229,9 @@ fn table_specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let g = target(s, p, "table.textCell")?;
|
||||
edit_table(s, &g, "table.textCell", |t| {
|
||||
for r in g.range.r0..=g.range.r1 {
|
||||
for c in g.range.c0..=g.range.c1 {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.graphic = None;
|
||||
}
|
||||
for (r, c) in t.selected_owners(g.range) {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.graphic = None;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
@@ -495,13 +494,17 @@ fn clamp_selection(d: &Document, sel: &mut Selection) {
|
||||
match story.and_then(|x| x.tables.get(&c.table)) {
|
||||
Some(tb) => {
|
||||
let (r, col) = tb.owner(c.row.min(tb.nrows() - 1), c.col.min(tb.ncols() - 1));
|
||||
let len = tb.cell(r, col).map_or(0, |x| x.text.len());
|
||||
sel.text =
|
||||
Some(TextSel { anchor: t.anchor.min(len), focus: t.focus.min(len), cell: Some(CellAddr { table: c.table, row: r, col }), ..t });
|
||||
let text = tb.cell(r, col).map_or("", |x| x.text.text.as_str());
|
||||
sel.text = Some(TextSel {
|
||||
anchor: floor_char_boundary(text, t.anchor),
|
||||
focus: floor_char_boundary(text, t.focus),
|
||||
cell: Some(CellAddr { table: c.table, row: r, col }),
|
||||
..t
|
||||
});
|
||||
}
|
||||
None => {
|
||||
let len = story.map_or(0, |x| x.len());
|
||||
sel.text = Some(TextSel { anchor: t.anchor.min(len), focus: t.anchor.min(len), cell: None, ..t });
|
||||
let pos = story.map_or(0, |x| floor_char_boundary(&x.text, t.anchor));
|
||||
sel.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -937,83 +940,83 @@ fn set_cell(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Local edits outrank previously imported edges, including explicit zero/None.
|
||||
let priority = if stroke.is_some() { next_cell_stroke_priority(t) } else { 0 };
|
||||
let rg = g.range;
|
||||
// Edges are inner or outer within the selection grown to whole merged cells, so a
|
||||
// merged cell selected through one of its grid positions keeps its own outline.
|
||||
let outline = t.expand_range(rg);
|
||||
// Each chosen edge starts from the stroke drawn there, which both copies of a shared
|
||||
// edge then take, so the edit changes only the attributes it names.
|
||||
let mut bases = std::collections::HashMap::new();
|
||||
if let Some((_, edges)) = &stroke {
|
||||
for r in rg.r0..=rg.r1 {
|
||||
for c in rg.c0..=rg.c1 {
|
||||
if owners.get(r * nc + c) != Some(&(r, c)) {
|
||||
continue;
|
||||
}
|
||||
let Some(cell) = t.cell(r, c) else { continue };
|
||||
for (i, on) in edges.sides(rg, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() {
|
||||
if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) {
|
||||
bases.insert((r, c, i), base);
|
||||
}
|
||||
for (r, c) in t.selected_owners(rg) {
|
||||
let Some(cell) = t.cell(r, c) else { continue };
|
||||
for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() {
|
||||
if let Some(base) = drawn_side(t, &owners, (r, c), i).filter(|_| on) {
|
||||
bases.insert((r, c, i), base);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for r in rg.r0..=rg.r1 {
|
||||
for c in rg.c0..=rg.c1 {
|
||||
if owners.get(r * nc + c) != Some(&(r, c)) {
|
||||
continue;
|
||||
}
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
if let Some(f) = &fill {
|
||||
cell.fill = f.clone();
|
||||
}
|
||||
if let Some(ti) = tint {
|
||||
cell.fill_tint = ti;
|
||||
}
|
||||
if let Some(i) = insets {
|
||||
cell.insets = i;
|
||||
}
|
||||
if let Some(v) = vj {
|
||||
cell.vj = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
}
|
||||
if let Some((edit, _)) = &stroke {
|
||||
for i in 0..4 {
|
||||
if let Some(base) = bases.remove(&(r, c, i)) {
|
||||
cell.strokes[i] = edit.apply(base);
|
||||
cell.stroke_defined[i] = true;
|
||||
cell.stroke_priorities[i] = priority;
|
||||
cell.border_overrides[i] = true;
|
||||
}
|
||||
let mut edited = std::collections::HashSet::new();
|
||||
for (r, c) in t.selected_owners(rg) {
|
||||
let Some(cell) = t.cell_mut(r, c) else { continue };
|
||||
if let Some(f) = &fill {
|
||||
cell.fill = f.clone();
|
||||
}
|
||||
if let Some(ti) = tint {
|
||||
cell.fill_tint = ti;
|
||||
}
|
||||
if let Some(i) = insets {
|
||||
cell.insets = i;
|
||||
}
|
||||
if let Some(v) = vj {
|
||||
cell.vj = v;
|
||||
}
|
||||
if let Some(tx) = &text {
|
||||
let len = cell.text.len();
|
||||
cell.text.replace(0..len, tx);
|
||||
}
|
||||
if let Some((edit, _)) = &stroke {
|
||||
for i in 0..4 {
|
||||
if let Some(base) = bases.remove(&(r, c, i)) {
|
||||
edited.insert((r, c, i));
|
||||
cell.strokes[i] = edit.apply(base);
|
||||
cell.stroke_defined[i] = true;
|
||||
cell.stroke_priorities[i] = priority;
|
||||
cell.border_overrides[i] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep both copies of unmerged shared boundaries consistent. Left/right name
|
||||
// physical sides, also in RTL tables.
|
||||
// Keep both copies of unmerged shared boundaries consistent: each edited edge is
|
||||
// copied to its neighbour. Left/right name physical sides, also in RTL tables.
|
||||
let mirror = |t: &mut Table, from: (usize, usize), edge: usize, to: (usize, usize), opposite: usize| {
|
||||
if edited.contains(&(from.0, from.1, edge)) {
|
||||
mirror_cell_edge(t, &owners, from, edge, to, opposite);
|
||||
}
|
||||
};
|
||||
if let Some((_, edges)) = &stroke {
|
||||
if edges.top
|
||||
&& let Some(row) = rg.r0.checked_sub(1)
|
||||
{
|
||||
for c in rg.c0..=rg.c1 {
|
||||
mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2);
|
||||
mirror(t, (rg.r0, c), 0, (row, c), 2);
|
||||
}
|
||||
}
|
||||
if edges.bottom && rg.r1.saturating_add(1) < nr {
|
||||
for c in rg.c0..=rg.c1 {
|
||||
mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0);
|
||||
mirror(t, (rg.r1, c), 2, (rg.r1 + 1, c), 0);
|
||||
}
|
||||
}
|
||||
if (if rtl { edges.right } else { edges.left })
|
||||
&& let Some(col) = rg.c0.checked_sub(1)
|
||||
{
|
||||
for r in rg.r0..=rg.r1 {
|
||||
mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 });
|
||||
mirror(t, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 });
|
||||
}
|
||||
}
|
||||
if (if rtl { edges.left } else { edges.right }) && rg.c1.saturating_add(1) < nc {
|
||||
for r in rg.r0..=rg.r1 {
|
||||
mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 });
|
||||
mirror(t, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1030,7 +1033,7 @@ fn get_cell_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?;
|
||||
let owners = t.owners();
|
||||
let nc = t.ncols();
|
||||
let outline = t.expand_range(g.range);
|
||||
let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft;
|
||||
// One value per key, or null once two edges (cells) disagree.
|
||||
fn merge(slot: &mut Option<Value>, v: Value) {
|
||||
@@ -1045,28 +1048,23 @@ fn get_cell_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (mut fill, mut fill_tint) = (None, None);
|
||||
// Each drawn edge once: a shared edge is a side of both its cells.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for r in g.range.r0..=g.range.r1 {
|
||||
for c in g.range.c0..=g.range.c1 {
|
||||
if owners.get(r * nc + c) != Some(&(r, c)) {
|
||||
for (r, c) in t.selected_owners(g.range) {
|
||||
let Some(cell) = t.cell(r, c) else { continue };
|
||||
merge(&mut fill, json!(cell.fill));
|
||||
merge(&mut fill_tint, json!(cell.fill_tint));
|
||||
for (i, on) in edges.sides(outline, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
let Some(cell) = t.cell(r, c) else { continue };
|
||||
merge(&mut fill, json!(cell.fill));
|
||||
merge(&mut fill_tint, json!(cell.fill_tint));
|
||||
for (i, on) in edges.sides(g.range, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() {
|
||||
if !on {
|
||||
for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) {
|
||||
let (x, y) = (Some((r, c)), side.neighbor);
|
||||
if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) {
|
||||
continue;
|
||||
}
|
||||
for side in compose::table::cell_side_strokes(t, &owners, (r, c), i) {
|
||||
let (x, y) = (Some((r, c)), side.neighbor);
|
||||
if !seen.insert((i % 2, side.boundary, x.min(y), x.max(y))) {
|
||||
continue;
|
||||
}
|
||||
let e = side.stroke;
|
||||
let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)];
|
||||
for (slot, v) in stroke.iter_mut().zip(values) {
|
||||
merge(slot, v);
|
||||
}
|
||||
let e = side.stroke;
|
||||
let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)];
|
||||
for (slot, v) in stroke.iter_mut().zip(values) {
|
||||
merge(slot, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1544,7 +1542,10 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cell = d
|
||||
.story(g.story)
|
||||
.and_then(|st| st.tables.get(&g.table))
|
||||
.and_then(|t| t.cell(g.range.r0, g.range.c0))
|
||||
.and_then(|t| {
|
||||
let (r, c) = t.owner(g.range.r0, g.range.c0);
|
||||
t.cell(r, c)
|
||||
})
|
||||
.ok_or_else(|| bad("style.cell.create", "no cell"))?;
|
||||
cs.fill = Some(cell.fill.clone());
|
||||
cs.fill_tint = Some(cell.fill_tint);
|
||||
@@ -1572,17 +1573,11 @@ fn cell_style_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cs = designcraft_doc::resolve_cell_style(styles, &name);
|
||||
let g = target(s, p, "style.cell.apply")?;
|
||||
edit_table(s, &g, "style.cell.apply", |t| {
|
||||
let owners = t.owners();
|
||||
let nc = t.ncols();
|
||||
let mut n = 0;
|
||||
for r in g.range.r0..=g.range.r1 {
|
||||
for c in g.range.c0..=g.range.c1 {
|
||||
if owners[r * nc + c] == (r, c)
|
||||
&& let Some(cell) = t.cell_mut(r, c)
|
||||
{
|
||||
cs.apply_to(cell);
|
||||
n += 1;
|
||||
}
|
||||
for (r, c) in t.selected_owners(g.range) {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cs.apply_to(cell);
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(json!({"cells": n}))
|
||||
@@ -1806,7 +1801,7 @@ fn drop_cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& ts.story == sid
|
||||
&& ts.table == tid
|
||||
&& tid2 == tid
|
||||
&& ts.range.contains(fr, fc)
|
||||
&& s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).is_some_and(|t| t.selected_owners(ts.range).binary_search(&(fr, fc)).is_ok())
|
||||
{
|
||||
let (nr, nc) = {
|
||||
let t = s.doc()?.doc.story(sid).and_then(|st| st.tables.get(&tid)).ok_or_else(|| bad(ID, "no table"))?;
|
||||
|
||||
@@ -933,14 +933,9 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
|
||||
&& let Some(t) = st.doc.story(ts.story).and_then(|x| x.tables.get(&ts.table))
|
||||
{
|
||||
let mut v = Vec::new();
|
||||
let owners = t.owners();
|
||||
for r in ts.range.r0..=ts.range.r1.min(t.nrows().saturating_sub(1)) {
|
||||
for c in ts.range.c0..=ts.range.c1.min(t.ncols().saturating_sub(1)) {
|
||||
if owners[r * t.ncols() + c] == (r, c) {
|
||||
let len = t.cell(r, c).map_or(0, |x| x.text.len());
|
||||
v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len });
|
||||
}
|
||||
}
|
||||
for (r, c) in t.selected_owners(ts.range) {
|
||||
let len = t.cell(r, c).map_or(0, |x| x.text.len());
|
||||
v.push(Target { story: ts.story, cell: Some(CellAddr { table: ts.table, row: r, col: c }), range: 0..len });
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,325 @@ fn check(s: &Session) {
|
||||
s.doc().unwrap().doc.check().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_text_replacement_floors_utf8_selection_and_allows_quote_typing() {
|
||||
for (original, (a, b), replacement, (after_a, after_b), quoted) in [
|
||||
("a", (1, 1), "é", (0, 0), Some("“é")), // Exact caret/quote baseline.
|
||||
("abc", (3, 3), "🙂", (0, 0), Some("“🙂")),
|
||||
("ab", (1, 2), "éX", (0, 2), None),
|
||||
("ab", (2, 1), "éX", (2, 0), None),
|
||||
("abcdef", (3, 5), "🙂éX", (0, 4), None),
|
||||
("abcdef", (5, 3), "🙂éX", (4, 0), None),
|
||||
("abc", (1, 2), "🙂", (0, 0), None), // Both endpoints floor to the same boundary.
|
||||
("abcdef", (2, 6), "é🙂", (2, 6), None), // Valid Unicode endpoints stay fixed.
|
||||
("abcdef", (1, 4), "ABCDEF", (1, 4), None),
|
||||
("abcdef", (4, 1), "ABCDEF", (4, 1), None),
|
||||
("abcdef", (6, 6), "é", (2, 2), None), // Shortening clamps to EOF.
|
||||
("abc", (1, 3), "", (0, 0), None),
|
||||
] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": ""})).unwrap();
|
||||
let sid = frame["story"].as_u64().unwrap();
|
||||
let fid = frame["id"].as_u64().unwrap();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap();
|
||||
// Real commands position the child selection; text.select addresses parent stories.
|
||||
for _ in a..original.len() {
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
}
|
||||
let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) };
|
||||
for _ in 0..steps {
|
||||
s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap();
|
||||
}
|
||||
let mut expected = s.doc().unwrap().selection.clone();
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
assert_eq!((selected.anchor, selected.focus), (a, b));
|
||||
assert_eq!(selected.frame, Some(doc::ItemId(fid)));
|
||||
assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 }));
|
||||
(selected.anchor, selected.focus) = (after_a, after_b);
|
||||
let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text.clone();
|
||||
s.execute("table.setCell", &json!({"text": replacement})).unwrap();
|
||||
assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, replacement);
|
||||
assert_eq!(s.doc().unwrap().selection, expected, "{original}: {a}->{b}, replacement {replacement}");
|
||||
assert!(replacement.is_char_boundary(after_a) && replacement.is_char_boundary(after_b));
|
||||
assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, parent);
|
||||
if let Some(quoted) = quoted {
|
||||
assert!(s.prefs.typographers_quotes);
|
||||
s.execute("text.insert", &json!({"text": "\""})).unwrap();
|
||||
assert_eq!(table_for(&s, sid, tid).cell(0, 0).unwrap().text.text, quoted);
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
(selected.anchor, selected.focus) = (3, 3); // Opening curly quote is three bytes.
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
}
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_edit_after_anchor_removal_floors_parent_caret() {
|
||||
for (original, (a, b), replacement, pos, quoted, typed_pos) in [("ab", (1, 2), "é", 0, "“é\n", 3), ("abcde", (5, 1), "é🙂", 2, "é”🙂\n", 5)]
|
||||
{
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let other = s.execute("frame.create", &json!({"rect": [320, 36, 580, 200], "content": "text"})).unwrap();
|
||||
let other_sid = other["story"].as_u64().unwrap();
|
||||
let other_tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("text.insert", &json!({"text": "keep", "raw": true})).unwrap();
|
||||
let mut expected_other = table_for(&s, other_sid, other_tid).clone();
|
||||
expected_other.cell_mut(0, 0).unwrap().insets = [7.0; 4];
|
||||
|
||||
let frame = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap();
|
||||
let sid = frame["story"].as_u64().unwrap();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 1, "cols": 1})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("text.insert", &json!({"text": original, "raw": true})).unwrap();
|
||||
for _ in a..original.len() {
|
||||
s.execute("text.move", &json!({"dir": "left"})).unwrap();
|
||||
}
|
||||
let (dir, steps) = if b >= a { ("right", b - a) } else { ("left", a - b) };
|
||||
for _ in 0..steps {
|
||||
s.execute("text.move", &json!({"dir": dir, "extend": true})).unwrap();
|
||||
}
|
||||
let mut expected = s.doc().unwrap().selection.clone();
|
||||
let selected = expected.text.unwrap();
|
||||
assert_eq!((selected.anchor, selected.focus), (a, b));
|
||||
assert_eq!(selected.frame, Some(doc::ItemId(frame["id"].as_u64().unwrap())));
|
||||
assert_eq!(selected.cell, Some(doc::CellAddr { table: tid, row: 0, col: 0 }));
|
||||
// Explicit parent replacement removes its anchor, leaving the child address stale.
|
||||
assert_eq!(
|
||||
s.execute("find.change", &json!({"story": sid, "find": doc::TABLE_ANCHOR.to_string(), "change": replacement})).unwrap()["count"],
|
||||
1
|
||||
);
|
||||
let parent = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap();
|
||||
assert_eq!(parent.text, format!("{replacement}\n"));
|
||||
assert!(!parent.tables.contains_key(&tid));
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
assert!(!parent.text.is_char_boundary(selected.anchor));
|
||||
// An explicit edit to the surviving table reaches the disappeared-table fallback.
|
||||
s.execute("table.setCell", &json!({"story": other_sid, "table": other_tid, "insets": 7})).unwrap();
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
(selected.anchor, selected.focus, selected.cell) = (pos, pos, None); // Both floor the OLD anchor.
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, format!("{replacement}\n"));
|
||||
assert_eq!(table_for(&s, other_sid, other_tid), &expected_other);
|
||||
s.execute("text.insert", &json!({"text": "\""})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().text, quoted);
|
||||
let selected = expected.text.as_mut().unwrap();
|
||||
(selected.anchor, selected.focus) = (typed_pos, typed_pos);
|
||||
assert_eq!(s.doc().unwrap().selection, expected);
|
||||
assert_eq!(table_for(&s, other_sid, other_tid), &expected_other);
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_merged_table(column: bool) -> (Session, u64, u64, u64) {
|
||||
let (mut s, sid, fid) = session_with_frame();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
for (i, text) in ["A", "B", "C", "D"].iter().enumerate() {
|
||||
s.execute("text.insert", &json!({"text": text})).unwrap();
|
||||
if i < 3 {
|
||||
s.execute("table.nextCell", &json!({})).unwrap();
|
||||
}
|
||||
}
|
||||
let merge = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) };
|
||||
s.execute("table.select", &merge).unwrap();
|
||||
s.execute("table.merge", &json!({})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [1, 1], "cols": [1, 1]})).unwrap();
|
||||
s.execute(if column { "table.selectColumn" } else { "table.selectRow" }, &json!({})).unwrap();
|
||||
(s, sid, tid, fid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_merged_owners_receive_text_formatting_and_character_styles() {
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(column);
|
||||
let selected = s.doc().unwrap().selection.clone();
|
||||
assert_eq!(selected.cells.unwrap().range, if column { doc::CellRange::new(0, 1, 1, 1) } else { doc::CellRange::new(1, 0, 1, 1) });
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let neighbor = if column { (1, 0) } else { (0, 1) };
|
||||
s.execute("type.char", &json!({"attrs": {"size": 23}})).unwrap();
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
let cell = table_for(&s, sid, tid).cell(r, c).unwrap();
|
||||
assert!(cell.text.runs().all(|(_, f)| f.over.size == Some(23.0)), "all runs of owner {r},{c}");
|
||||
assert_eq!(cell.text.text, before.cell(r, c).unwrap().text.text);
|
||||
}
|
||||
assert_eq!(table_for(&s, sid, tid).cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(s.doc().unwrap().selection, selected);
|
||||
s.execute("style.character.create", &json!({"name": "Selected owners", "chars": {"tracking": 37}})).unwrap();
|
||||
s.execute("style.character.apply", &json!({"name": "Selected owners"})).unwrap();
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
assert!(table_for(&s, sid, tid).cell(r, c).unwrap().text.runs().all(|(_, f)| f.style == "Selected owners"));
|
||||
}
|
||||
let after = table_for(&s, sid, tid);
|
||||
assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(after.rows, before.rows);
|
||||
assert_eq!(after.columns, before.columns);
|
||||
assert_eq!(s.doc().unwrap().selection, selected, "formatting preserves the original grid rectangle");
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_merged_owners_receive_cell_options_and_unique_style_counts() {
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(column);
|
||||
let range = s.doc().unwrap().selection.cells.unwrap().range;
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let neighbor = if column { (1, 0) } else { (0, 1) };
|
||||
let covered = if column { (0, 1) } else { (1, 0) };
|
||||
s.execute("table.setCell", &json!({"fill": "[Black]", "insets": 7, "text": "REPLACED"})).unwrap();
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
let cell = table_for(&s, sid, tid).cell(r, c).unwrap();
|
||||
assert_eq!(serde_json::to_value(&cell.fill).unwrap(), json!("[Black]"));
|
||||
assert_eq!(cell.insets, [7.0; 4]);
|
||||
assert_eq!(cell.text.text, "REPLACED");
|
||||
}
|
||||
s.execute("style.cell.create", &json!({"name": "Selected cell owners", "insets": 9})).unwrap();
|
||||
assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 2);
|
||||
let after = table_for(&s, sid, tid);
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
assert_eq!(after.cell(r, c).unwrap().style, "Selected cell owners");
|
||||
assert_eq!(after.cell(r, c).unwrap().insets, [9.0; 4]);
|
||||
}
|
||||
assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1));
|
||||
assert_eq!(after.rows, before.rows);
|
||||
assert_eq!(after.columns, before.columns);
|
||||
assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, range);
|
||||
let merged = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) };
|
||||
s.execute("table.select", &merged).unwrap();
|
||||
assert_eq!(s.execute("style.cell.apply", &json!({"name": "Selected cell owners"})).unwrap()["cells"], 1);
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_part_of_a_merged_cell_strokes_its_whole_outline() {
|
||||
// Row 0 is one merged cell; selecting its second grid position selects the whole cell, so
|
||||
// all four of its edges are outer edges.
|
||||
for rtl in [false, true] {
|
||||
for outer in [false, true] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(true);
|
||||
if rtl {
|
||||
s.execute("table.options", &json!({"direction": "rightToLeft"})).unwrap();
|
||||
}
|
||||
s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap();
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let edges = if outer { "outer" } else { "inner" };
|
||||
s.execute("table.setCell", &json!({"stroke": {"weight": 3, "edges": edges}})).unwrap();
|
||||
let after = table_for(&s, sid, tid);
|
||||
let owner = after.cell(0, 0).unwrap();
|
||||
if outer {
|
||||
assert_eq!(owner.border_overrides, [true; 4]);
|
||||
assert_eq!(owner.stroke_defined, [true; 4]);
|
||||
assert!(owner.strokes.iter().all(|e| e.weight == 3.0));
|
||||
assert_eq!(owner.stroke_priorities, [1; 4]);
|
||||
} else {
|
||||
assert_eq!(Some(owner), before.cell(0, 0), "a single merged cell has no inner edges");
|
||||
}
|
||||
for (r, c) in [(0, 1), (1, 0), (1, 1)] {
|
||||
assert_eq!(after.cell(r, c), before.cell(r, c), "strict merged/covered mirror guard preserves {r},{c}");
|
||||
}
|
||||
let shown = s.execute("table.getCellStroke", &json!({"edges": "outer"})).unwrap();
|
||||
assert!(shown["edges"].as_u64().unwrap() >= 4, "the merged cell's outline is read: {shown}");
|
||||
if outer {
|
||||
assert_eq!(shown["weight"], 3.0);
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.cells.unwrap().range, doc::CellRange::cell(0, 1));
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_style_sampling_resolves_covered_point_but_retains_upper_left_policy() {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(true);
|
||||
s.execute("table.setCell", &json!({"row": 0, "col": 0, "insets": 7})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [0, 0], "cols": [1, 1]})).unwrap();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
s.execute("style.cell.create", &json!({"name": "Covered", "fromSelection": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Covered").unwrap().insets, Some([7.0; 4]));
|
||||
assert_eq!(table_for(&s, sid, tid), &before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
|
||||
s.execute("table.select", &json!({"rows": [0, 1], "cols": [1, 1]})).unwrap();
|
||||
s.execute("table.merge", &json!({})).unwrap();
|
||||
for (row, col, insets, size) in [(0, 1, 7, 17), (1, 0, 11, 29)] {
|
||||
s.execute("table.setCell", &json!({"row": row, "col": col, "text": "Text", "insets": insets})).unwrap();
|
||||
s.execute("table.select", &json!({"rows": [row, row], "cols": [col, col]})).unwrap();
|
||||
s.execute("type.char", &json!({"attrs": {"size": size}})).unwrap();
|
||||
}
|
||||
s.execute("table.selectRow", &json!({})).unwrap();
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
// Row-major owners are [(0,1),(1,0)], while the rectangle starts at (1,0).
|
||||
assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 17.0);
|
||||
s.execute("style.cell.create", &json!({"name": "Upper left", "fromSelection": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.cell.iter().find(|c| c.name == "Upper left").unwrap().insets, Some([11.0; 4]));
|
||||
assert_eq!(table_for(&s, sid, tid), &before);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_merged_owners_convert_graphics_back_to_text() {
|
||||
let png = designcraft_render::Rendered { width: 1, height: 1, pixels: vec![255, 0, 0, 255] }.to_png();
|
||||
let image = crate::cmd::base64_encode(&png);
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, tid, _) = selected_merged_table(column);
|
||||
let neighbor = if column { (1, 0) } else { (0, 1) };
|
||||
let covered = if column { (0, 1) } else { (1, 0) };
|
||||
for (r, c) in [covered, (1, 1), neighbor] {
|
||||
let placed = s.execute("table.placeGraphic", &json!({"row": r, "col": c, "base64": image, "name": "cell.png"})).unwrap();
|
||||
if (r, c) == covered {
|
||||
assert_eq!((placed["row"].as_u64(), placed["col"].as_u64()), (Some(0), Some(0)));
|
||||
}
|
||||
}
|
||||
let before = table_for(&s, sid, tid).clone();
|
||||
let selected = s.doc().unwrap().selection.cells.unwrap();
|
||||
s.execute("table.textCell", &json!({})).unwrap();
|
||||
let after = table_for(&s, sid, tid);
|
||||
for (r, c) in [(0, 0), (1, 1)] {
|
||||
assert!(before.cell(r, c).unwrap().graphic.is_some());
|
||||
assert!(after.cell(r, c).unwrap().graphic.is_none());
|
||||
assert_eq!(after.cell(r, c).unwrap().text, before.cell(r, c).unwrap().text);
|
||||
}
|
||||
assert_eq!(after.cell(neighbor.0, neighbor.1), before.cell(neighbor.0, neighbor.1));
|
||||
assert_eq!(after.cell(covered.0, covered.1), before.cell(covered.0, covered.1));
|
||||
assert_eq!(s.doc().unwrap().selection.cells, Some(selected));
|
||||
check(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_selected_owner_drag_keeps_merged_move_restrictions_atomic() {
|
||||
for column in [true, false] {
|
||||
let (mut s, sid, _, fid) = selected_merged_table(column);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let revision = s.doc().unwrap().revision;
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let redo = s.doc().unwrap().history.redo.len();
|
||||
let cs = s.cache.get(&before, doc::StoryId(sid), None);
|
||||
let cells = &cs.frames[0].tables[0].cells;
|
||||
let point = |row, col| {
|
||||
let cell = cells.iter().find(|c| c.row == row && c.col == col).unwrap();
|
||||
before.text_xf(before.item(doc::ItemId(fid)).unwrap()) * cell.rect.center()
|
||||
};
|
||||
let from = point(0, 0);
|
||||
let to = if column { point(1, 0) } else { point(0, 1) };
|
||||
let err = s.execute("table.dropCells", &json!({"frame": fid, "from": [from.x, from.y], "to": [to.x, to.y]})).unwrap_err();
|
||||
assert!(err.to_string().contains(if column { "unmerge cells that span columns" } else { "unmerge cells that span rows" }), "{err}");
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc, before);
|
||||
assert_eq!(st.selection, selection);
|
||||
assert_eq!(st.revision, revision);
|
||||
assert_eq!((st.history.undo.len(), st.history.redo.len()), (undo, redo));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_type_in_cells_and_tab() {
|
||||
let (mut s, sid, _) = session_with_frame();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::hash::BuildHasher;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -155,12 +156,13 @@ fn load_zip(bytes: &[u8]) -> Result<Document, FormatError> {
|
||||
let names: Vec<String> = z.file_names().map(str::to_string).collect();
|
||||
// Files saved with one copy per placement: identical copies share one buffer as they are
|
||||
// read, so memory holds each distinct file once.
|
||||
let mut loaded: HashMap<usize, Vec<Arc<Vec<u8>>>> = HashMap::new();
|
||||
let fingerprint = std::collections::hash_map::RandomState::new();
|
||||
let mut loaded: HashMap<u64, Vec<Arc<Vec<u8>>>> = HashMap::new();
|
||||
for name in names {
|
||||
let Some(stem) = name.strip_prefix("assets/") else { continue };
|
||||
let Some(id) = stem.split('.').next().and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
if let (Some(data), Some(a)) = (read(&mut z, &name), doc.assets.get_mut(&AssetId(id))) {
|
||||
let same = loaded.entry(data.len()).or_default();
|
||||
let same = loaded.entry(fingerprint.hash_one(data.as_slice())).or_default();
|
||||
let data = match same.iter().find(|d| ***d == data) {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
@@ -568,6 +570,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_size_archive_assets_preserve_content_and_share_only_identical_buffers() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
let mut pairs = Vec::new();
|
||||
for n in 0..128u64 {
|
||||
let mut bytes = vec![42; 1024];
|
||||
bytes[1016..].copy_from_slice(&n.to_le_bytes());
|
||||
let a = png_asset(&mut d, &bytes);
|
||||
let b = png_asset(&mut d, &bytes);
|
||||
// Distinct links must remain independently relinkable even when their bytes match.
|
||||
Arc::make_mut(d.assets.get_mut(&a).unwrap()).link = Some(format!("/a/{n}.png"));
|
||||
Arc::make_mut(d.assets.get_mut(&b).unwrap()).link = Some(format!("/b/{n}.png"));
|
||||
place(&mut d, a, 1.0);
|
||||
place(&mut d, b, 2.0);
|
||||
pairs.push((a, b));
|
||||
}
|
||||
let back = load(&save_uncompacted(&d)).unwrap();
|
||||
assert_eq!(back.assets.len(), 256);
|
||||
for (n, (a, b)) in pairs.into_iter().enumerate() {
|
||||
assert_eq!(back.assets[&a].data[1016..], (n as u64).to_le_bytes());
|
||||
assert!(Arc::ptr_eq(&back.assets[&a].data, &back.assets[&b].data));
|
||||
assert_eq!(back.assets[&a].link, d.assets[&a].link);
|
||||
assert_eq!(back.assets[&b].link, d.assets[&b].link);
|
||||
}
|
||||
assert_eq!(back.spreads, d.spreads);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn older_files_with_one_asset_per_placement_open_with_one() {
|
||||
let mut d = Document::new(&NewDocument::default());
|
||||
|
||||
@@ -1053,7 +1053,15 @@ impl Exporter<'_> {
|
||||
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
|
||||
if let Some(g) = designcraft_color::swatch::resolve_gradient(&self.doc.swatches, swatch) {
|
||||
let kind = g.kind;
|
||||
let expanded = g.expanded_stops();
|
||||
let mut expanded = g.expanded_stops();
|
||||
// Grey and CMYK stops blend as inks, so a grey stop beside a CMYK one is the K ink.
|
||||
if !self.rgb_only && expanded.iter().any(|(_, c, _)| matches!(c, Color::Cmyk { .. })) {
|
||||
for (_, c, _) in &mut expanded {
|
||||
if let Color::Gray { k } = *c {
|
||||
*c = Color::cmyk(0.0, 0.0, 0.0, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
// A PDF shading needs one colour space. Midpoint expansion can introduce RGB even
|
||||
// when every authored stop is CMYK/Gray; PDF/A can also change the output space.
|
||||
let components = |c: &Color| match c {
|
||||
|
||||
@@ -53,7 +53,7 @@ fn mixed_gradient_export_survives_in_subprocess() {
|
||||
let rgb = Color::rgb(1.0, 0.0, 0.0);
|
||||
let cmyk = Color::cmyk(1.0, 0.0, 0.0, 0.0);
|
||||
let gray = Color::gray(0.4);
|
||||
for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, cmyk], vec![gray, rgb, cmyk]] {
|
||||
for colors in [vec![cmyk, rgb], vec![rgb, cmyk], vec![rgb, gray], vec![gray, rgb, cmyk]] {
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] {
|
||||
let d = document(&colors, kind, 0.5, 0.7);
|
||||
@@ -78,15 +78,14 @@ fn mixed_gradient_export_survives_in_subprocess() {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Expansion inserts an RGB midpoint even when the authoring stops share CMYK or Gray.
|
||||
// A midpoint's expanded stop blends in the stops' own space: CMYK or Gray stays homogeneous.
|
||||
for colors in [[cmyk, Color::cmyk(0.0, 1.0, 0.0, 0.0)], [gray, Color::gray(0.8)]] {
|
||||
for kind in [GradientKind::Linear, GradientKind::Radial] {
|
||||
for standard in [Standard::None, Standard::PdfX4, Standard::PdfA2b] {
|
||||
let report =
|
||||
export_pdf_with_report(&document(&colors, kind, 0.25, 1.0), &Cache::new(), &PdfOptions { standard, ..Default::default() })
|
||||
.unwrap();
|
||||
let archival_cmyk = standard == Standard::PdfA2b && matches!(colors[0], Color::Cmyk { .. });
|
||||
assert_eq!(conversion_warnings(&report.warnings), usize::from(!archival_cmyk), "{standard:?}: {:?}", report.warnings);
|
||||
assert_eq!(conversion_warnings(&report.warnings), 0, "{standard:?}: {:?}", report.warnings);
|
||||
assert_eq!(Pdf::new(report.bytes).unwrap().pages().len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +107,8 @@ fn homogeneous_gradients_keep_device_spaces_and_components() {
|
||||
([Color::rgb(1.0, 0.0, 0.0), Color::rgb(0.0, 0.0, 1.0)], "DeviceRGB", vec![1.0, 0.0, 0.0], vec![0.0, 0.0, 1.0]),
|
||||
([Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)], "DeviceCMYK", vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]),
|
||||
([Color::gray(0.2), Color::gray(0.8)], "DeviceGray", vec![0.8], vec![0.2]),
|
||||
// Grey beside CMYK is the K ink, so the shading stays CMYK.
|
||||
([Color::gray(0.4), Color::cmyk(1.0, 0.0, 0.0, 0.0)], "DeviceCMYK", vec![0.0, 0.0, 0.0, 0.4], vec![1.0, 0.0, 0.0, 0.0]),
|
||||
] {
|
||||
for standard in [Standard::None, Standard::PdfX4] {
|
||||
let report = export_pdf_with_report(
|
||||
|
||||
@@ -1059,7 +1059,9 @@ pub fn color_of(c: &designcraft_color::Color, alpha: f32) -> peniko::Color {
|
||||
fn set_fill_paint(ctx: &mut RenderContext, doc: &Document, fill: &designcraft_doc::Fill, bounds: Rect) -> bool {
|
||||
let (swatch, tint) = (fill.swatch.as_str(), fill.tint);
|
||||
if let Some(g) = designcraft_color::swatch::resolve_gradient(&doc.swatches, swatch) {
|
||||
let stops: Vec<peniko::ColorStop> = g.expanded_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect();
|
||||
// Sampled so process colours blend as inks, as InDesign shows them (and as the PDF's CMYK
|
||||
// shading prints).
|
||||
let stops: Vec<peniko::ColorStop> = g.display_stops().iter().map(|(o, c, a)| peniko::ColorStop::from((*o, color_of(c, *a)))).collect();
|
||||
if stops.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
//! A gradient between process colours shows the inks' blend, as InDesign shows it.
|
||||
use designcraft_color::swatch::Swatch;
|
||||
use designcraft_color::{Color, Gradient, GradientKind, GradientStop, SwatchValue};
|
||||
use designcraft_compose::Cache;
|
||||
use designcraft_doc::build::NewDocument;
|
||||
use designcraft_doc::{Document, Fill, Item, ItemId, Shape, SpreadRef, Stroke};
|
||||
use designcraft_geom::{Affine, Rect, shapes};
|
||||
use designcraft_render::{Placed, RenderOptions, Renderer};
|
||||
|
||||
#[test]
|
||||
fn a_cmyk_gradient_is_c50_m50_halfway() {
|
||||
let mut d = Document::new(&NewDocument { width: 100.0, height: 20.0, facing_pages: false, ..Default::default() });
|
||||
let stop = |offset: f32, color: Color| GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 };
|
||||
let gradient =
|
||||
Gradient { kind: GradientKind::Linear, stops: vec![stop(0.0, Color::cmyk(0.0, 1.0, 0.0, 0.0)), stop(1.0, Color::cmyk(1.0, 0.0, 0.0, 0.0))] };
|
||||
d.swatches.push(Swatch { name: "Magenta to Cyan".into(), value: SwatchValue::Gradient { gradient }, locked: false, named: true, hidden: false });
|
||||
let id = ItemId(d.alloc());
|
||||
let mut item = Item::new(id, d.default_layer(), Shape::Rectangle, shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 20.0)));
|
||||
item.fill = Fill::swatch("Magenta to Cyan");
|
||||
item.stroke = Stroke::default();
|
||||
d.insert_item(SpreadRef::Doc(0), item, None).unwrap();
|
||||
let img = Renderer::new().render(
|
||||
&d,
|
||||
&Cache::new(),
|
||||
&[Placed { spread: SpreadRef::Doc(0), xf: Affine::IDENTITY }],
|
||||
100,
|
||||
20,
|
||||
Affine::IDENTITY,
|
||||
&RenderOptions { paper: false, background: Some([255; 4]), ..Default::default() },
|
||||
);
|
||||
let want = Color::cmyk(0.5, 0.5, 0.0, 0.0).to_rgba8(1.0);
|
||||
let got = img.pixel(50, 10);
|
||||
for ch in 0..3 {
|
||||
assert!(got[ch].abs_diff(want[ch]) <= 4, "halfway {got:?}, the inks' blend is {want:?}");
|
||||
}
|
||||
}
|
||||
@@ -1209,11 +1209,12 @@ fn draw_text_selection(
|
||||
let range = ts.range();
|
||||
// Selected table cells: tint every selected cell.
|
||||
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
|
||||
let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default();
|
||||
for ft in &cs.frames {
|
||||
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { continue };
|
||||
let m = a * doc.text_xf(it);
|
||||
for t in ft.tables.iter().filter(|t| t.table == sel.table) {
|
||||
for c in t.cells.iter().filter(|c| sel.range.contains(c.row, c.col)) {
|
||||
for c in t.cells.iter().filter(|c| selected.binary_search(&(c.row, c.col)).is_ok()) {
|
||||
let q = [
|
||||
xf.to_screen(m * Point::new(c.rect.x0, c.rect.y0)),
|
||||
xf.to_screen(m * Point::new(c.rect.x1, c.rect.y0)),
|
||||
@@ -3128,6 +3129,79 @@ mod tests {
|
||||
assert_eq!(app.session.doc().unwrap().doc.spreads[0].items.len(), items, "no paths added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_merged_owners_paint_the_same_row_and_column_targets() {
|
||||
for column in [true, false] {
|
||||
for merged in [false, true] {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({"pages": 1})).unwrap();
|
||||
app.run("frame.create", json!({"rect": [36, 36, 300, 300], "content": "text"})).unwrap();
|
||||
app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap();
|
||||
if merged {
|
||||
let range = if column { json!({"rows": [0, 0], "cols": [0, 1]}) } else { json!({"rows": [0, 1], "cols": [0, 0]}) };
|
||||
app.run("table.select", range).unwrap();
|
||||
app.run("table.merge", json!({})).unwrap();
|
||||
}
|
||||
app.run("table.select", json!({"rows": [1, 1], "cols": [1, 1]})).unwrap();
|
||||
app.run(if column { "table.selectColumn" } else { "table.selectRow" }, json!({})).unwrap();
|
||||
let st = app.session.doc().unwrap();
|
||||
let selection = st.selection.clone();
|
||||
let ts = selection.text.unwrap();
|
||||
let selected = selection.cells.unwrap();
|
||||
assert_eq!(
|
||||
selected.range,
|
||||
if column { designcraft_doc::CellRange::new(0, 1, 1, 1) } else { designcraft_doc::CellRange::new(1, 0, 1, 1) }
|
||||
);
|
||||
let layout = CanvasLayout::new(&st.doc, st.editing_parents);
|
||||
let cs = app.session.cache.get(&st.doc, ts.story, None);
|
||||
let ft = &cs.frames[0];
|
||||
let table = ft.tables.iter().find(|t| t.table == selected.table).unwrap();
|
||||
let (a, _) = item_canvas_xf(&st.doc, &layout, ft.frame).unwrap();
|
||||
let m = a * st.doc.text_xf(st.doc.item(ft.frame).unwrap());
|
||||
let xf = Xf { min: pos2(0.0, 0.0), origin: Point::new(0.0, 0.0), zoom: 1.0, rot: 0, pivot: pos2(0.0, 0.0) };
|
||||
let owners = if merged {
|
||||
[(0, 0), (1, 1)]
|
||||
} else if column {
|
||||
[(0, 1), (1, 1)]
|
||||
} else {
|
||||
[(1, 0), (1, 1)]
|
||||
};
|
||||
let expected: Vec<_> = owners
|
||||
.into_iter()
|
||||
.map(|(r, c)| {
|
||||
let rect = table.cells.iter().find(|cell| cell.row == r && cell.col == c).unwrap().rect;
|
||||
vec![
|
||||
xf.to_screen(m * Point::new(rect.x0, rect.y0)),
|
||||
xf.to_screen(m * Point::new(rect.x1, rect.y0)),
|
||||
xf.to_screen(m * Point::new(rect.x1, rect.y1)),
|
||||
xf.to_screen(m * Point::new(rect.x0, rect.y1)),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let ctx = egui::Context::default();
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
draw_text_selection(&app, ui.painter(), &xf, &st.doc, &layout, ts, Some(selected));
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
let tint = Color32::from_rgba_unmultiplied(80, 140, 255, 90);
|
||||
let painted: Vec<_> = output
|
||||
.shapes
|
||||
.into_iter()
|
||||
.filter_map(|s| match s.shape {
|
||||
egui::Shape::Path(path) if path.fill == tint => {
|
||||
assert!(path.closed);
|
||||
assert_eq!(path.stroke, egui::epaint::PathStroke::NONE);
|
||||
Some(path.points)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(painted, expected, "only the two touched owners are tinted; column={column}, merged={merged}");
|
||||
assert_eq!(app.session.doc().unwrap().selection, selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_clicking_a_ruler_sets_that_rulers_units() {
|
||||
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
|
||||
@@ -235,6 +235,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("window.panel.close", "Close Panel", None, "{panel} — hide a panel"),
|
||||
("window.dockPanel", "Dock Panel", None, "{panel} — back into the dock's icon column"),
|
||||
("window.controlBar", "Control", Some("Cmd+Alt+6"), "{}"),
|
||||
// Object › Fitting with InDesign's shortcuts (one `object.fit` mode each).
|
||||
("fit.fillFrameProportionally", "Fill Frame Proportionally", Some("Cmd+Alt+Shift+C"), "{} — object.fit {mode: fillProportionally}"),
|
||||
("fit.contentProportionally", "Fit Content Proportionally", Some("Cmd+Alt+Shift+E"), "{} — object.fit {mode: fitProportionally}"),
|
||||
("fit.frameToContent", "Fit Frame to Content", Some("Cmd+Alt+C"), "{} — object.fit {mode: fitFrameToContent}"),
|
||||
("fit.contentToFrame", "Fit Content to Frame", Some("Cmd+Alt+E"), "{} — object.fit {mode: fitContentToFrame}"),
|
||||
("fit.centerContent", "Center Content", Some("Cmd+Shift+E"), "{} — object.fit {mode: centerContent}"),
|
||||
("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"),
|
||||
("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"),
|
||||
("window.minimize", "Minimize Window", None, "{} — minimize the desktop window"),
|
||||
@@ -627,11 +633,11 @@ pub const MENUS: &[(&str, &[&str])] = &[
|
||||
"cmd:anchored.release",
|
||||
"<",
|
||||
">Fitting",
|
||||
"cmd:object.fit|Fill Frame Proportionally|{\"mode\":\"fillProportionally\"}",
|
||||
"cmd:object.fit|Fit Content Proportionally|{\"mode\":\"fitProportionally\"}",
|
||||
"cmd:object.fit|Fit Frame to Content|{\"mode\":\"fitFrameToContent\"}",
|
||||
"cmd:object.fit|Fit Content to Frame|{\"mode\":\"fitContentToFrame\"}",
|
||||
"cmd:object.fit|Center Content|{\"mode\":\"centerContent\"}",
|
||||
"ui:fit.fillFrameProportionally",
|
||||
"ui:fit.contentProportionally",
|
||||
"ui:fit.frameToContent",
|
||||
"ui:fit.contentToFrame",
|
||||
"ui:fit.centerContent",
|
||||
"-",
|
||||
"ui:app.fittingOptions",
|
||||
"<",
|
||||
@@ -1541,6 +1547,16 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"fit.fillFrameProportionally" | "fit.contentProportionally" | "fit.frameToContent" | "fit.contentToFrame" | "fit.centerContent" => {
|
||||
let mode = match id {
|
||||
"fit.fillFrameProportionally" => "fillProportionally",
|
||||
"fit.contentProportionally" => "fitProportionally",
|
||||
"fit.frameToContent" => "fitFrameToContent",
|
||||
"fit.contentToFrame" => "fitContentToFrame",
|
||||
_ => "centerContent",
|
||||
};
|
||||
app.run("object.fit", json!({"mode": mode}))
|
||||
}
|
||||
"app.keyboardShortcuts" => {
|
||||
app.ui.dialog = Some(crate::dialogs::Dialog::new("keyboardShortcuts", json!({"query": "", "recording": ""})));
|
||||
Ok(Value::Null)
|
||||
@@ -2255,6 +2271,9 @@ pub fn menu_enabled(app: &DesignApp, id: &str) -> bool {
|
||||
if id == "app.justificationDialog" {
|
||||
return enabled(app, "type.para");
|
||||
}
|
||||
if id.starts_with("fit.") {
|
||||
return enabled(app, "object.fit");
|
||||
}
|
||||
if ui_label(id).is_some() {
|
||||
return app.session.active().is_some() || id.starts_with("app.") || id.starts_with("window.") || id.starts_with("help.");
|
||||
}
|
||||
@@ -3682,6 +3701,47 @@ mod tests {
|
||||
assert!(run_ui(&mut app, "view.proofSetup", &json!({"target": "nope"})).unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_has_indesigns_shortcuts() {
|
||||
// LYH Studio episode 6 (7:00): "Fit Content to Frame (Ctrl + Shift + Alt + C)" fills the
|
||||
// photo frames proportionally.
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
app.run("file.new", json!({})).unwrap();
|
||||
let png = designcraft_render::Rendered { width: 40, height: 20, pixels: vec![200; 40 * 20 * 4] }.to_png();
|
||||
let f = app.run("frame.create", json!({"rect": [100, 100, 300, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("file.place", json!({"base64": designcraft_engine::cmd::base64_encode(&png), "name": "a.png", "frame": f})).unwrap();
|
||||
app.run("selection.set", json!({"ids": [f]})).unwrap();
|
||||
let width = |app: &crate::DesignApp| {
|
||||
let it = app.session.doc().unwrap().doc.item(designcraft_doc::ItemId(f)).unwrap().clone();
|
||||
let designcraft_doc::Content::Graphic(g) = &it.content else { panic!("graphic") };
|
||||
it.xf.transform_rect_bbox(g.xf.transform_rect_bbox(designcraft_geom::Rect::new(0.0, 0.0, g.size.0, g.size.1))).width()
|
||||
};
|
||||
for (id, keys) in [
|
||||
("fit.fillFrameProportionally", "Cmd+Alt+Shift+C"),
|
||||
("fit.contentProportionally", "Cmd+Alt+Shift+E"),
|
||||
("fit.frameToContent", "Cmd+Alt+C"),
|
||||
("fit.contentToFrame", "Cmd+Alt+E"),
|
||||
("fit.centerContent", "Cmd+Shift+E"),
|
||||
] {
|
||||
assert_eq!(shortcut_of(&app, id).as_deref(), Some(keys));
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let press = |app: &mut crate::DesignApp, key: egui::Key| {
|
||||
let modifiers =
|
||||
egui::Modifiers { alt: true, ctrl: !cfg!(target_os = "macos"), shift: true, mac_cmd: cfg!(target_os = "macos"), command: true };
|
||||
let input = egui::RawInput {
|
||||
events: vec![egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }],
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(input, |ui| shortcuts(app, &ui.ctx().clone()));
|
||||
out.textures_delta.clear();
|
||||
};
|
||||
press(&mut app, egui::Key::C);
|
||||
assert!((width(&app) - 400.0).abs() < 1e-6, "filled: {}", width(&app));
|
||||
press(&mut app, egui::Key::E);
|
||||
assert!((width(&app) - 200.0).abs() < 1e-6, "fitted: {}", width(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_hides_panels_and_documents_cycle() {
|
||||
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
|
||||
@@ -4403,7 +4463,7 @@ mod tests {
|
||||
});
|
||||
assert!(special.is_some_and(|c| c.iter().any(|i| matches!(i, Item::Sub(n, _) if n == "Symbols"))));
|
||||
// Fixed-parameter variants keep their own labels.
|
||||
assert!(all.iter().any(|(l, id, p)| l == "Fill Frame Proportionally" && id == "object.fit" && p["mode"] == "fillProportionally"));
|
||||
assert!(all.iter().any(|(l, id, _)| l == "Fill Frame Proportionally" && id == "fit.fillFrameProportionally"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File format parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (.indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (.indd and .indt open through indd-utils; XLSX cached values preserve numeric precision and text; shared print spooler; Windows CI passes; content-indexed asset deduplication, #727) · **Target:** Adobe InDesign 2026 (21.6)
|
||||
|
||||
Every format InDesign 2026 reads or writes, with DesignCraft's support, fidelity and tests. The
|
||||
InDesign list comes from its bundle's `Info.plist` (`CFBundleDocumentTypes`,
|
||||
@@ -22,7 +22,7 @@ score below).
|
||||
| InDesign document | `.indd` | read/write (native) | ✓ (converted to IDML in memory by [storytold/indd-utils](https://github.com/storytold/indd-utils), then the IDML importer; #21, #114) | ✗ | Converter warnings join the open result; unreadable files are open errors. Synthetic-bytes tests only; indd-utils reports 96.9% of IDML values reproduced on ~650 pairs. No real-file corpus test | 30% |
|
||||
| InDesign template | `.indt` | read/write | ✓ (as `.indd`) | ✗ | As `.indd` | 2% |
|
||||
| InDesign Markup | `.idml` | read/write | ✓ | ✓ | Synthetic fixtures in `crates/idml/src/tests.rs` (51 tests); exported files verified by opening in InDesign 2026 by hand; indd-utils' 98-document run imports 811 spreads / 2,866 stories / 5,812 items. Open: #193, #338, #339, #340, #211. No real-file corpus test | 20% |
|
||||
| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests | — |
|
||||
| DesignCraft native | `.designcraft` | — | ✓ | ✓ | zip + JSON, versioned; round-trip tests. Asset loading and compaction index by content hash, then confirm byte equality; equal-sized distinct files fall into separate search buckets (#727). Separate links, MIME types and PDF pages retain their identity | — |
|
||||
| InDesign book | `.indb` | read/write | ✗ (own `.dcbook`) | ✗ | Books are shallow (see gaps.md) | 2% |
|
||||
| InDesign library | `.indl`, `.inlx` | read/write | ✗ (own `.dclib`) | ✗ | | 1% |
|
||||
| Snippet | `.idms` | read/write | ✗ (own snippets) | ✗ | | 1% |
|
||||
@@ -108,6 +108,7 @@ up to 30 points.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Asset loading and compaction use content fingerprints with exact equality checks; tests cover equal-sized distinct buffers, shared copies, independent links and missing data (#727) |
|
||||
| 2026-10-13 | minor | `.indd` and `.indt` read through storytold/indd-utils; INDD remaining work is a real-file corpus (10–30 h); score unchanged until it is measured |
|
||||
| 2026-10-12 | minor | XLSX table placement and data merge preserve small numeric values and cached formula strings; synthetic regressions in `textimport::xlsx::tests`. Scores unchanged. |
|
||||
| 2026-10-10 | major | Created from InDesign 2026's declared document types and filters, `plan/indesign/08-file-formats.md` and the code |
|
||||
|
||||
@@ -450,7 +450,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level
|
||||
|
||||
Task notes that headed ROADMAP.md:
|
||||
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
- FID1: PDF export normalizes mixed emitted gradient color spaces to RGB with an explicit warning; homogeneous device-space gradients retain their components. Process stops (CMYK, grey) blend as inks on screen and at midpoints, so a midpoint keeps the shading CMYK. Linear/radial, midpoint, archival and print policies have synthetic regression coverage.
|
||||
- AR1.1: Kashida justification tests verify space fallback without Tatweel fonts using an isolated font database, while retaining elongation assertions when fonts support it.
|
||||
- AR1: Arabic IDML controls now retain character direction, kashida switches, diacritic offsets/forms, paragraph policy values and independent story/table directions. Shaping preserves joining context and script/language selection; paragraph-level bidi, contextual digit conversion, mark-safe elongation, RTL columns/tables and physical column insertion have regression coverage. Vendor-specific justification and diacritic presets remain explicit preflight warnings; see docs/arabic-typography.md.
|
||||
- CJK1: IDML composite fonts now retain character mappings and metrics; explicit aki, tsume, jidori, em alignment/leading, named/custom kinsoku boundaries, hanging punctuation, tate-chu-yoko offsets and kenten symbols flow through composition and IDML export. Automatic kerning no longer imports inactive numeric values as enormous manual spacing. Composite definitions are editable through commands with undo/cache invalidation. Mojikumi definitions are retained and unsupported spacing-table / push-in-push-out policies are reported by preflight. Full InDesign CJK composition parity, frame-grid layout and detailed ruby remain unfinished. Warichu sets the selected run in smaller lines inside the line after the line is broken.
|
||||
|
||||
Reference in New Issue
Block a user