go further 2

This commit is contained in:
jif-oai
2026-03-03 20:42:08 +00:00
parent 59a1058b30
commit 1452f52d2c
7 changed files with 1428 additions and 177 deletions

View File

@@ -161,8 +161,16 @@ impl PresentationArtifactRequest {
}],
ImageInputSource::DataUrl(_)
| ImageInputSource::Blob(_)
| ImageInputSource::Uri(_)
| ImageInputSource::Placeholder => Vec::new(),
ImageInputSource::Uri(uri) => {
return Err(PresentationArtifactError::UnsupportedFeature {
action: self.action.clone(),
message: format!(
"remote image URIs are not supported for `{}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)",
self.action
),
});
}
}
}
"replace_image" => {
@@ -181,8 +189,16 @@ impl PresentationArtifactRequest {
}],
(None, Some(_), None, None, None)
| (None, None, Some(_), None, None)
| (None, None, None, Some(_), None)
| (None, None, None, None, Some(_)) => Vec::new(),
(None, None, None, Some(uri), None) => {
return Err(PresentationArtifactError::UnsupportedFeature {
action: self.action.clone(),
message: format!(
"remote image URIs are not supported for `{}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)",
self.action
),
});
}
_ => {
return Err(PresentationArtifactError::InvalidArgs {
action: self.action.clone(),

View File

@@ -219,6 +219,9 @@ impl PresentationArtifactManager {
}
})?;
let mut document = PresentationDocument::from_ppt_rs(imported);
if let Some(slide_size) = import_pptx_slide_size(&path)? {
document.slide_size = slide_size;
}
import_pptx_images(&path, &mut document, &request.action)?;
document
};
@@ -1589,10 +1592,6 @@ impl PresentationArtifactManager {
ImageInputSource::Uri(uri) => Some(load_image_payload_from_uri(&uri, "replace_image")?),
ImageInputSource::Placeholder => None,
};
let fit_mode = args.fit.unwrap_or(ImageFitMode::Stretch);
let lock_aspect_ratio = args
.lock_aspect_ratio
.unwrap_or(fit_mode != ImageFitMode::Stretch);
let crop = args
.crop
.map(|crop| normalize_image_crop(crop, &request.action))
@@ -1606,8 +1605,10 @@ impl PresentationArtifactManager {
});
};
image.payload = image_payload;
image.fit_mode = fit_mode;
image.crop = crop;
image.fit_mode = args.fit.unwrap_or(image.fit_mode);
if let Some(crop) = crop {
image.crop = Some(crop);
}
if let Some(rotation) = args.rotation {
image.rotation_degrees = Some(rotation);
}
@@ -1617,9 +1618,15 @@ impl PresentationArtifactManager {
if let Some(flip_vertical) = args.flip_vertical {
image.flip_vertical = flip_vertical;
}
image.lock_aspect_ratio = lock_aspect_ratio;
image.alt_text = args.alt;
image.prompt = args.prompt;
if let Some(lock_aspect_ratio) = args.lock_aspect_ratio {
image.lock_aspect_ratio = lock_aspect_ratio;
}
if let Some(alt) = args.alt {
image.alt_text = Some(alt);
}
if let Some(prompt) = args.prompt {
image.prompt = Some(prompt);
}
image.is_placeholder = is_placeholder;
Ok(PresentationArtifactResponse::new(
artifact_id,
@@ -1871,12 +1878,43 @@ impl PresentationArtifactManager {
message: format!("element `{}` is not a table", args.element_id),
});
};
let row_count = table.rows.len();
let column_count = table.rows.first().map(Vec::len).unwrap_or(0);
let start_row = args.start_row as usize;
let end_row = args.end_row as usize;
let start_column = args.start_column as usize;
let end_column = args.end_column as usize;
if start_row > end_row || start_column > end_column {
return Err(PresentationArtifactError::InvalidArgs {
action: request.action,
message: "merge bounds must be ordered from top-left to bottom-right".to_string(),
});
}
if end_row >= row_count || end_column >= column_count {
return Err(PresentationArtifactError::InvalidArgs {
action: request.action,
message: format!(
"merge bounds [{start_row},{start_column}]..=[{end_row},{end_column}] exceed table size {row_count}x{column_count}"
),
});
}
let region = TableMergeRegion {
start_row: args.start_row as usize,
end_row: args.end_row as usize,
start_column: args.start_column as usize,
end_column: args.end_column as usize,
start_row,
end_row,
start_column,
end_column,
};
if table.merges.iter().any(|merge| {
merge.start_row <= region.end_row
&& region.start_row <= merge.end_row
&& merge.start_column <= region.end_column
&& region.start_column <= merge.end_column
}) {
return Err(PresentationArtifactError::InvalidArgs {
action: request.action,
message: format!("merge region overlaps an existing merge in `{}`", args.element_id),
});
}
table.merges.push(region);
Ok(PresentationArtifactResponse::new(
artifact_id,
@@ -2811,9 +2849,40 @@ impl PresentationArtifactManager {
|| text_layout.wrap.is_some()
|| text_layout.auto_fit.is_some()
|| text_layout.vertical_alignment.is_some();
let position_rotation = args
.position
.as_ref()
.and_then(|position| position.rotation);
let position_flip_horizontal = args
.position
.as_ref()
.and_then(|position| position.flip_horizontal);
let position_flip_vertical = args
.position
.as_ref()
.and_then(|position| position.flip_vertical);
let has_position_transform = position_rotation.is_some()
|| position_flip_horizontal.is_some()
|| position_flip_vertical.is_some();
let has_image_fields =
args.fit.is_some() || args.crop.is_some() || args.lock_aspect_ratio.is_some();
let element = document.find_element_mut(&args.element_id, &request.action)?;
match element {
PresentationElement::Text(text) => {
if args.stroke.is_some()
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| has_position_transform
|| has_image_fields
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message:
"text elements support only `position`, `z_order`, `fill`, and `text_layout` updates"
.to_string(),
});
}
if let Some(position) = args.position {
text.frame = apply_partial_position(text.frame, position);
}
@@ -2823,32 +2892,23 @@ impl PresentationArtifactManager {
if has_text_layout {
text.rich_text.layout = text_layout;
}
if args.stroke.is_some()
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
{
}
PresentationElement::Shape(shape) => {
if has_image_fields {
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message:
"text elements support only `position`, `z_order`, and `fill` updates"
"shape elements support only `position`, `fill`, `stroke`, `rotation`, `flip_horizontal`, `flip_vertical`, `z_order`, and `text_layout` updates"
.to_string(),
});
}
}
PresentationElement::Shape(shape) => {
let position_rotation = args
.position
.as_ref()
.and_then(|position| position.rotation);
let position_flip_horizontal = args
.position
.as_ref()
.and_then(|position| position.flip_horizontal);
let position_flip_vertical = args
.position
.as_ref()
.and_then(|position| position.flip_vertical);
if has_text_layout && shape.text.is_none() {
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message: "shape elements without text do not support `text_layout` updates"
.to_string(),
});
}
if let Some(position) = args.position {
shape.frame = apply_partial_position(shape.frame, position);
}
@@ -2879,9 +2939,9 @@ impl PresentationArtifactManager {
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| args.fit.is_some()
|| args.crop.is_some()
|| args.lock_aspect_ratio.is_some()
|| has_position_transform
|| has_image_fields
|| has_text_layout
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
@@ -2914,19 +2974,7 @@ impl PresentationArtifactManager {
}
}
PresentationElement::Image(image) => {
let position_rotation = args
.position
.as_ref()
.and_then(|position| position.rotation);
let position_flip_horizontal = args
.position
.as_ref()
.and_then(|position| position.flip_horizontal);
let position_flip_vertical = args
.position
.as_ref()
.and_then(|position| position.flip_vertical);
if args.fill.is_some() || args.stroke.is_some() {
if args.fill.is_some() || args.stroke.is_some() || has_text_layout {
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
message:
@@ -2965,6 +3013,9 @@ impl PresentationArtifactManager {
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| has_position_transform
|| has_image_fields
|| has_text_layout
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,
@@ -2982,6 +3033,9 @@ impl PresentationArtifactManager {
|| args.rotation.is_some()
|| args.flip_horizontal.is_some()
|| args.flip_vertical.is_some()
|| has_position_transform
|| has_image_fields
|| has_text_layout
{
return Err(PresentationArtifactError::UnsupportedFeature {
action: request.action,

View File

@@ -132,6 +132,44 @@ impl HyperlinkTarget {
fn is_external(&self) -> bool {
matches!(self, Self::Url(_) | Self::Email { .. } | Self::File(_))
}
fn adjust_for_insert(&mut self, inserted_index: usize) {
if let Self::Slide(slide_index) = self
&& *slide_index as usize >= inserted_index
{
*slide_index += 1;
}
}
fn adjust_for_move(&mut self, from_index: usize, to_index: usize) {
let Self::Slide(slide_index_ref) = self else {
return;
};
let slide_index = *slide_index_ref as usize;
*slide_index_ref = if slide_index == from_index {
to_index as u32
} else if from_index < to_index && (from_index + 1..=to_index).contains(&slide_index) {
(slide_index - 1) as u32
} else if to_index < from_index && (to_index..from_index).contains(&slide_index) {
(slide_index + 1) as u32
} else {
*slide_index_ref
};
}
fn adjust_for_delete(&mut self, deleted_index: usize) -> bool {
let Self::Slide(slide_index_ref) = self else {
return false;
};
let slide_index = *slide_index_ref as usize;
if slide_index == deleted_index {
return true;
}
if slide_index > deleted_index {
*slide_index_ref -= 1;
}
false
}
}
impl HyperlinkState {
@@ -217,6 +255,18 @@ impl HyperlinkState {
ppt_rs::escape_xml(&self.target.relationship_target()),
)
}
fn adjust_for_insert(&mut self, inserted_index: usize) {
self.target.adjust_for_insert(inserted_index);
}
fn adjust_for_move(&mut self, from_index: usize, to_index: usize) {
self.target.adjust_for_move(from_index, to_index);
}
fn adjust_for_delete(&mut self, deleted_index: usize) -> bool {
self.target.adjust_for_delete(deleted_index)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -714,6 +764,7 @@ impl PresentationDocument {
}
Some(_) => {}
}
self.adjust_hyperlinks_for_insert(inserted_index);
}
fn adjust_active_slide_for_move(&mut self, from_index: usize, to_index: usize) {
@@ -728,6 +779,7 @@ impl PresentationDocument {
active_index
});
}
self.adjust_hyperlinks_for_move(from_index, to_index);
}
fn adjust_active_slide_for_delete(&mut self, deleted_index: usize) {
@@ -740,6 +792,36 @@ impl PresentationDocument {
Some(active_index) if deleted_index < active_index => Some(active_index - 1),
Some(active_index) => Some(active_index),
};
self.adjust_hyperlinks_for_delete(deleted_index);
}
fn adjust_hyperlinks_for_insert(&mut self, inserted_index: usize) {
self.visit_hyperlinks_mut(|hyperlink| {
hyperlink.adjust_for_insert(inserted_index);
true
});
}
fn adjust_hyperlinks_for_move(&mut self, from_index: usize, to_index: usize) {
self.visit_hyperlinks_mut(|hyperlink| {
hyperlink.adjust_for_move(from_index, to_index);
true
});
}
fn adjust_hyperlinks_for_delete(&mut self, deleted_index: usize) {
self.visit_hyperlinks_mut(|hyperlink| !hyperlink.adjust_for_delete(deleted_index));
}
fn visit_hyperlinks_mut<F>(&mut self, mut visit: F)
where
F: FnMut(&mut HyperlinkState) -> bool,
{
for slide in &mut self.slides {
for element in &mut slide.elements {
element.visit_hyperlinks_mut(&mut visit);
}
}
}
fn next_layout_id(&mut self) -> String {
@@ -1053,6 +1135,38 @@ fn import_pptx_images(
Ok(())
}
fn import_pptx_slide_size(path: &Path) -> Result<Option<Rect>, PresentationArtifactError> {
let file = std::fs::File::open(path).map_err(|error| PresentationArtifactError::ImportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let mut archive =
ZipArchive::new(file).map_err(|error| PresentationArtifactError::ImportFailed {
path: path.to_path_buf(),
message: error.to_string(),
})?;
let Some(xml) = zip_entry_string_if_exists(&mut archive, "ppt/presentation.xml").map_err(
|message| PresentationArtifactError::ImportFailed {
path: path.to_path_buf(),
message,
},
)?
else {
return Ok(None);
};
let Some(width) =
xml_tag_attribute(&xml, "<p:sldSz", "cx").and_then(|value| value.parse::<u32>().ok())
else {
return Ok(None);
};
let Some(height) =
xml_tag_attribute(&xml, "<p:sldSz", "cy").and_then(|value| value.parse::<u32>().ok())
else {
return Ok(None);
};
Ok(Some(Rect::from_emu(0, 0, width, height)))
}
fn zip_entry_string_if_exists<R: Read + Seek>(
archive: &mut ZipArchive<R>,
path: &str,
@@ -1479,6 +1593,45 @@ impl PresentationElement {
Self::Chart(element) => element.z_order = z_order,
}
}
fn visit_hyperlinks_mut<F>(&mut self, visit: &mut F)
where
F: FnMut(&mut HyperlinkState) -> bool,
{
match self {
Self::Text(element) => {
if let Some(hyperlink) = element.hyperlink.as_mut()
&& !visit(hyperlink)
{
element.hyperlink = None;
}
for range in &mut element.rich_text.ranges {
if let Some(hyperlink) = range.hyperlink.as_mut()
&& !visit(hyperlink)
{
range.hyperlink = None;
}
}
}
Self::Shape(element) => {
if let Some(hyperlink) = element.hyperlink.as_mut()
&& !visit(hyperlink)
{
element.hyperlink = None;
}
if let Some(rich_text) = element.rich_text.as_mut() {
for range in &mut rich_text.ranges {
if let Some(hyperlink) = range.hyperlink.as_mut()
&& !visit(hyperlink)
{
range.hyperlink = None;
}
}
}
}
Self::Connector(_) | Self::Image(_) | Self::Table(_) | Self::Chart(_) => {}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -1092,8 +1092,7 @@ fn export_text_body_xml(
let top_inset = points_to_emu(insets.top);
let bottom_inset = points_to_emu(insets.bottom);
format!(
// codespell:ignore lIns,rIns,tIns,bIns
r#"<p:txBody><a:bodyPr wrap="{wrap}" lIns="{left_inset}" rIns="{right_inset}" tIns="{top_inset}" bIns="{bottom_inset}" anchor="{anchor}">{auto_fit}</a:bodyPr><a:lstStyle/>{paragraphs}</p:txBody>"#
r#"<p:txBody><a:bodyPr wrap="{wrap}" lIns="{left_inset}" rIns="{right_inset}" tIns="{top_inset}" bIns="{bottom_inset}" anchor="{anchor}">{auto_fit}</a:bodyPr><a:lstStyle/>{paragraphs}</p:txBody>"# // codespell:ignore lIns,rIns,tIns,bIns
)
}
@@ -1145,8 +1144,7 @@ fn export_table_cell_text_body_xml(
let top_inset = points_to_emu(insets.top);
let bottom_inset = points_to_emu(insets.bottom);
format!(
// codespell:ignore lIns,rIns,tIns,bIns
r#" lIns="{left_inset}" rIns="{right_inset}" tIns="{top_inset}" bIns="{bottom_inset}""#,
r#" lIns="{left_inset}" rIns="{right_inset}" tIns="{top_inset}" bIns="{bottom_inset}""#, // codespell:ignore lIns,rIns,tIns,bIns
)
});
let paragraphs = export_text_paragraphs_xml(

View File

@@ -153,7 +153,7 @@ fn render_slide_image(
}
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| (preview_render_layer(element), element.z_order()));
ordered.sort_by_key(|element| element.z_order());
for element in ordered {
render_element(&mut canvas, document, slide, element, render_scale)?;
}
@@ -682,38 +682,41 @@ fn render_bar_chart(
height: u32,
scale: f32,
) {
let baseline = top + height;
draw_chart_axes(image, left, top, width, height);
let (min_value, max_value) = chart_value_domain(chart);
let baseline = chart_axis_y(top, height, min_value, max_value, 0.0);
draw_chart_axes(image, left, top, width, height, baseline);
let series_count = chart.series.len().max(1) as u32;
let category_count = chart.categories.len().max(1) as u32;
let max_value = chart
.series
.iter()
.flat_map(|series| series.values.iter().copied())
.fold(0.0f64, f64::max)
.max(1.0);
let group_width = width as f32 / category_count as f32;
let bar_width = (group_width / series_count as f32 * 0.72).max(2.0);
for (series_index, series) in chart.series.iter().enumerate() {
let color = chart_series_color(series, series_index);
for (value_index, value) in series.values.iter().enumerate() {
let bar_height = ((*value / max_value) as f32 * height as f32).round().max(0.0) as u32;
let x = left as f32
+ group_width * value_index as f32
+ (group_width - bar_width * series_count as f32) / 2.0
+ bar_width * series_index as f32;
let y = baseline.saturating_sub(bar_height);
let value_y = chart_axis_y(top, height, min_value, max_value, *value);
let y = value_y.min(baseline);
let bar_height = value_y.abs_diff(baseline).max(1);
fill_rect(
image,
x.round() as u32,
y,
bar_width.round().max(1.0) as u32,
bar_height.max(1),
bar_height,
color,
);
}
}
render_category_labels(image, &chart.categories, left, baseline + 4, width, scale);
render_category_labels(
image,
&chart.categories,
left,
top + height + 4,
width,
scale,
);
}
fn render_line_chart(
@@ -725,21 +728,16 @@ fn render_line_chart(
height: u32,
scale: f32,
) {
draw_chart_axes(image, left, top, width, height);
let baseline = top + height;
let (min_value, max_value) = chart_value_domain(chart);
let baseline = chart_axis_y(top, height, min_value, max_value, 0.0);
draw_chart_axes(image, left, top, width, height, baseline);
let point_count = chart.categories.len().max(2);
let max_value = chart
.series
.iter()
.flat_map(|series| series.values.iter().copied())
.fold(0.0f64, f64::max)
.max(1.0);
for (series_index, series) in chart.series.iter().enumerate() {
let color = chart_series_color(series, series_index);
let mut points = Vec::new();
for (value_index, value) in series.values.iter().enumerate() {
let x = left as f32 + width as f32 * value_index as f32 / (point_count - 1) as f32;
let y = baseline as f32 - ((*value / max_value) as f32 * height as f32);
let y = chart_axis_y(top, height, min_value, max_value, *value) as f32;
points.push((x, y));
}
if points.len() >= 2
@@ -770,7 +768,38 @@ fn render_line_chart(
);
}
}
render_category_labels(image, &chart.categories, left, baseline + 4, width, scale);
render_category_labels(
image,
&chart.categories,
left,
top + height + 4,
width,
scale,
);
}
fn chart_value_domain(chart: &ChartElement) -> (f64, f64) {
let mut min_value = 0.0f64;
let mut max_value = 0.0f64;
for value in chart
.series
.iter()
.flat_map(|series| series.values.iter().copied())
{
min_value = min_value.min(value);
max_value = max_value.max(value);
}
if (max_value - min_value).abs() < f64::EPSILON {
max_value += 1.0;
}
(min_value, max_value)
}
fn chart_axis_y(top: u32, height: u32, min_value: f64, max_value: f64, value: f64) -> u32 {
let span = (max_value - min_value).max(f64::EPSILON);
let normalized = ((value - min_value) / span).clamp(0.0, 1.0);
let bottom = top + height;
bottom.saturating_sub((normalized * height as f64).round() as u32)
}
fn render_pie_chart(
@@ -1675,17 +1704,6 @@ fn parse_rgba(hex: &str, alpha: u8) -> Rgba<u8> {
Rgba([0, 0, 0, alpha])
}
fn preview_render_layer(element: &PresentationElement) -> usize {
match element {
PresentationElement::Text(_)
| PresentationElement::Shape(_)
| PresentationElement::Connector(_)
| PresentationElement::Image(ImageElement { payload: None, .. }) => 0,
PresentationElement::Image(_) | PresentationElement::Chart(_) => 1,
PresentationElement::Table(_) => 2,
}
}
fn shape_path(
geometry: ShapeGeometry,
width: u32,
@@ -2161,10 +2179,17 @@ fn draw_vertical_line(image: &mut RgbaImage, x: u32, color: Rgba<u8>, thickness:
fill_rect(image, x.saturating_sub(thickness / 2), 0, thickness.max(1), image.height(), color);
}
fn draw_chart_axes(image: &mut RgbaImage, left: u32, top: u32, width: u32, height: u32) {
fn draw_chart_axes(
image: &mut RgbaImage,
left: u32,
top: u32,
width: u32,
plot_height: u32,
baseline: u32,
) {
let axis_color = parse_rgba(DEFAULT_TABLE_GRID_HEX, 255);
fill_rect(image, left, top, 1, height, axis_color);
fill_rect(image, left, top + height, width, 1, axis_color);
fill_rect(image, left, top, 1, plot_height, axis_color);
fill_rect(image, left, baseline, width, 1, axis_color);
}
fn render_category_labels(

View File

@@ -230,54 +230,12 @@ fn load_image_payload_from_uri(
uri: &str,
action: &str,
) -> Result<ImagePayload, PresentationArtifactError> {
let response =
reqwest::blocking::get(uri).map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: {error}"),
})?;
let status = response.status();
if !status.is_success() {
return Err(PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to fetch image `{uri}`: HTTP {status}"),
});
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim().to_string());
let bytes = response
.bytes()
.map_err(|error| PresentationArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("failed to read image `{uri}`: {error}"),
})?;
build_image_payload(
bytes.to_vec(),
infer_remote_image_filename(uri, content_type.as_deref()),
action,
)
}
fn infer_remote_image_filename(uri: &str, content_type: Option<&str>) -> String {
let path_name = reqwest::Url::parse(uri)
.ok()
.and_then(|url| {
url.path_segments()
.and_then(Iterator::last)
.map(str::to_owned)
})
.filter(|segment| !segment.is_empty());
match (path_name, content_type) {
(Some(path_name), _) if Path::new(&path_name).extension().is_some() => path_name,
(Some(path_name), Some(content_type)) => {
format!("{path_name}.{}", image_extension_from_mime(content_type))
}
(Some(path_name), None) => path_name,
(None, Some(content_type)) => format!("image.{}", image_extension_from_mime(content_type)),
(None, None) => "image.png".to_string(),
}
Err(PresentationArtifactError::UnsupportedFeature {
action: action.to_string(),
message: format!(
"remote image URIs are not supported for `{action}`; download the image locally or provide `data_url`/`blob` instead (`{uri}`)"
),
})
}
fn build_image_payload(

File diff suppressed because it is too large Load Diff