feat: massive improvement

This commit is contained in:
jif-oai
2026-03-03 17:29:40 +00:00
parent 9047e4dc6f
commit 57a13ada75
15 changed files with 5045 additions and 280 deletions
+2
View File
@@ -849,6 +849,7 @@
"foldhash_0.1.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}",
"foldhash_0.2.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"ahash\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"fxhash\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"hashbrown\",\"req\":\"^0.15\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rapidhash\",\"req\":\"^3.1.0\"},{\"kind\":\"dev\",\"name\":\"uuid\",\"req\":\"^1.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}",
"font8x8_0.3.1": "{\"dependencies\":[],\"features\":{\"default\":[\"unicode\",\"std\"],\"std\":[],\"unicode\":[]}}",
"fontdue_0.9.3": "{\"dependencies\":[{\"name\":\"hashbrown\",\"optional\":true,\"req\":\"^0.15\"},{\"name\":\"rayon\",\"optional\":true,\"req\":\"^1.10\"},{\"default_features\":false,\"features\":[\"opentype-layout\"],\"name\":\"ttf-parser\",\"req\":\"^0.21\"}],\"features\":{\"default\":[\"simd\",\"hashbrown\"],\"parallel\":[\"rayon\",\"hashbrown\",\"hashbrown/rayon\"],\"simd\":[],\"std\":[]}}",
"foreign-types-shared_0.1.1": "{\"dependencies\":[],\"features\":{}}",
"foreign-types_0.3.2": "{\"dependencies\":[{\"name\":\"foreign-types-shared\",\"req\":\"^0.1\"}],\"features\":{}}",
"form_urlencoded_1.2.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"percent-encoding\",\"req\":\"^2.3.0\"}],\"features\":{\"alloc\":[\"percent-encoding/alloc\"],\"default\":[\"std\"],\"std\":[\"alloc\",\"percent-encoding/std\"]}}",
@@ -1420,6 +1421,7 @@
"try-lock_0.2.5": "{\"dependencies\":[],\"features\":{}}",
"ts-rs-macros_11.1.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\",\"extra-traits\"],\"name\":\"syn\",\"req\":\"^2.0.28\"},{\"name\":\"termcolor\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"no-serde-warnings\":[],\"serde-compat\":[\"termcolor\"]}}",
"ts-rs_11.1.0": "{\"dependencies\":[{\"features\":[\"serde\"],\"name\":\"bigdecimal\",\"optional\":true,\"req\":\">=0.0.13, <0.5\"},{\"name\":\"bson\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"serde\"],\"kind\":\"dev\",\"name\":\"chrono\",\"req\":\"^0.4\"},{\"name\":\"dprint-plugin-typescript\",\"optional\":true,\"req\":\"=0.95\"},{\"name\":\"heapless\",\"optional\":true,\"req\":\">=0.7, <0.9\"},{\"name\":\"indexmap\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ordered-float\",\"optional\":true,\"req\":\">=3, <6\"},{\"name\":\"semver\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"name\":\"smol_str\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\"],\"name\":\"tokio\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"sync\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.40\"},{\"name\":\"ts-rs-macros\",\"req\":\"=11.1.0\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"}],\"features\":{\"bigdecimal-impl\":[\"bigdecimal\"],\"bson-uuid-impl\":[\"bson\"],\"bytes-impl\":[\"bytes\"],\"chrono-impl\":[\"chrono\"],\"default\":[\"serde-compat\"],\"format\":[\"dprint-plugin-typescript\"],\"heapless-impl\":[\"heapless\"],\"import-esm\":[],\"indexmap-impl\":[\"indexmap\"],\"no-serde-warnings\":[\"ts-rs-macros/no-serde-warnings\"],\"ordered-float-impl\":[\"ordered-float\"],\"semver-impl\":[\"semver\"],\"serde-compat\":[\"ts-rs-macros/serde-compat\"],\"serde-json-impl\":[\"serde_json\"],\"smol_str-impl\":[\"smol_str\"],\"tokio-impl\":[\"tokio\"],\"url-impl\":[\"url\"],\"uuid-impl\":[\"uuid\"]}}",
"ttf-parser_0.21.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22.1\"},{\"kind\":\"dev\",\"name\":\"pico-args\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"tiny-skia-path\",\"req\":\"^0.11.4\"},{\"kind\":\"dev\",\"name\":\"xmlwriter\",\"req\":\"^0.1\"}],\"features\":{\"apple-layout\":[],\"default\":[\"std\",\"opentype-layout\",\"apple-layout\",\"variable-fonts\",\"glyph-names\"],\"glyph-names\":[],\"gvar-alloc\":[\"std\"],\"opentype-layout\":[],\"std\":[],\"variable-fonts\":[]}}",
"two-face_0.5.1": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"cargo-lock\",\"req\":\"^10.1.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.44.3\"},{\"default_features\":false,\"features\":[\"read\"],\"kind\":\"dev\",\"name\":\"object\",\"req\":\"^0.36.7\"},{\"name\":\"serde\",\"req\":\"^1.0.228\"},{\"name\":\"serde_derive\",\"req\":\"^1.0.228\"},{\"kind\":\"dev\",\"name\":\"similar\",\"req\":\"^2.7.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"strum\",\"req\":\"^0.26.3\"},{\"default_features\":false,\"features\":[\"dump-load\",\"parsing\"],\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"default_features\":false,\"features\":[\"html\"],\"kind\":\"dev\",\"name\":\"syntect\",\"req\":\"^5.3.0\"},{\"kind\":\"dev\",\"name\":\"toml\",\"req\":\"^0.8.23\"},{\"default_features\":false,\"features\":[\"std\",\"xxhash64\"],\"kind\":\"dev\",\"name\":\"twox-hash\",\"req\":\"^2.1.2\"}],\"features\":{\"default\":[\"syntect-onig\"],\"syntect-default-fancy\":[\"syntect-fancy\",\"syntect/default-fancy\"],\"syntect-default-onig\":[\"syntect-onig\",\"syntect/default-onig\"],\"syntect-fancy\":[\"syntect/regex-fancy\"],\"syntect-onig\":[\"syntect/regex-onig\"]}}",
"type-map_0.5.1": "{\"dependencies\":[{\"name\":\"rustc-hash\",\"req\":\"^2\"}],\"features\":{}}",
"typenum_1.19.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"scale-info\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"const-generics\":[],\"force_unix_path_separator\":[],\"i128\":[],\"no_std\":[],\"scale_info\":[\"scale-info/derive\"],\"strict\":[]}}",
+17
View File
@@ -1552,6 +1552,7 @@ version = "0.0.0"
dependencies = [
"base64 0.22.1",
"font8x8",
"fontdue",
"image",
"ppt-rs",
"pretty_assertions",
@@ -4102,6 +4103,16 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "875488b8711a968268c7cf5d139578713097ca4635a76044e8fe8eedf831d07e"
[[package]]
name = "fontdue"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e57e16b3fe8ff4364c0661fdaac543fb38b29ea9bc9c2f45612d90adf931d2b"
dependencies = [
"hashbrown 0.15.5",
"ttf-parser",
]
[[package]]
name = "foreign-types"
version = "0.3.2"
@@ -10568,6 +10579,12 @@ dependencies = [
"termcolor",
]
[[package]]
name = "ttf-parser"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8"
[[package]]
name = "tungstenite"
version = "0.27.0"
+1
View File
@@ -219,6 +219,7 @@ owo-colors = "4.3.0"
path-absolutize = "3.1.1"
pathdiff = "0.2"
font8x8 = "0.3.1"
fontdue = "0.9.3"
tiny-skia = "0.11.4"
portable-pty = "0.9.0"
ppt-rs = "0.2.6"
@@ -14,6 +14,7 @@ workspace = true
[dependencies]
base64 = { workspace = true }
font8x8 = { workspace = true }
fontdue = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
ppt-rs = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] }
@@ -4,9 +4,6 @@ use image::GenericImageView;
use image::ImageFormat;
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use ppt_rs::Chart;
use ppt_rs::ChartSeries;
use ppt_rs::ChartType;
use ppt_rs::Hyperlink as PptHyperlink;
use ppt_rs::HyperlinkAction as PptHyperlinkAction;
use ppt_rs::Image;
@@ -307,7 +307,7 @@ impl PresentationArtifactManager {
document,
slide_index,
RenderOptions {
scale: 1.0,
scale,
include_background: true,
},
)?;
@@ -315,7 +315,7 @@ impl PresentationArtifactManager {
&png_bytes,
&output_path,
preview_format,
scale,
1.0,
quality,
&request.action,
)?;
@@ -332,7 +332,7 @@ impl PresentationArtifactManager {
document,
slide_index,
RenderOptions {
scale: 1.0,
scale,
include_background: true,
},
)?;
@@ -342,7 +342,7 @@ impl PresentationArtifactManager {
&png_bytes,
&target,
preview_format,
scale,
1.0,
quality,
&request.action,
)?;
@@ -85,14 +85,14 @@ struct NamedTextStyle {
built_in: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct HyperlinkState {
target: HyperlinkTarget,
tooltip: Option<String>,
highlight_click: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
enum HyperlinkTarget {
Url(String),
Slide(u32),
@@ -926,16 +926,16 @@ impl PresentationDocument {
})
}
fn to_ppt_rs(&self) -> Presentation {
fn to_ppt_rs(&self) -> Result<Presentation, PresentationArtifactError> {
let mut presentation = self
.name
.as_deref()
.map(Presentation::with_title)
.unwrap_or_default();
for slide in &self.slides {
presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size));
presentation = presentation.add_slide(slide.to_ppt_rs(self.slide_size, self)?);
}
presentation
Ok(presentation)
}
}
@@ -1223,7 +1223,11 @@ fn xml_attribute(tag: &str, attribute: &str) -> Option<String> {
}
impl PresentationSlide {
fn to_ppt_rs(&self, slide_size: Rect) -> SlideContent {
fn to_ppt_rs(
&self,
slide_size: Rect,
document: &PresentationDocument,
) -> Result<SlideContent, PresentationArtifactError> {
let mut content = SlideContent::new("").layout(SlideLayout::Blank);
if self.notes.visible && !self.notes.text.is_empty() {
content = content.notes(&self.notes.text);
@@ -1391,24 +1395,22 @@ impl PresentationSlide {
content = content.table(builder.build());
}
PresentationElement::Chart(chart) => {
let mut ppt_chart = Chart::new(
chart.title.as_deref().unwrap_or("Chart"),
chart.chart_type.to_ppt_rs(),
chart.categories,
points_to_emu(chart.frame.left),
points_to_emu(chart.frame.top),
let chart_bytes = render_chart_png_bytes(document, &chart)?;
let ppt_chart = Image::from_bytes(
chart_bytes,
points_to_emu(chart.frame.width),
points_to_emu(chart.frame.height),
"png",
)
.position(
points_to_emu(chart.frame.left),
points_to_emu(chart.frame.top),
);
for series in chart.series {
ppt_chart =
ppt_chart.add_series(ChartSeries::new(&series.name, series.values));
}
content = content.add_chart(ppt_chart);
content = content.add_image(ppt_chart);
}
}
}
content
Ok(content)
}
}
@@ -1724,34 +1726,6 @@ enum ChartTypeSpec {
Combo,
}
impl ChartTypeSpec {
fn to_ppt_rs(self) -> ChartType {
match self {
Self::Bar => ChartType::Bar,
Self::BarHorizontal => ChartType::BarHorizontal,
Self::BarStacked => ChartType::BarStacked,
Self::BarStacked100 => ChartType::BarStacked100,
Self::Line => ChartType::Line,
Self::LineMarkers => ChartType::LineMarkers,
Self::LineStacked => ChartType::LineStacked,
Self::Pie => ChartType::Pie,
Self::Doughnut => ChartType::Doughnut,
Self::Area => ChartType::Area,
Self::AreaStacked => ChartType::AreaStacked,
Self::AreaStacked100 => ChartType::AreaStacked100,
Self::Scatter => ChartType::Scatter,
Self::ScatterLines => ChartType::ScatterLines,
Self::ScatterSmooth => ChartType::ScatterSmooth,
Self::Bubble => ChartType::Bubble,
Self::Radar => ChartType::Radar,
Self::RadarFilled => ChartType::RadarFilled,
Self::StockHlc => ChartType::StockHLC,
Self::StockOhlc => ChartType::StockOHLC,
Self::Combo => ChartType::Combo,
}
}
}
impl ConnectorKind {
fn to_ppt_rs(self) -> ConnectorType {
match self {
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,11 @@
use font8x8::BASIC_FONTS;
use font8x8::UnicodeFonts;
use fontdue::Font;
use image::DynamicImage;
use image::Rgba;
use image::RgbaImage;
use std::f32::consts::PI;
use std::sync::{Arc, Mutex, OnceLock};
use tiny_skia::FillRule;
use tiny_skia::LineCap;
use tiny_skia::LineJoin;
@@ -22,6 +24,19 @@ const DEFAULT_TABLE_GRID_HEX: &str = "BFC5CC";
const DEFAULT_COMMENT_HEX: &str = "F3C83C";
const DEFAULT_TEXT_INSET: u32 = 6;
const MIN_TEXT_PIXELS: u32 = 8;
const PREVIEW_SUPERSAMPLE_SCALE: f32 = 2.0;
const CHART_EXPORT_SCALE: f32 = 2.0;
const DEFAULT_PREVIEW_FONT_SIZE_RATIO: f32 = 0.85;
const PREVIEW_FONT_DIRS: [&str; 3] = [
"/Library/Fonts",
"/System/Library/Fonts/Supplemental",
"/System/Library/Fonts",
];
const PREVIEW_FONT_FALLBACKS: [&str; 5] =
["Arial", "Helvetica", "Avenir", "Times", "Courier"];
static PREVIEW_FONT_CACHE: OnceLock<Mutex<std::collections::HashMap<String, Option<Arc<Font>>>>> =
OnceLock::new();
#[derive(Debug, Clone, Copy)]
pub(crate) struct RenderOptions {
@@ -47,6 +62,7 @@ struct TextRenderConfig {
width: u32,
font_px: u32,
line_height: u32,
baseline_offset: u32,
wrap: TextWrapMode,
vertical_alignment: TextVerticalAlignment,
alignment: TextAlignment,
@@ -72,12 +88,57 @@ fn render_slide_png(
});
}
let target_width = scaled_dimension(document.slide_size.width, options.scale);
let target_height = scaled_dimension(document.slide_size.height, options.scale);
let render_scale = options.scale * PREVIEW_SUPERSAMPLE_SCALE;
let mut canvas = render_slide_image(document, slide_index, options, render_scale)?;
if PREVIEW_SUPERSAMPLE_SCALE > 1.0 && (canvas.width() != target_width || canvas.height() != target_height)
{
canvas = image::imageops::resize(
&canvas,
target_width.max(1),
target_height.max(1),
image::imageops::FilterType::Lanczos3,
);
}
let mut output = std::io::Cursor::new(Vec::new());
DynamicImage::ImageRgba8(canvas)
.write_to(&mut output, image::ImageFormat::Png)
.map_err(|error| PresentationArtifactError::RenderFailed {
action: "render_preview".to_string(),
message: error.to_string(),
})?;
Ok(output.into_inner())
}
fn render_chart_png_bytes(
document: &PresentationDocument,
chart: &ChartElement,
) -> Result<Vec<u8>, PresentationArtifactError> {
let rendered = render_chart_element(chart, document, CHART_EXPORT_SCALE)?;
let mut output = std::io::Cursor::new(Vec::new());
DynamicImage::ImageRgba8(rendered)
.write_to(&mut output, image::ImageFormat::Png)
.map_err(|error| PresentationArtifactError::RenderFailed {
action: "export_pptx".to_string(),
message: error.to_string(),
})?;
Ok(output.into_inner())
}
fn render_slide_image(
document: &PresentationDocument,
slide_index: usize,
options: RenderOptions,
render_scale: f32,
) -> Result<RgbaImage, PresentationArtifactError> {
let slide = document
.slides
.get(slide_index)
.ok_or_else(|| index_out_of_range("render_preview", slide_index, document.slides.len()))?;
let width = scaled_dimension(document.slide_size.width, options.scale);
let height = scaled_dimension(document.slide_size.height, options.scale);
let width = scaled_dimension(document.slide_size.width, render_scale);
let height = scaled_dimension(document.slide_size.height, render_scale);
let mut canvas = RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 0]));
if options.include_background {
@@ -94,19 +155,11 @@ fn render_slide_png(
let mut ordered = slide.elements.iter().collect::<Vec<_>>();
ordered.sort_by_key(|element| element.z_order());
for element in ordered {
render_element(&mut canvas, document, slide, element, options.scale)?;
render_element(&mut canvas, document, slide, element, render_scale)?;
}
render_comment_overlays(&mut canvas, document, slide, options.scale);
let mut output = std::io::Cursor::new(Vec::new());
DynamicImage::ImageRgba8(canvas)
.write_to(&mut output, image::ImageFormat::Png)
.map_err(|error| PresentationArtifactError::RenderFailed {
action: "render_preview".to_string(),
message: error.to_string(),
})?;
Ok(output.into_inner())
render_comment_overlays(&mut canvas, document, slide, render_scale);
Ok(canvas)
}
fn render_element(
@@ -776,9 +829,30 @@ fn render_chart_legend(
if cursor >= left + width {
break;
}
fill_rect(image, cursor, top + (height.saturating_sub(swatch)) / 2, swatch, swatch, chart_series_color(series, index));
fill_rect(
image,
cursor,
top + (height.saturating_sub(swatch)) / 2,
swatch,
swatch,
chart_series_color(series, index),
);
cursor += swatch + scaled_dimension(4, scale);
let label_width = scaled_dimension((series.name.chars().count() as u32).saturating_mul(7), scale).max(40);
let label_style = TextStyle {
font_size: Some(12),
color: Some(DEFAULT_TEXT_HEX.to_string()),
..TextStyle::default()
};
let label_rich_text = RichTextState {
layout: TextLayoutState {
vertical_alignment: Some(TextVerticalAlignment::Middle),
..TextLayoutState::default()
},
..RichTextState::default()
};
let label_width = measure_text_width(&series.name, &label_style, &label_rich_text, None, scale)
.saturating_add(scaled_dimension(8, scale))
.max(40);
draw_text_in_bounds(
image,
TextBounds {
@@ -788,18 +862,8 @@ fn render_chart_legend(
height,
},
&series.name,
&TextStyle {
font_size: Some(12),
color: Some(DEFAULT_TEXT_HEX.to_string()),
..TextStyle::default()
},
&RichTextState {
layout: TextLayoutState {
vertical_alignment: Some(TextVerticalAlignment::Middle),
..TextLayoutState::default()
},
..RichTextState::default()
},
&label_style,
&label_rich_text,
None,
scale,
);
@@ -807,6 +871,20 @@ fn render_chart_legend(
}
}
fn measure_text_width(
text: &str,
style: &TextStyle,
rich_text: &RichTextState,
hyperlink: Option<&HyperlinkState>,
scale: f32,
) -> u32 {
let font_px = scaled_dimension(style.font_size.unwrap_or(14), scale).max(MIN_TEXT_PIXELS);
styled_glyphs(text, style, rich_text, hyperlink)
.iter()
.map(|glyph| measure_glyph_width(glyph, font_px))
.sum()
}
fn render_comment_overlays(
canvas: &mut RgbaImage,
document: &PresentationDocument,
@@ -922,11 +1000,12 @@ fn draw_text_in_bounds(
let mut font_px = scaled_dimension(style.font_size.unwrap_or(14), scale).max(MIN_TEXT_PIXELS);
let auto_fit = rich_text.layout.auto_fit.unwrap_or(TextAutoFitMode::None);
let config = loop {
let line_height = ((font_px as f32) * 1.35).round().max(font_px as f32) as u32;
let (line_height, baseline_offset) = preview_line_metrics(style, font_px);
let config = TextRenderConfig {
width: content_width,
font_px,
line_height,
baseline_offset,
wrap,
vertical_alignment,
alignment,
@@ -972,7 +1051,8 @@ fn draw_text_in_bounds(
let mut x = start_x;
for glyph in &line.glyphs {
let glyph_width = draw_bitmap_glyph(image, x, y, glyph, config.font_px);
let glyph_width =
draw_bitmap_glyph(image, x, y, glyph, config.font_px, config.baseline_offset);
x = x.saturating_add(glyph_width);
if glyph.ch == ' ' && justify_gap > 0 {
x = x.saturating_add(justify_gap);
@@ -1087,6 +1167,20 @@ fn draw_bitmap_glyph(
top: u32,
glyph: &StyledGlyph,
font_px: u32,
baseline_offset: u32,
) -> u32 {
if let Some(font) = preview_font_for_style(&glyph.style) {
return draw_outline_glyph(image, left, top, glyph, font_px, baseline_offset, &font);
}
draw_bitmap_glyph_fallback(image, left, top, glyph, font_px)
}
fn draw_bitmap_glyph_fallback(
image: &mut RgbaImage,
left: u32,
top: u32,
glyph: &StyledGlyph,
font_px: u32,
) -> u32 {
let scale = (font_px / 8).max(1);
if glyph.ch == ' ' {
@@ -1122,6 +1216,30 @@ fn draw_bitmap_glyph(
}
fn measure_glyph_width(glyph: &StyledGlyph, font_px: u32) -> u32 {
if let Some(font) = preview_font_for_style(&glyph.style) {
return measure_outline_glyph_width(glyph, font_px, &font);
}
measure_bitmap_glyph_width(glyph, font_px)
}
fn preview_line_metrics(style: &TextStyle, font_px: u32) -> (u32, u32) {
if let Some(font) = preview_font_for_style(style)
&& let Some(metrics) = font.horizontal_line_metrics(font_px as f32)
{
return (
metrics.new_line_size.ceil().max(font_px as f32).max(1.0) as u32,
metrics.ascent.ceil().max(1.0) as u32,
);
}
(
((font_px as f32) * 1.35).round().max(font_px as f32) as u32,
((font_px as f32) * DEFAULT_PREVIEW_FONT_SIZE_RATIO)
.round()
.max(1.0) as u32,
)
}
fn measure_bitmap_glyph_width(glyph: &StyledGlyph, font_px: u32) -> u32 {
let scale = (font_px / 8).max(1);
match glyph.ch {
' ' => 4 * scale,
@@ -1130,6 +1248,207 @@ fn measure_glyph_width(glyph: &StyledGlyph, font_px: u32) -> u32 {
}
}
fn preview_font_for_style(style: &TextStyle) -> Option<Arc<Font>> {
let key = normalize_font_name(style.font_family.as_deref().unwrap_or("default"));
let cache = PREVIEW_FONT_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
if let Ok(cache) = cache.lock()
&& let Some(font) = cache.get(&key).cloned()
{
return font;
}
let font = load_preview_font(style.font_family.as_deref());
if let Ok(mut cache) = cache.lock() {
cache.insert(key, font.clone());
}
font
}
fn load_preview_font(requested_family: Option<&str>) -> Option<Arc<Font>> {
let mut candidates = requested_family
.into_iter()
.flat_map(preview_font_family_candidates)
.collect::<Vec<_>>();
candidates.extend(PREVIEW_FONT_FALLBACKS.iter().map(std::string::ToString::to_string));
for family in candidates {
if let Some(path) = find_system_font_path(&family)
&& let Some(font) = load_preview_font_from_path(&path)
{
return Some(font);
}
}
None
}
fn preview_font_family_candidates(family: &str) -> Vec<String> {
let normalized = normalize_font_name(family);
let mut candidates = vec![family.to_string()];
if normalized.contains("aptos") {
candidates.extend(["Arial", "Helvetica", "Avenir"].map(str::to_string));
} else if normalized.contains("timesnewroman") {
candidates.push("Times".to_string());
} else if normalized.contains("couriernew") {
candidates.push("Courier".to_string());
}
candidates
}
fn find_system_font_path(family: &str) -> Option<PathBuf> {
let needle = normalize_font_name(family);
let mut partial_match = None;
for directory in PREVIEW_FONT_DIRS {
let Ok(entries) = std::fs::read_dir(directory) else {
continue;
};
for entry in entries.filter_map(Result::ok) {
let path = entry.path();
if !supported_preview_font_path(&path) {
continue;
}
let Some(stem) = path.file_stem() else {
continue;
};
let stem = stem.to_string_lossy();
let normalized_stem = normalize_font_name(&stem);
if normalized_stem == needle {
return Some(path);
}
if partial_match.is_none() && normalized_stem.contains(&needle) {
partial_match = Some(path);
}
}
}
partial_match
}
fn supported_preview_font_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.map(|extension| matches!(extension.to_ascii_lowercase().as_str(), "ttf" | "otf"))
.unwrap_or(false)
}
fn normalize_font_name(name: &str) -> String {
name.chars()
.filter(char::is_ascii_alphanumeric)
.flat_map(char::to_lowercase)
.collect()
}
fn load_preview_font_from_path(path: &Path) -> Option<Arc<Font>> {
let bytes = std::fs::read(path).ok()?;
Font::from_bytes(bytes, fontdue::FontSettings::default())
.ok()
.map(Arc::new)
}
fn measure_outline_glyph_width(glyph: &StyledGlyph, font_px: u32, font: &Font) -> u32 {
let font_px = font_px.max(MIN_TEXT_PIXELS) as f32;
if glyph.ch == '\t' {
let space_metrics = font.metrics(' ', font_px);
return (space_metrics.advance_width.max(font_px / 3.0) * 4.0)
.ceil()
.max(1.0) as u32;
}
let metrics = font.metrics(glyph.ch, font_px);
metrics
.advance_width
.max(metrics.width as f32 + metrics.xmin.max(0) as f32)
.max(font_px / 3.0)
.ceil()
.max(1.0) as u32
}
fn draw_outline_glyph(
image: &mut RgbaImage,
left: u32,
top: u32,
glyph: &StyledGlyph,
font_px: u32,
baseline_offset: u32,
font: &Font,
) -> u32 {
let font_px = font_px.max(MIN_TEXT_PIXELS);
let width = measure_outline_glyph_width(glyph, font_px, font);
if glyph.ch == ' ' || glyph.ch == '\t' {
return width;
}
let (metrics, bitmap) = font.rasterize(glyph.ch, font_px as f32);
let baseline = top as i32 + baseline_offset as i32;
let color = glyph_color(&glyph.style, glyph.is_link);
blit_font_bitmap(image, left, baseline, glyph, &metrics, &bitmap, color);
if glyph.style.bold {
blit_font_bitmap(
image,
left.saturating_add(1),
baseline,
glyph,
&metrics,
&bitmap,
color,
);
}
if glyph.style.underline || glyph.is_link {
let underline_y = top.saturating_add(font_px).saturating_sub(font_px / 8 + 1);
fill_rect(image, left, underline_y, width.max(1), (font_px / 12).max(1), color);
}
width
}
fn blit_font_bitmap(
image: &mut RgbaImage,
left: u32,
baseline: i32,
glyph: &StyledGlyph,
metrics: &fontdue::Metrics,
bitmap: &[u8],
color: Rgba<u8>,
) {
if metrics.width == 0 || metrics.height == 0 {
return;
}
let origin_x = left as i32 + metrics.xmin;
let origin_y = baseline - metrics.height as i32 - metrics.ymin;
let italic_skew = if glyph.style.italic {
(metrics.height as f32 * 0.18).round().max(1.0) as i32
} else {
0
};
for row in 0..metrics.height {
let row_skew = if italic_skew > 0 {
(((metrics.height - row) as f32 / metrics.height as f32) * italic_skew as f32).round()
as i32
} else {
0
};
for column in 0..metrics.width {
let alpha = bitmap[row * metrics.width + column];
if alpha == 0 {
continue;
}
let dest_x = origin_x + column as i32 + row_skew;
let dest_y = origin_y + row as i32;
if dest_x < 0
|| dest_y < 0
|| dest_x >= image.width() as i32
|| dest_y >= image.height() as i32
{
continue;
}
let source = Rgba([
color.0[0],
color.0[1],
color.0[2],
((u16::from(alpha) * u16::from(color.0[3])) / 255) as u8,
]);
blend_pixel(image.get_pixel_mut(dest_x as u32, dest_y as u32), source);
}
}
}
fn glyph_color(style: &TextStyle, is_link: bool) -> Rgba<u8> {
if let Some(color) = &style.color {
return parse_rgba(color, 255);
+495 -1
View File
@@ -281,6 +281,408 @@ fn exported_images_are_real_pictures_with_media_parts() -> Result<(), Box<dyn st
Ok(())
}
#[test]
fn exported_charts_are_real_pictures_with_media_parts() -> 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": "Chart Export" }),
},
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(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(created.artifact_id.clone()),
action: "add_chart".to_string(),
args: serde_json::json!({
"slide_index": 0,
"chart_type": "bar",
"position": { "left": 48, "top": 64, "width": 320, "height": 220 },
"title": "Weekly Active Teams",
"categories": ["Week 1", "Week 2", "Week 3", "Week 4"],
"series": [
{ "name": "Actual", "values": [12, 18, 24, 31], "fill": "#2B5797" },
{ "name": "Target", "values": [15, 20, 26, 30], "fill": "#D97A00" }
]
}),
},
temp_dir.path(),
)?;
let export_path = temp_dir.path().join("charts-as-images.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 pptx_path = temp_dir.path().join("charts-as-images.pptx");
let slide_xml = zip_entry_text(&pptx_path, "ppt/slides/slide1.xml")?;
let rels_xml = zip_entry_text(&pptx_path, "ppt/slides/_rels/slide1.xml.rels")?;
let content_types_xml = zip_entry_text(&pptx_path, "[Content_Types].xml")?;
let entry_names = zip_entry_names(&pptx_path)?;
assert!(slide_xml.contains("<p:pic>"));
assert!(slide_xml.contains(r#"descr="Weekly Active Teams""#));
assert!(slide_xml.contains(r#"r:embed="rIdImage1""#));
assert!(!slide_xml.contains("<c:chart"));
assert!(rels_xml.contains("relationships/image"));
assert!(!rels_xml.contains("relationships/chart"));
assert!(content_types_xml.contains(r#"Extension="png" ContentType="image/png""#));
assert!(!content_types_xml.contains("drawingml.chart+xml"));
assert!(entry_names.contains(&"ppt/media/image1.png".to_string()));
assert!(
!entry_names
.iter()
.any(|name| name.starts_with("ppt/charts/"))
);
Ok(())
}
#[test]
fn exported_theme_uses_document_fonts_and_colors() -> 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": "Theme Export",
"theme": {
"color_scheme": {
"accent1": "#123456",
"bg1": "#FAF7F0",
"tx1": "#222222"
},
"major_font": "Aptos Display",
"minor_font": "Aptos"
}
}),
},
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("theme-export.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 theme_xml = zip_entry_text(
&temp_dir.path().join("theme-export.pptx"),
"ppt/theme/theme1.xml",
)?;
assert!(theme_xml.contains(r#"<a:majorFont>"#));
assert!(theme_xml.contains(r#"typeface="Aptos Display""#));
assert!(theme_xml.contains(r#"typeface="Aptos""#));
assert!(theme_xml.contains(r#"<a:accent1><a:srgbClr val="123456"/></a:accent1>"#));
assert!(theme_xml.contains(r#"<a:lt1><a:srgbClr val="FAF7F0"/></a:lt1>"#));
assert!(theme_xml.contains(r#"<a:dk1><a:srgbClr val="222222"/></a:dk1>"#));
Ok(())
}
#[test]
fn exported_text_shapes_preserve_text_styling() -> 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": "Styled Text Export",
"theme": {
"color_scheme": {},
"major_font": "Aptos Display",
"minor_font": "Aptos"
}
}),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id.clone();
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let added_text = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_text_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
"text": "Alpha Beta",
"position": { "left": 40, "top": 40, "width": 220, "height": 80 },
"font_size": 24,
"font_family": "Aptos",
"color": "#112233",
"alignment": "right",
"bold": true,
"text_layout": {
"vertical_alignment": "bottom",
"auto_fit": "shrinkText"
}
}),
},
temp_dir.path(),
)?;
let text_id = added_text
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.first())
.cloned()
.expect("text id");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "format_text_range".to_string(),
args: serde_json::json!({
"element_id": text_id,
"query": "Beta",
"link": {
"uri": "https://example.com/docs",
"is_external": true
},
"spacing_before": 12,
"spacing_after": 6,
"line_spacing": 1.5,
"styling": {
"italic": true,
"underline": true,
"color": "#445566"
}
}),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "add_shape".to_string(),
args: serde_json::json!({
"slide_index": 0,
"geometry": "rectangle",
"position": { "left": 300, "top": 60, "width": 180, "height": 100 },
"fill": "#2F6B5F",
"text": "Status",
"text_style": {
"font_family": "Aptos Display",
"font_size": 20,
"color": "#FFFFFF",
"alignment": "center",
"bold": true
},
"text_layout": {
"vertical_alignment": "middle"
}
}),
},
temp_dir.path(),
)?;
let export_path = temp_dir.path().join("styled-text-export.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_xml = zip_entry_text(
&temp_dir.path().join("styled-text-export.pptx"),
"ppt/slides/slide1.xml",
)?;
let rels_xml = zip_entry_text(
&temp_dir.path().join("styled-text-export.pptx"),
"ppt/slides/_rels/slide1.xml.rels",
)?;
assert!(slide_xml.contains(r#"anchor="b""#));
assert!(slide_xml.contains(r#"<a:normAutofit/>"#));
assert!(slide_xml.contains(r#"<a:pPr algn="r">"#));
assert!(slide_xml.contains(r#"<a:spcBef><a:spcPts val="1200"/></a:spcBef>"#));
assert!(slide_xml.contains(r#"<a:spcAft><a:spcPts val="600"/></a:spcAft>"#));
assert!(slide_xml.contains(r#"<a:lnSpc><a:spcPct val="150000"/></a:lnSpc>"#));
assert!(slide_xml.contains(r#"sz="2400""#));
assert!(slide_xml.contains(r#"b="1""#));
assert!(slide_xml.contains(r#"typeface="Aptos""#));
assert!(slide_xml.contains(r#"<a:srgbClr val="112233"/>"#));
assert!(slide_xml.contains(">Beta<"));
assert!(slide_xml.contains(r#"i="1""#));
assert!(slide_xml.contains(r#"u="sng""#));
assert!(slide_xml.contains(r#"<a:srgbClr val="445566"/>"#));
assert!(slide_xml.contains(r#"<a:hlinkClick r:id="rIdRange_"#));
assert!(slide_xml.contains(r#"typeface="Aptos Display""#));
assert!(slide_xml.contains(r#"<a:pPr algn="ctr">"#));
assert!(slide_xml.contains(r#"anchor="ctr""#));
assert!(slide_xml.contains(r#"<a:srgbClr val="FFFFFF"/>"#));
assert!(rels_xml.contains("relationships/hyperlink"));
assert!(rels_xml.contains(r#"Target="https://example.com/docs""#));
Ok(())
}
#[test]
fn exported_table_cells_preserve_rich_text_styling() -> 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": "Styled Table Export",
"theme": {
"color_scheme": {},
"major_font": "Aptos Display",
"minor_font": "Aptos"
}
}),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id.clone();
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({}),
},
temp_dir.path(),
)?;
let added_table = manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_table".to_string(),
args: serde_json::json!({
"slide_index": 0,
"position": { "left": 40, "top": 40, "width": 240, "height": 90 },
"rows": [["Alpha Beta"]]
}),
},
temp_dir.path(),
)?;
let table_id = added_table
.artifact_snapshot
.as_ref()
.and_then(|snapshot| snapshot.slides.first())
.and_then(|slide| slide.element_ids.first())
.cloned()
.expect("table id");
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "update_table_cell".to_string(),
args: serde_json::json!({
"element_id": table_id,
"row": 0,
"column": 0,
"value": "Alpha Beta",
"alignment": "right",
"styling": {
"font_family": "Aptos",
"font_size": 20,
"color": "#112233",
"bold": true
}
}),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "format_text_range".to_string(),
args: serde_json::json!({
"element_id": table_id,
"row": 0,
"column": 0,
"query": "Beta",
"link": {
"uri": "https://example.com/table",
"is_external": true
},
"spacing_before": 8,
"spacing_after": 4,
"line_spacing": 1.25,
"styling": {
"italic": true,
"underline": true,
"color": "#445566"
}
}),
},
temp_dir.path(),
)?;
let export_path = temp_dir.path().join("styled-table-export.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_xml = zip_entry_text(
&temp_dir.path().join("styled-table-export.pptx"),
"ppt/slides/slide1.xml",
)?;
let rels_xml = zip_entry_text(
&temp_dir.path().join("styled-table-export.pptx"),
"ppt/slides/_rels/slide1.xml.rels",
)?;
assert!(slide_xml.contains(r#"<a:pPr algn="r">"#));
assert!(slide_xml.contains(r#"<a:spcBef><a:spcPts val="800"/></a:spcBef>"#));
assert!(slide_xml.contains(r#"<a:spcAft><a:spcPts val="400"/></a:spcAft>"#));
assert!(slide_xml.contains(r#"<a:lnSpc><a:spcPct val="125000"/></a:lnSpc>"#));
assert!(slide_xml.contains(r#"sz="2000""#));
assert!(slide_xml.contains(r#"b="1""#));
assert!(slide_xml.contains(r#"typeface="Aptos""#));
assert!(slide_xml.contains(r#"<a:srgbClr val="112233"/>"#));
assert!(slide_xml.contains(">Beta<"));
assert!(slide_xml.contains(r#"i="1""#));
assert!(slide_xml.contains(r#"u="sng""#));
assert!(slide_xml.contains(r#"<a:srgbClr val="445566"/>"#));
assert!(slide_xml.contains(r#"<a:hlinkClick r:id="rIdRange_"#));
assert!(rels_xml.contains("relationships/hyperlink"));
assert!(rels_xml.contains(r#"Target="https://example.com/table""#));
Ok(())
}
#[test]
fn tool_request_accepts_sequential_actions() -> Result<(), Box<dyn std::error::Error>> {
let request: PresentationArtifactToolRequest = serde_json::from_value(serde_json::json!({
@@ -2205,6 +2607,95 @@ fn notes_visibility_controls_exported_notes() -> Result<(), Box<dyn std::error::
Ok(())
}
#[test]
fn exported_notes_preserve_rich_text_styling() -> 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": "Styled Notes Export",
"theme": {
"color_scheme": {
"tx1": "#222222"
},
"major_font": "Aptos Display",
"minor_font": "Aptos"
}
}),
},
temp_dir.path(),
)?;
let artifact_id = created.artifact_id.clone();
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id.clone()),
action: "add_slide".to_string(),
args: serde_json::json!({ "notes": "Alpha Beta" }),
},
temp_dir.path(),
)?;
manager.execute(
PresentationArtifactRequest {
artifact_id: Some(artifact_id),
action: "format_text_range".to_string(),
args: serde_json::json!({
"slide_index": 0,
"notes": true,
"query": "Beta",
"link": {
"uri": "https://example.com/notes",
"is_external": true
},
"spacing_before": 10,
"spacing_after": 5,
"line_spacing": 1.4,
"styling": {
"italic": true,
"underline": true,
"color": "#445566"
}
}),
},
temp_dir.path(),
)?;
let export_path = temp_dir.path().join("styled-notes-export.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 notes_xml = zip_entry_text(
&temp_dir.path().join("styled-notes-export.pptx"),
"ppt/notesSlides/notesSlide1.xml",
)?;
let rels_xml = zip_entry_text(
&temp_dir.path().join("styled-notes-export.pptx"),
"ppt/notesSlides/_rels/notesSlide1.xml.rels",
)?;
assert!(notes_xml.contains(r#"typeface="Aptos""#));
assert!(notes_xml.contains(r#"sz="1200""#));
assert!(notes_xml.contains(r#"<a:srgbClr val="222222"/>"#));
assert!(notes_xml.contains(">Beta<"));
assert!(notes_xml.contains(r#"i="1""#));
assert!(notes_xml.contains(r#"u="sng""#));
assert!(notes_xml.contains(r#"<a:srgbClr val="445566"/>"#));
assert!(notes_xml.contains(r#"<a:hlinkClick r:id="rIdRange_"#));
assert!(notes_xml.contains(r#"<a:spcBef><a:spcPts val="1000"/></a:spcBef>"#));
assert!(notes_xml.contains(r#"<a:spcAft><a:spcPts val="500"/></a:spcAft>"#));
assert!(notes_xml.contains(r#"<a:lnSpc><a:spcPct val="140000"/></a:lnSpc>"#));
assert!(rels_xml.contains("relationships/hyperlink"));
assert!(rels_xml.contains(r#"Target="https://example.com/notes""#));
Ok(())
}
#[test]
fn image_placeholders_and_anchor_updates_work() -> Result<(), Box<dyn std::error::Error>> {
let temp_dir = tempfile::tempdir()?;
@@ -3689,7 +4180,10 @@ fn render_preview_renders_image_and_table_content() -> Result<(), Box<dyn std::e
)?;
assert_eq!(preview.get_pixel(80, 80).0, [0x2A, 0x80, 0xD7, 0xFF]);
assert_eq!(preview.get_pixel(270, 70).0, [0xEA, 0xF0, 0xF8, 0xFF]);
assert!(
rect_contains_pixel(&preview, 260, 72, 40, 24, [0xEA, 0xF0, 0xF8, 0xFF]),
"expected to find the table header fill color in the rendered preview"
);
Ok(())
}
+2 -1
View File
@@ -1704,7 +1704,7 @@ impl SpreadsheetArtifactManager {
let args: CreateDifferentialFormatArgs = parse_args(&request.action, &request.args)?;
let artifact_id = required_artifact_id(&request)?;
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
let style_id = artifact.create_differential_format(args.format);
let style_id = artifact.create_differential_format(args.format)?;
let format = artifact
.get_differential_format(style_id)
.cloned()
@@ -2265,6 +2265,7 @@ impl SpreadsheetArtifactManager {
let action = request.action.clone();
let artifact_id = required_artifact_id(&request)?;
let artifact = self.get_artifact_mut(&artifact_id, &request.action)?;
artifact.validate_style_index(style_index, &request.action)?;
{
let sheet = artifact.sheet_lookup_mut(
&request.action,
+7 -18
View File
@@ -982,6 +982,9 @@ impl SpreadsheetSheet {
for address in range.addresses() {
let cell = self.get_or_create_cell_mut(address);
cell.formula = formula.clone();
if cell.formula.is_none() {
cell.value = None;
}
}
self.cells.retain(|_, cell| !cell.is_empty());
Ok(())
@@ -1094,6 +1097,9 @@ impl SpreadsheetSheet {
};
let cell = self.get_or_create_cell_mut(address);
cell.formula = formula.clone();
if cell.formula.is_none() {
cell.value = None;
}
}
}
self.cells.retain(|_, cell| !cell.is_empty());
@@ -1741,24 +1747,7 @@ impl SpreadsheetArtifact {
}
match selected.as_str() {
"xlsx" => {
for sheet in &self.sheets {
if !sheet.charts.is_empty()
|| !sheet.tables.is_empty()
|| !sheet.conditional_formats.is_empty()
|| !sheet.pivot_tables.is_empty()
{
return Err(SpreadsheetArtifactError::ExportFailed {
path: path.to_path_buf(),
message: format!(
"xlsx export does not yet support charts, tables, conditional formats, or pivot tables on sheet `{}`; use json or bin export instead",
sheet.name
),
});
}
}
write_xlsx(self, path)
}
"xlsx" => write_xlsx(self, path),
"json" => {
let json = self.to_json()?;
std::fs::write(path, json).map_err(|error| {
+113 -2
View File
@@ -239,6 +239,18 @@ impl SpreadsheetNumberFormat {
}
fn normalized(mut self) -> Self {
if let Some(format_id) = self.format_id
&& let Some(format_code) = builtin_number_format_code(format_id)
{
self.format_code = Some(format_code);
return self;
}
if self.format_id.is_none() {
self.format_id = self
.format_code
.as_deref()
.and_then(builtin_number_format_id);
}
if self.format_code.is_none() {
self.format_code = self.format_id.and_then(builtin_number_format_code);
}
@@ -493,6 +505,7 @@ impl SpreadsheetArtifact {
} else {
format
};
self.validate_cell_format_references(&created, "create_cell_format")?;
Ok(insert_with_next_id(&mut self.cell_formats, created))
}
@@ -500,8 +513,12 @@ impl SpreadsheetArtifact {
self.cell_formats.get(&format_id)
}
pub fn create_differential_format(&mut self, format: SpreadsheetDifferentialFormat) -> u32 {
insert_with_next_id(&mut self.differential_formats, format)
pub fn create_differential_format(
&mut self,
format: SpreadsheetDifferentialFormat,
) -> Result<u32, SpreadsheetArtifactError> {
self.validate_differential_format_references(&format, "create_differential_format")?;
Ok(insert_with_next_id(&mut self.differential_formats, format))
}
pub fn get_differential_format(
@@ -534,6 +551,70 @@ impl SpreadsheetArtifact {
wrap_text: resolved.wrap_text,
})
}
pub fn validate_style_index(
&self,
style_index: u32,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
if style_index == 0 || self.cell_formats.contains_key(&style_index) {
return Ok(());
}
Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("style index `{style_index}` was not found"),
})
}
fn validate_cell_format_references(
&self,
format: &SpreadsheetCellFormat,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
validate_optional_reference(
format.text_style_id,
&self.text_styles,
"text style",
action,
)?;
validate_optional_reference(format.fill_id, &self.fills, "fill", action)?;
validate_optional_reference(format.border_id, &self.borders, "border", action)?;
validate_optional_reference(
format.number_format_id,
&self.number_formats,
"number format",
action,
)?;
validate_optional_reference(
format.base_cell_style_format_id,
&self.cell_formats,
"base cell format",
action,
)?;
Ok(())
}
fn validate_differential_format_references(
&self,
format: &SpreadsheetDifferentialFormat,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
validate_optional_reference(
format.text_style_id,
&self.text_styles,
"text style",
action,
)?;
validate_optional_reference(format.fill_id, &self.fills, "fill", action)?;
validate_optional_reference(format.border_id, &self.borders, "border", action)?;
validate_optional_reference(
format.number_format_id,
&self.number_formats,
"number format",
action,
)?;
Ok(())
}
}
impl SpreadsheetSheet {
@@ -566,6 +647,23 @@ fn resolve_cell_format_recursive(
})
}
fn validate_optional_reference<T>(
id: Option<u32>,
map: &BTreeMap<u32, T>,
kind: &str,
action: &str,
) -> Result<(), SpreadsheetArtifactError> {
if let Some(id) = id
&& !map.contains_key(&id)
{
return Err(SpreadsheetArtifactError::InvalidArgs {
action: action.to_string(),
message: format!("{kind} `{id}` was not found"),
});
}
Ok(())
}
fn builtin_number_format_code(format_id: u32) -> Option<String> {
match format_id {
0 => Some("General".to_string()),
@@ -578,3 +676,16 @@ fn builtin_number_format_code(format_id: u32) -> Option<String> {
_ => None,
}
}
fn builtin_number_format_id(format_code: &str) -> Option<u32> {
match format_code {
"General" => Some(0),
"0" => Some(1),
"0.00" => Some(2),
"#,##0" => Some(3),
"#,##0.00" => Some(4),
"0%" => Some(9),
"0.00%" => Some(10),
_ => None,
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff