diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4afccd454..81e8fc2e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,11 @@ The release run heads these entries with the version and opens a fresh
## Unreleased
+- An ODF `draw:object` chart is drawn from the chart part's own markup, not
+ from the replacement image beside it: bar, line, area, scatter, pie and ring,
+ with their titles, legends, axes and series colours. An object holding no
+ chart we can read keeps the replacement. Closes #179.
+
- An ODF custom shape is drawn as the shape its `draw:enhanced-geometry`
describes rather than as its bounding box: `draw:enhanced-path`,
`draw:equation`, `draw:modifiers` and the two mirror attributes. Closes #159.
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 82b0ab3d4..ddf8fc430 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -177,6 +177,7 @@ set(ODR_SOURCE_FILES
"src/odr/internal/markdown/markdown_parser.cpp"
"src/odr/internal/markdown/markdown_style.cpp"
+ "src/odr/internal/odf/odf_chart.cpp"
"src/odr/internal/odf/odf_crypto.cpp"
"src/odr/internal/odf/odf_document.cpp"
"src/odr/internal/odf/odf_element_registry.cpp"
diff --git a/src/odr/internal/odf/PLAN.md b/src/odr/internal/odf/PLAN.md
index 56655c5d8..44256683e 100644
--- a/src/odr/internal/odf/PLAN.md
+++ b/src/odr/internal/odf/PLAN.md
@@ -4,17 +4,15 @@ Closing #771: everything an ODF shape carries beyond its bounding box —
`draw:transform`, the shape elements with no parser, `draw:enhanced-geometry`,
and the `draw:object` chart. The module as it stands is in
[`AGENTS.md`](AGENTS.md); the feature checklist is [`README.md`](README.md).
-Keep this file honest as stages land, and delete it when they all have.
+Keep this file honest as stages land, and delete it when nothing is left
+under *Today*.
## Today
-`parse_any_element_tree` (`odf_parser.cpp`) knows nine drawing tags —
-`draw:frame`, `draw:image`, `draw:rect`, `draw:line`, `draw:circle`,
-`draw:custom-shape`, `draw:text-box`, `draw:g`, `draw:a`. Everything else falls
-through its final `return {null_element_id, …}` and vanishes with its subtree.
-`draw:custom-shape` reaches `html::translate_custom_shape` as a positioned
-`
` with fill and stroke: the box, never the shape. `draw:transform` is not
-read, so a rotated shape draws unrotated.
+All five stages have landed. What is left is listed under each of them and in
+[`README.md`](README.md): `draw:text-areas` and `draw:handle` on an enhanced
+geometry, the arrowheads `draw:marker` names, `dr3d:scene`, and the chart
+features below stage 5.
## The corpus
@@ -148,12 +146,23 @@ subpath painted differently from the rest.
`hasstroke` and `hasfill` are always true — the geometry reader has no style
in hand — and no corpus formula reads them.
-### 5 — `draw:object` charts
+### 5 — `draw:object` charts — landed
-`` in the embedded part, rendered from its series, axes and
-`table:table` of plotted data, with the SVM replacement kept as the fallback.
-Closes #179. Large enough to deserve splitting again if it grows; it shares
-nothing with stages 1–4 but the `draw:frame` it hangs off.
+`odf_chart.cpp` renders the embedded part's `` to svg, and the
+object reaches the renderer as an image carrying it, so the existing image path
+writes it out. The `draw:image` beside an object is the replacement the producer
+wrote, and is skipped where the object itself draws; an object with no chart we
+can read — a formula, an ole blob — still leaves it. Closes #179.
+
+Decisions: the plotted values come from the chart's own `local-table` rather
+than the cells it names in the host document, which is the snapshot the part
+carries and the only one an embedded chart is guaranteed; the layout comes from
+`chart:plot-area` and `chartooo:coordinate-region`, so it matches what the
+producer laid out rather than something we invent.
+
+Open: stacked and percentage plots, secondary axes, trend lines, data labels,
+and the number format an axis names — a date axis shows its serial number
+today.
## Not scoped
diff --git a/src/odr/internal/odf/README.md b/src/odr/internal/odf/README.md
index 0cc7428d1..a22746528 100644
--- a/src/odr/internal/odf/README.md
+++ b/src/odr/internal/odf/README.md
@@ -55,6 +55,13 @@ Roughly ordered by importance.
- [x] images
- [x] internal and external references
- [x] svm
+- [x] embedded objects (`draw:object`)
+ - [x] charts (`chart:bar`, `line`, `area`, `scatter`, `circle`, `ring`),
+ drawn from the chart part's own `local-table` #179
+ - [ ] stacked and percentage plots, secondary axes, trend lines, data labels
+ - [ ] the axis number format (a date axis shows its serial number)
+ - [ ] `draw:object-ole` (an OLE blob, not ODF markup)
+ - [x] anything else falls back to the `draw:image` replacement
- [x] tables
- [x] column width, row height, table width
- [x] cell vertical alignment, background, padding, borders
diff --git a/src/odr/internal/odf/odf_chart.cpp b/src/odr/internal/odf/odf_chart.cpp
new file mode 100644
index 000000000..92b99f1ea
--- /dev/null
+++ b/src/odr/internal/odf/odf_chart.cpp
@@ -0,0 +1,619 @@
+#include
+
+#include
+#include
+#include
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace odr::internal::odf {
+
+namespace {
+
+/// The chart's own coordinates: 1/100 mm, which is what `svg:width` and the
+/// plot area's box reduce to.
+struct Box final {
+ double x{0};
+ double y{0};
+ double width{0};
+ double height{0};
+};
+
+struct Series final {
+ std::string label;
+ std::vector> values;
+ std::string colour;
+};
+
+/// What libreoffice paints an unstyled series with, in order.
+constexpr std::array default_colours{
+ "#004586", "#ff420e", "#ffd320", "#579d1c", "#7e0021", "#83caff",
+ "#314004", "#aecf00", "#4b1f6f", "#ff950e", "#c5000b", "#0084d1"};
+
+/// 1/100 mm per typographic point, the unit chart text is sized in.
+constexpr double units_per_point = 2540.0 / 72.0;
+
+/// The size a legend entry and an axis label are drawn at, and roughly what
+/// one of their characters is wide in ems.
+constexpr double label_points = 9;
+constexpr double character_width = 0.55;
+
+std::string number(const double value) {
+ return util::number::to_string_significant(value == 0 ? 0 : value, 6);
+}
+
+double read_length(const pugi::xml_attribute attribute,
+ const double fallback = 0) {
+ return read_hundredth_millimetres(attribute).value_or(fallback);
+}
+
+void collect_text(const pugi::xml_node node, std::string &out) {
+ for (const pugi::xml_node child : node.children()) {
+ if (child.type() == pugi::node_pcdata) {
+ out += child.value();
+ } else {
+ collect_text(child, out);
+ }
+ }
+}
+
+/// The text of a `text:p` run, which is all a chart title or label is.
+std::string read_text(const pugi::xml_node node) {
+ std::string result;
+ for (const pugi::xml_node paragraph : node.children("text:p")) {
+ collect_text(paragraph, result);
+ }
+ // A title is written with its trailing newline; one line is all we draw.
+ while (!result.empty() && (result.back() == '\n' || result.back() == '\r' ||
+ result.back() == ' ')) {
+ result.pop_back();
+ }
+ return result;
+}
+
+/// A step that lands on 1, 2 or 5 times a power of ten, so the axis reads.
+double nice_step(const double rough) {
+ if (rough <= 0) {
+ return 1;
+ }
+ const double magnitude = std::pow(10, std::floor(std::log10(rough)));
+ const double normalised = rough / magnitude;
+ if (normalised <= 1) {
+ return magnitude;
+ }
+ if (normalised <= 2) {
+ return 2 * magnitude;
+ }
+ if (normalised <= 5) {
+ return 5 * magnitude;
+ }
+ return 10 * magnitude;
+}
+
+/// Reads `` and draws it.
+class ChartWriter {
+public:
+ explicit ChartWriter(const pugi::xml_node content_root)
+ : m_chart{content_root.child("office:body")
+ .child("office:chart")
+ .child("chart:chart")} {
+ for (const pugi::xml_node style :
+ content_root.child("office:automatic-styles")
+ .children("style:style")) {
+ m_styles.emplace(style.attribute("style:name").value(), style);
+ }
+ }
+
+ [[nodiscard]] std::optional render() {
+ if (!m_chart) {
+ return {};
+ }
+ m_size.width = read_length(m_chart.attribute("svg:width"));
+ m_size.height = read_length(m_chart.attribute("svg:height"));
+ if (m_size.width <= 0 || m_size.height <= 0) {
+ return {};
+ }
+
+ const pugi::xml_node plot_area = m_chart.child("chart:plot-area");
+ read_class();
+ read_plot_box(plot_area);
+ read_data(plot_area);
+ if (m_series.empty()) {
+ return {};
+ }
+
+ open();
+ write_title();
+ write_legend();
+ if (m_pie) {
+ write_pie();
+ } else {
+ write_value_axis();
+ write_category_axis();
+ write_series();
+ }
+ m_out += "";
+ return m_out;
+ }
+
+private:
+ pugi::xml_node m_chart;
+ std::unordered_map m_styles;
+
+ Box m_size;
+ Box m_plot;
+ std::string m_class;
+ bool m_bars{false};
+ bool m_area{false};
+ bool m_symbols{false};
+ bool m_pie{false};
+
+ std::vector m_categories;
+ std::vector m_series;
+ double m_minimum{0};
+ double m_maximum{0};
+ double m_step{1};
+
+ std::string m_out;
+
+ [[nodiscard]] pugi::xml_node style_of(const pugi::xml_node node) const {
+ const auto it = m_styles.find(node.attribute("chart:style-name").value());
+ return it == m_styles.end() ? pugi::xml_node() : it->second;
+ }
+
+ void read_class() {
+ m_class = m_chart.attribute("chart:class").value();
+ m_bars = m_class == "chart:bar";
+ m_area = m_class == "chart:area";
+ m_pie = m_class == "chart:circle" || m_class == "chart:ring";
+ m_symbols =
+ m_class == "chart:scatter" || style_of(m_chart.child("chart:plot-area"))
+ .child("style:chart-properties")
+ .attribute("chart:symbol-type");
+ }
+
+ /// `chartooo:coordinate-region` is the region the data is drawn in, which the
+ /// plot area's own box only bounds.
+ void read_plot_box(const pugi::xml_node plot_area) {
+ pugi::xml_node box = plot_area.child("chartooo:coordinate-region");
+ if (!box) {
+ box = plot_area;
+ }
+ m_plot.x = read_length(box.attribute("svg:x"));
+ m_plot.y = read_length(box.attribute("svg:y"));
+ m_plot.width = read_length(box.attribute("svg:width"), m_size.width);
+ m_plot.height = read_length(box.attribute("svg:height"), m_size.height);
+ }
+
+ /// The `local-table` carries the plotted values; its header columns are the
+ /// categories and its header rows the series labels.
+ void read_data(const pugi::xml_node plot_area) {
+ const pugi::xml_node table = m_chart.find_child_by_attribute(
+ "table:table", "table:name", "local-table");
+ if (!table) {
+ return;
+ }
+
+ const std::size_t leading = std::max(
+ 1, count_cells(table.child("table:table-header-columns")
+ .child("table:table-column")));
+
+ std::vector labels;
+ for (const pugi::xml_node header :
+ table.child("table:table-header-rows").children("table:table-row")) {
+ labels = read_row_text(header);
+ break;
+ }
+
+ std::vector>> columns;
+ for (const pugi::xml_node row :
+ table.child("table:table-rows").children("table:table-row")) {
+ const std::vector cells = read_row(row);
+ if (cells.size() <= leading) {
+ continue;
+ }
+ m_categories.emplace_back(read_text(cells[leading - 1]));
+ for (std::size_t i = leading; i < cells.size(); ++i) {
+ if (columns.size() < i - leading + 1) {
+ columns.resize(i - leading + 1);
+ }
+ columns[i - leading].push_back(read_value(cells[i]));
+ }
+ }
+
+ std::size_t index = 0;
+ for (const pugi::xml_node series : plot_area.children("chart:series")) {
+ if (index >= columns.size()) {
+ break;
+ }
+ Series result;
+ result.values = columns[index];
+ if (leading + index < labels.size()) {
+ result.label = labels[leading + index];
+ }
+ result.colour = read_colour(series, index);
+ m_series.push_back(std::move(result));
+ ++index;
+ }
+ // A chart with no `chart:series` at all still has its table.
+ for (; index < columns.size() && m_series.empty(); ++index) {
+ m_series.push_back({.label = {},
+ .values = columns[index],
+ .colour = std::string(colour_at(index))});
+ }
+
+ trim();
+ read_range();
+ }
+
+ [[nodiscard]] static std::size_t count_cells(const pugi::xml_node column) {
+ std::size_t result = 0;
+ for (pugi::xml_node node = column; node;
+ node = node.next_sibling("table:table-column")) {
+ result += node.attribute("table:number-columns-repeated").as_uint(1);
+ }
+ return result;
+ }
+
+ [[nodiscard]] static std::vector
+ read_row(const pugi::xml_node row) {
+ std::vector result;
+ for (const pugi::xml_node cell : row.children()) {
+ if (std::strcmp(cell.name(), "table:table-cell") != 0 &&
+ std::strcmp(cell.name(), "table:covered-table-cell") != 0) {
+ continue;
+ }
+ const auto repeated =
+ cell.attribute("table:number-columns-repeated").as_uint(1);
+ for (unsigned i = 0; i < repeated; ++i) {
+ result.push_back(cell);
+ }
+ }
+ return result;
+ }
+
+ [[nodiscard]] static std::vector
+ read_row_text(const pugi::xml_node row) {
+ std::vector result;
+ for (const pugi::xml_node cell : read_row(row)) {
+ result.emplace_back(read_text(cell));
+ }
+ return result;
+ }
+
+ /// A missing data point is written `office:value="NaN"`.
+ [[nodiscard]] static std::optional
+ read_value(const pugi::xml_node cell) {
+ const pugi::xml_attribute value = cell.attribute("office:value");
+ if (!value) {
+ return {};
+ }
+ const double result = value.as_double();
+ return std::isfinite(result) ? std::optional(result) : std::nullopt;
+ }
+
+ /// The table is written to the chart's full range, so it ends in rows that
+ /// carry neither a category nor a value.
+ void trim() {
+ while (!m_categories.empty()) {
+ const std::size_t last = m_categories.size() - 1;
+ if (!m_categories[last].empty()) {
+ return;
+ }
+ for (const Series &series : m_series) {
+ if (last < series.values.size() && series.values[last].has_value()) {
+ return;
+ }
+ }
+ m_categories.pop_back();
+ for (Series &series : m_series) {
+ if (last < series.values.size()) {
+ series.values.pop_back();
+ }
+ }
+ }
+ }
+
+ [[nodiscard]] static std::string_view colour_at(const std::size_t index) {
+ return default_colours[index % default_colours.size()];
+ }
+
+ [[nodiscard]] std::string read_colour(const pugi::xml_node series,
+ const std::size_t index) const {
+ const pugi::xml_node properties =
+ style_of(series).child("style:graphic-properties");
+ for (const char *name : {"draw:fill-color", "svg:stroke-color"}) {
+ if (const pugi::xml_attribute colour = properties.attribute(name);
+ colour && colour.value()[0] == '#') {
+ return colour.value();
+ }
+ }
+ return std::string(colour_at(index));
+ }
+
+ void read_range() {
+ bool empty = true;
+ for (const Series &series : m_series) {
+ for (const std::optional value : series.values) {
+ if (!value.has_value()) {
+ continue;
+ }
+ m_minimum = empty ? *value : std::min(m_minimum, *value);
+ m_maximum = empty ? *value : std::max(m_maximum, *value);
+ empty = false;
+ }
+ }
+ if (empty) {
+ m_series.clear();
+ return;
+ }
+ // A bar or an area is measured from zero, or it lies about its size.
+ if (m_bars || m_area) {
+ m_minimum = std::min(m_minimum, 0.0);
+ m_maximum = std::max(m_maximum, 0.0);
+ }
+ if (m_maximum == m_minimum) {
+ m_maximum = m_minimum + 1;
+ }
+ m_step = nice_step((m_maximum - m_minimum) / 5);
+ m_minimum = std::floor(m_minimum / m_step) * m_step;
+ m_maximum = std::ceil(m_maximum / m_step) * m_step;
+ }
+
+ [[nodiscard]] double value_to_y(const double value) const {
+ return m_plot.y +
+ m_plot.height * (1 - (value - m_minimum) / (m_maximum - m_minimum));
+ }
+
+ void open() {
+ m_out += R"(