mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 13:46:52 -06:00
T: graphic cells — images in table cells
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0454044d74
commit
08495e45d0
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user