diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 543f2430d..0146fe60b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -662,6 +662,51 @@ fn wrap_obstacles(it: &Item, parent: Affine, inv: Affine, skip: ItemId, left_pag } } +/// Adds to `exclusions` the objects on `sp` that wrap the text of frame `item`. Only objects that +/// reach the frame's text area wrap its text: one elsewhere on the spread would otherwise cost a +/// narrow column its room (the 1.5 em minimum segment of `free_slots` applies once an object cuts +/// the column). +fn frame_wrap_exclusions(doc: &Document, sp: &designcraft_doc::Spread, item: &Item, inv: Affine, exclusions: &mut Vec) { + let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); + for other in &sp.items { + if doc.layer(other.layer).is_some_and(|l| !l.visible) { + continue; + } + wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, exclusions); + } + let a = item.text_area(); + exclusions.retain(|e| { + let r = e.rect; + r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 + }); +} + +/// The text area and options a frame composes into. On a path, one line as long as the path (from +/// the start offset). +fn frame_text_area(doc: &Document, st: &Story, item: &Item, tf: &designcraft_doc::TextFrame) -> (Rect, TextFrameOptions) { + match &tf.options.path { + Some(pt) => { + let len = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), false).length(); + let size = st + .paras + .first() + .map(|p| doc.styles.resolve_para(p).1) + .map(|base| st.runs().map(|(_, f)| doc.styles.resolve_char(&base, f).size).fold(base.size, f64::max)) + .unwrap_or(12.0); + let opts = TextFrameOptions { + columns: 1, + inset: [0.0; 4], + first_baseline: designcraft_doc::FirstBaseline::Ascent, + // Keep the path identity: its synthetic area uses full line extents. + ..tf.options.clone() + }; + let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); + (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) + } + None => (item.text_area(), tf.options.clone()), + } +} + /// Build the frame specs of a story from the document (geometry, wrap, page names, grid). pub fn frame_specs(doc: &Document, sid: StoryId) -> Vec { frame_specs_placed(doc, sid, &[]) @@ -694,21 +739,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId && loc.is_some() && let Some(sp) = spread { - let left = |x: f64| sp.page_at_x(x).and_then(|pi| sp.pages.get(pi)).is_some_and(|p| p.side == designcraft_doc::PageSide::Left); - for other in &sp.items { - if doc.layer(other.layer).is_some_and(|l| !l.visible) { - continue; - } - wrap_obstacles(other, Affine::IDENTITY, inv, item.id, &left, 0, &mut exclusions); - } - // Only objects that reach the frame's text area wrap its text: one elsewhere on the - // spread would otherwise cost a narrow column its room (the 1.5 em minimum segment of - // `free_slots` applies once an object cuts the column). - let a = item.text_area(); - exclusions.retain(|e| { - let r = e.rect; - r.is_finite() && r.x0 < a.x1 && r.x1 > a.x0 && r.y0 < a.y1 && r.y1 > a.y0 - }); + frame_wrap_exclusions(doc, sp, item, inv, &mut exclusions); } let (page_name, page, side) = match (spread_ref, spread) { (Some(designcraft_doc::SpreadRef::Doc(si)), Some(sp)) => { @@ -750,27 +781,7 @@ pub(crate) fn frame_specs_placed(doc: &Document, sid: StoryId, placed: &[(ItemId }; let grid = (inc > 0.0 && inc.is_finite() && start.is_finite()).then_some((start - ty, inc)); // Type on a path: one line as long as the path (from the start offset). - let (area, opts) = match &tf.options.path { - Some(pt) => { - let len = designcraft_geom::warp::PathWarp::new(&item.path.to_bezpath(), false).length(); - let size = st - .paras - .first() - .map(|p| doc.styles.resolve_para(p).1) - .map(|base| st.runs().map(|(_, f)| doc.styles.resolve_char(&base, f).size).fold(base.size, f64::max)) - .unwrap_or(12.0); - let opts = TextFrameOptions { - columns: 1, - inset: [0.0; 4], - first_baseline: designcraft_doc::FirstBaseline::Ascent, - // Keep the path identity: its synthetic area uses full line extents. - ..tf.options.clone() - }; - let end = pt.end.map_or(len, |e| e.max(0.0).min(len)); - (Rect::new(0.0, 0.0, (end - pt.start).max(1.0), size * 1.6), opts) - } - None => (item.text_area(), tf.options.clone()), - }; + let (area, opts) = frame_text_area(doc, st, item, tf); // A non-rectangular frame keeps the top inset inward from its outline; its rectangular text // area (all four insets) still bounds the lines. let outline = match &tf.options.path { diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 17f0671e6..2938af768 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -273,56 +273,67 @@ fn convert_all(s: &mut Session, p: &Value) -> Result { }; s.edit(|d, sel| { for plan in &plans { - // A selection in a table cell or a footnote keeps its place in that text: it is - // converted first, so the loop below finds nothing left there. - if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid) - && let Some(c) = t.cell - { - let value = match c.footnote_id() { - Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)), - None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v), - }; - if let Some(v) = value - && let Some(text) = d.text_story_mut(plan.sid, Some(c)) - { - let grown = replace_nested(text, i, v, 1); - t.anchor = shifted(t.anchor, &grown); - t.focus = shifted(t.focus, &grown); - } - } - let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?; - let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect(); - let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default()); - let mut nested = false; - for (id, v) in &plan.tables { - if let Some(t) = st.tables.get_mut(id) - && t.cells.iter().any(|c| holds(&c.text, i, 1)) - { - for c in &mut std::sync::Arc::make_mut(t).cells { - replace_nested(&mut c.text, i, v, 1); - } - nested = true; - } - } - for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) { - if holds(¬e.text, i, 1) { - replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1); - nested = true; - } - } - if nested { - st.rev = st.rev.wrapping_add(1); - } - // A selection in the story keeps its place in the text. - if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) { - t.anchor = shifted(t.anchor, &grown); - t.focus = shifted(t.focus, &grown); - } + convert_plan(d, sel, i, plan)?; } Ok(Value::Null) }) } +/// Converts one story's instances of variable `i` as planned, keeping the selection in place. +fn convert_plan(d: &mut designcraft_doc::Document, sel: &mut designcraft_doc::Selection, i: usize, plan: &StoryPlan) -> Result<()> { + // A selection in a table cell or a footnote keeps its place in that text: it is + // converted first, so the loop below finds nothing left there. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid) + && let Some(c) = t.cell + { + let value = match c.footnote_id() { + Some(id) => d.story(plan.sid).and_then(|st| st.notes.iter().position(|n| n.id == id)).and_then(|k| plan.notes.get(k)), + None => plan.tables.iter().find(|(id, _)| *id == c.table).map(|(_, v)| v), + }; + if let Some(v) = value + && let Some(text) = d.text_story_mut(plan.sid, Some(c)) + { + let grown = replace_nested(text, i, v, 1); + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + } + let st = d.story_mut(plan.sid).ok_or(designcraft_doc::DocError::NoStory(plan.sid))?; + let at: Vec<(usize, usize)> = plan.own.iter().map(|(pos, _)| (*pos, i)).collect(); + let grown = replace_instances(st, &at, &|pos| plan.own.iter().find(|(k, _)| *k == pos).map(|(_, v)| v.clone()).unwrap_or_default()); + if replace_in_tables_and_notes(st, i, plan) { + st.rev = st.rev.wrapping_add(1); + } + // A selection in the story keeps its place in the text. + if let Some(t) = sel.text.as_mut().filter(|t| t.story == plan.sid && t.cell.is_none()) { + t.anchor = shifted(t.anchor, &grown); + t.focus = shifted(t.focus, &grown); + } + Ok(()) +} + +/// Replaces variable `i` in the tables and foot/endnotes of `st` that hold it. Returns whether any did. +fn replace_in_tables_and_notes(st: &mut designcraft_doc::Story, i: usize, plan: &StoryPlan) -> bool { + let mut nested = false; + for (id, v) in &plan.tables { + if let Some(t) = st.tables.get_mut(id) + && t.cells.iter().any(|c| holds(&c.text, i, 1)) + { + for c in &mut std::sync::Arc::make_mut(t).cells { + replace_nested(&mut c.text, i, v, 1); + } + nested = true; + } + } + for (note, v) in st.notes.iter_mut().zip(&plan.notes).chain(st.endnotes.iter_mut().zip(&plan.endnotes)) { + if holds(¬e.text, i, 1) { + replace_nested(&mut std::sync::Arc::make_mut(note).text, i, v, 1); + nested = true; + } + } + nested +} + /// Nesting limit for tables in cells and notes (hostile documents). const MAX_DEPTH: usize = 32; diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0099e9b36..52221c401 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -1451,116 +1451,7 @@ impl Exporter<'_> { } fn graphic_unclipped(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) { - // Video / sound without a poster: a dark frame with a play mark. - if let Some(asset) = self.doc.assets.get(&g.asset) - && designcraft_doc::media_kind(&asset.mime).is_some() - { - let (w, h) = g.size; - let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h) * 0.18); - let mut tri = BezPath::new(); - tri.move_to((cx - r * 0.6, cy - r)); - tri.line_to((cx + r, cy)); - tri.line_to((cx - r * 0.6, cy + r)); - tri.close_path(); - s.push_transform(&tf(g.xf)); - s.set_stroke(None); - for (bp, c) in [(Rect::new(0.0, 0.0, w, h).to_path(0.1), rgb::Color::new(48, 48, 52)), (tri, rgb::Color::new(235, 235, 235))] { - if let Some(p) = to_path(&bp) { - s.set_fill(Some(Fill { paint: c.into(), ..Default::default() })); - s.draw_path(&p); - } - } - s.set_fill(None); - s.pop(); - return; - } - // Placed PDFs go in as vectors (the page as a form XObject). - let doc = self.doc; - if let Some(asset) = doc.assets.get(&g.asset) - && asset.data.starts_with(b"%PDF") - { - // Cropped to a box: the whole page, placed so that the box spans graphic space. - let shown = asset.shown_box(); - let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); - let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); - let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return }; - let request = (g.asset, hidden.map(<[String]>::to_vec)); - let variant = match self.variants.get(&request) { - Some(variant) => variant.clone(), - None => { - #[cfg(test)] - { - self.pdf_preparations += 1; - } - match crate::optional_content::prepare(&asset.data, hidden) { - Ok(variant) => { - self.variants.insert(request, variant.clone()); - variant - } - Err(error) => { - let message = match error { - PdfError::Write(message) => message, - other => other.to_string(), - }; - self.layer_error.get_or_insert(PdfError::Write(format!("{}: {message}", asset.name))); - return; - } - } - } - }; - if let Some(warning) = variant.warning { - self.warn(format!("{}: {warning}", asset.name)); - } - let key = (g.asset, variant.hidden); - let placed = match self.pdfs.get(&key) { - Some(p) => p.clone(), - None => { - let p = self.placed_pdf(asset, variant.data); - self.pdfs.insert(key, p.clone()); - p - } - }; - match placed { - // krilla would fail the whole export at the end on a page the PDF doesn't have. - Some((_, pages)) if asset.page as usize >= pages => { - self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1)); - } - Some((placed, _)) => { - self.has_optional_content |= variant.has_optional_content; - s.push_transform(&tf(g.xf)); - let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1))); - if let Some(clip) = &clip { - s.push_clip_path(clip, &FillRule::NonZero); - } - s.push_transform(&tf(Affine::translate((-l * page_w, -t * page_h)))); - s.draw_pdf_page(&placed, size, asset.page as usize); - s.pop(); - if clip.is_some() { - s.pop(); - } - s.pop(); - } - None => {} - } - return; - } - // Placed SVGs go in as vectors too. - if let Some(asset) = self.doc.assets.get(&g.asset) - && designcraft_images::is_svg(&asset.data) - { - let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return }; - let tree = self.svgs.entry(g.asset).or_insert_with(|| designcraft_images::svg_tree(&asset.data).map(Arc::new)).clone(); - match tree { - Some(tree) => { - use krilla_svg::SurfaceExt; - s.push_transform(&tf(g.xf)); - if s.draw_svg(&tree, size, krilla_svg::SvgSettings::default()).is_none() { - self.warn(format!("{}: parts of the placed SVG could not be drawn", asset.name)); - } - s.pop(); - } - None => self.warn(format!("{}: can't read the placed SVG", asset.name)), - } + if self.graphic_media_placeholder(s, g) || self.graphic_placed_pdf(s, g, hidden) || self.graphic_placed_svg(s, g) { return; } if self.colorized(s, g) { @@ -1573,6 +1464,121 @@ impl Exporter<'_> { s.pop(); } + /// Video or sound without a poster: a dark frame with a play mark. `false` when `g` is not one. + fn graphic_media_placeholder(&self, s: &mut Surface, g: &designcraft_doc::Graphic) -> bool { + let Some(asset) = self.doc.assets.get(&g.asset) else { return false }; + if designcraft_doc::media_kind(&asset.mime).is_none() { + return false; + } + let (w, h) = g.size; + let (cx, cy, r) = (w / 2.0, h / 2.0, w.min(h) * 0.18); + let mut tri = BezPath::new(); + tri.move_to((cx - r * 0.6, cy - r)); + tri.line_to((cx + r, cy)); + tri.line_to((cx - r * 0.6, cy + r)); + tri.close_path(); + s.push_transform(&tf(g.xf)); + s.set_stroke(None); + for (bp, c) in [(Rect::new(0.0, 0.0, w, h).to_path(0.1), rgb::Color::new(48, 48, 52)), (tri, rgb::Color::new(235, 235, 235))] { + if let Some(p) = to_path(&bp) { + s.set_fill(Some(Fill { paint: c.into(), ..Default::default() })); + s.draw_path(&p); + } + } + s.set_fill(None); + s.pop(); + true + } + + /// A placed PDF goes in as vectors (the page as a form XObject). `false` when `g` is not one. + fn graphic_placed_pdf(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic, hidden: Option<&[String]>) -> bool { + let doc = self.doc; + let Some(asset) = doc.assets.get(&g.asset).filter(|asset| asset.data.starts_with(b"%PDF")) else { return false }; + // Cropped to a box: the whole page, placed so that the box spans graphic space. + let shown = asset.shown_box(); + let [l, t, r, b] = shown.unwrap_or([0.0, 0.0, 1.0, 1.0]); + let (page_w, page_h) = (g.size.0 / (r - l), g.size.1 / (b - t)); + let Some(size) = Size::from_wh(page_w.max(1e-3) as f32, page_h.max(1e-3) as f32) else { return true }; + let request = (g.asset, hidden.map(<[String]>::to_vec)); + let variant = match self.variants.get(&request) { + Some(variant) => variant.clone(), + None => { + #[cfg(test)] + { + self.pdf_preparations += 1; + } + match crate::optional_content::prepare(&asset.data, hidden) { + Ok(variant) => { + self.variants.insert(request, variant.clone()); + variant + } + Err(error) => { + let message = match error { + PdfError::Write(message) => message, + other => other.to_string(), + }; + self.layer_error.get_or_insert(PdfError::Write(format!("{}: {message}", asset.name))); + return true; + } + } + } + }; + if let Some(warning) = variant.warning { + self.warn(format!("{}: {warning}", asset.name)); + } + let key = (g.asset, variant.hidden); + let placed = match self.pdfs.get(&key) { + Some(p) => p.clone(), + None => { + let p = self.placed_pdf(asset, variant.data); + self.pdfs.insert(key, p.clone()); + p + } + }; + match placed { + // krilla would fail the whole export at the end on a page the PDF doesn't have. + Some((_, pages)) if asset.page as usize >= pages => { + self.warn(format!("{}: the placed PDF has no page {}", asset.name, u64::from(asset.page) + 1)); + } + Some((placed, _)) => { + self.has_optional_content |= variant.has_optional_content; + s.push_transform(&tf(g.xf)); + let clip = shown.and_then(|_| to_path(&Rect::new(0.0, 0.0, g.size.0, g.size.1).to_path(0.1))); + if let Some(clip) = &clip { + s.push_clip_path(clip, &FillRule::NonZero); + } + s.push_transform(&tf(Affine::translate((-l * page_w, -t * page_h)))); + s.draw_pdf_page(&placed, size, asset.page as usize); + s.pop(); + if clip.is_some() { + s.pop(); + } + s.pop(); + } + None => {} + } + true + } + + /// A placed SVG goes in as vectors too. `false` when `g` is not one. + fn graphic_placed_svg(&mut self, s: &mut Surface, g: &designcraft_doc::Graphic) -> bool { + let Some(asset) = self.doc.assets.get(&g.asset).filter(|asset| designcraft_images::is_svg(&asset.data)) else { return false }; + let Some(size) = Size::from_wh(g.size.0.max(1e-3) as f32, g.size.1.max(1e-3) as f32) else { return true }; + let tree = self.svgs.entry(g.asset).or_insert_with(|| designcraft_images::svg_tree(&asset.data).map(Arc::new)).clone(); + match tree { + Some(tree) => { + use krilla_svg::SurfaceExt; + s.push_transform(&tf(g.xf)); + if s.draw_svg(&tree, size, krilla_svg::SvgSettings::default()).is_none() { + self.warn(format!("{}: parts of the placed SVG could not be drawn", asset.name)); + } + s.pop(); + } + None => self.warn(format!("{}: can't read the placed SVG", asset.name)), + } + true + } + /// A greyscale or bitmap image coloured by the swatch applied to it (#1094): the swatch is /// painted through the image's ink coverage (its darkness), over 0% ink for a greyscale /// image, whose white is opaque. `false`, drawing nothing, when the image keeps its own diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e3226044b..f9cf6c74a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -3260,6 +3260,29 @@ fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool { || crate::chrome::search_had_focus(ctx) } +/// The command whose shortcut `key` with `modifiers` stands for, and whether the modifiers match +/// that shortcut exactly. +fn command_shortcut(app: &DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool) -> Option<(String, bool)> { + for (id, sc) in &effective_shortcuts(app) { + let id = id.as_str(); + if let Some((m, k)) = parse_shortcut(sc) + && let Some(exact) = key_matches(m, k, key, modifiers) + && (m.command || m.alt || !typing) + && !(id == "edit.clear" && typing) + { + // Single-key shortcuts (W) only when not typing. + if !m.command && !m.alt && !m.shift && typing { + continue; + } + if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) { + continue; // Handled by the active tool / text clipboard events. + } + return Some((id.to_string(), exact)); + } + } + None +} + /// Runs the presentation key, command shortcut or tool shortcut a key press stands for. `consume` /// takes the press from the input where nothing else may see it. Returns whether the press was used. fn shortcut_key(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, typing: bool, consume: &mut dyn FnMut()) -> bool { @@ -3285,28 +3308,7 @@ fn shortcut_key(app: &mut DesignApp, key: egui::Key, modifiers: egui::Modifiers, return true; } } - // Command shortcuts. - let mut fired: Option<(String, bool)> = None; - let all = effective_shortcuts(app); - for (id, sc) in &all { - let id = id.as_str(); - if let Some((m, k)) = parse_shortcut(sc) - && let Some(exact) = key_matches(m, k, key, modifiers) - && (m.command || m.alt || !typing) - && !(id == "edit.clear" && typing) - { - // Single-key shortcuts (W) only when not typing. - if !m.command && !m.alt && !m.shift && typing { - continue; - } - if id == "edit.clear" || (typing && matches!(id, "edit.copy" | "edit.cut" | "edit.paste" | "edit.pasteInPlace")) { - continue; // Handled by the active tool / text clipboard events. - } - fired = Some((id.to_string(), exact)); - break; - } - } - if let Some((id, exact)) = fired { + if let Some((id, exact)) = command_shortcut(app, key, modifiers, typing) { // The native menu bar carries the shortcuts in effect, the user's included, and its // accelerator fires only for the shortcut's own key and modifiers; the Plus key standing // in for = never reaches it.