mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-11 16:31:54 -06:00
Gap 19: ODT charts opened and saved as editable charts
A chart object (`draw:object` whose sub-document is an `office:chart`, ODF 1.3 §10.4.3, Part 3 §11) used to open as its replacement picture. It is now read into WordCraft's chart model: column, bar (stacked or not), line, area, pie, doughnut and scatter charts, with categories, series names and values (gaps included) taken from the chart's own table through the series' cell range addresses, plus title, legend position and data labels. The engine draws opened charts in the document's theme colours, and Chart Design's commands edit them. Charts WordCraft can't edit as they are (3D, percent-stacked, mixed plot types, a second value axis, radar and the like) keep their picture. WordCraft charts are written as ODF chart objects shaped like LibreOffice's: a data table, each series and slice in its accent colour, pies clockwise from the top. They validate against the ODF 1.3 schema, and LibreOffice draws them like WordCraft does. In flat documents they are written inline as an `office:document`. Bug fixed: flat ODT reading ended the body at the first nested `</office:body>`, so an inline sub-document dropped the text after it. Element lookup now follows nesting, with a bounded depth. Flow model: new `Inline::Chart`, kept only by flows that ask for boxes (ODT); other formats still get the alt text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GBVQLyDnrG8Tn1yDQcUabC
This commit is contained in:
@@ -14,12 +14,21 @@ pub fn open(ext: &str, bytes: &[u8], page: &wordcraft_doc::SectionProps) -> Opti
|
||||
other => wordcraft_formats::import_on(other, bytes, page).map(|r| {
|
||||
r.map(|mut doc| {
|
||||
crate::io::fit_pictures(&mut doc);
|
||||
draw_charts(&mut doc);
|
||||
doc
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the charts a reader left undrawn (ODT charts come as their chart model alone): in the
|
||||
/// document's theme colours, at their size.
|
||||
fn draw_charts(doc: &mut Document) {
|
||||
let theme = doc.settings.theme_colors.clone();
|
||||
let undrawn = |o: &wordcraft_doc::para::InlineObject| matches!(o, wordcraft_doc::para::InlineObject::Graphic { graphic, .. } if graphic.chart.is_some() && graphic.items.is_empty());
|
||||
doc.objects_mut(&undrawn, &mut |o| crate::cmd::chart::redraw(o, &theme));
|
||||
}
|
||||
|
||||
/// Save to a format other than plain text / JSON.
|
||||
pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Option<Result<Vec<u8>, String>> {
|
||||
// .docx/.docm/.dotx/.dotm: the extension picks the package flavour Word expects.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod comment_ids;
|
||||
mod date_fields;
|
||||
mod macro_packages;
|
||||
mod odt_charts;
|
||||
mod odt_fields;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod odt_sections;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
//! A chart in an ODT file (ODF 1.3 §10.4.3, an embedded `office:chart` object) opens as a chart
|
||||
//! WordCraft draws and edits: Chart Design's commands change it like a chart made here. The ODT
|
||||
//! is original and synthetic, shaped like LibreOffice's.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::chart::ChartType;
|
||||
use wordcraft_doc::para::InlineObject;
|
||||
use wordcraft_engine::{Session, io};
|
||||
|
||||
const NS: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
|
||||
fn odt() -> Vec<u8> {
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS}><office:body><office:text><text:p>Sales <draw:frame svg:width="4in" svg:height="2.5in"><draw:object xlink:href="./Object 1"/></draw:frame></text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let chart = r#"<office:document-content><office:body><office:chart><chart:chart chart:class="chart:line"><chart:title><text:p>Visits</text:p></chart:title><chart:plot-area><chart:axis chart:dimension="x"><chart:categories table:cell-range-address="local-table.$A$2:.$A$4"/></chart:axis><chart:series chart:values-cell-range-address="local-table.$B$2:.$B$4" chart:label-cell-address="local-table.$B$1"/></chart:plot-area><table:table table:name="local-table"><table:table-header-rows><table:table-row><table:table-cell/><table:table-cell office:value-type="string"><text:p>Site</text:p></table:table-cell></table:table-row></table:table-header-rows><table:table-rows><table:table-row><table:table-cell office:value-type="string"><text:p>Mon</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="3"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Tue</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="5"/></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Wed</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="4"/></table:table-row></table:table-rows></table:table></chart:chart></office:chart></office:body></office:document-content>"#;
|
||||
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for (name, data) in [("mimetype", "application/vnd.oasis.opendocument.text"), ("content.xml", content.as_str()), ("Object 1/content.xml", chart)]
|
||||
{
|
||||
z.start_file(name, o).unwrap();
|
||||
z.write_all(data.as_bytes()).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
/// The document's charts: their type, title, and whether they're drawn.
|
||||
fn charts(s: &Session) -> Vec<(ChartType, Option<String>, bool)> {
|
||||
s.doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.iter())
|
||||
.filter_map(|o| match o {
|
||||
InlineObject::Graphic { graphic, .. } => graphic.chart.as_ref().map(|c| (c.kind, c.title.clone(), !graphic.items.is_empty())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_odt_chart_opens_drawn_and_editable() {
|
||||
let mut s = Session::new(io::open_bytes("chart.odt", &odt()).unwrap());
|
||||
assert_eq!(charts(&s), [(ChartType::Line, Some("Visits".to_string()), true)]);
|
||||
s.run("select.objects", &json!({"index": 0})).unwrap();
|
||||
s.run("chart.type", &json!({"type": "column"})).unwrap();
|
||||
s.run("chart.title", &json!({"text": "Weekly visits"})).unwrap();
|
||||
assert_eq!(charts(&s), [(ChartType::Column, Some("Weekly visits".to_string()), true)]);
|
||||
}
|
||||
@@ -155,7 +155,7 @@ pub fn inlines_html(inlines: &[Inline]) -> String {
|
||||
}
|
||||
}
|
||||
// No picture to show: the browser shows the alt text in its place.
|
||||
Inline::Figure(alt) => o.push_str(&format!("<img alt=\"{}\">", esc(alt))),
|
||||
Inline::Figure(alt) | Inline::Chart(crate::model::Chart { alt, .. }) => o.push_str(&format!("<img alt=\"{}\">", esc(alt))),
|
||||
// MathML, which browsers, Word and LibreOffice show as an equation (#816).
|
||||
Inline::Equation { linear, display } => o.push_str(&crate::mathml::linear_to_mathml(linear, *display)),
|
||||
}
|
||||
|
||||
@@ -241,6 +241,7 @@ impl Writer {
|
||||
}
|
||||
Inline::Anchor(name) => o.push_str(&format!("\\hypertarget{{{}}}{{}}", escape_url(name))),
|
||||
Inline::Figure(_)
|
||||
| Inline::Chart(_)
|
||||
| Inline::Note { .. }
|
||||
| Inline::Comment { .. }
|
||||
| Inline::CommentEnd(_)
|
||||
|
||||
@@ -132,7 +132,9 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String {
|
||||
let safe: String = a.chars().filter(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')).collect();
|
||||
pieces.push((String::new(), Fmt::default(), Some(format!("<a id=\"{safe}\"></a>"))));
|
||||
}
|
||||
Inline::Figure(alt) => pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt))))),
|
||||
Inline::Figure(alt) | Inline::Chart(crate::model::Chart { alt, .. }) => {
|
||||
pieces.push((String::new(), Fmt::default(), Some(format!("![{}]()", escape(alt)))))
|
||||
}
|
||||
Inline::Equation { .. } => {}
|
||||
Inline::Field { result, fmt, .. } => pieces.push((result.clone(), fmt.clone(), None)),
|
||||
Inline::Note { .. }
|
||||
|
||||
@@ -26,7 +26,7 @@ pub use from_doc::{
|
||||
pub use media::{base64_decode, base64_encode, data_uri, decode_bounded, image_px, make_img, mime_of, percent_decode, sized_img, sniff_image};
|
||||
pub use to_doc::{char_props, part_blocks, to_doc, to_doc_in};
|
||||
pub use types::{
|
||||
Affix, BoxLook, CODE_STYLE, Cell, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH,
|
||||
Affix, BoxLook, CODE_STYLE, Cell, Chart, FBlock, FComment, FTable, Flow, FlowOpts, Fmt, FmtChange, Img, Inline, Kind, Label, ListInfo, MAX_DEPTH,
|
||||
MAX_FONT_NAME, MAX_GROUP_DEPTH, MAX_LINK, MONO_FONT, Member, Meta, Para, RUBY_BASE_SIZE, RevMark, Spacing, clean_text, equations_as_text,
|
||||
flatten_tables, kept_font, kept_link, ranged_anchors, ruby_as_text, ruby_object,
|
||||
};
|
||||
|
||||
@@ -489,11 +489,20 @@ fn flow_paras_in(doc: &Document, p: &Paragraph, opts: FlowOpts) -> Vec<Para> {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
Some(InlineObject::Graphic { alt, .. }) => {
|
||||
Some(InlineObject::Graphic { alt, w, h, float, graphic }) => {
|
||||
if !buf.is_empty() {
|
||||
out.push_text(&std::mem::take(&mut buf), &f);
|
||||
}
|
||||
if !alt.trim().is_empty() {
|
||||
// An editable chart, for formats that keep charts (ODT).
|
||||
if let Some(spec) = graphic.chart.as_ref().filter(|_| opts.boxes) {
|
||||
out.inlines.push(Inline::Chart(crate::model::Chart {
|
||||
spec: spec.clone(),
|
||||
w: *w,
|
||||
h: *h,
|
||||
alt: alt.clone(),
|
||||
float: kept_float(float).filter(|_| opts.paras),
|
||||
}));
|
||||
} else if !alt.trim().is_empty() {
|
||||
out.inlines.push(Inline::Figure(alt.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,19 +321,43 @@ impl<'a> Builder<'a> {
|
||||
|
||||
/// The document object for picture, shape or group `i` (members of groups `depth` deep).
|
||||
fn drawing(&mut self, i: &Inline, depth: usize) -> Option<InlineObject> {
|
||||
// Offsets and distances on the page.
|
||||
let fit_float = |float: &Option<Box<wordcraft_doc::para::Float>>| {
|
||||
float.as_deref().cloned().map(|mut f| {
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(-1584.0, 1584.0) } else { 0.0 };
|
||||
(f.x, f.y) = (fit(f.x), fit(f.y));
|
||||
(f.dist, f.dist_top, f.dist_bottom) = (fit(f.dist).max(0.0), fit(f.dist_top).max(0.0), fit(f.dist_bottom).max(0.0));
|
||||
f
|
||||
})
|
||||
};
|
||||
match i {
|
||||
Inline::Image(img) => {
|
||||
let key = self.doc.add_media(img.data.to_vec(), &img.ext);
|
||||
let (w, h) = (finite_or(img.w, 72.0).clamp(1.0, 1584.0), finite_or(img.h, 72.0).clamp(1.0, 1584.0));
|
||||
let float = img.float.as_deref().cloned().map(|mut f| {
|
||||
// Offsets and distances on the page.
|
||||
let fit = |v: f32| if v.is_finite() { v.clamp(-1584.0, 1584.0) } else { 0.0 };
|
||||
(f.x, f.y) = (fit(f.x), fit(f.y));
|
||||
(f.dist, f.dist_top, f.dist_bottom) = (fit(f.dist).max(0.0), fit(f.dist_top).max(0.0), fit(f.dist_bottom).max(0.0));
|
||||
f
|
||||
});
|
||||
let float = fit_float(&img.float);
|
||||
Some(InlineObject::Image { media: key, w, h, alt: img.alt.clone(), float: float.unwrap_or_default(), crop: [0.0; 4], ole: None })
|
||||
}
|
||||
// Its drawing is made from the chart by the program that opens the document
|
||||
// (`wordcraft_engine`'s chart drawing), which has the theme's colours.
|
||||
Inline::Chart(c) => {
|
||||
let (w, h) = (finite_or(c.w, 360.0).clamp(1.0, 1584.0), finite_or(c.h, 216.0).clamp(1.0, 1584.0));
|
||||
let mut spec = (*c.spec).clone();
|
||||
spec.sanitize();
|
||||
let graphic = wordcraft_doc::graphic::Graphic {
|
||||
kind: wordcraft_doc::graphic::GraphicKind::Chart,
|
||||
w,
|
||||
h,
|
||||
chart: Some(Arc::new(spec)),
|
||||
..Default::default()
|
||||
};
|
||||
Some(InlineObject::Graphic {
|
||||
w,
|
||||
h,
|
||||
alt: clean_text(&c.alt),
|
||||
float: fit_float(&c.float).unwrap_or_default(),
|
||||
graphic: Arc::new(graphic),
|
||||
})
|
||||
}
|
||||
Inline::Shape { kind, w, h, float, blocks, look, path } => {
|
||||
// A text box always has its story; a shape only when it holds text.
|
||||
let story = if *kind == wordcraft_doc::para::ShapeKind::TextBox || !blocks.is_empty() {
|
||||
@@ -450,7 +474,7 @@ impl<'a> Builder<'a> {
|
||||
append(&mut out, "", &CharProps::default(), Some(obj));
|
||||
}
|
||||
Inline::FieldEnd => append(&mut out, "", &CharProps::default(), Some(InlineObject::FieldEnd)),
|
||||
Inline::Image(_) | Inline::Shape { .. } | Inline::Group { .. } => {
|
||||
Inline::Image(_) | Inline::Chart(_) | Inline::Shape { .. } | Inline::Group { .. } => {
|
||||
if let Some(obj) = self.drawing(i, 0) {
|
||||
append(&mut out, "", &CharProps::default(), Some(obj));
|
||||
}
|
||||
|
||||
@@ -152,6 +152,18 @@ pub struct Img {
|
||||
pub float: Option<Box<wordcraft_doc::para::Float>>,
|
||||
}
|
||||
|
||||
/// An editable chart in a flow ([`Inline::Chart`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Chart {
|
||||
pub spec: Arc<wordcraft_doc::chart::ChartSpec>,
|
||||
/// Display size, points.
|
||||
pub w: f32,
|
||||
pub h: f32,
|
||||
pub alt: String,
|
||||
/// How a floating chart is placed and wrapped; `None`: in line with the text.
|
||||
pub float: Option<Box<wordcraft_doc::para::Float>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Inline {
|
||||
Text(String, Fmt),
|
||||
@@ -165,6 +177,9 @@ pub enum Inline {
|
||||
/// A chart or diagram known here only by its alt text: written where alt text has a place
|
||||
/// (an HTML or Markdown image), left out of editable formats so it never turns into body text.
|
||||
Figure(String),
|
||||
/// A chart WordCraft can edit, `w` × `h` points: only in flows asked for boxes
|
||||
/// ([`FlowOpts::boxes`]); others get its alt text as an [`Inline::Figure`].
|
||||
Chart(Chart),
|
||||
/// An equation in the linear format WordCraft keeps them in (`x=(-b±√(b^2-4ac))/2a`). Formats
|
||||
/// without equations write `linear` as text.
|
||||
Equation {
|
||||
@@ -615,6 +630,7 @@ impl Para {
|
||||
Inline::Image(_)
|
||||
| Inline::Anchor(_)
|
||||
| Inline::Figure(_)
|
||||
| Inline::Chart(_)
|
||||
| Inline::Note { .. }
|
||||
| Inline::Comment { .. }
|
||||
| Inline::CommentEnd(_)
|
||||
@@ -641,6 +657,7 @@ impl Para {
|
||||
Inline::Image(_)
|
||||
| Inline::Anchor(_)
|
||||
| Inline::Figure(_)
|
||||
| Inline::Chart(_)
|
||||
| Inline::Note { .. }
|
||||
| Inline::Comment { .. }
|
||||
| Inline::CommentEnd(_)
|
||||
|
||||
@@ -38,6 +38,7 @@ use wordcraft_doc::{Align, Document, RevisionKind, Rgb, SectionProps};
|
||||
use crate::model::{self, Cell, FBlock, FComment, FTable, Flow, Fmt, Inline, Kind, ListInfo, Meta, Para, RevMark, make_img, mime_of};
|
||||
|
||||
mod body;
|
||||
mod chart;
|
||||
mod datastyle;
|
||||
mod fields;
|
||||
mod flat;
|
||||
|
||||
@@ -19,7 +19,8 @@ use notes::{OpenField, OpenNote, balance_comments};
|
||||
use tables::TableB;
|
||||
use tracked::{DelState, Region, change_mark};
|
||||
|
||||
type Frame = (Option<f32>, Option<f32>, Option<Vec<u8>>, String, Option<(String, bool)>);
|
||||
/// An open frame: its size, picture, alt text, equation and chart.
|
||||
type Frame = (Option<f32>, Option<f32>, Option<Vec<u8>>, String, Option<(String, bool)>, Option<wordcraft_doc::chart::ChartSpec>);
|
||||
|
||||
pub(super) struct Body<'a> {
|
||||
styles: &'a Styles,
|
||||
@@ -682,7 +683,7 @@ impl Body<'_> {
|
||||
"frame" => {
|
||||
let w = get(a, "svg:width").and_then(length);
|
||||
let h = get(a, "svg:height").and_then(length);
|
||||
self.frame = Some((w, h, None, String::new(), None));
|
||||
self.frame = Some((w, h, None, String::new(), None, None));
|
||||
let g = get(a, "draw:style-name").map(|s| self.styles.graphic(s)).unwrap_or_default();
|
||||
self.frame_float = Some(super::frames::place(a, w.unwrap_or(0.0), h.unwrap_or(0.0), &g));
|
||||
self.member_start(a, w.unwrap_or(0.0), h.unwrap_or(0.0), None);
|
||||
@@ -694,23 +695,19 @@ impl Body<'_> {
|
||||
&& let Some(fr) = &mut self.frame
|
||||
&& fr.2.is_none()
|
||||
&& fr.4.is_none()
|
||||
&& fr.5.is_none()
|
||||
{
|
||||
let path = href.trim_start_matches("./");
|
||||
fr.2 = (self.zip)(path);
|
||||
}
|
||||
}
|
||||
// An embedded object (ODF 1.3 §10.4.3): a formula sub-document's `content.xml` holds
|
||||
// a `math:math` tree (#3099). Other object kinds have none, so the frame still falls
|
||||
// back to its `draw:image` replacement preview, read at `end` below.
|
||||
// a `math:math` tree (#3099), a chart's an `office:chart`. Other object kinds (and
|
||||
// charts WordCraft can't edit) fall back to the frame's `draw:image` replacement
|
||||
// preview, read at `end` below.
|
||||
"object" => {
|
||||
if let Some(href) = get(a, "xlink:href")
|
||||
&& let Some(fr) = &mut self.frame
|
||||
&& fr.4.is_none()
|
||||
{
|
||||
let path = format!("{}/content.xml", href.trim_start_matches("./").trim_end_matches('/'));
|
||||
if let Some(xml) = (self.zip)(&path) {
|
||||
fr.4 = read_formula(&xml);
|
||||
}
|
||||
if let Some(href) = get(a, "xlink:href") {
|
||||
self.read_object(href);
|
||||
}
|
||||
}
|
||||
"desc" | "title" if self.frame.is_some() => self.in_desc = true,
|
||||
@@ -734,6 +731,21 @@ impl Body<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The open frame's object at `href`: its formula, or else its chart.
|
||||
fn read_object(&mut self, href: &str) {
|
||||
if let Some(fr) = &mut self.frame
|
||||
&& fr.4.is_none()
|
||||
{
|
||||
let path = format!("{}/content.xml", href.trim_start_matches("./").trim_end_matches('/'));
|
||||
if let Some(xml) = (self.zip)(&path) {
|
||||
fr.4 = read_formula(&xml);
|
||||
if fr.4.is_none() {
|
||||
fr.5 = super::chart::read_chart(&xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn start_inline(&mut self, q: &str, a: &Attrs) -> bool {
|
||||
// Inside a field everything is its shown text.
|
||||
if self.field.is_some() {
|
||||
|
||||
@@ -75,7 +75,7 @@ impl Body<'_> {
|
||||
/// End a frame or drawing shape: its picture, equation, text box or shape goes into the
|
||||
/// paragraph.
|
||||
pub(in crate::odt) fn end_frame(&mut self) {
|
||||
if let Some((w, h, img, alt, eq)) = self.frame.take() {
|
||||
if let Some((w, h, img, alt, eq, chart)) = self.frame.take() {
|
||||
let boxed = self.frame_box.take();
|
||||
let mut float = self.frame_float.take();
|
||||
if boxed.is_some()
|
||||
@@ -101,6 +101,13 @@ impl Body<'_> {
|
||||
Some(Inline::Shape { kind, w: w.unwrap_or(72.0), h: h.unwrap_or(1.0), float: float.map(Box::new), blocks, look, path })
|
||||
}
|
||||
(None, Some((linear, display))) => Some(Inline::Equation { linear, display }),
|
||||
(None, None) if let Some(spec) = chart => Some(Inline::Chart(model::Chart {
|
||||
spec: std::sync::Arc::new(spec),
|
||||
w: w.unwrap_or(360.0),
|
||||
h: h.unwrap_or(216.0),
|
||||
alt: alt.trim().to_string(),
|
||||
float: float.map(Box::new),
|
||||
})),
|
||||
(None, None) => img.and_then(|data| make_img(data, w, h, alt.trim())).map(|mut img| {
|
||||
img.float = float.map(Box::new);
|
||||
Inline::Image(img)
|
||||
@@ -189,7 +196,7 @@ impl Body<'_> {
|
||||
self.frame_look = g.shape_look(kind, &self.styles.defs);
|
||||
self.shape_path = crate::odt::shapes::freeform(local(q), a, w, h).map(std::sync::Arc::new);
|
||||
self.shape_kind = Some(kind);
|
||||
let frame = (Some((Some(w), Some(h), None, String::new(), None)), Some(float), std::mem::take(&mut self.frame_look));
|
||||
let frame = (Some((Some(w), Some(h), None, String::new(), None, None)), Some(float), std::mem::take(&mut self.frame_look));
|
||||
self.start_note(a);
|
||||
if let Some(n) = &mut self.note {
|
||||
n.text_box = Some(frame);
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
//! Charts embedded in a document (ODF 1.3 §10.4.3 `draw:object` whose sub-document is an
|
||||
//! `office:chart`, Part 3 §11): read into WordCraft's editable chart model when the chart is one
|
||||
//! WordCraft can edit as it is (one plot type in two dimensions, its data in the chart's own
|
||||
//! table). Anything else returns `None`, and the frame keeps its replacement picture.
|
||||
//!
|
||||
//! The data comes from the chart's `table:table` (§11.1): the series' and categories' cell range
|
||||
//! addresses (§19.583, `local-table.$B$2:.$B$5`) point into it, counting its header rows and
|
||||
//! columns like any other.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_CELLS, MAX_POINTS, MAX_SERIES, MAX_TEXT};
|
||||
|
||||
use super::xml::Attrs;
|
||||
use super::{Ev, events, get};
|
||||
|
||||
/// Most XML events read from a chart sub-document.
|
||||
const MAX_EVENTS: usize = 2_000_000;
|
||||
/// Most rows read from a chart's table: a header row and a row per category.
|
||||
const MAX_ROWS: usize = MAX_POINTS + 1;
|
||||
/// Most columns read from it: a label column and two per series (a scatter series' x values).
|
||||
const MAX_COLS: usize = MAX_SERIES * 2 + 1;
|
||||
|
||||
/// One cell of the chart's table: its text, and its number when it holds one (`Some(None)`: a
|
||||
/// number cell without a usable value, LibreOffice's `NaN` gap).
|
||||
#[derive(Clone, Default)]
|
||||
struct DataCell {
|
||||
text: String,
|
||||
num: Option<Option<f64>>,
|
||||
}
|
||||
|
||||
/// A `chart:series` element as read.
|
||||
#[derive(Default)]
|
||||
struct RawSeries {
|
||||
values: Option<String>,
|
||||
label: Option<String>,
|
||||
class: Option<String>,
|
||||
style: Option<String>,
|
||||
domain: Option<String>,
|
||||
secondary: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Reader {
|
||||
/// Chart styles' `style:chart-properties`, by style name.
|
||||
props: HashMap<String, Attrs>,
|
||||
style: Option<String>,
|
||||
class: Option<String>,
|
||||
plot_style: Option<String>,
|
||||
title: Option<String>,
|
||||
in_title: bool,
|
||||
/// The legend's position, when there is a legend.
|
||||
legend: Option<String>,
|
||||
categories: Option<String>,
|
||||
series: Vec<RawSeries>,
|
||||
in_series: bool,
|
||||
/// The chart's table: its rows of cells, and how deep in tables the reader is.
|
||||
rows: Vec<Vec<DataCell>>,
|
||||
table_depth: usize,
|
||||
/// How many times the open row and cell repeat.
|
||||
row_repeat: usize,
|
||||
cell_repeat: usize,
|
||||
cell: Option<DataCell>,
|
||||
cells: usize,
|
||||
in_chart: bool,
|
||||
}
|
||||
|
||||
/// A count attribute (`table:number-rows-repeated`…): 1 when absent or unusable, at most `max`.
|
||||
fn repeat(a: &Attrs, k: &str, max: usize) -> usize {
|
||||
get(a, k).and_then(|v| v.trim().parse::<usize>().ok()).unwrap_or(1).clamp(1, max.max(1))
|
||||
}
|
||||
|
||||
impl Reader {
|
||||
fn start(&mut self, q: &str, a: &Attrs) {
|
||||
match q {
|
||||
"style:style" => self.style = get(a, "style:name").map(str::to_string),
|
||||
"style:chart-properties" => {
|
||||
if let Some(name) = self.style.clone() {
|
||||
self.props.insert(name, a.clone());
|
||||
}
|
||||
}
|
||||
"chart:chart" if !self.in_chart => {
|
||||
self.in_chart = true;
|
||||
self.class = get(a, "chart:class").map(str::to_string);
|
||||
}
|
||||
"chart:title" => self.in_title = true,
|
||||
"text:p" if self.in_title => {
|
||||
let t = self.title.get_or_insert_with(String::new);
|
||||
if !t.is_empty() {
|
||||
t.push(' ');
|
||||
}
|
||||
}
|
||||
"chart:legend" => self.legend = Some(get(a, "chart:legend-position").unwrap_or("end").to_string()),
|
||||
"chart:plot-area" => self.plot_style = get(a, "chart:style-name").map(str::to_string),
|
||||
"chart:categories" if self.categories.is_none() => self.categories = get(a, "table:cell-range-address").map(str::to_string),
|
||||
"chart:series" => {
|
||||
self.in_series = true;
|
||||
if self.series.len() < MAX_SERIES {
|
||||
self.series.push(RawSeries {
|
||||
values: get(a, "chart:values-cell-range-address").map(str::to_string),
|
||||
label: get(a, "chart:label-cell-address").map(str::to_string),
|
||||
class: get(a, "chart:class").map(str::to_string),
|
||||
style: get(a, "chart:style-name").map(str::to_string),
|
||||
domain: None,
|
||||
secondary: get(a, "chart:attached-axis").is_some_and(|v| v.starts_with("secondary")),
|
||||
});
|
||||
}
|
||||
}
|
||||
"chart:domain" if self.in_series => {
|
||||
if let Some(s) = self.series.last_mut().filter(|s| s.domain.is_none()) {
|
||||
s.domain = get(a, "table:cell-range-address").map(str::to_string);
|
||||
}
|
||||
}
|
||||
"table:table" => self.table_depth += 1,
|
||||
"table:table-row" if self.table_depth == 1 => {
|
||||
self.row_repeat = repeat(a, "table:number-rows-repeated", MAX_ROWS);
|
||||
if self.rows.len() < MAX_ROWS {
|
||||
self.rows.push(Vec::new());
|
||||
}
|
||||
}
|
||||
"table:table-cell" | "table:covered-table-cell" if self.table_depth == 1 => {
|
||||
self.cell_repeat = repeat(a, "table:number-columns-repeated", MAX_COLS);
|
||||
let num = (get(a, "office:value-type") == Some("float") || get(a, "office:value-type") == Some("percentage"))
|
||||
.then(|| get(a, "office:value").and_then(|v| v.trim().parse::<f64>().ok()).filter(|v| v.is_finite()));
|
||||
self.cell = Some(DataCell { text: String::new(), num });
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn end(&mut self, q: &str) {
|
||||
match q {
|
||||
"style:style" => self.style = None,
|
||||
"chart:title" => self.in_title = false,
|
||||
"chart:series" => self.in_series = false,
|
||||
"table:table" => self.table_depth = self.table_depth.saturating_sub(1),
|
||||
"table:table-cell" | "table:covered-table-cell" if self.table_depth == 1 => {
|
||||
if let Some(cell) = self.cell.take()
|
||||
&& let Some(row) = self.rows.last_mut()
|
||||
{
|
||||
let n = self.cell_repeat.min(MAX_COLS.saturating_sub(row.len())).min(MAX_CELLS.saturating_sub(self.cells));
|
||||
self.cells += n;
|
||||
row.extend(std::iter::repeat_n(cell, n));
|
||||
}
|
||||
}
|
||||
"table:table-row" if self.table_depth == 1 => {
|
||||
// A repeated row: its copies, within the table's and the chart's limits.
|
||||
if let Some(row) = self.rows.last().cloned() {
|
||||
let room = MAX_ROWS.saturating_sub(self.rows.len());
|
||||
let by_cells = MAX_CELLS.saturating_sub(self.cells).checked_div(row.len()).unwrap_or(room);
|
||||
let n = self.row_repeat.saturating_sub(1).min(room).min(by_cells);
|
||||
self.cells += n.saturating_mul(row.len());
|
||||
self.rows.extend(std::iter::repeat_n(row, n));
|
||||
}
|
||||
self.row_repeat = 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn text(&mut self, t: &str) {
|
||||
if self.in_title
|
||||
&& let Some(title) = &mut self.title
|
||||
&& title.len() < MAX_TEXT * 4
|
||||
{
|
||||
title.push_str(t);
|
||||
} else if let Some(cell) = &mut self.cell
|
||||
&& cell.text.len() < MAX_TEXT * 4
|
||||
{
|
||||
cell.text.push_str(t);
|
||||
}
|
||||
}
|
||||
|
||||
/// The chart properties of style `name`, attribute `k`.
|
||||
fn prop(&self, name: Option<&str>, k: &str) -> Option<&str> {
|
||||
name.and_then(|n| self.props.get(n)).and_then(|a| get(a, k))
|
||||
}
|
||||
|
||||
fn cell(&self, (col, row): (usize, usize)) -> Option<&DataCell> {
|
||||
self.rows.get(row).and_then(|r| r.get(col))
|
||||
}
|
||||
|
||||
/// The cells a range address covers, in order, at most [`MAX_POINTS`].
|
||||
fn range(&self, addr: &str) -> Vec<Option<&DataCell>> {
|
||||
cells_of(addr).into_iter().map(|at| self.cell(at)).collect()
|
||||
}
|
||||
|
||||
fn chart(&self) -> Option<ChartSpec> {
|
||||
let class = self.class.as_deref().map(|c| c.trim_start_matches("chart:"))?;
|
||||
let plot = self.plot_style.as_deref();
|
||||
let on = |k: &str| self.prop(plot, k) == Some("true");
|
||||
if on("chart:three-dimensional") || on("chart:percentage") || on("chart:deep") {
|
||||
return None;
|
||||
}
|
||||
let stacked = on("chart:stacked");
|
||||
let kind = match class {
|
||||
"bar" => match (on("chart:vertical"), stacked) {
|
||||
(false, false) => ChartType::Column,
|
||||
(false, true) => ChartType::ColumnStacked,
|
||||
(true, false) => ChartType::Bar,
|
||||
(true, true) => ChartType::BarStacked,
|
||||
},
|
||||
"line" if !stacked => ChartType::Line,
|
||||
"area" if !stacked => ChartType::Area,
|
||||
"circle" => ChartType::Pie,
|
||||
"ring" => ChartType::Doughnut,
|
||||
"scatter" => ChartType::Scatter,
|
||||
_ => return None,
|
||||
};
|
||||
// One plot type on one value axis: a series of another type, or on a second axis, is
|
||||
// a combination chart WordCraft can't edit.
|
||||
let own = |c: &str| {
|
||||
let c = c.trim_start_matches("chart:");
|
||||
c == class || (kind == ChartType::Doughnut && c == "circle")
|
||||
};
|
||||
if self.series.is_empty() || self.series.iter().any(|s| s.secondary || !s.class.as_deref().is_none_or(own)) {
|
||||
return None;
|
||||
}
|
||||
let label = |c: Option<&DataCell>| c.map(|c| c.text.trim().to_string()).unwrap_or_default();
|
||||
let series = self
|
||||
.series
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
// A series without its own range takes the table's next column.
|
||||
let values = match &s.values {
|
||||
Some(addr) => self.range(addr).into_iter().map(|c| c.and_then(value)).collect(),
|
||||
None => self.rows.iter().skip(1).take(MAX_POINTS).map(|r| r.get(i + 1).and_then(value)).collect(),
|
||||
};
|
||||
let name = s.label.as_deref().and_then(|a| cells_of(a).first().copied()).map(|at| label(self.cell(at))).unwrap_or_default();
|
||||
let name = if name.is_empty() { format!("Series {}", i + 1) } else { name };
|
||||
ChartSeries { name, values }
|
||||
})
|
||||
.collect();
|
||||
// A scatter chart's x values: its first series' domain.
|
||||
let domain = self.series.first().and_then(|s| s.domain.as_deref()).filter(|_| kind == ChartType::Scatter);
|
||||
let categories = match domain.or(self.categories.as_deref()) {
|
||||
Some(addr) => self.range(addr).into_iter().map(label).collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
let data_labels = std::iter::once(plot)
|
||||
.chain(self.series.iter().map(|s| s.style.as_deref()))
|
||||
.any(|st| matches!(self.prop(st, "chart:data-label-number"), Some("value" | "percentage" | "value-and-percentage")));
|
||||
let legend = self.legend.as_deref().map(|p| match p {
|
||||
"start" => LegendPos::Left,
|
||||
p if p.starts_with("top") => LegendPos::Top,
|
||||
p if p.starts_with("bottom") => LegendPos::Bottom,
|
||||
_ => LegendPos::Right,
|
||||
});
|
||||
let title = self.title.as_deref().map(str::trim).filter(|t| !t.is_empty()).map(str::to_string);
|
||||
Some(ChartSpec { kind, categories, series, title, legend, data_labels }.sanitized())
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell's number: its value, or its text read as a number.
|
||||
fn value(c: &DataCell) -> Option<f64> {
|
||||
match c.num {
|
||||
Some(v) => v,
|
||||
None => c.text.trim().parse::<f64>().ok().filter(|v| v.is_finite()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A cell address (`$B$2`, `local-table.B2`, `'my table'.$B$2`): its column and row, from 0.
|
||||
fn cell_at(s: &str) -> Option<(usize, usize)> {
|
||||
// The table name, when there is one, ends at the last dot.
|
||||
let s = s.rsplit('.').next().unwrap_or(s).trim().trim_start_matches('$');
|
||||
let letters = s.bytes().take_while(u8::is_ascii_alphabetic).count();
|
||||
if letters == 0 || letters > 3 {
|
||||
return None;
|
||||
}
|
||||
let (col, rest) = s.split_at_checked(letters)?;
|
||||
let col = col.bytes().try_fold(0usize, |n, b| n.checked_mul(26)?.checked_add(usize::from(b.to_ascii_uppercase() - b'A') + 1))?;
|
||||
let row: usize = rest.trim_start_matches('$').parse().ok()?;
|
||||
Some((col.checked_sub(1)?, row.checked_sub(1)?))
|
||||
}
|
||||
|
||||
/// The cells of a range address's first range (`local-table.$B$2:.$B$5`), in reading order: down
|
||||
/// a column, or along a row (a block: down its first column), at most [`MAX_POINTS`].
|
||||
fn cells_of(addr: &str) -> Vec<(usize, usize)> {
|
||||
let first = addr.split_whitespace().next().unwrap_or_default();
|
||||
let mut ends = first.splitn(2, ':');
|
||||
let Some(a) = ends.next().and_then(cell_at) else { return Vec::new() };
|
||||
let b = match ends.next() {
|
||||
Some(e) => match cell_at(e) {
|
||||
Some(b) => b,
|
||||
None => return Vec::new(),
|
||||
},
|
||||
None => a,
|
||||
};
|
||||
let (c0, c1) = (a.0.min(b.0), a.0.max(b.0));
|
||||
let (r0, r1) = (a.1.min(b.1), a.1.max(b.1));
|
||||
if r0 == r1 && c0 != c1 { (c0..=c1).take(MAX_POINTS).map(|c| (c, r0)).collect() } else { (r0..=r1).take(MAX_POINTS).map(|r| (c0, r)).collect() }
|
||||
}
|
||||
|
||||
/// The chart a chart sub-document's `content.xml` holds, when WordCraft can edit it.
|
||||
pub(in crate::odt) fn read_chart(xml: &[u8]) -> Option<ChartSpec> {
|
||||
let mut r = Reader::default();
|
||||
let mut count = 0usize;
|
||||
let mut done = false;
|
||||
events(xml, |ev| {
|
||||
count += 1;
|
||||
if done || count > MAX_EVENTS {
|
||||
return;
|
||||
}
|
||||
match ev {
|
||||
Ev::Start(q, a) => r.start(&q, &a),
|
||||
Ev::End(q) => {
|
||||
if q == "chart:chart" && r.in_chart {
|
||||
done = true;
|
||||
}
|
||||
r.end(&q);
|
||||
}
|
||||
Ev::Text(t) => r.text(&t),
|
||||
}
|
||||
});
|
||||
// A sub-document cut off before its chart ended is no chart.
|
||||
if !done {
|
||||
return None;
|
||||
}
|
||||
r.chart()
|
||||
}
|
||||
@@ -35,12 +35,36 @@ fn element<'a>(xml: &'a str, name: &str) -> &'a str {
|
||||
if xml.get(..tag_end).is_some_and(|t| t.ends_with('/')) {
|
||||
return xml.get(start..=tag_end).unwrap_or("");
|
||||
}
|
||||
// Its end: the close tag at its own depth (a chart object inside holds an `office:body`,
|
||||
// an `office:document` of its own).
|
||||
let close = format!("</{name}>");
|
||||
match xml.get(tag_end..).and_then(|r| r.find(&close)) {
|
||||
Some(e) => xml.get(start..tag_end + e + close.len()).unwrap_or(""),
|
||||
// Unclosed (malformed): to the end.
|
||||
None => xml.get(start..).unwrap_or(""),
|
||||
let mut depth = 0usize;
|
||||
let mut at = tag_end;
|
||||
// Nesting is bounded like any XML here: deeper is malformed.
|
||||
for _ in 0..super::limits::MAX_XML_DEPTH.saturating_mul(2) {
|
||||
let rest = xml.get(at..).unwrap_or("");
|
||||
let next_close = rest.find(&close);
|
||||
let next_open = rest.match_indices(&open).map(|(i, _)| i).find(|&i| {
|
||||
let after = rest.get(i + open.len()..).unwrap_or("");
|
||||
after.chars().next().is_some_and(|c| c == '>' || c == '/' || c.is_whitespace())
|
||||
// Not self-closed.
|
||||
&& after.find('>').and_then(|e| after.get(..e)).is_some_and(|t| !t.ends_with('/'))
|
||||
});
|
||||
match (next_open, next_close) {
|
||||
(Some(o), Some(c)) if o < c => {
|
||||
depth += 1;
|
||||
at += o + open.len();
|
||||
}
|
||||
(_, Some(c)) if depth > 0 => {
|
||||
depth -= 1;
|
||||
at += c + close.len();
|
||||
}
|
||||
(_, Some(c)) => return xml.get(start..at + c + close.len()).unwrap_or(""),
|
||||
// Unclosed (malformed): to the end.
|
||||
(_, None) => return xml.get(start..).unwrap_or(""),
|
||||
}
|
||||
}
|
||||
xml.get(start..).unwrap_or("")
|
||||
}
|
||||
|
||||
/// What is inside element `el` (an element string as [`element`] gives).
|
||||
@@ -232,6 +256,11 @@ pub fn export_flat(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
let math = bytes_of(&format!("{}/content.xml", target.trim_start_matches("./")));
|
||||
let math = String::from_utf8_lossy(&math);
|
||||
let math = math.split_once("?>").map_or(math.as_ref(), |(_, m)| m).trim();
|
||||
// A sub-document (a chart) goes in as an `office:document` (§3.1.2), its root.
|
||||
let math = match math.strip_prefix("<office:document-content").and_then(|m| m.strip_suffix("</office:document-content>")) {
|
||||
Some(inner) => format!("<office:document{inner}</office:document>"),
|
||||
None => math.to_string(),
|
||||
};
|
||||
// A plain `draw:object` around it: the reference's other attributes go with it.
|
||||
let rest_tag_end = after.find("/>").unwrap_or(0);
|
||||
flat.push_str(before.rfind("<draw:object").and_then(|i| before.get(..i)).unwrap_or(before));
|
||||
|
||||
@@ -506,7 +506,9 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
doc,
|
||||
model::FlowOpts { tracked: true, fields: true, notes: true, comments: true, lists: true, chars: true, paras: true, boxes: true },
|
||||
);
|
||||
let accents = doc.settings.theme_colors.get(4..10).map(<[_]>::to_vec).unwrap_or_default();
|
||||
let mut w = Writer::default();
|
||||
w.accents.clone_from(&accents);
|
||||
w.ranged = model::ranged_anchors(&flow.blocks);
|
||||
let mut body = String::new();
|
||||
let sections = flow_sections(&flow.blocks, &doc.last_section);
|
||||
@@ -570,6 +572,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
w.automatic_styles()
|
||||
);
|
||||
let mut hw = Writer::with_prefix("M");
|
||||
hw.accents = accents;
|
||||
let parts: Vec<PageParts> = pages.iter().map(|(_, sec)| page_parts(doc, sec, &mut hw)).collect();
|
||||
let mut named = std::mem::take(&mut w.named);
|
||||
named.append(&mut hw.named);
|
||||
@@ -590,7 +593,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
1,
|
||||
);
|
||||
w.pictures.append(&mut hw.pictures);
|
||||
w.formulas.append(&mut hw.formulas);
|
||||
w.objects.append(&mut hw.objects);
|
||||
let meta = meta_xml(&flow.meta);
|
||||
let mut manifest = String::from(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<manifest:manifest xmlns:manifest=\"urn:oasis:names:tc:opendocument:xmlns:manifest:1.0\" manifest:version=\"1.3\">\n<manifest:file-entry manifest:full-path=\"/\" manifest:version=\"1.3\" manifest:media-type=\"application/vnd.oasis.opendocument.text\"/>\n<manifest:file-entry manifest:full-path=\"content.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"styles.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"meta.xml\" manifest:media-type=\"text/xml\"/>\n<manifest:file-entry manifest:full-path=\"settings.xml\" manifest:media-type=\"text/xml\"/>\n",
|
||||
@@ -598,9 +601,9 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
for (name, _, mime) in &w.pictures {
|
||||
manifest.push_str(&format!("<manifest:file-entry manifest:full-path=\"{}\" manifest:media-type=\"{mime}\"/>\n", x(name)));
|
||||
}
|
||||
for (dir, _) in &w.formulas {
|
||||
for (dir, _, mime) in &w.objects {
|
||||
manifest.push_str(&format!(
|
||||
"<manifest:file-entry manifest:full-path=\"{0}/\" manifest:version=\"1.3\" manifest:media-type=\"application/vnd.oasis.opendocument.formula\"/>\n<manifest:file-entry manifest:full-path=\"{0}/content.xml\" manifest:media-type=\"text/xml\"/>\n",
|
||||
"<manifest:file-entry manifest:full-path=\"{0}/\" manifest:version=\"1.3\" manifest:media-type=\"{mime}\"/>\n<manifest:file-entry manifest:full-path=\"{0}/content.xml\" manifest:media-type=\"text/xml\"/>\n",
|
||||
x(dir)
|
||||
));
|
||||
}
|
||||
@@ -623,7 +626,7 @@ pub fn export(doc: &Document) -> Result<Vec<u8>, String> {
|
||||
z.start_file(name, deflated).map_err(err)?;
|
||||
z.write_all(data).map_err(ioerr)?;
|
||||
}
|
||||
for (dir, xml) in &w.formulas {
|
||||
for (dir, xml, _) in &w.objects {
|
||||
z.start_file(format!("{dir}/content.xml"), deflated).map_err(err)?;
|
||||
z.write_all(xml.as_bytes()).map_err(ioerr)?;
|
||||
}
|
||||
|
||||
@@ -198,7 +198,7 @@ pub(super) fn package(content: &str, styles: Option<&str>) -> Vec<u8> {
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn paras(f: &Flow) -> Vec<&Para> {
|
||||
pub(super) fn paras(f: &Flow) -> Vec<&Para> {
|
||||
f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }).collect()
|
||||
}
|
||||
|
||||
@@ -1146,6 +1146,12 @@ pub(super) fn content_of(bytes: &[u8]) -> String {
|
||||
String::from_utf8(read_entry(&mut z, "content.xml").unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// Entry `name` of a package, as text.
|
||||
pub(super) fn entry(bytes: &[u8], name: &str) -> String {
|
||||
let mut z = archive(bytes);
|
||||
String::from_utf8(read_entry(&mut z, name).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
/// The `styles.xml` of a package.
|
||||
pub(super) fn styles_of(bytes: &[u8]) -> String {
|
||||
let mut z = archive(bytes);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod bookmarks;
|
||||
mod char_styles;
|
||||
mod chars;
|
||||
mod charts;
|
||||
mod comments;
|
||||
mod drop_caps;
|
||||
mod equations;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
//! Charts (ODF 1.3 §10.4.3 embedded objects holding an `office:chart` sub-document, Part 3
|
||||
//! §11): read into WordCraft's editable chart (type, categories, series, title, legend, data
|
||||
//! labels) from the chart's own data table, so they're drawn and edited like charts WordCraft
|
||||
//! makes. Charts WordCraft can't edit keep their replacement picture.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::model::Inline;
|
||||
use crate::odt::parse;
|
||||
use crate::odt::tests::{NS_T, paras, zip_of};
|
||||
use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos};
|
||||
|
||||
const NS_C: &str = r#"xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:chart="urn:oasis:names:tc:opendocument:xmlns:chart:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink""#;
|
||||
|
||||
/// LibreOffice's data table for categories Q1–Q4 and series North and South (South has a gap
|
||||
/// in Q2, written `NaN`), with its labels in the first row and column.
|
||||
const TABLE: &str = r#"<table:table table:name="local-table"><table:table-header-columns><table:table-column/></table:table-header-columns><table:table-columns><table:table-column table:number-columns-repeated="2"/></table:table-columns><table:table-header-rows><table:table-row><table:table-cell><text:p/></table:table-cell><table:table-cell office:value-type="string"><text:p>North</text:p></table:table-cell><table:table-cell office:value-type="string"><text:p>South</text:p></table:table-cell></table:table-row></table:table-header-rows><table:table-rows><table:table-row><table:table-cell office:value-type="string"><text:p>Q1</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="10"><text:p>10</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="12"><text:p>12</text:p></table:table-cell></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Q2</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="14"><text:p>14</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="NaN"><text:p>NaN</text:p></table:table-cell></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Q3</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="8"><text:p>8</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="11"><text:p>11</text:p></table:table-cell></table:table-row><table:table-row><table:table-cell office:value-type="string"><text:p>Q4</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="16"><text:p>16</text:p></table:table-cell><table:table-cell office:value-type="float" office:value="13"><text:p>13</text:p></table:table-cell></table:table-row></table:table-rows></table:table>"#;
|
||||
|
||||
/// The two series over [`TABLE`], each `class`, with style `ser` (and its data points).
|
||||
fn series(class: &str) -> String {
|
||||
format!(
|
||||
r#"<chart:series chart:style-name="ser" chart:values-cell-range-address="local-table.$B$2:.$B$5" chart:label-cell-address="local-table.$B$1" chart:class="{class}"><chart:data-point chart:repeated="4"/></chart:series><chart:series chart:style-name="ser" chart:values-cell-range-address="local-table.$C$2:.$C$5" chart:label-cell-address="local-table.$C$1" chart:class="{class}"><chart:data-point chart:repeated="4"/></chart:series>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A chart sub-document's `content.xml` as LibreOffice writes it: chart class `class`, plot
|
||||
/// area chart properties `plot`, series style chart properties `ser`, and `inner` (title,
|
||||
/// legend) before the plot area, holding `series`.
|
||||
fn chart_xml(class: &str, plot: &str, ser: &str, inner: &str, series: &str) -> String {
|
||||
format!(
|
||||
r#"<?xml version="1.0" encoding="UTF-8"?><office:document-content {NS_C}><office:automatic-styles><style:style style:name="plot" style:family="chart"><style:chart-properties {plot}/></style:style><style:style style:name="ser" style:family="chart"><style:chart-properties {ser}/></style:style></office:automatic-styles><office:body><office:chart><chart:chart svg:width="15cm" svg:height="7.5cm" chart:class="{class}">{inner}<chart:plot-area chart:style-name="plot" chart:data-source-has-labels="both"><chart:axis chart:dimension="x" chart:name="primary-x"><chart:categories table:cell-range-address="local-table.$A$2:.$A$5"/></chart:axis><chart:axis chart:dimension="y" chart:name="primary-y"/>{series}<chart:wall/><chart:floor/></chart:plot-area>{TABLE}</chart:chart></office:chart></office:body></office:document-content>"#
|
||||
)
|
||||
}
|
||||
|
||||
/// What the document holding chart object `chart` (with a replacement picture) reads as: the
|
||||
/// paragraph's charts, and whether a picture was read instead.
|
||||
fn read(chart: &str) -> (Vec<crate::model::Chart>, bool) {
|
||||
let frame = r#"<draw:frame draw:name="Object1" text:anchor-type="as-char" svg:width="5.9055in" svg:height="2.9528in"><draw:object xlink:href="./Object 1" xlink:type="simple"/><draw:image xlink:href="./ObjectReplacements/Object 1"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink"><office:body><office:text><text:p>Before {frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let img = image::RgbaImage::from_fn(4, 2, |_, _| image::Rgba([0, 80, 160, 255]));
|
||||
let mut png = Vec::new();
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let flow =
|
||||
parse(&zip_of(&[("content.xml", content.as_bytes()), ("Object 1/content.xml", chart.as_bytes()), ("ObjectReplacements/Object 1", &png)]))
|
||||
.unwrap();
|
||||
let inlines: Vec<Inline> = paras(&flow).into_iter().flat_map(|p| p.inlines.clone()).collect();
|
||||
let charts = inlines.iter().filter_map(|i| if let Inline::Chart(c) = i { Some(c.clone()) } else { None }).collect();
|
||||
(charts, inlines.iter().any(|i| matches!(i, Inline::Image(_))))
|
||||
}
|
||||
|
||||
fn spec(chart: &str) -> Option<ChartSpec> {
|
||||
let (charts, _) = read(chart);
|
||||
charts.first().map(|c| (*c.spec).clone())
|
||||
}
|
||||
|
||||
fn north_south() -> Vec<ChartSeries> {
|
||||
vec![
|
||||
ChartSeries { name: "North".into(), values: vec![Some(10.0), Some(14.0), Some(8.0), Some(16.0)] },
|
||||
ChartSeries { name: "South".into(), values: vec![Some(12.0), None, Some(11.0), Some(13.0)] },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_column_chart_reads_its_data_title_and_legend() {
|
||||
let inner = r#"<chart:title><text:p>Sales by quarter</text:p></chart:title><chart:legend chart:legend-position="end"/>"#;
|
||||
let xml = chart_xml("chart:bar", r#"chart:vertical="false""#, r#"chart:data-label-number="none""#, inner, &series("chart:bar"));
|
||||
let (charts, picture) = read(&xml);
|
||||
assert!(!picture, "the chart replaces its preview picture");
|
||||
let c = charts.first().expect("a chart");
|
||||
assert_eq!(
|
||||
*c.spec,
|
||||
ChartSpec {
|
||||
kind: ChartType::Column,
|
||||
categories: vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()],
|
||||
series: north_south(),
|
||||
title: Some("Sales by quarter".into()),
|
||||
legend: Some(LegendPos::Right),
|
||||
data_labels: false,
|
||||
}
|
||||
);
|
||||
// The frame's size, in points.
|
||||
assert_eq!(((c.w * 10.0).round(), (c.h * 10.0).round()), (4252.0, 2126.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bar_stacking_legend_and_labels_come_from_the_styles() {
|
||||
let cases = [
|
||||
(r#"chart:vertical="true""#, ChartType::Bar),
|
||||
(r#"chart:vertical="true" chart:stacked="true""#, ChartType::BarStacked),
|
||||
(r#"chart:stacked="true""#, ChartType::ColumnStacked),
|
||||
];
|
||||
for (plot, kind) in cases {
|
||||
let xml = chart_xml(
|
||||
"chart:bar",
|
||||
plot,
|
||||
r#"chart:data-label-number="value""#,
|
||||
r#"<chart:legend chart:legend-position="bottom"/>"#,
|
||||
&series("chart:bar"),
|
||||
);
|
||||
let s = spec(&xml).expect(plot);
|
||||
assert_eq!((s.kind, s.legend, s.data_labels, s.title.clone()), (kind, Some(LegendPos::Bottom), true, None), "{plot}");
|
||||
assert_eq!(s.series, north_south());
|
||||
}
|
||||
// No legend element: no legend. Legend corners and sides.
|
||||
for (pos, want) in [
|
||||
("", None),
|
||||
("start", Some(LegendPos::Left)),
|
||||
("top", Some(LegendPos::Top)),
|
||||
("top-end", Some(LegendPos::Top)),
|
||||
("bottom-start", Some(LegendPos::Bottom)),
|
||||
] {
|
||||
let legend = if pos.is_empty() { String::new() } else { format!(r#"<chart:legend chart:legend-position="{pos}"/>"#) };
|
||||
let s = spec(&chart_xml("chart:bar", "", "", &legend, &series("chart:bar"))).unwrap();
|
||||
assert_eq!(s.legend, want, "{pos}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_area_pie_doughnut_and_scatter_classes() {
|
||||
for (class, ser_class, kind) in [
|
||||
("chart:line", "chart:line", ChartType::Line),
|
||||
("chart:area", "chart:area", ChartType::Area),
|
||||
("chart:circle", "chart:circle", ChartType::Pie),
|
||||
// LibreOffice writes a doughnut's series as `chart:circle` in a `chart:ring` chart.
|
||||
("chart:ring", "chart:circle", ChartType::Doughnut),
|
||||
] {
|
||||
let s = spec(&chart_xml(class, "", "", "", &series(ser_class))).expect(class);
|
||||
assert_eq!(s.kind, kind, "{class}");
|
||||
assert_eq!(s.series, north_south());
|
||||
}
|
||||
// A scatter chart's x values are its first series' domain: they're its categories.
|
||||
let scatter = r#"<chart:series chart:values-cell-range-address="local-table.$C$2:.$C$5" chart:label-cell-address="local-table.$C$1" chart:class="chart:scatter"><chart:domain table:cell-range-address="local-table.$B$2:.$B$5"/></chart:series>"#;
|
||||
let s = spec(&chart_xml("chart:scatter", "", "", "", scatter)).unwrap();
|
||||
assert_eq!(s.kind, ChartType::Scatter);
|
||||
assert_eq!(s.categories, vec!["10", "14", "8", "16"]);
|
||||
assert_eq!(s.series, vec![ChartSeries { name: "South".into(), values: vec![Some(12.0), None, Some(11.0), Some(13.0)] }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charts_wordcraft_cannot_edit_keep_their_picture() {
|
||||
// Percent-stacked, a radar chart, a chart mixing columns and a line, 3D: the picture.
|
||||
let mixed = format!(
|
||||
"{}{}",
|
||||
series("chart:bar").split("</chart:series>").next().unwrap_or_default().to_string() + "</chart:series>",
|
||||
r#"<chart:series chart:values-cell-range-address="local-table.$C$2:.$C$5" chart:class="chart:line"/>"#
|
||||
);
|
||||
for xml in [
|
||||
chart_xml("chart:bar", r#"chart:stacked="true" chart:percentage="true""#, "", "", &series("chart:bar")),
|
||||
chart_xml("chart:radar", "", "", "", &series("chart:radar")),
|
||||
chart_xml("chart:bar", "", "", "", &mixed),
|
||||
chart_xml("chart:bar", r#"chart:three-dimensional="true""#, "", "", &series("chart:bar")),
|
||||
// Not a chart at all, or broken.
|
||||
"<office:document-content><office:body><office:text/></office:body></office:document-content>".to_string(),
|
||||
"<office:document-content><office:body><office:chart><chart:chart chart:class=\"chart:bar\">".to_string(),
|
||||
] {
|
||||
let (charts, picture) = read(&xml);
|
||||
assert!(charts.is_empty() && picture, "{xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_chart_tables_are_bounded() {
|
||||
// Huge repeat counts, references far outside the table and into reversed or broken ranges:
|
||||
// never a panic or a huge allocation, and the series stay within the chart's limits.
|
||||
let table = r#"<table:table><table:table-rows><table:table-row table:number-rows-repeated="2000000000"><table:table-cell table:number-columns-repeated="2000000000" office:value-type="float" office:value="1e308"/></table:table-row></table:table-rows></table:table>"#;
|
||||
let series = r#"<chart:series chart:values-cell-range-address="t.$A$1:.$A$99999999"/><chart:series chart:values-cell-range-address="t.$ZZZZZZZZ$1:.$A$1"/><chart:series chart:values-cell-range-address="t.$B$9:.$B$1"/><chart:series chart:values-cell-range-address=":::"/><chart:series chart:label-cell-address="t.$A$0"/>"#;
|
||||
let xml = format!(
|
||||
r#"<office:document-content><office:body><office:chart><chart:chart chart:class="chart:line"><chart:plot-area><chart:axis chart:dimension="x"><chart:categories table:cell-range-address="t.A1:XFD1048576"/></chart:axis>{series}</chart:plot-area>{table}</chart:chart></office:chart></office:body></office:document-content>"#
|
||||
);
|
||||
let s = spec(&xml).expect("a chart");
|
||||
assert!(s.series.len() <= wordcraft_doc::chart::MAX_SERIES);
|
||||
assert!(s.series.iter().all(|x| x.values.len() <= wordcraft_doc::chart::MAX_POINTS));
|
||||
assert!(s.categories.len() <= wordcraft_doc::chart::MAX_POINTS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_imported_chart_is_an_editable_chart_object() {
|
||||
let xml = chart_xml("chart:line", "", "", "", &series("chart:line"));
|
||||
let frame = r#"<draw:frame svg:width="4in" svg:height="2in"><draw:object xlink:href="./Object 1"/></draw:frame>"#;
|
||||
let content = format!(
|
||||
r#"<office:document-content {NS_T} xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink"><office:body><office:text><text:p>{frame}</text:p></office:text></office:body></office:document-content>"#
|
||||
);
|
||||
let doc = crate::odt::import(&zip_of(&[("content.xml", content.as_bytes()), ("Object 1/content.xml", xml.as_bytes())])).unwrap();
|
||||
let graphics: Vec<Arc<wordcraft_doc::graphic::Graphic>> = doc
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects_with_offsets())
|
||||
.filter_map(
|
||||
|(_, o)| if let wordcraft_doc::para::InlineObject::Graphic { graphic, w, h, .. } = o { Some((graphic.clone(), *w, *h)) } else { None },
|
||||
)
|
||||
.map(|(g, w, h)| {
|
||||
assert_eq!((w, h), (288.0, 144.0));
|
||||
g
|
||||
})
|
||||
.collect();
|
||||
let g = graphics.first().expect("a chart object");
|
||||
assert_eq!(g.kind, wordcraft_doc::graphic::GraphicKind::Chart);
|
||||
assert_eq!(g.chart.as_ref().map(|c| c.kind), Some(ChartType::Line));
|
||||
}
|
||||
|
||||
/// A document holding `spec` as a chart `w` × `h` points, after some text.
|
||||
fn doc_with(spec: ChartSpec, float: bool) -> wordcraft_doc::Document {
|
||||
let flow = crate::model::Flow {
|
||||
blocks: vec![crate::model::FBlock::Para(crate::model::Para {
|
||||
inlines: vec![
|
||||
Inline::Text("See ".into(), Default::default()),
|
||||
Inline::Chart(crate::model::Chart {
|
||||
spec: Arc::new(spec),
|
||||
w: 300.0,
|
||||
h: 180.0,
|
||||
alt: "Sales chart".into(),
|
||||
float: float.then(|| {
|
||||
Box::new(wordcraft_doc::para::Float { wrap: wordcraft_doc::para::Wrap::Square, x: 20.0, y: 10.0, ..Default::default() })
|
||||
}),
|
||||
}),
|
||||
],
|
||||
..Default::default()
|
||||
})],
|
||||
..Default::default()
|
||||
};
|
||||
crate::model::to_doc(&flow)
|
||||
}
|
||||
|
||||
/// The charts the saved document reads back as.
|
||||
fn reread(bytes: &[u8]) -> Vec<crate::model::Chart> {
|
||||
paras(&parse(bytes).unwrap())
|
||||
.into_iter()
|
||||
.flat_map(|p| p.inlines.clone())
|
||||
.filter_map(|i| if let Inline::Chart(c) = i { Some(c) } else { None })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_chart_type_is_saved_as_a_chart_object_and_reads_back() {
|
||||
for kind in ChartType::ALL {
|
||||
let mut spec = ChartSpec::sample(kind);
|
||||
spec.title = Some("Q & A <totals>".into());
|
||||
spec.data_labels = kind == ChartType::Bar;
|
||||
spec.legend = Some(LegendPos::Left);
|
||||
if let Some(s) = spec.series.first_mut()
|
||||
&& let Some(v) = s.values.get_mut(1)
|
||||
{
|
||||
*v = None;
|
||||
}
|
||||
let bytes = crate::odt::export(&doc_with(spec.clone(), false)).unwrap();
|
||||
let back = reread(&bytes);
|
||||
assert_eq!(back.len(), 1, "{kind:?}");
|
||||
assert_eq!(*back[0].spec, spec.clone().sanitized(), "{kind:?}");
|
||||
assert_eq!((back[0].w, back[0].h, back[0].alt.as_str()), (300.0, 180.0, "Sales chart"), "{kind:?}");
|
||||
// The package lists the chart object.
|
||||
let z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
|
||||
assert!(z.file_names().any(|n| n == "Object 1/content.xml"), "{kind:?}");
|
||||
let manifest = crate::odt::tests::entry(&bytes, "META-INF/manifest.xml");
|
||||
assert!(
|
||||
manifest
|
||||
.contains(r#"manifest:full-path="Object 1/" manifest:version="1.3" manifest:media-type="application/vnd.oasis.opendocument.chart""#)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floating_chart_keeps_its_place_and_wrap() {
|
||||
let bytes = crate::odt::export(&doc_with(ChartSpec::sample(ChartType::Column), true)).unwrap();
|
||||
let back = reread(&bytes);
|
||||
let f = back.first().and_then(|c| c.float.as_deref()).expect("a floating chart");
|
||||
assert_eq!((f.wrap, f.x.round(), f.y.round()), (wordcraft_doc::para::Wrap::Square, 20.0, 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_document_holds_its_chart_inline() {
|
||||
let flat = crate::odt::export_flat(&doc_with(ChartSpec::sample(ChartType::Pie), false)).unwrap();
|
||||
let text = String::from_utf8(flat.clone()).unwrap();
|
||||
assert!(text.contains("<draw:object><office:document"), "{text}");
|
||||
let back: Vec<crate::model::Chart> = crate::odt::import_flat(&flat)
|
||||
.unwrap()
|
||||
.body
|
||||
.iter()
|
||||
.filter_map(|b| b.as_para())
|
||||
.flat_map(|p| p.objects.clone())
|
||||
.filter_map(|o| match o {
|
||||
wordcraft_doc::para::InlineObject::Graphic { graphic, w, h, alt, .. } => {
|
||||
graphic.chart.clone().map(|spec| crate::model::Chart { spec, w, h, alt, float: None })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(back.first().map(|c| c.spec.kind), Some(ChartType::Pie));
|
||||
}
|
||||
@@ -109,3 +109,18 @@ fn hostile_flat_documents_do_not_panic() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inline_sub_document_does_not_end_the_body() {
|
||||
// A chart object inline holds an `office:document` with an `office:body` of its own: the
|
||||
// outer body goes on past it (the text after the chart stays), and deep nesting is bounded.
|
||||
let chart = r#"<draw:frame svg:width="2in" svg:height="1in"><draw:object><office:document office:mimetype="application/vnd.oasis.opendocument.chart"><office:body><office:chart/></office:body></office:document></draw:object></draw:frame>"#;
|
||||
let xml = format!(
|
||||
r#"<?xml version="1.0"?><office:document xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"><office:body><office:text><text:p>Before{chart}</text:p><text:p>After the chart.</text:p></office:text></office:body></office:document>"#
|
||||
);
|
||||
let d = import_flat(xml.as_bytes()).unwrap();
|
||||
let text: Vec<String> = d.body.iter().filter_map(|b| b.as_para()).map(|p| p.text.replace(wordcraft_doc::para::OBJ, "")).collect();
|
||||
assert_eq!(text, ["Before", "After the chart."]);
|
||||
let deep = format!("<office:document><office:body>{}", "<office:body>".repeat(5000));
|
||||
let _ = import_flat(deep.as_bytes());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::{Align, FBlock, FTable, Fmt, HashMap, Inline, Kind, Para, RevMark, Rgb, VAlign, mime_of, model};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, LineSpacing};
|
||||
|
||||
mod charts;
|
||||
mod drawing;
|
||||
mod indexes;
|
||||
mod notes;
|
||||
@@ -143,8 +144,10 @@ pub(super) struct Writer {
|
||||
/// Graphic styles of floating pictures: their `style:graphic-properties` attributes.
|
||||
graphic_styles: Vec<String>,
|
||||
pub(super) pictures: Vec<(String, std::sync::Arc<Vec<u8>>, String)>,
|
||||
/// Formula sub-documents: their directory and their `content.xml`.
|
||||
pub(super) formulas: Vec<(String, String)>,
|
||||
/// Object sub-documents (formulas, charts): their directory, `content.xml` and media type.
|
||||
pub(super) objects: Vec<(String, String, &'static str)>,
|
||||
/// The theme's accent colours, which charts' series are drawn in.
|
||||
pub(super) accents: Vec<wordcraft_doc::Rgb>,
|
||||
/// `text:changed-region` elements written so far.
|
||||
pub(super) regions: Vec<String>,
|
||||
/// The insertion open in the body, and its region id.
|
||||
@@ -395,6 +398,10 @@ impl Writer {
|
||||
prev_space = false;
|
||||
}
|
||||
Inline::Figure(_) => {}
|
||||
Inline::Chart(c) => {
|
||||
self.chart(c, Place::Text(c.float.as_deref()), out);
|
||||
prev_space = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! Charts written as ODF chart objects (ODF 1.3 §10.4.3 `draw:object` holding an `office:chart`
|
||||
//! sub-document, Part 3 §11), shaped like LibreOffice's: the chart's data in its own table
|
||||
//! (`local-table`, labels in the first row and column), each series pointing at its column, its
|
||||
//! colour the theme accent WordCraft draws it in.
|
||||
|
||||
use wordcraft_doc::Rgb;
|
||||
use wordcraft_doc::chart::{ChartSpec, ChartType, LegendPos};
|
||||
|
||||
use super::drawing::{Place, placement};
|
||||
use super::{Writer, pt, x};
|
||||
use crate::model;
|
||||
|
||||
/// The table's column `i` (from 0) as a cell address names it: `A`, `B`… `Z`, `AA`…
|
||||
fn column(i: usize) -> String {
|
||||
let mut n = i + 1;
|
||||
let mut s = Vec::new();
|
||||
while n > 0 {
|
||||
let d = (n - 1) % 26;
|
||||
s.push(b'A' + d as u8);
|
||||
n = (n - 1) / 26;
|
||||
}
|
||||
s.iter().rev().map(|&b| char::from(b)).collect()
|
||||
}
|
||||
|
||||
/// `local-table.$C$2:.$C$5`: column `col`'s rows 2 to `n + 1` (the data under its label).
|
||||
fn data_range(col: usize, n: usize) -> String {
|
||||
let c = column(col);
|
||||
format!("local-table.${c}$2:.${c}${}", n + 1)
|
||||
}
|
||||
|
||||
/// Most slices given their own colour (the rest take the chart's own colours).
|
||||
const MAX_SLICE_STYLES: usize = 64;
|
||||
|
||||
/// The `i`th series' (or slice's) colour, `#rrggbb`: the accents in turn.
|
||||
fn accent(accents: &[Rgb], i: usize) -> String {
|
||||
match accents.get(i.checked_rem(accents.len()).unwrap_or(0)) {
|
||||
Some(c) => format!("#{}", c.hex().to_ascii_lowercase()),
|
||||
None => "#4472c4".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as a table cell's `office:value`: gaps are `NaN`, as LibreOffice writes them.
|
||||
fn number(v: Option<f64>) -> String {
|
||||
match v.filter(|v| v.is_finite()) {
|
||||
Some(v) => format!("{v}"),
|
||||
None => "NaN".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A chart sub-document's `content.xml` for `spec`, `w` × `h` points, series in `accents`.
|
||||
pub(in crate::odt) fn chart_xml(spec: &ChartSpec, w: f32, h: f32, accents: &[Rgb]) -> String {
|
||||
let spec = spec.clone().sanitized();
|
||||
let (class, series_class) = match spec.kind {
|
||||
ChartType::Column | ChartType::ColumnStacked | ChartType::Bar | ChartType::BarStacked => ("bar", "bar"),
|
||||
ChartType::Line => ("line", "line"),
|
||||
ChartType::Area => ("area", "area"),
|
||||
ChartType::Pie => ("circle", "circle"),
|
||||
ChartType::Doughnut => ("ring", "circle"),
|
||||
ChartType::Scatter => ("scatter", "scatter"),
|
||||
};
|
||||
let vertical = matches!(spec.kind, ChartType::Bar | ChartType::BarStacked);
|
||||
let stacked = matches!(spec.kind, ChartType::ColumnStacked | ChartType::BarStacked);
|
||||
let labels = if spec.data_labels { " chart:data-label-number=\"value\"" } else { " chart:data-label-number=\"none\"" };
|
||||
let n = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len());
|
||||
// Scatter charts are points without lines.
|
||||
let points = if spec.kind == ChartType::Scatter { " chart:lines=\"false\" chart:symbol-type=\"automatic\"" } else { "" };
|
||||
// A white wall, labelled axes in grey, a legend without a frame: as WordCraft draws them.
|
||||
let mut styles = format!(
|
||||
"<style:style style:name=\"plot\" style:family=\"chart\"><style:chart-properties chart:vertical=\"{vertical}\" chart:stacked=\"{stacked}\"{labels}{points}/></style:style>\
|
||||
<style:style style:name=\"wall\" style:family=\"chart\"><style:graphic-properties draw:stroke=\"none\" draw:fill=\"none\"/></style:style>\
|
||||
<style:style style:name=\"axis\" style:family=\"chart\"><style:chart-properties chart:display-label=\"true\"/><style:graphic-properties svg:stroke-color=\"#b3b3b3\"/><style:text-properties fo:font-size=\"8pt\"/></style:style>\
|
||||
<style:style style:name=\"round\" style:family=\"chart\"><style:chart-properties chart:reverse-direction=\"true\"/></style:style>\
|
||||
<style:style style:name=\"grid\" style:family=\"chart\"><style:graphic-properties svg:stroke-color=\"#d9d9d9\"/></style:style>\
|
||||
<style:style style:name=\"legend\" style:family=\"chart\"><style:graphic-properties draw:stroke=\"none\" draw:fill=\"none\"/><style:text-properties fo:font-size=\"8pt\"/></style:style>"
|
||||
);
|
||||
// A pie's slices take the accents in turn.
|
||||
if spec.kind.is_round() {
|
||||
for k in 0..n.min(MAX_SLICE_STYLES) {
|
||||
styles.push_str(&format!(
|
||||
"<style:style style:name=\"p{k}\" style:family=\"chart\"><style:graphic-properties draw:stroke=\"none\" draw:fill-color=\"{}\"/></style:style>",
|
||||
accent(accents, k)
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut series = String::new();
|
||||
for i in 0..spec.series.len() {
|
||||
let col = i + 1;
|
||||
let fill = accent(accents, i);
|
||||
let line = match spec.kind {
|
||||
ChartType::Line => format!(" draw:stroke=\"solid\" svg:stroke-color=\"{fill}\" svg:stroke-width=\"0.06cm\""),
|
||||
_ => " draw:stroke=\"none\"".to_string(),
|
||||
};
|
||||
styles.push_str(&format!(
|
||||
"<style:style style:name=\"s{col}\" style:family=\"chart\"><style:chart-properties{labels}/><style:graphic-properties draw:fill-color=\"{fill}\"{line}/></style:style>"
|
||||
));
|
||||
// A scatter series' x values are the first column's.
|
||||
let domain = if spec.kind == ChartType::Scatter && n > 0 {
|
||||
format!("<chart:domain table:cell-range-address=\"{}\"/>", data_range(0, n))
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let values = if n > 0 { format!(" chart:values-cell-range-address=\"{}\"", data_range(col, n)) } else { String::new() };
|
||||
let slices: String = if spec.kind.is_round() {
|
||||
(0..n.min(MAX_SLICE_STYLES)).map(|k| format!("<chart:data-point chart:style-name=\"p{k}\"/>")).collect()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
series.push_str(&format!(
|
||||
"<chart:series chart:style-name=\"s{col}\"{values} chart:label-cell-address=\"local-table.${}$1\" chart:class=\"chart:{series_class}\">{domain}{slices}</chart:series>",
|
||||
column(col)
|
||||
));
|
||||
}
|
||||
let title = spec.title.as_deref().map(|t| format!("<chart:title><text:p>{}</text:p></chart:title>", x(t))).unwrap_or_default();
|
||||
let legend = spec
|
||||
.legend
|
||||
.map(|p| {
|
||||
let pos = match p {
|
||||
LegendPos::Right => "end",
|
||||
LegendPos::Left => "start",
|
||||
LegendPos::Top => "top",
|
||||
LegendPos::Bottom => "bottom",
|
||||
};
|
||||
format!("<chart:legend chart:legend-position=\"{pos}\" chart:style-name=\"legend\"/>")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let categories = if n > 0 { format!("<chart:categories table:cell-range-address=\"{}\"/>", data_range(0, n)) } else { String::new() };
|
||||
let axes = format!(
|
||||
"<chart:axis chart:dimension=\"x\" chart:name=\"primary-x\" chart:style-name=\"axis\">{categories}</chart:axis><chart:axis chart:dimension=\"y\" chart:name=\"primary-y\" chart:style-name=\"{}\">{}</chart:axis>",
|
||||
// A pie runs clockwise from the top, as WordCraft draws it.
|
||||
if spec.kind.is_round() { "round" } else { "axis" },
|
||||
if spec.kind.is_round() { "" } else { "<chart:grid chart:class=\"major\" chart:style-name=\"grid\"/>" }
|
||||
);
|
||||
// The table: the series' names over their columns, a row per category.
|
||||
let mut table =
|
||||
String::from("<table:table table:name=\"local-table\"><table:table-header-columns><table:table-column/></table:table-header-columns>");
|
||||
if !spec.series.is_empty() {
|
||||
table.push_str(&format!(
|
||||
"<table:table-columns><table:table-column table:number-columns-repeated=\"{}\"/></table:table-columns>",
|
||||
spec.series.len()
|
||||
));
|
||||
}
|
||||
table.push_str("<table:table-header-rows><table:table-row><table:table-cell><text:p/></table:table-cell>");
|
||||
for s in &spec.series {
|
||||
table.push_str(&format!("<table:table-cell office:value-type=\"string\"><text:p>{}</text:p></table:table-cell>", x(&s.name)));
|
||||
}
|
||||
table.push_str("</table:table-row></table:table-header-rows><table:table-rows>");
|
||||
for k in 0..n {
|
||||
let cat = spec.categories.get(k).map(String::as_str).unwrap_or("");
|
||||
table.push_str("<table:table-row>");
|
||||
// A scatter chart's x values are numbers.
|
||||
match cat.trim().parse::<f64>().ok().filter(|v| v.is_finite() && spec.kind == ChartType::Scatter) {
|
||||
Some(v) => table.push_str(&format!(
|
||||
"<table:table-cell office:value-type=\"float\" office:value=\"{v}\"><text:p>{}</text:p></table:table-cell>",
|
||||
x(cat)
|
||||
)),
|
||||
None => table.push_str(&format!("<table:table-cell office:value-type=\"string\"><text:p>{}</text:p></table:table-cell>", x(cat))),
|
||||
}
|
||||
for s in &spec.series {
|
||||
let v = number(s.values.get(k).copied().flatten());
|
||||
table.push_str(&format!("<table:table-cell office:value-type=\"float\" office:value=\"{v}\"><text:p>{v}</text:p></table:table-cell>"));
|
||||
}
|
||||
table.push_str("</table:table-row>");
|
||||
}
|
||||
table.push_str("</table:table-rows></table:table>");
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<office:document-content xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\" xmlns:style=\"urn:oasis:names:tc:opendocument:xmlns:style:1.0\" xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\" xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\" xmlns:draw=\"urn:oasis:names:tc:opendocument:xmlns:drawing:1.0\" xmlns:chart=\"urn:oasis:names:tc:opendocument:xmlns:chart:1.0\" xmlns:svg=\"urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0\" xmlns:fo=\"urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" office:version=\"1.3\"><office:automatic-styles>{styles}</office:automatic-styles><office:body><office:chart><chart:chart svg:width=\"{}\" svg:height=\"{}\" chart:class=\"chart:{class}\">{title}{legend}<chart:plot-area chart:style-name=\"plot\" chart:data-source-has-labels=\"both\">{axes}{series}<chart:wall chart:style-name=\"wall\"/></chart:plot-area>{table}</chart:chart></office:chart></office:body></office:document-content>\n",
|
||||
pt(w),
|
||||
pt(h)
|
||||
)
|
||||
}
|
||||
|
||||
impl Writer {
|
||||
/// A chart: its frame here, placed as its float says, and its chart object in the package.
|
||||
pub(in crate::odt) fn chart(&mut self, c: &model::Chart, place: Place<'_>, out: &mut String) {
|
||||
let n = self.objects.len() + 1;
|
||||
let name = format!("Object {}{n}", self.prefix);
|
||||
self.objects.push((name.clone(), chart_xml(&c.spec, c.w, c.h, &self.accents), "application/vnd.oasis.opendocument.chart"));
|
||||
let (anchor, style) = match place {
|
||||
Place::Text(None) => (" text:anchor-type=\"as-char\"".to_string(), String::new()),
|
||||
_ => {
|
||||
let (anchor, props, xy) = placement(place, c.w, c.h);
|
||||
(anchor, format!(" draw:style-name=\"{}\"{xy}", self.graphic_style(props)))
|
||||
}
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"<draw:frame draw:name=\"{}Chart{n}\"{style}{anchor} svg:width=\"{}\" svg:height=\"{}\" draw:z-index=\"0\"><draw:object xlink:href=\"./{}\" xlink:type=\"simple\" xlink:show=\"embed\" xlink:actuate=\"onLoad\"/>",
|
||||
self.prefix,
|
||||
pt(c.w),
|
||||
pt(c.h),
|
||||
x(&name)
|
||||
));
|
||||
if !c.alt.is_empty() {
|
||||
out.push_str(&format!("<svg:desc>{}</svg:desc>", x(&c.alt)));
|
||||
}
|
||||
out.push_str("</draw:frame>");
|
||||
}
|
||||
}
|
||||
@@ -236,6 +236,7 @@ impl Writer {
|
||||
self.drawing_shape(*kind, (*w, *h), Place::Member(x, y, float.as_deref()), (look, path.as_deref()), blocks, out)
|
||||
}
|
||||
Inline::Image(img) => self.image(img, Place::Member(x, y, img.float.as_deref()), out),
|
||||
Inline::Chart(c) => self.chart(c, Place::Member(x, y, c.float.as_deref()), out),
|
||||
Inline::Group { members, .. } if depth < model::MAX_GROUP_DEPTH => {
|
||||
out.push_str("<draw:g>");
|
||||
self.members(members, (x, y), depth + 1, out);
|
||||
@@ -365,10 +366,14 @@ impl Writer {
|
||||
/// pointing at a sub-document whose `content.xml` is the equation's MathML, its frame about
|
||||
/// the size the formula takes (LibreOffice scales the formula to the frame).
|
||||
pub(in crate::odt) fn formula(&mut self, linear: &str, display: bool, out: &mut String) {
|
||||
let n = self.formulas.len() + 1;
|
||||
let n = self.objects.len() + 1;
|
||||
let name = format!("Object {}{n}", self.prefix);
|
||||
let mathml = crate::mathml::linear_to_mathml(linear, display);
|
||||
self.formulas.push((name.clone(), format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{mathml}\n")));
|
||||
self.objects.push((
|
||||
name.clone(),
|
||||
format!("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{mathml}\n"),
|
||||
"application/vnd.oasis.opendocument.formula",
|
||||
));
|
||||
let (w, h) = crate::odt::formula_size::size(linear);
|
||||
// Centred on the line, as LibreOffice sets its own formulas.
|
||||
let style = self.graphic_style(" style:vertical-pos=\"middle\" style:vertical-rel=\"text\"".into());
|
||||
|
||||
@@ -401,6 +401,7 @@ impl Writer {
|
||||
Inline::Ruby { base, fmt, .. } => self.run(base, fmt, out),
|
||||
Inline::Field { result, fmt, .. } => self.run(result, fmt, out),
|
||||
Inline::Figure(_)
|
||||
| Inline::Chart(_)
|
||||
| Inline::Equation { .. }
|
||||
| Inline::Note { .. }
|
||||
| Inline::Comment { .. }
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user