mirror of
https://github.com/openai/codex.git
synced 2026-09-14 11:57:03 +00:00
go further 2
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user