T: graphic cells — images in table cells

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 04:30:47 -04:00
co-authored by Claude Opus 5.5
parent 0454044d74
commit 08495e45d0
12 changed files with 163 additions and 8 deletions
+1
View File
@@ -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.
+3
View File
@@ -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<designcraft_doc::Graphic>,
}
/// 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.
+5
View File
@@ -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<crate::Graphic>,
}
impl Default for Cell {
@@ -134,6 +138,7 @@ impl Default for Cell {
rotation: 0.0,
strokes: Default::default(),
style: String::new(),
graphic: None,
}
}
}
+1 -1
View File
@@ -351,7 +351,7 @@ pub fn base64_decode(s: &str) -> Vec<u8> {
out
}
fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)> {
pub(crate) fn read_source(p: &Value) -> Result<(Vec<u8>, String, Option<String>)> {
if let Some(b) = str_param(p, "base64") {
return Ok((base64_decode(b), str_param(p, "name").unwrap_or("image").to_string(), None));
}
+109
View File
@@ -112,6 +112,36 @@ fn table_specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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<u8> = (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();
+1 -1
View File
@@ -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")
+9 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+7 -1
View File
@@ -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()));
}
}
+12
View File
@@ -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<Result<Value,
None => 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") {
+11
View File
@@ -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");
+3 -3
View File
@@ -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 |