mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
feat: massive improvement
This commit is contained in:
Generated
+2
@@ -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\":[]}}",
|
||||
|
||||
Generated
+17
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user