Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/bugfixes/issue-239/after.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-239/before.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/bugfixes/issue-239/gt.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions crates/office2pdf/src/parser/chart.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ pub(crate) fn parse_chart_xml(xml: &str) -> Option<Chart> {
}

let chart_type = chart_type?;

// Charts may omit <c:cat> entirely; Excel then labels the category axis
// 1..N (the point count of the longest series).
if categories.is_empty() {
let point_count: usize = series.iter().map(|s| s.values.len()).max().unwrap_or(0);
categories = (1..=point_count).map(|i| i.to_string()).collect();
}

Some(Chart {
chart_type,
title,
Expand Down
19 changes: 12 additions & 7 deletions crates/office2pdf/src/parser/xlsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ use std::io::Cursor;
use crate::config::ConvertOptions;
use crate::error::{ConvertError, ConvertWarning};
use crate::ir::{
Document, ImageData, Margins, Metadata, Page, PageSize, SheetPage, StyleSheet, Table, TableRow,
Chart, Document, ImageData, Margins, Metadata, Page, PageSize, SheetPage, StyleSheet, Table,
TableRow,
};
use crate::parser::Parser;

Expand Down Expand Up @@ -213,7 +214,8 @@ impl XlsxParser {
let sheet_name = sheet.get_name().to_string();
let raw_images = image_map.remove(&sheet_name);
let raw_text_boxes = text_box_map.remove(&sheet_name);
if raw_images.is_some() || raw_text_boxes.is_some() {
let raw_charts = chart_map.remove(&sheet_name);
if raw_images.is_some() || raw_text_boxes.is_some() || raw_charts.is_some() {
let stub_ctx = empty_sheet_context();
let images: Vec<crate::ir::SheetImage> = raw_images
.unwrap_or_default()
Expand All @@ -225,7 +227,8 @@ impl XlsxParser {
.into_iter()
.map(|anchor| anchored_text_box(anchor, sheet, &stub_ctx))
.collect();
if !images.is_empty() || !text_boxes.is_empty() {
let charts: Vec<(u32, Chart)> = raw_charts.unwrap_or_default();
if !images.is_empty() || !text_boxes.is_empty() || !charts.is_empty() {
chunks.push(Document {
metadata: metadata.clone(),
pages: vec![Page::Sheet(SheetPage {
Expand All @@ -235,7 +238,7 @@ impl XlsxParser {
table: Table::default(),
header: None,
footer: None,
charts: Vec::new(),
charts,
images,
text_boxes,
})],
Expand Down Expand Up @@ -394,7 +397,8 @@ impl Parser for XlsxParser {
let sheet_name = sheet.get_name().to_string();
let raw_images = image_map.remove(&sheet_name);
let raw_text_boxes = text_box_map.remove(&sheet_name);
if raw_images.is_some() || raw_text_boxes.is_some() {
let raw_charts = chart_map.remove(&sheet_name);
if raw_images.is_some() || raw_text_boxes.is_some() || raw_charts.is_some() {
let stub_ctx = empty_sheet_context();
let images: Vec<crate::ir::SheetImage> = raw_images
.unwrap_or_default()
Expand All @@ -406,15 +410,16 @@ impl Parser for XlsxParser {
.into_iter()
.map(|anchor| anchored_text_box(anchor, sheet, &stub_ctx))
.collect();
if !images.is_empty() || !text_boxes.is_empty() {
let charts: Vec<(u32, Chart)> = raw_charts.unwrap_or_default();
if !images.is_empty() || !text_boxes.is_empty() || !charts.is_empty() {
pages.push(Page::Sheet(SheetPage {
name: sheet_name,
size: PageSize::default(),
margins: sheet_print_margins(sheet),
table: Table::default(),
header: None,
footer: None,
charts: Vec::new(),
charts,
images,
text_boxes,
}));
Expand Down
52 changes: 46 additions & 6 deletions crates/office2pdf/src/render/typst_gen_diagram_visual_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,21 @@ fn test_codegen_chart_line_trend_indicators() {
})])]);

let output = generate_typst(&doc).unwrap();
// Multi-point line charts now render as an axis-scaled polyline plot
// (not a trend-indicator table).
assert!(
output.source.contains("Line Chart"),
"Expected line chart label, got:\n{}",
output.source.contains("Trends"),
"Expected chart title, got:\n{}",
output.source
);
assert!(
output.source.contains("path(stroke:"),
"Expected polyline path for the line chart, got:\n{}",
output.source
);
let has_trend =
output.source.contains('↑') || output.source.contains('↓') || output.source.contains('→');
assert!(
has_trend,
"Expected trend indicators in line chart, got:\n{}",
output.source.contains("Sales"),
"Expected series name in legend, got:\n{}",
output.source
);
}
Expand Down Expand Up @@ -294,3 +299,38 @@ fn test_smartart_codegen_special_chars() {
output.source
);
}

#[test]
fn test_codegen_chart_line_plot() {
let doc = make_doc(vec![make_flow_page(vec![Block::Chart(Chart {
chart_type: ChartType::Line,
title: None,
categories: vec!["1".to_string(), "2".to_string(), "3".to_string()],
series: vec![
ChartSeries {
name: Some("A".to_string()),
values: vec![1.0, 2.0, 3.0],
},
ChartSeries {
name: Some("B".to_string()),
values: vec![10.0, 9.0, 14.0],
},
],
})])]);

let output = generate_typst(&doc).unwrap();
assert!(
output.source.contains("path(stroke:"),
"line chart must draw polyline paths; got:\n{}",
output.source
);
assert!(
output.source.contains("line(end:"),
"line chart must draw axis gridlines; got:\n{}",
output.source
);
// Both series names appear in the legend.
assert!(output.source.contains("[A]") && output.source.contains("[B]"));
// Category labels 1..3 present.
assert!(output.source.contains("[1]") && output.source.contains("[3]"));
}
160 changes: 160 additions & 0 deletions crates/office2pdf/src/render/typst_gen_diagrams.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ pub(super) fn generate_chart(out: &mut String, chart: &Chart) {
generate_chart_axis(out, chart);
return;
}
// Line/area charts render as a polyline plot over the same axis.
if matches!(chart.chart_type, ChartType::Line | ChartType::Area)
&& !chart.series.is_empty()
&& chart.categories.len() >= 2
{
generate_chart_line_plot(out, chart);
return;
}

let _ = writeln!(
out,
Expand Down Expand Up @@ -364,6 +372,158 @@ fn generate_chart_bar(out: &mut String, chart: &Chart) {
}
}

/// Render a line/area chart as a polyline plot over a value axis, matching
/// the native Excel/PowerPoint composition (gridlines, tick labels, category
/// axis, markers, legend).
fn generate_chart_line_plot(out: &mut String, chart: &Chart) {
const PLOT_W: f64 = 320.0;
const PLOT_H: f64 = 210.0;
const VALUE_GAP: f64 = 24.0; // value tick label gutter (left)
const CAT_GAP: f64 = 18.0; // category label gutter (bottom)
const LEGEND_W: f64 = 88.0;
const INSET: f64 = 10.0; // keep first/last points off the axes
const GAP: f64 = 6.0;

let categories: usize = chart.categories.len();
let series: &[crate::ir::ChartSeries] = &chart.series;

let max_value: f64 = series
.iter()
.flat_map(|s| s.values.iter())
.copied()
.fold(0.0_f64, f64::max);
let (nice_max, step) = nice_axis(max_value);

if let Some(title) = chart.title.as_deref() {
let _ = writeln!(
out,
"#align(center)[#text(size: 11pt, weight: \"bold\")[{}]]",
escape_typst(title)
);
out.push_str("#v(4pt)\n");
}

let plot_x: f64 = VALUE_GAP + GAP;
let plot_y: f64 = 0.0;
let total_w: f64 = plot_x + PLOT_W + GAP + LEGEND_W;
let total_h: f64 = PLOT_H + CAT_GAP;
let _ = writeln!(
out,
"#box(width: {}pt, height: {}pt)[",
format_f64(total_w),
format_f64(total_h)
);

// Horizontal gridlines + value tick labels.
let mut tick: f64 = 0.0;
while tick <= nice_max + step * 1e-6 {
let y: f64 = plot_y + (1.0 - tick / nice_max) * PLOT_H;
let _ = writeln!(
out,
"#place(top + left, dx: {}pt, dy: {}pt, line(end: ({}pt, 0pt), stroke: 0.6pt + rgb(200, 200, 200)))",
format_f64(plot_x),
format_f64(y),
format_f64(PLOT_W)
);
let _ = writeln!(
out,
"#place(top + left, dx: 0pt, dy: {}pt, box(width: {}pt, height: 10pt)[#align(right + horizon)[#text(size: 8pt)[{}]]])",
format_f64(y - 5.0),
format_f64(VALUE_GAP),
chart_value_label(tick)
);
tick += step;
}

let point_x = |index: usize| -> f64 {
if categories <= 1 {
plot_x + PLOT_W / 2.0
} else {
plot_x + INSET + (index as f64 / (categories as f64 - 1.0)) * (PLOT_W - 2.0 * INSET)
}
};
let point_y =
|value: f64| -> f64 { plot_y + (1.0 - (value / nice_max).clamp(0.0, 1.0)) * PLOT_H };

// Category axis labels.
for (index, category) in chart.categories.iter().enumerate() {
let x: f64 = point_x(index);
let _ = writeln!(
out,
"#place(top + left, dx: {}pt, dy: {}pt, box(width: 24pt)[#align(center)[#text(size: 8pt)[{}]]])",
format_f64(x - 12.0),
format_f64(plot_y + PLOT_H + 3.0),
escape_typst(category)
);
}

// Series polylines + markers.
for (s_index, s) in series.iter().enumerate() {
let color: &str = CHART_SERIES_COLORS[s_index % CHART_SERIES_COLORS.len()];
let points: Vec<(f64, f64)> = s
.values
.iter()
.enumerate()
.map(|(index, value)| (point_x(index), point_y(*value)))
.collect();
if points.len() >= 2 {
let coords: String = points
.iter()
.map(|(x, y)| format!("({}pt, {}pt)", format_f64(*x), format_f64(*y)))
.collect::<Vec<_>>()
.join(", ");
let _ = writeln!(
out,
"#place(top + left, path(stroke: 2pt + {color}, {coords}))"
);
}
// Point markers.
for (x, y) in &points {
let _ = writeln!(
out,
"#place(top + left, dx: {}pt, dy: {}pt, rect(width: 5pt, height: 5pt, fill: {color}, stroke: none))",
format_f64(x - 2.5),
format_f64(y - 2.5)
);
}
}

// Value/category axis lines.
let _ = writeln!(
out,
"#place(top + left, dx: {}pt, dy: {}pt, line(end: (0pt, {}pt), stroke: 0.8pt + rgb(120, 120, 120)))",
format_f64(plot_x),
format_f64(plot_y),
format_f64(PLOT_H)
);
let _ = writeln!(
out,
"#place(top + left, dx: {}pt, dy: {}pt, line(end: ({}pt, 0pt), stroke: 0.8pt + rgb(120, 120, 120)))",
format_f64(plot_x),
format_f64(plot_y + PLOT_H),
format_f64(PLOT_W)
);

// Legend on the right.
let legend_x: f64 = plot_x + PLOT_W + GAP;
let legend_h: f64 = series.len() as f64 * 16.0;
let legend_y: f64 = (PLOT_H - legend_h).max(0.0) / 2.0;
for (s_index, s) in series.iter().enumerate() {
let color: &str = CHART_SERIES_COLORS[s_index % CHART_SERIES_COLORS.len()];
let default_name: String = format!("Series {}", s_index + 1);
let name: &str = s.name.as_deref().unwrap_or(&default_name);
let _ = writeln!(
out,
"#place(top + left, dx: {}pt, dy: {}pt, box[#box(width: 12pt, height: 3pt, fill: {color}, baseline: -3pt) #text(size: 9pt)[{}]])",
format_f64(legend_x),
format_f64(legend_y + s_index as f64 * 16.0),
escape_typst(name)
);
}

out.push_str("]\n");
}

fn generate_chart_pie(out: &mut String, chart: &Chart) {
let Some(series) = chart.series.first() else {
return;
Expand Down
26 changes: 26 additions & 0 deletions crates/office2pdf/tests/xlsx_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -583,3 +583,29 @@ fn with_text_box_renders_anchored_text() {
Some(Color::new(0, 0, 0xFF))
);
}

// ---------------------------------------------------------------------------
// Embedded charts on chart-only sheets (issue #239)
// ---------------------------------------------------------------------------

#[test]
fn with_chart_renders_embedded_chart() {
// WithChart.xlsx puts its chart on a sheet with no cells; that sheet was
// skipped entirely, dropping the chart (same class as #238's image-only
// sheets, which the fix did not extend to charts).
let pages = sheet_pages("poi/WithChart.xlsx");
let total_charts: usize = pages.iter().map(|sp| sp.charts.len()).sum();
assert!(
total_charts >= 1,
"the embedded chart must be extracted, got {total_charts}"
);
let chart = pages
.iter()
.flat_map(|sp| sp.charts.iter())
.next()
.expect("a chart");
assert!(
!chart.1.series.is_empty(),
"chart must carry its series data"
);
}
Loading