mirror of
https://github.com/storytold/wordcraft.git
synced 2026-11-13 14:02:45 +00: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:
25 files changed
+1053
-42
No files matched your search
@@ -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 { .. }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with Microsoft Word
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the partLine truncated
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-01 · **Change:** trivial (ODT charts opened and saved as editable charts; flat ODT sub-documents no longer end the body); previously trivial (ODT leading read as an at-least spacing); previously trivial (ODT tracked formatting changes; changed regions named by `xml:id`); previously trivial (ODT drop caps; tab stops no longer drop a paragraph's background); previously trivial (flat ODT `.fodt` opened and saved; ODT fidelity re-estimated at ~80%); previously trivial (ODT page settings; a footnote no longer takes a footer's text); previously trivial (ODT dates in their formats, sequences, chapters and references); previously trivial (ODT bookmarks over text); previously trivial (ODT tables of contents and indexes; ODT fidelity re-estimated); previously trivial (ODT polygons, polylines and paths); previously trivial (ODT arrowheads, dashes and gradients); previously trivial (ODT groups of shapes and pictures); previously trivial (ODT rotated and mirrored shapes and pictures); previously trivial (ODT drawing shapes read and written); previously trivial (ODT text box frames keep their line, fill, padding and fit); previously trivial (ODT Asian and complex-script fonts); previously trivial (ODT right-to-left paragraphs); previously trivial (ODT text boxes kept); previously trivial (ODT unnumbered headings detached from the outline); previously trivial (ODT contextual spacing and layout settings); previously trivial (ODT custom character styles kept); previously trivial (ODT list level positions); previously trivial (ODT custom paragraph styles kept); previously trivial (ODT equations written as formula objects); previously trivial (ODT numbered headings written as headings); previously trivial (ODT floating pictures); previously trivial (ODT paragraph borders and background); previously trivial (ODT heading numbering); previously trivial (ODT font-face names); previously trivial (ODT table cell borders and padding); previously trivial (ODT heading, title, quote and preformatted styles keep their look); previously trivial (ODT tab stops read and written); previously trivial (ODT character formatting read and written); previously trivial (ODT lists keep their number formats, bullets and identity); previously trivial (ODT comments read and written); previously trivial (ODT footnotes and endnotes read and written); previously trivial (ODT headers, footers and text fields written; fields read); previously trivial (ODT paragraph spacing, indents and line spacing read and written); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments <!--> and <!---> at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously trivial: the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: Arabic list labels: `arabicAlpha` and `arabicAbjad` draw the 28 Arabic letters (alphabetical and abjad order), doubling past the last letter; Hebrew, Hindi, Thai and Vietnamese still show decimal (`doc::section::tests::arabic_letters_count_in_both_orders`); previously trivial: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries aLine truncated
|
||||
|
||||
Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the
|
||||
share of a typical real-world file's content and formatting that survives (read: shows correctly;
|
||||
@@ -57,7 +57,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅
|
||||
| PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice |
|
||||
| PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser |
|
||||
| `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154; Windows and Enhanced Metafiles, `\wmetafile` and `\emfblip`, read and written, #4259), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL |
|
||||
| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~80% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as an at-least spacing of a 12 pt line and the leading, which puts the extra space above each line as LibreOffice draws it (LibreOffice also leaves it after the last line)) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, word and character Line truncated
|
||||
| `.odt` OpenDocument Text (and flat `.fodt`) | 2% | R/W | 🟡 | 🟡 | ~82% | 30–50 | `crates/formats/src/tests.rs`, `crates/formats/src/odt/tests.rs` and one file per feature in `crates/formats/src/odt/tests_more/`, `crates/engine/tests/it/odt_sections.rs`, `odt_toc.rs`, `odt_fields.rs`, `odt_charts.rs`; checked against LibreOffice's own documents (round trips compared page by page) and the ODF 1.3 schema | Flat OpenDocument text (`.fodt`: the whole document in one `office:document`, pictures inline as base64 `office:binary-data`, formulas inline) is opened and saved as a package is, both validating against the schema. Tables of contents and other indexes (`text:table-of-content`, `text:illustration-index`/`text:table-index`, `text:alphabetical-index`, `text:bibliography`) open as Word's `TOC` (levels from the source's `text:outline-level`; `\c` with the caption name for a table of figures), `INDEX` and `BIBLIOGRAPHY` fields — in the title paragraph when the index has one, as WordCraft inserts a table, else around the entries — so References › Update Table regenerates them; LibreOffice's Contents 1–10, Contents Heading and Figure Index 1 styles are WordCraft's TOC 1–9, TOC Heading and Table of Figures; WordCraft's tables are written back as ODF indexes with their source and entry templates (LibreOffice updates them), entries' links in the Index Link style; user and object indexes are read as their paragraphs; index marks (`text:alphabetical-index-mark`, Word's `XE`) are not read. Custom character styles (a common text style other than LibreOffice's link, note-mark and list-label styles kept as a character style with its name, parent and own formatting, its text in it; written back as common text styles), custom paragraph styles (a common style other than Standard, the kinds' and the note texts' kept as a document style with its name, parent and own formatting, its paragraphs in it rather than given its formatting directly; written back as common styles, named so LibreOffice keeps them), paragraph borders (each side's line and padding) and background, heading numbering (`text:outline-style`: each level's format, prefix, suffix, levels shown and start number the headings through their styles, as Word's numbered headings, and are written back), styles (the file's own Heading 1–6, Title, Quotations and Preformatted Text definitions give those styles' look on open — font, size, percent sizes included, bold, colour, spacing, alignment, keep with next — and saving writes the document's own style definitions rather than fixed ones), tab stops (left, centre, right, decimal; dot, hyphen, underline and middle-dot leaders; LibreOffice's indent-relative positions converted, and a `settings.xml` written saying positions run from the margin) and the default tab interval, character formatting (underline styles and colour, double strike, caps, small caps, hidden text kept hidden, letter spacing, scale, raised and lowered text, outline, shadow, emboss, engrave, language, Asian and complex-script fonts, complex-script size, weight and posture and both script languages (per run and as document defaults), a monospaced font by its own name, a `style:font-name` read as the family its `style:font-face` declares, and the default style's pair kerning) both ways, lists (#87; each `text:list` a list of its own unless it continues one by `text:continue-numbering` or `text:continue-list`, an item's `text:start-value` restarting from it; each level's number format — 1, i, I, a, A, none —, prefix, suffix, levels shown, start value, bullet character, indent, hanging and number alignment read from its list style and written back), floating pictures (a frame anchored to a paragraph, character or page: wrap — square on both sides, left, right or the larger side, top and bottom, behind or in front of text —, horizontal and vertical alignment or offset and what it is relative to, distances from the text, read into the picture's placement and written back in a graphic style), tables (cell borders — width, style, colour, an edge LibreOffice gives one cell shared by both — and cell padding read and written, each shared edge written once so lines don't double, a table without lines written without), pictures, page breaks (#86), paragraph spacing, indents and line spacing (proportional, fixed, at least; leading read as an at-least spacing of a 12 pt line and the leading, which puts the extra space above each line as LibreOffice draws it (LibreOffice also leaves it after the last line)) both ways, with the default and Standard styles' spacing as the document defaults (ODT's are none, not Word's 8 pt after and 1.15 lines), headers and footers of each master page (every, left and first page) read on open and written on save, with the header and footer distances converted between Word's page-edge measures and ODF's header bands (#863), text fields (page number and count, date, time, title, subject, author, file name, woLine truncated
|
||||
| `.txt` Plain text | 2% | R/W | ✅ | ✅ | ~95% | 1–2 | unit tests | Encoding detection. Files with other extensions (`.m3u`/`.m3u8` playlists, logs, config files, no extension) open as text when they are text, Word and RTF files by their signature; binary files are refused (#569). A Word or RTF extension that contradicts the content also opens by signature, as in Word: RTF saved as `.doc` (hh.ru résumés, accounting exports), a `.docx` renamed `.doc`, a Word 97-2003 file renamed `.docx`; a Word XML Document (Word 2003 XML or Flat OPC) or a web page (`<html>`, `<!DOCTYPE html>`) under a `.doc`, `.docx` or `.rtf` name opens as what it is (#2853, `tests::word_xml_and_web_pages_saved_as_doc_open_by_content`). Word's encoding choice dialog and line-ending options missing; such files save only through Save As |
|
||||
| `.htm/.html` Web Page and Web Page, Filtered | 1% | R/W | 🟡 | 🟡 | ~60% | 6–10 | `crates/formats/src/tests.rs` | Tables (#102), relative pictures embedded (#113), missing `</head>` tolerated (#154); the empty comments `<!-->` and `<!--->` end at once instead of swallowing the rest of the page (#5040). Header cells (`<th>`) bold unless their CSS says otherwise; `text-align` on a cell, or inherited from its row, row group or table, aligns the cell's paragraphs (#1381). HTML gives no page size, so it opens on the region's paper (A4 or Letter), as Markdown and plain text do; `@page` isn't read (#1381). No Word "Web Page" (with `_files` folder) round-trip. The same reader takes HTML from the system clipboard: Paste keeps another program's formatting (a Word document, a web page; Windows' "HTML Format" header understood), Merge Formatting keeps its emphasis (#687). A pasted `<blockquote>` (LibreOffice's paragraph indented on both sides) becomes a Normal paragraph indented by the page's `blockquote` margins, else 0.5 in each side; opening an .html file keeps it as the Quote style (#1270). Equations are written as MathML (with the linear format as a UnicodeMath annotation) and read back from MathML: our annotation, else the presentation elements, else a TeX annotation (#816). A copy of one equation alone also goes in the clipboard's MathML formats (Windows "MathML" and "MathML Presentation", macOS `public.mathml.presentation` and `public.mathml`; W3C MathML 3 §6.2.4), which Microsoft documents Word reading first when it pastes math (#1297; the paste into Word is not yet verified); Ruby (furigana, phonetic guides) is written as `<ruby>base<rt>reading</rt></ruby>` in the base text's formatting and read back from `<ruby>`/`<rb>`/`<rt>` (`<rp>` fallback parentheses dropped; malformed markup keeps its text), so docx/doc → HTML → doc keeps the annotation; HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) CRLF and lone CR input newlines normalize before tokenization, preserving preformatted lines and leaving decoded character references separate (#4810). |
|
||||
| `.mht/.mhtml` Single File Web Page | <1% | R/W | ❌ | ❌ | 0% | 3–5 | — | |
|
||||
@@ -279,6 +279,7 @@ Custom document properties retain their bookmark link targets through DOCX saves
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-01 | trivial | ODT: charts (`draw:object` holding an `office:chart`) open as editable WordCraft charts — type, categories, series and gaps from the chart's own table, title, legend, data labels — instead of their replacement picture, drawn in the theme's colours, and WordCraft charts are saved as ODF chart objects LibreOffice draws and edits (each series and slice in its accent colour); charts WordCraft can't edit keep their picture. Flat ODT: an inline sub-document's own `office:body` no longer ends the document's body early, so text after an inline chart is kept (`odt::tests_more::charts`, `odt::tests_more::flat::an_inline_sub_document_does_not_end_the_body`, `odt_charts.rs`) |
|
||||
| 2026-11-01 | trivial | ODT: leading (`style:line-spacing`, extra space between lines) is read as at least a 12 pt line and the leading instead of a proportional spacing, so the extra space goes above each line as LibreOffice draws it: a LibreOffice spacing document went from 6,190 px apart after a round trip to 2,973, and WordCraft's drawing of it from 8,570 to 5,521 (`leading_is_at_least_a_line_and_the_leading`) |
|
||||
| 2026-11-01 | trivial | ODT: tracked formatting changes (`text:format-change` between change marks) open as formatting revisions with their author and date (the old formatting is the text's own: ODF doesn't record it, LibreOffice can't reject them after reopening either), instead of being dropped, and WordCraft's formatting revisions are written as format changes. Fixed: changed regions were written without the `xml:id` ODF 1.3 requires (and the change marks refer to), so a document with tracked changes didn't validate; a region named by `xml:id` alone is read too |
|
||||
| 2026-11-01 | trivial | ODT: drop caps (`style:drop-cap`, lines and distance) read and written. Fixed: a paragraph with its own tab stops lost the background, borders, contextual spacing and right-to-left direction written after them, which went into the style's tab stops instead of onto it (`tab_stops_and_other_paragraph_properties_together`) |
|
||||
|
||||
Reference in new issue
Block a user