diff --git a/ROADMAP.md b/ROADMAP.md index 745f32b5c..2ced3603c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -87,6 +87,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields need AcroForm support the PDF writer lacks. - Power Zoom (Hand tool, Alt-press): zoom out to the spread, aim the red view rectangle, release to zoom back in there. InDesign triggers it by press-and-hold instead. +- Graphic cells (`table.placeGraphic`, `table.textCell`, Table › Convert Cell Type, Table panel): images in table cells, fitted or filled, clipped to the cell on screen and in PDF. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index b09d17ecd..b191f29c0 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -30,6 +30,8 @@ pub struct PlacedCell { pub overset: bool, /// A header/footer row repeated in a continuation fragment. pub repeated: bool, + /// Graphic cell content (drawn at `clip`'s top left, clipped to it). + pub graphic: Option, } /// One cell edge (or the table border) to stroke. @@ -365,6 +367,7 @@ fn emit( clip, overset, repeated, + graphic: cell.graphic.clone(), }); // Edges: every cell draws its top and left; the fragment's bottom/right cells their bottom/right. // The table border replaces outer edges. diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 0531e618e..80774a449 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -119,6 +119,10 @@ pub struct Cell { /// Applied cell style ("" = [None]). #[serde(default, skip_serializing_if = "String::is_empty")] pub style: String, + /// Graphic cell: an image in the cell instead of text (its `xf` maps into the cell's + /// content box, origin at the box's top left). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub graphic: Option, } impl Default for Cell { @@ -134,6 +138,7 @@ impl Default for Cell { rotation: 0.0, strokes: Default::default(), style: String::new(), + graphic: None, } } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ae78c2ff8..dd0e81be0 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -351,7 +351,7 @@ pub fn base64_decode(s: &str) -> Vec { out } -fn read_source(p: &Value) -> Result<(Vec, String, Option)> { +pub(crate) fn read_source(p: &Value) -> Result<(Vec, String, Option)> { if let Some(b) = str_param(p, "base64") { return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None)); } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 6b57ccfa1..7aa0a0da6 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -112,6 +112,36 @@ fn table_specs() -> Vec { cmd!("table.deleteColumn", "Delete Column", ["Table", "Delete"], None, "{} — the columns of the target cells", in_table, delete_cols), cmd!("table.delete", "Delete Table", ["Table", "Delete"], None, "{}", in_table, delete_table), cmd!("table.merge", "Merge Cells", ["Table"], None, "{} — merge the target cell range", in_table, merge), + cmd!( + "table.placeGraphic", + "Convert Cell to Graphic Cell", + ["Table", "Convert Cell Type"], + None, + "{path | base64+name, fit?: proportional|fill (default proportional)} — an image in the target cell (its text is kept but hidden)", + in_table, + place_graphic + ), + cmd!( + "table.textCell", + "Convert Cell to Text Cell", + ["Table", "Convert Cell Type"], + None, + "{} — drop the target cells' graphics", + in_table, + |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; + } + } + } + ok() + }) + } + ), cmd!( "table.moveRow", "Move Row", @@ -1102,6 +1132,53 @@ mod style_tests { } } +fn place_graphic(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.placeGraphic"; + let g = target(s, p, ID)?; + let (bytes, name, link) = super::file::read_source(p)?; + let (pw, ph) = designcraft_render::image_size(&bytes).ok_or_else(|| bad(ID, "unsupported or corrupt image"))?; + let fill = p.get("fit").and_then(Value::as_str) == Some("fill"); + s.edit(|d, sel| { + let aid = designcraft_doc::AssetId(d.alloc()); + let mime = designcraft_render::image_mime(&bytes).to_string(); + d.assets.insert( + aid, + std::sync::Arc::new(designcraft_doc::Asset { + page: 0, + id: aid, + name, + mime, + link, + data: std::sync::Arc::new(bytes), + pixels: Some((pw, ph)), + }), + ); + let st = d.story_mut(g.story).ok_or_else(|| bad(ID, "no story"))?; + let t = st.table_mut(g.table).ok_or_else(|| bad(ID, "no table"))?; + let (r, c) = t.owner(g.range.r0, g.range.c0); + let cell = t.cell(r, c).ok_or_else(|| bad(ID, "no cell"))?; + let (rs, cs) = (cell.row_span.max(1) as usize, cell.col_span.max(1) as usize); + let w: f64 = t.columns[c..(c + cs).min(t.ncols())].iter().map(|x| x.width).sum(); + let h: f64 = t.rows[r..(r + rs).min(t.nrows())].iter().map(|x| x.height).sum(); + let ins = cell.insets; + let (bw, bh) = ((w - ins[1] - ins[3]).max(1.0), (h - ins[0] - ins[2]).max(1.0)); + let (nw, nh) = (pw as f64, ph as f64); + let k = if fill { (bw / nw).max(bh / nh) } else { (bw / nw).min(bh / nh) }; + let xf = designcraft_geom::Affine::translate(((bw - nw * k) / 2.0, (bh - nh * k) / 2.0)) * designcraft_geom::Affine::scale(k); + let cell = t.cell_mut(r, c).expect("exists"); + cell.graphic = Some(designcraft_doc::Graphic { + asset: aid, + size: (nw, nh), + xf, + auto_fit: if fill { designcraft_doc::Fitting::FillProportionally } else { designcraft_doc::Fitting::FitProportionally }, + fit_align: 4, + crop: [0.0; 4], + }); + clamp_selection(d, sel); + Ok(json!({"asset": aid.0, "row": r, "col": c})) + }) +} + fn move_rc(s: &mut Session, p: &Value, rows: bool) -> Result { const ID: &str = "table.move"; let g = target(s, p, ID)?; @@ -1182,6 +1259,38 @@ mod sort_tests { assert_eq!((r["rows"].as_u64(), r["cols"].as_u64()), (Some(5), Some(3))); } + #[test] + fn graphic_cells_draw_their_image() { + 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": 2, "cols": 2})).unwrap(); + s.execute("table.rowHeight", &json!({"height": 100})).ok(); + // A red image into the first cell. + let px: Vec = (0..40 * 40).flat_map(|_| [255, 0, 0, 255]).collect(); + let png = designcraft_render::Rendered { width: 40, height: 40, pixels: px }.to_png(); + s.execute("table.setCell", &json!({"rows": [0, 0], "cols": [0, 0], "text": ""})).ok(); + let g = s.execute("table.placeGraphic", &json!({"base64": super::super::file::base64_encode(&png), "name": "r.png", "fit": "fill"})).unwrap(); + assert_eq!((g["row"].as_u64(), g["col"].as_u64()), (Some(0), Some(0))); + let d = s.doc().unwrap().doc.clone(); + let st = d.stories.values().find(|st| !st.tables.is_empty()).unwrap(); + let t = st.tables.values().next().unwrap(); + let cs = s.cache.get(&d, st.id, None); + let cell = cs.frames[0].tables[0].cells.iter().find(|c| c.row == 0 && c.col == 0).unwrap().clone(); + let fx = d.item(cs.frames[0].frame).unwrap().xf; + let centre = fx * cell.clip.center(); + let mut rr = designcraft_render::Renderer::new(); + rr.threads = 0; + let img = rr.render_page(&d, &s.cache, 0, 1.0, false, &Default::default()).unwrap(); + let p = img.pixel(centre.x as u32, centre.y as u32); + assert!(p[0] > 200 && p[1] < 60, "red in the graphic cell: {p:?}"); + assert!(t.cell(0, 0).unwrap().graphic.is_some()); + s.execute("table.textCell", &json!({})).unwrap(); + let d = s.doc().unwrap().doc.clone(); + assert!(d.stories.values().flat_map(|st| st.tables.values()).all(|t| t.cell(0, 0).unwrap().graphic.is_none())); + } + #[test] fn move_rows_and_columns() { let mut s = Session::new(); diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index ff422f37f..6ed3af647 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1026,7 +1026,7 @@ impl Exporter<'_> { img } - fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { + pub(crate) fn graphic(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) { // Placed PDFs go in as vectors (the page as a form XObject). if let Some(asset) = self.doc.assets.get(&g.asset) && asset.data.starts_with(b"%PDF") diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index d391e78ee..6cee3b1c2 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -130,7 +130,15 @@ impl Exporter<'_> { } } for c in &t.cells { - if let Some(cft) = c.text.frames.first() { + if let Some(g) = &c.graphic { + if let Some(clip) = to_path(&c.clip.to_path(0.1)) { + s.push_clip_path(&clip, &krilla::paint::FillRule::NonZero); + s.push_transform(&tf(Affine::translate((c.clip.x0, c.clip.y0)))); + self.graphic(s, g); + s.pop(); + s.pop(); + } + } else if let Some(cft) = c.text.frames.first() { s.push_transform(&tf(Affine::translate(c.origin.to_vec2()))); self.frame_text(s, &c.text, cft, &c.source); s.pop(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc857cc5e..058dbd8fe 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -636,7 +636,7 @@ impl Renderer { } } - fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) { + pub(crate) fn draw_graphic(&mut self, ctx: &mut RenderContext, f: &Frame, g: &designcraft_doc::Graphic, xf: Affine) { let Some(asset) = f.doc.assets.get(&g.asset) else { return }; if f.opts.quality == DisplayQuality::Fast { ctx.set_transform(f.view * xf * g.xf); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 47cad1baa..a00e6caf7 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -383,7 +383,13 @@ impl Renderer { } } for c in &t.cells { - if let Some(cft) = c.text.frames.first() { + if let Some(g) = &c.graphic { + ctx.set_transform(f.view * xf); + ctx.push_clip_layer(&c.clip.to_path(0.1)); + self.draw_graphic(ctx, f, g, xf * Affine::translate((c.clip.x0, c.clip.y0))); + ctx.set_transform(f.view * xf); + ctx.pop_layer(); + } else if let Some(cft) = c.text.frames.first() { self.draw_text(ctx, f, &c.text, cft, xf * Affine::translate(c.origin.to_vec2())); } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cd25b0237..7b525be82 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,6 +26,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.fittingOptions", "Frame Fitting Options…", None, "{} — the dialog for object.fittingOptions"), ("app.menus", "Menus…", None, "{} — show or hide menu items"), ("window.hideMenuItem", "Hide Menu Item", None, "{item: \"Menu/Label\", hidden?: bool}"), + ("app.graphicCell", "Convert Cell to Graphic Cell…", None, "{} — pick an image for the target table cell"), ("app.placeAndLink", "Place and Link", None, "{} — a linked copy of the selected frame's story, beside it"), ("app.placeWithOptions", "Place with Import Options…", Some("Cmd+Shift+D"), "{} — Word/RTF style mapping before placing"), ("app.exportText", "Export Text…", None, "{path?} — the story being edited, as Text Only (.txt) or Rich Text Format (.rtf)"), @@ -461,6 +462,10 @@ pub const MENUS: &[(&str, &[&str])] = &[ "-", "cmd:table.merge", "cmd:table.unmerge", + ">Convert Cell Type", + "ui:app.graphicCell|Convert Cell to Graphic Cell…", + "cmd:table.textCell", + "<", "cmd:table.splitHorizontally", "cmd:table.splitVertically", "cmd:table.distributeColumns", @@ -677,6 +682,13 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Ok(Value::Null), } } + "app.graphicCell" => { + let Some(pick) = app.services.pick_open.as_mut() else { return Some(Err("needs a file picker".into())) }; + match pick("place") { + Some(path) => app.run("table.placeGraphic", json!({"path": path})), + None => Ok(Value::Null), + } + } "app.loadSwatches" => { if let Some(pick) = app.services.pick_open.as_mut() { return Some(match pick("swatches") { diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index f9e4e8187..82e28e109 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -28,6 +28,17 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let run = |app: &mut DesignApp, id: &str, p: Value| { let _ = app.run(id, p); }; + ui.horizontal(|ui| { + caption(ui, "Cell Type"); + if ui.small_button("Graphic…").clicked() + && let Err(e) = app.run("app.graphicCell", json!({})) + { + app.status(format!("Table: {e}")); + } + if ui.small_button("Text").clicked() { + let _ = app.run("table.textCell", json!({})); + } + }); // Move the target row / column (InDesign drags them). ui.horizontal(|ui| { caption(ui, "Move"); diff --git a/docs/parity.md b/docs/parity.md index 58c2b4c41..f93e4c3b5 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | | P1 | 97 | 92 | 4 | 1 | 97% | -| P2 | 76 | 35 | 24 | 17 | 62% | +| P2 | 76 | 36 | 24 | 16 | 63% | | Area | Parity | |---|---| @@ -26,7 +26,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Type & text frames | 94% | | Typography | 98% | | Styles | 97% | -| Tables | 94% | +| Tables | 98% | | Long documents | 96% | | Interactivity & digital | 90% | | Output & production | 80% | @@ -252,7 +252,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Tables | Header/footer rows | P1 | D | | Tables | Table options | P1 | D | | Tables | Cell options | P1 | D | -| Tables | Graphic cells | P2 | M | +| Tables | Graphic cells | P2 | D | | Tables | Table styles & cell styles | P1 | D | | Tables | Table panel | P1 | D | | Tables | Overset cells | P1 | D |