fix: integrate batched community PRs (#170's PPTX exporter on the page-indexed compose cache and crate::io, with a Georgian menu label; #171: a deleted style's replacement is never based on itself)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-15 00:35:27 -04:00
co-authored by Claude Opus 5.5
parent 4b85aa4e6a
commit a930276fd7
7 changed files with 37 additions and 13 deletions
Generated
+2 -2
View File
@@ -1112,7 +1112,7 @@ dependencies = [
[[package]] [[package]]
name = "designcraft-pptx" name = "designcraft-pptx"
version = "0.5.0" version = "0.8.0"
dependencies = [ dependencies = [
"designcraft-color", "designcraft-color",
"designcraft-compose", "designcraft-compose",
@@ -1121,7 +1121,7 @@ dependencies = [
"designcraft-geom", "designcraft-geom",
"designcraft-images", "designcraft-images",
"image", "image",
"quick-xml 0.38.4", "quick-xml",
"skrifa 0.47.0", "skrifa 0.47.0",
"thiserror 2.0.21", "thiserror 2.0.21",
"zip", "zip",
+1 -1
View File
@@ -102,7 +102,7 @@ fn export_pptx(s: &mut Session, p: &Value) -> Result<Value> {
match str_param(p, "path") { match str_param(p, "path") {
Some(path) => { Some(path) => {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
std::fs::write(path, &r.bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; crate::io::write_file(path, &r.bytes).map_err(EngineError::Other)?;
Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})) Ok(json!({"path": path, "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings}))
} }
None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})), None => Ok(json!({"base64": super::file::base64_encode(&r.bytes), "bytes": r.bytes.len(), "slides": r.slides, "warnings": r.warnings})),
+10 -5
View File
@@ -539,12 +539,16 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
/// instead removes the entry, after its former uses point at the replacement). /// instead removes the entry, after its former uses point at the replacement).
fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) { fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &str) {
let st = d.styles_mut(); let st = d.styles_mut();
// A style is never based on itself: when `to` was based on `from`, it takes `from`'s parent.
let parent_of = |based_on: Option<&String>| based_on.filter(|b| b.as_str() != to).cloned();
if para { if para {
let from_parent = parent_of(st.paragraph.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.paragraph { for s in &mut st.paragraph {
for r in [&mut s.based_on, &mut s.next_style] { if s.based_on.as_deref() == Some(from) {
if r.as_deref() == Some(from) { s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) };
*r = Some(to.to_string()); }
} if s.next_style.as_deref() == Some(from) {
s.next_style = Some(to.to_string());
} }
} }
for o in &mut st.object { for o in &mut st.object {
@@ -553,9 +557,10 @@ fn redirect_style_refs(d: &mut designcraft_doc::Document, para: bool, from: &str
} }
} }
} else { } else {
let from_parent = parent_of(st.character.iter().find(|x| x.name == from).and_then(|x| x.based_on.as_ref()));
for s in &mut st.character { for s in &mut st.character {
if s.based_on.as_deref() == Some(from) { if s.based_on.as_deref() == Some(from) {
s.based_on = Some(to.to_string()); s.based_on = if s.name == to { from_parent.clone() } else { Some(to.to_string()) };
} }
} }
for s in &mut st.paragraph { for s in &mut st.paragraph {
+17
View File
@@ -701,6 +701,23 @@ fn paragraph_style_delete_rejects_missing_unknown_and_self_replacement() {
assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style"); assert!(s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": name})).is_err(), "replaceWith can't be the deleted style");
} }
#[test]
fn deleting_a_style_never_leaves_its_replacement_based_on_itself() {
let mut s = session();
let para = |s: &Session, n: &str| s.doc().unwrap().doc.styles.para(n).unwrap().based_on.clone();
s.execute("style.paragraph.create", &json!({"name": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Sub", "basedOn": "Head"})).unwrap();
s.execute("style.paragraph.create", &json!({"name": "Minor", "basedOn": "Sub"})).unwrap();
// Minor is based on Sub; deleting Sub in favour of Minor gives Minor Sub's parent.
s.execute("style.paragraph.delete", &json!({"name": "Sub", "replaceWith": "Minor"})).unwrap();
assert_eq!(para(&s, "Minor").as_deref(), Some("Head"));
s.execute("style.character.create", &json!({"name": "Strong"})).unwrap();
s.execute("style.character.create", &json!({"name": "Stronger", "basedOn": "Strong"})).unwrap();
s.execute("style.character.delete", &json!({"name": "Strong", "replaceWith": "Stronger"})).unwrap();
let based = s.doc().unwrap().doc.styles.character.iter().find(|c| c.name == "Stronger").unwrap().based_on.clone();
assert_ne!(based.as_deref(), Some("Stronger"));
}
#[test] #[test]
fn character_style_delete_reassigns_every_kind_of_reference() { fn character_style_delete_reassigns_every_kind_of_reference() {
let mut s = session(); let mut s = session();
+5 -4
View File
@@ -208,7 +208,8 @@ pub struct Exporter<'a> {
pub to_slide: Affine, pub to_slide: Affine,
/// The page's bleed area in spread space (items outside it are left out). /// The page's bleed area in spread space (items outside it are left out).
clip: Rect, clip: Rect,
pub page_name: Option<String>, /// The page parent items are composed for (page numbers, running heads).
pub page: Option<usize>,
} }
impl<'a> Exporter<'a> { impl<'a> Exporter<'a> {
@@ -227,7 +228,7 @@ impl<'a> Exporter<'a> {
rel_of: HashMap::new(), rel_of: HashMap::new(),
to_slide: Affine::IDENTITY, to_slide: Affine::IDENTITY,
clip: Rect::ZERO, clip: Rect::ZERO,
page_name: None, page: None,
} }
} }
@@ -276,9 +277,9 @@ impl<'a> Exporter<'a> {
continue; continue;
} }
// Parent items compose per page (page numbers, running heads). // Parent items compose per page (page numbers, running heads).
self.page_name = Some(page_name.clone()); self.page = Some(abs);
self.item(it, Affine::translate((dx, 0.0)), 1.0); self.item(it, Affine::translate((dx, 0.0)), 1.0);
self.page_name = None; self.page = None;
} }
} }
for it in &sp.items { for it in &sp.items {
+1 -1
View File
@@ -92,7 +92,7 @@ pub(crate) struct Segment {
pub fn frame(ex: &mut Exporter, it: &Item, tfr: &TextFrame, xf: Affine, opacity: f32) { pub fn frame(ex: &mut Exporter, it: &Item, tfr: &TextFrame, xf: Affine, opacity: f32) {
let doc = ex.doc; let doc = ex.doc;
let cs = ex.cache.get(doc, tfr.story, ex.page_name.as_deref()); let cs = ex.cache.get(doc, tfr.story, ex.page);
let Some(ft) = cs.frame(it.id) else { let Some(ft) = cs.frame(it.id) else {
ex.shape(it, xf, opacity, None); ex.shape(it, xf, opacity, None);
return; return;
+1
View File
@@ -1830,4 +1830,5 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Convert to Flex Layout", "მოქნილ განლაგებად გარდაქმნა"), ("Convert to Flex Layout", "მოქნილ განლაგებად გარდაქმნა"),
("Remove Flex Layout", "მოქნილი განლაგების წაშლა"), ("Remove Flex Layout", "მოქნილი განლაგების წაშლა"),
("Show Flex Controls", "მოქნილი მართვის ელემენტების ჩვენება"), ("Show Flex Controls", "მოქნილი მართვის ელემენტების ჩვენება"),
("Export PowerPoint…", "PowerPoint-ად ექსპორტი…"),
]; ];