go further

This commit is contained in:
jif-oai
2026-03-03 20:16:57 +00:00
parent 61d77d8daf
commit 59a1058b30
9 changed files with 224 additions and 7 deletions

View File

@@ -104,6 +104,7 @@ impl PresentationArtifactManager {
"get_summary" => self.get_summary(request),
"list_slides" => self.list_slides(request),
"list_layouts" => self.list_layouts(request),
"list_masters" => self.list_masters(request),
"list_layout_placeholders" => self.list_layout_placeholders(request),
"list_slide_placeholders" => self.list_slide_placeholders(request),
"inspect" => self.inspect(request),
@@ -149,6 +150,8 @@ impl PresentationArtifactManager {
"insert_text_after" => self.insert_text_after(request),
"set_hyperlink" => self.set_hyperlink(request),
"set_comment_author" => self.set_comment_author(request),
"list_comment_threads" => self.list_comment_threads(request),
"get_comment_thread" => self.get_comment_thread(request),
"add_comment_thread" => self.add_comment_thread(request),
"add_comment_reply" => self.add_comment_reply(request),
"toggle_comment_reaction" => self.toggle_comment_reaction(request),
@@ -453,6 +456,24 @@ impl PresentationArtifactManager {
Ok(response)
}
fn list_masters(
&mut self,
request: PresentationArtifactRequest,
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
let artifact_id = required_artifact_id(&request)?;
let document = self.get_document(&artifact_id, &request.action)?;
let masters = master_layout_list(document);
let mut response = PresentationArtifactResponse::new(
artifact_id,
request.action,
format!("Listed {} masters", masters.len()),
snapshot_for_document(document),
);
response.layout_list = Some(masters);
response.theme = Some(document.theme_snapshot());
Ok(response)
}
fn list_layout_placeholders(
&mut self,
request: PresentationArtifactRequest,
@@ -2512,6 +2533,54 @@ impl PresentationArtifactManager {
))
}
fn list_comment_threads(
&mut self,
request: PresentationArtifactRequest,
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
let artifact_id = required_artifact_id(&request)?;
let document = self.get_document(&artifact_id, &request.action)?;
let mut response = PresentationArtifactResponse::new(
artifact_id,
request.action,
format!("Listed {} comment threads", document.comment_threads.len()),
snapshot_for_document(document),
);
response.resolved_record = Some(serde_json::json!({
"commentSelf": document.comment_self.as_ref().map(comment_author_to_proto),
"commentThreads": document
.comment_threads
.iter()
.map(comment_thread_to_proto)
.collect::<Vec<_>>(),
}));
Ok(response)
}
fn get_comment_thread(
&mut self,
request: PresentationArtifactRequest,
) -> Result<PresentationArtifactResponse, PresentationArtifactError> {
let args: CommentThreadIdArgs = parse_args(&request.action, &request.args)?;
let artifact_id = required_artifact_id(&request)?;
let document = self.get_document(&artifact_id, &request.action)?;
let thread = document
.comment_threads
.iter()
.find(|thread| thread.thread_id == args.thread_id)
.ok_or_else(|| PresentationArtifactError::InvalidArgs {
action: request.action.clone(),
message: format!("unknown comment thread `{}`", args.thread_id),
})?;
let mut response = PresentationArtifactResponse::new(
artifact_id,
request.action,
format!("Retrieved comment thread `{}`", args.thread_id),
snapshot_for_document(document),
);
response.resolved_record = Some(comment_thread_to_proto(thread));
Ok(response)
}
fn add_comment_thread(
&mut self,
request: PresentationArtifactRequest,

View File

@@ -285,7 +285,7 @@ enum TextVerticalAlignment {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommentAuthorProfile {
pub(super) struct CommentAuthorProfile {
display_name: String,
initials: String,
email: Option<String>,
@@ -332,7 +332,7 @@ enum CommentTarget {
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CommentThread {
pub(super) struct CommentThread {
thread_id: String,
target: CommentTarget,
position: Option<CommentPosition>,

View File

@@ -25,8 +25,11 @@ fn is_read_only_action(action: &str) -> bool {
"get_summary"
| "list_slides"
| "list_layouts"
| "list_masters"
| "list_layout_placeholders"
| "list_slide_placeholders"
| "list_comment_threads"
| "get_comment_thread"
| "inspect"
| "resolve"
| "to_proto"

View File

@@ -1,4 +1,9 @@
const CODEX_METADATA_ENTRY: &str = "ppt/codex-document.json";
const DEFAULT_SLIDE_MASTER_TEXT_STYLES: &str = r#"<p:txStyles>
<p:titleStyle/>
<p:bodyStyle/>
<p:otherStyle/>
</p:txStyles>"#;
fn import_codex_metadata_document(path: &Path) -> Result<Option<PresentationDocument>, String> {
let file = std::fs::File::open(path).map_err(|error| error.to_string())?;
@@ -333,6 +338,12 @@ fn patch_pptx_package(
.map_err(|error| error.to_string())?;
continue;
}
if name == "ppt/slideMasters/slideMaster1.xml" {
writer
.write_all(update_slide_master_xml(bytes)?.as_bytes())
.map_err(|error| error.to_string())?;
continue;
}
if let Some(slide_number) = parse_slide_xml_path(&name) {
writer
.write_all(
@@ -452,6 +463,24 @@ fn update_presentation_xml_dimensions(
)
}
fn update_slide_master_xml(existing_bytes: Vec<u8>) -> Result<String, String> {
let existing = String::from_utf8(existing_bytes).map_err(|error| error.to_string())?;
if existing.contains("<p:txStyles>") {
return Ok(existing);
}
let closing_tag = "</p:sldMaster>";
let start = existing
.find(closing_tag)
.ok_or_else(|| "slide master xml is missing `</p:sldMaster>`".to_string())?;
Ok(format!(
"{}{}{}",
&existing[..start],
DEFAULT_SLIDE_MASTER_TEXT_STYLES,
&existing[start..]
))
}
fn replace_self_closing_xml_tag(xml: &str, tag: &str, replacement: &str) -> Result<String, String> {
let start = xml
.find(&format!("<{tag} "))

View File

@@ -472,7 +472,7 @@ fn chart_data_label_override_to_proto(override_spec: &ChartDataLabelOverride) ->
})
}
fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
pub(super) fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
serde_json::json!({
"displayName": author.display_name,
"initials": author.initials,
@@ -480,7 +480,7 @@ fn comment_author_to_proto(author: &CommentAuthorProfile) -> Value {
})
}
fn comment_thread_to_proto(thread: &CommentThread) -> Value {
pub(super) fn comment_thread_to_proto(thread: &CommentThread) -> Value {
serde_json::json!({
"kind": "comment",
"threadId": thread.thread_id,

View File

@@ -1184,7 +1184,9 @@ fn push_text_line(
if !matches!(glyph.ch, ' ' | '\t') {
break;
}
let trimmed = current.pop().expect("last glyph must exist");
let Some(trimmed) = current.pop() else {
break;
};
*current_width = current_width.saturating_sub(measure_glyph_width(&trimmed, font_px));
if trimmed.ch == ' ' {
*current_spaces = current_spaces.saturating_sub(1);

View File

@@ -69,6 +69,13 @@ fn layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
.collect()
}
fn master_layout_list(document: &PresentationDocument) -> Vec<LayoutListEntry> {
layout_list(document)
.into_iter()
.filter(|layout| layout.kind == "master")
.collect()
}
fn points_to_emu(points: u32) -> u32 {
points.saturating_mul(POINT_TO_EMU)
}

View File

@@ -132,7 +132,7 @@ fn manager_can_import_exported_presentation() -> Result<(), Box<dyn std::error::
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
artifact_id: Some(artifact_id),
action: "add_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
@@ -281,6 +281,48 @@ fn exported_images_are_real_pictures_with_media_parts() -> Result<(), Box<dyn st
Ok(())
}
#[test]
fn exported_slide_master_includes_text_styles() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
let mut manager = PresentationArtifactManager::default();
let created = manager.execute(
PresentationArtifactRequest {
artifact_id: None,
action: "create".to_string(),
args: serde_json::json!({ "name": "Slide Master Styles" }),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(created.artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let export_path = temp_dir.path().join("slide-master-styles.pptx");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(created.artifact_id),
action: "export_pptx".to_string(),
args: serde_json::json!({ "path": export_path }),
},
temp_dir.path(),
)?;
let slide_master_xml = zip_entry_text(
&temp_dir.path().join("slide-master-styles.pptx"),
"ppt/slideMasters/slideMaster1.xml",
)?;
assert!(slide_master_xml.contains("<p:txStyles>"));
assert!(slide_master_xml.contains("<p:titleStyle/>"));
assert!(slide_master_xml.contains("<p:bodyStyle/>"));
assert!(slide_master_xml.contains("<p:otherStyle/>"));
Ok(())
}
#[test]
fn exported_charts_are_real_pictures_with_media_parts() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
@@ -489,7 +531,7 @@ fn exported_text_shapes_preserve_text_styling() -> Result<(), Box<dyn std::error
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
artifact_id: Some(artifact_id),
action: "add_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
@@ -1627,6 +1669,23 @@ fn manager_supports_layout_theme_notes_and_inspect() -> Result<(), Box<dyn std::
temp_dir.path(),
)?;
assert_eq!(child_layouts.layout_list.as_ref().map(Vec::len), Some(2));
let listed_masters = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "list_masters".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
assert_eq!(listed_masters.layout_list.as_ref().map(Vec::len), Some(1));
assert_eq!(
listed_masters
.layout_list
.as_ref()
.and_then(|layouts| layouts.first())
.map(|layout| (layout.name.clone(), layout.kind.clone())),
Some(("Brand Master".to_string(), "master".to_string()))
);
let layout_id = child_layouts
.layout_list
.as_ref()
@@ -3488,6 +3547,41 @@ fn rich_text_comments_tables_and_charts_roundtrip_through_metadata()
},
temp_dir.path(),
)?;
let comment_threads = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "list_comment_threads".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let comment_threads = comment_threads
.resolved_record
.expect("comment thread collection");
assert_eq!(
comment_threads["commentSelf"]["displayName"],
serde_json::json!("Jamie Fox")
);
assert_eq!(
comment_threads["commentThreads"].as_array().map(Vec::len),
Some(1)
);
assert_eq!(
comment_threads["commentThreads"][0]["threadId"],
serde_json::json!("thread_1")
);
let comment_thread = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "get_comment_thread".to_string(),
args: serde_json::json!({ "thread_id": "thread_1" }),
},
temp_dir.path(),
)?;
let comment_thread = comment_thread.resolved_record.expect("comment thread");
assert_eq!(comment_thread["anchor"], serde_json::json!("th/thread_1"));
assert_eq!(comment_thread["status"], serde_json::json!("active"));
assert_eq!(comment_thread["messages"].as_array().map(Vec::len), Some(2));
let table_added = manager.execute(
PresentationArtifactRequest {

View File

@@ -14,6 +14,7 @@ Supported actions:
- `get_summary`
- `list_slides`
- `list_layouts`
- `list_masters`
- `list_layout_placeholders`
- `list_slide_placeholders`
- `inspect`
@@ -63,6 +64,8 @@ Supported actions:
- `insert_text_after`
- `set_hyperlink`
- `set_comment_author`
- `list_comment_threads`
- `get_comment_thread`
- `add_comment_thread`
- `add_comment_reply`
- `toggle_comment_reaction`
@@ -104,6 +107,8 @@ Example layout flow:
Layout references in `create_layout.parent_layout_id`, `add_layout_placeholder.layout_id`, `add_slide`, `insert_slide`, `set_slide_layout`, and `list_layout_placeholders` accept either a layout id or a layout name. Name matching prefers exact id, then exact name, then case-insensitive name.
Use `list_masters` when you only want layouts with `kind: "master"` instead of the full mixed layout list.
`insert_slide` accepts `index` or `after_slide_index`. If neither is provided, the new slide is inserted immediately after the active slide, or appended if no active slide is set yet.
Example inspect:
@@ -124,6 +129,14 @@ Rich text is supported on notes, text boxes, shapes with text, and table cells.
Comment threads are supported through `set_comment_author`, `add_comment_thread`, `add_comment_reply`, `toggle_comment_reaction`, `resolve_comment_thread`, and `reopen_comment_thread`. Thread anchors resolve as `th/<thread_id>`, and comment records appear in both `inspect` and `to_proto`.
Use `list_comment_threads` for an explicit collection payload and `get_comment_thread` when you already know the thread id.
Example list comment threads:
`{"artifact_id":"presentation_x","actions":[{"action":"list_comment_threads","args":{}}]}`
Example get comment thread:
`{"artifact_id":"presentation_x","actions":[{"action":"get_comment_thread","args":{"thread_id":"thread_1"}}]}`
Charts support richer series metadata plus `update_chart` and `add_chart_series`, including legend, axis, data-label, marker, fill, and per-point override state.
Exported PPTX files embed Codex metadata so rich text, comment threads, and advanced table/chart state round-trip through `export_pptx` and `import_pptx` even when the base OOXML representation is lossy.