P: live captions that follow their source's metadata

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 10:48:58 -04:00
1 parent 7acccc354c
commit 84476ffef4
7 files changed
+81 -8

No files matched your search

+1
View File
@@ -109,6 +109,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope
- Effects: Inner Glow (edge or centre), Bevel and Emboss (inner bevel lit from the light angle), Satin (`object.innerGlow`, `object.bevel`, `object.satin`); PDF export now keeps soft effects: objects with shadows, glows, feathers, bevels or satin go out as 300 ppi transparent images of their appearance (threaded text frames excepted).
- Directional Feather (`object.directionalFeather {widths: [top, left, bottom, right]}`): each side fades over its own width.
- View ▸ Flattener Preview (`view.flattenerPreview`): objects involving transparency highlighted in red; knockout groups export as images (PDF output has no knockout groups).
- Live captions (`object.caption {live: true}`, Object ▸ Captions ▸ Generate Live Caption): the caption text is re-filled from the source object's metadata after every edit (same undo step).
- 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.
+13
View File
@@ -467,6 +467,14 @@ pub struct ObjectLiquid {
pub pin_right: bool,
}
/// The source and template of a live caption.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct LiveCaption {
pub source: ItemId,
pub template: String,
}
/// Media panel options for a placed video or sound.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", default)]
@@ -908,6 +916,10 @@ pub struct Item {
/// Liquid Layout (object-based pages): pins and resize permissions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub liquid: Option<ObjectLiquid>,
/// Live caption: this text frame shows `template` filled from the source object's metadata,
/// kept current as the source changes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub live_caption: Option<LiveCaption>,
/// Object › Object Layer Options: layers of a placed PDF that are hidden.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pdf_hidden_layers: Vec<String>,
@@ -967,6 +979,7 @@ impl Item {
xml_tag: String::new(),
button: None,
pdf_hidden_layers: Vec::new(),
live_caption: None,
media: None,
liquid: None,
states: Vec::new(),
+49 -2
View File
@@ -14,7 +14,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Generate Static Caption",
["Object", "Captions"],
None,
"{text?: template (default \"{name}\"; variables {name} {path} {altText} {label} {ppi} {dimensions} {format}), position?: below|above|left|right, offset? (pt, 0), height? (pt, 24), style?: paragraph style, ids?} → {ids}",
"{text?: template (default \"{name}\"; variables {name} {path} {altText} {label} {ppi} {dimensions} {format}), position?: below|above|left|right, offset? (pt, 0), height? (pt, 24), style?: paragraph style, live?: bool (kept current as the source changes), ids?} → {ids}",
has_selection,
caption
)]
@@ -44,7 +44,7 @@ fn variable(d: &Document, it: &Item, xf: designcraft_geom::Affine, name: &str) -
}
}
fn fill(d: &Document, it: &Item, xf: designcraft_geom::Affine, template: &str) -> String {
pub(crate) fn fill(d: &Document, it: &Item, xf: designcraft_geom::Affine, template: &str) -> String {
let mut out = String::new();
let mut rest = template;
while let Some(i) = rest.find('{') {
@@ -75,6 +75,7 @@ fn caption(s: &mut Session, p: &Value) -> Result<Value> {
let offset = p.get("offset").and_then(Value::as_f64).unwrap_or(0.0);
let height = p.get("height").and_then(Value::as_f64).unwrap_or(24.0).max(1.0);
let style = str_param(p, "style").map(str::to_string);
let live = p.get("live").and_then(Value::as_bool).unwrap_or(false);
if let Some(st) = &style
&& s.doc()?.doc.styles.para(st).is_none()
{
@@ -96,7 +97,11 @@ fn caption(s: &mut Session, p: &Value) -> Result<Value> {
_ => Rect::new(b.x0, b.y1 + offset, b.x1, b.y1 + offset + height),
};
let para = ParaFormat { style: style.clone().unwrap_or_else(|| d.styles.default_paragraph.clone()), ..Default::default() };
let source = *id;
let (cid, _) = d.add_text_frame(loc.spread, r, lid, &text, para)?;
if live && let Some(c) = d.item_mut(cid) {
c.live_caption = Some(designcraft_doc::LiveCaption { source, template: template.clone() });
}
made.push(cid);
}
if made.is_empty() {
@@ -107,6 +112,29 @@ fn caption(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// Bring live captions up to date with their source objects (the text frame's story is
/// replaced when its text changed; a caption whose source is gone keeps its last text).
pub(crate) fn refresh_live(d: &mut Document) -> bool {
let mut todo = Vec::new();
for id in d.all_items() {
let Some(c) = d.item(id) else { continue };
let (Some(lc), Some(tf)) = (&c.live_caption, c.text_frame()) else { continue };
let (Some(loc), Some(src)) = (d.find(lc.source), d.item(lc.source)) else { continue };
let text = fill(d, src, d.parent_xf(&loc) * src.xf, &lc.template);
if d.story(tf.story).is_some_and(|st| st.text != text) {
todo.push((tf.story, text));
}
}
let changed = !todo.is_empty();
for (sid, text) in todo {
if let Some(st) = d.story_mut(sid) {
let len = st.len();
st.replace(0..len, &text);
}
}
changed
}
#[cfg(test)]
mod tests {
use serde_json::json;
@@ -130,4 +158,23 @@ mod tests {
assert_eq!(d.stories[&sid].text, "Figure fig-1: Harbour at dawn");
assert!(s.execute("object.caption", &json!({"ids": [a], "position": "sideways"})).is_err());
}
#[test]
fn live_caption_follows_its_source() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let a = s.execute("frame.create", &json!({"rect": [72, 72, 272, 172]})).unwrap()["id"].as_u64().unwrap();
s.execute("object.altText", &json!({"text": "First", "ids": [a]})).unwrap();
let r = s.execute("object.caption", &json!({"ids": [a], "text": "Photo: {altText}", "live": true})).unwrap();
let cid = designcraft_doc::ItemId(r["ids"][0].as_u64().unwrap());
let text = |s: &Session| {
let d = &s.doc().unwrap().doc;
d.stories[&d.item(cid).unwrap().text_frame().unwrap().story].text.clone()
};
assert_eq!(text(&s), "Photo: First");
s.execute("object.altText", &json!({"text": "Second", "ids": [a]})).unwrap();
assert_eq!(text(&s), "Photo: Second", "updated with the source");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(text(&s), "Photo: First", "one undo step for both");
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod anchors;
mod articles;
pub mod book;
mod buttons;
mod captions;
pub(crate) mod captions;
mod changes;
mod colorsettings;
mod conditions;
+11
View File
@@ -348,6 +348,17 @@ impl Session {
if self.prefs.smart_text_reflow && spec.undoable {
self.smart_reflow();
}
// Live captions follow their sources.
if spec.undoable
&& let Some(st) = self.active_mut()
&& st.interaction.is_none()
&& st.doc.spreads.iter().any(|sp| sp.items.iter().any(|i| i.live_caption.is_some()))
{
let mut d = (*st.doc).clone();
if cmd::captions::refresh_live(&mut d) {
st.doc = Arc::new(d);
}
}
// Record undo if the document changed (and we're not previewing an interaction).
if let (Some((uid, old)), Some(st)) = (before, self.active_mut())
&& st.uid == uid
+1
View File
@@ -483,6 +483,7 @@ pub const MENUS: &[(&str, &[&str])] = &[
"<",
">Captions",
"cmd:object.caption",
"cmd:object.caption|Generate Live Caption|{\"live\": true}",
"<",
">Anchored Object",
"cmd:anchored.options",
+5 -5
View File
@@ -11,7 +11,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
|---|---|---|---|---|---|
| P0 | 102 | 100 | 2 | 0 | 99% |
| P1 | 97 | 94 | 3 | 0 | 98% |
| P2 | 76 | 51 | 25 | 0 | 84% |
| P2 | 76 | 53 | 23 | 0 | 85% |
| Area | Parity |
|---|---|
@@ -22,12 +22,12 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia
| Transform | 100% |
| Fill, stroke, colour | 99% |
| Effects & transparency | 92% |
| Placing & links | 91% |
| Placing & links | 93% |
| Type & text frames | 99% |
| Typography | 98% |
| Styles | 97% |
| Tables | 98% |
| Long documents | 96% |
| Long documents | 100% |
| Interactivity & digital | 90% |
| Output & production | 82% |
| XML & automation | 83% |
@@ -176,7 +176,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Placing & links | Effective/actual PPI | P0 | D |
| Placing & links | Relink to folder / extension | P1 | D |
| Placing & links | Copy Links To / Package | P1 | D |
| Placing & links | Captions | P2 | P |
| Placing & links | Captions | P2 | D |
| Placing & links | Image import options | P1 | D |
| Placing & links | Linked text/stories | P2 | D |
| Placing & links | Display performance | P0 | D |
@@ -267,7 +267,7 @@ Open P0 items: Vector/page formats (PDF/AI/SVG/EPS place), Export to PDF (print)
| Long documents | Cross-references | P2 | D |
| Long documents | Running headers via variables | P1 | D |
| Long documents | Footnotes/endnotes | P1 | D |
| Long documents | Captions | P2 | P |
| Long documents | Captions | P2 | D |
| Interactivity & digital | Hyperlinks panel | P1 | D |
| Interactivity & digital | Bookmarks | P1 | D |
| Interactivity & digital | Buttons and Forms | P2 | P |