diff --git a/src/formats/odf/mod.rs b/src/formats/odf/mod.rs
index 053f1f3e..e4a64553 100644
--- a/src/formats/odf/mod.rs
+++ b/src/formats/odf/mod.rs
@@ -295,4 +295,31 @@ mod tests {
"encryption probing must not swallow fatal errors, got: {err}"
);
}
+
+ #[test]
+ fn form_checkboxes_anchored_in_a_cell_follow_its_content() {
+ let content = r#"
+
+
+
+
+
+
+
+ 14
+ L/R
+
+
+
+ "#;
+ let doc = parse(&odt_with_content(content)).unwrap();
+ let md = crate::render::markdown::document_to_markdown(&doc);
+ assert_eq!(md, "| | | |\n| --- | --- | --- |\n| 14 | L/R [x] Roof | [ ] Wall |\n");
+ }
}
diff --git a/src/formats/odf/table.rs b/src/formats/odf/table.rs
index 9f7a571c..226446b2 100644
--- a/src/formats/odf/table.rs
+++ b/src/formats/odf/table.rs
@@ -14,6 +14,8 @@ use crate::model::{Block, Cell, GridBuilder, Inline, TableKind};
use crate::package::limits;
use crate::package::xml::{Element, ns};
use crate::shared::header::resolve_header_rows;
+use crate::shared::text::clean_text;
+use std::collections::HashMap;
pub fn parse_table(elem: &Element, ctx: &Ctx) -> Result, ConvertError> {
let mut state = TableState {
@@ -23,6 +25,7 @@ pub fn parse_table(elem: &Element, ctx: &Ctx) -> Result, ConvertError
pending_rows: 0,
header_rows: 0,
rows_emitted: 0,
+ checkboxes: read_checkboxes(elem),
};
walk_rows(elem, ctx, &mut state, true)?;
let mut table = state.builder.finish(TableKind::Data);
@@ -42,6 +45,57 @@ struct TableState {
pending_rows: u64,
header_rows: usize,
rows_emitted: usize,
+ /// The sheet's form checkboxes by control id, as the inlines a
+ /// `draw:control` in a cell expands to.
+ checkboxes: HashMap>,
+}
+
+/// Checkbox controls declared in the table's `office:forms`. The mixed
+/// ("unknown") state has no token and is left out.
+fn read_checkboxes(table: &Element) -> HashMap> {
+ let mut out = HashMap::new();
+ for cb in table.find_all(ns::OFFICE, "forms").flat_map(|f| f.descendants(ns::FORM, "checkbox"))
+ {
+ let Some(id) = cb.attr_qualified(ns::XML, "id").or_else(|| cb.attr(ns::FORM, "id")) else {
+ continue;
+ };
+ let state = cb.attr(ns::FORM, "current-state").or_else(|| cb.attr(ns::FORM, "state"));
+ let checked = match state {
+ Some("checked") => true,
+ Some("unchecked") | None => false,
+ Some(_) => continue,
+ };
+ let mut inlines = vec![Inline::Checkbox(checked)];
+ let label = cb.attr(ns::FORM, "label").map(clean_text).unwrap_or_default();
+ if !label.is_empty() {
+ inlines.push(Inline::plain(format!(" {label}")));
+ }
+ out.insert(id.to_string(), inlines);
+ }
+ out
+}
+
+/// Append the checkboxes anchored in a cell after its content, on the same
+/// line. Controls under `table:shapes` are anchored to the page by
+/// coordinates alone, so they are not placed.
+fn append_cell_checkboxes(
+ cell: &Element,
+ checkboxes: &HashMap>,
+ blocks: &mut Vec,
+) {
+ for control in cell.find_all(ns::DRAW, "control") {
+ let Some(found) = control.attr(ns::DRAW, "control").and_then(|id| checkboxes.get(id))
+ else {
+ continue;
+ };
+ match blocks.last_mut() {
+ Some(Block::Paragraph(inlines)) => {
+ inlines.push(Inline::plain(" "));
+ inlines.extend(found.iter().cloned());
+ }
+ _ => blocks.push(Block::Paragraph(found.clone())),
+ }
+ }
}
impl TableState {
@@ -181,7 +235,7 @@ fn emit_row(
// Parse the row template exactly once: repeated rows clone the parsed
// cells instead of reparsing, so per-parse side effects (notes, assets)
// happen once and the duplicated text bytes are charged up front.
- let cells = parse_row_cells(row, ctx)?;
+ let cells = parse_row_cells(row, ctx, &state.checkboxes)?;
state.charge(repeat.saturating_sub(1))?;
for cell in &cells {
if let RowCell::Cell { repeat: cell_repeat, bytes, .. } = cell {
@@ -198,7 +252,11 @@ fn emit_row(
}
/// Parse one row's cells into the reusable template.
-fn parse_row_cells(row: &Element, ctx: &Ctx) -> Result, ConvertError> {
+fn parse_row_cells(
+ row: &Element,
+ ctx: &Ctx,
+ checkboxes: &HashMap>,
+) -> Result, ConvertError> {
let mut out = Vec::new();
for cell in row.child_elems() {
let repeat: u64 = cell
@@ -223,7 +281,8 @@ fn parse_row_cells(row: &Element, ctx: &Ctx) -> Result, ConvertErro
.and_then(|v| v.parse().ok())
.unwrap_or(1)
.max(1);
- let blocks = cell_blocks(cell, ctx)?;
+ let mut blocks = cell_blocks(cell, ctx)?;
+ append_cell_checkboxes(cell, checkboxes, &mut blocks);
let bytes = block_bytes(&blocks);
out.push(RowCell::Cell { repeat, col_span, row_span, blocks, bytes });
}
diff --git a/src/formats/sheet/controls.rs b/src/formats/sheet/controls.rs
new file mode 100644
index 00000000..6e736538
--- /dev/null
+++ b/src/formats/sheet/controls.rs
@@ -0,0 +1,107 @@
+//! Form control checkboxes: drawing objects floating over the grid, each
+//! anchored to a cell. The OOXML containers (xlsx, xlsm, xlsb) keep them in
+//! the worksheet's legacy VML drawing part; BIFF keeps them in OBJ records.
+//! Either way a checkbox lands in the cell its anchor starts in.
+
+use crate::error::ConvertError;
+use crate::model::Inline;
+use crate::package::Package;
+use crate::package::path;
+use crate::package::relationships::{TargetMode, read_rels, rels_part_for};
+use crate::package::xml::{Element, ns};
+use crate::shared::text::{clean_text, collapse_ws};
+use std::collections::HashMap;
+
+const VML_DRAWING_REL: &str =
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing";
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) struct Checkbox {
+ pub(super) checked: bool,
+ pub(super) caption: String,
+}
+
+/// Checkboxes anchored in each cell, in drawing order.
+pub(super) type Checkboxes = HashMap<(u32, u32), Vec>;
+
+/// A cell's inlines: its own text, then each checkbox with its caption.
+pub(super) fn cell_inlines(text: Option, boxes: &[Checkbox]) -> Vec {
+ let mut out: Vec = text.into_iter().map(Inline::plain).collect();
+ for b in boxes {
+ if !out.is_empty() {
+ out.push(Inline::plain(" "));
+ }
+ out.push(Inline::Checkbox(b.checked));
+ if !b.caption.is_empty() {
+ out.push(Inline::plain(format!(" {}", b.caption)));
+ }
+ }
+ out
+}
+
+/// The checkboxes in a worksheet part's VML drawings.
+pub(super) fn read_vml_checkboxes(
+ pkg: &mut Package,
+ sheet_part: &str,
+) -> Result {
+ let rels = read_rels(pkg, &rels_part_for(sheet_part))?;
+ let mut out = Checkboxes::new();
+ let mut targets: Vec = rels
+ .iter()
+ .filter(|(_, r)| r.rel_type == VML_DRAWING_REL && r.mode == TargetMode::Internal)
+ .filter_map(|(_, r)| path::resolve(sheet_part, &r.target).ok())
+ .map(|t| t.path)
+ .collect();
+ targets.sort();
+ targets.dedup();
+ for target in targets {
+ if let Some(root) = pkg.optional_xml_part(&target)? {
+ vml_checkboxes(&root, &mut out);
+ }
+ }
+ Ok(out)
+}
+
+fn vml_checkboxes(root: &Element, out: &mut Checkboxes) {
+ for shape in root.descendants(ns::VML, "shape") {
+ let Some(data) = shape
+ .find(ns::X_VML, "ClientData")
+ .filter(|d| d.attr_unqualified("ObjectType") == Some("Checkbox"))
+ else {
+ continue;
+ };
+ if shape
+ .attr_unqualified("style")
+ .is_some_and(|s| s.replace(' ', "").contains("visibility:hidden"))
+ {
+ continue;
+ }
+ let Some(at) = data.find(ns::X_VML, "Anchor").and_then(|a| anchor_cell(&a.text())) else {
+ log::debug!("skipping a checkbox with no readable anchor");
+ continue;
+ };
+ // Absent means unchecked; 2 is the mixed state, which has no token.
+ let checked = match data.find(ns::X_VML, "Checked").map(|c| c.text()) {
+ None => false,
+ Some(v) => match v.trim() {
+ "0" => false,
+ "1" => true,
+ _ => continue,
+ },
+ };
+ let caption = shape
+ .find(ns::VML, "textbox")
+ .map(|t| collapse_ws(&clean_text(&t.text())).trim().to_string())
+ .unwrap_or_default();
+ out.entry(at).or_default().push(Checkbox { checked, caption });
+ }
+}
+
+/// The (row, col) an `x:Anchor` starts in: `LeftColumn, LeftOffset, TopRow, TopOffset, ...`.
+fn anchor_cell(anchor: &str) -> Option<(u32, u32)> {
+ let mut parts = anchor.split(',').map(|p| p.trim().parse::().ok());
+ let col = parts.next()??;
+ parts.next()??;
+ let row = parts.next()??;
+ Some((row, col))
+}
diff --git a/src/formats/sheet/mod.rs b/src/formats/sheet/mod.rs
index 5df92ad3..051c5ac4 100644
--- a/src/formats/sheet/mod.rs
+++ b/src/formats/sheet/mod.rs
@@ -3,6 +3,7 @@
//! OLE-based BIFF (xls). All three share the number format engine and grid
//! assembly, so one workbook saved in any of them converts identically.
+mod controls;
mod numfmt;
mod xls;
mod xlsb;
diff --git a/src/formats/sheet/xls.rs b/src/formats/sheet/xls.rs
index e457dfa8..28488fdc 100644
--- a/src/formats/sheet/xls.rs
+++ b/src/formats/sheet/xls.rs
@@ -5,6 +5,7 @@
//! assembly as the SpreadsheetML reader, so a workbook saved as .xls and as
//! .xlsx converts identically.
+use super::controls::Checkbox;
use super::xlsx::{
CellFormat, SheetContent, build_table, format_as_text, render_number, resolve_format,
};
@@ -13,6 +14,7 @@ use crate::error::ConvertError;
use crate::model::{Block, Document, Inline};
use crate::package::limits;
use crate::shared::binary::{get_u16, get_u32, read_ole_stream, utf16le_units};
+use crate::shared::officeart;
use crate::shared::text::clean_text;
use std::collections::HashMap;
use std::io::Cursor;
@@ -40,6 +42,9 @@ const MULRK: u16 = 0x00BD;
const BOOLERR: u16 = 0x0205;
const FORMULA: u16 = 0x0006;
const STRING: u16 = 0x0207;
+const MSODRAWING: u16 = 0x00EC;
+const OBJ: u16 = 0x005D;
+const TXO: u16 = 0x01B6;
/// BOF `dt` value for a worksheet (or dialog sheet) substream.
const WORKSHEET_SUBSTREAM: u16 = 0x0010;
@@ -498,6 +503,10 @@ fn read_sheet(
// A FORMULA whose cached value is a string: (row, col, ixfe) waiting
// for the STRING record that carries the text.
let mut pending: Option<(u32, u32, u16)> = None;
+ // A drawing object's shape arrives in MSODRAWING ahead of its OBJ, and
+ // a checkbox's caption in the TXO after it.
+ let mut shape: Option = None;
+ let mut last_checkbox: Option<(u32, u32)> = None;
while let Some((rec_type, body, next)) = next_record(data, pos, records)? {
pos = next;
match rec_type {
@@ -659,6 +668,33 @@ fn read_sheet(
}
}
}
+ MSODRAWING if globals.biff8 => {
+ let (segs, after) = continued(data, body, pos, records)?;
+ pos = after;
+ shape = if segs.len() == 1 { last_shape(body) } else { last_shape(&segs.concat()) };
+ last_checkbox = None;
+ }
+ OBJ if globals.biff8 => {
+ last_checkbox = None;
+ if let Some(checked) = obj_checkbox(body)
+ && let Some(Shape { anchor: at, hidden: false }) = shape.take()
+ && at.1 < MAX_COLS
+ {
+ let caption = String::new();
+ out.checkboxes.entry(at).or_default().push(Checkbox { checked, caption });
+ last_checkbox = Some(at);
+ }
+ }
+ TXO => {
+ let (segs, after) = continued(data, body, pos, records)?;
+ pos = after;
+ if let Some(at) = last_checkbox.take()
+ && let Some(caption) = txo_text(&segs)
+ && let Some(b) = out.checkboxes.get_mut(&at).and_then(|v| v.last_mut())
+ {
+ b.caption = clean_text(&caption);
+ }
+ }
STRING => {
let (segs, after) = continued(data, body, pos, records)?;
pos = after;
@@ -680,6 +716,111 @@ fn read_sheet(
Ok(Some(out))
}
+/// What an OBJ needs from its shape: the cell the client anchor starts in,
+/// and whether the shape is hidden.
+struct Shape {
+ anchor: (u32, u32),
+ hidden: bool,
+}
+
+/// The last shape in an MSODRAWING body. Containers are entered rather
+/// than skipped, so the scan stays linear whatever the nesting.
+fn last_shape(data: &[u8]) -> Option {
+ const SP_CONTAINER: u16 = 0xF004;
+ const OPT: u16 = 0xF00B;
+ const CLIENT_ANCHOR: u16 = 0xF010;
+ // Group shape boolean properties: fHidden with its use bit.
+ const PID_GROUP_SHAPE: u16 = 0x03BF;
+ const F_HIDDEN: u32 = 0x0000_0002;
+ const F_USE_HIDDEN: u32 = 0x0002_0000;
+ let mut anchor = None;
+ let mut hidden = false;
+ let mut off = 0usize;
+ while let Some((ver_inst, rec_type, body)) = officeart::record_at(data, off) {
+ if ver_inst & 0x000F == 0x000F {
+ if rec_type == SP_CONTAINER {
+ anchor = None;
+ hidden = false;
+ }
+ off += 8;
+ continue;
+ }
+ match rec_type {
+ CLIENT_ANCHOR => {
+ if let (Some(col), Some(row)) = (get_u16(body, 2), get_u16(body, 6)) {
+ anchor = Some((u32::from(row), u32::from(col)));
+ }
+ }
+ OPT => {
+ for i in 0..usize::from(ver_inst >> 4) {
+ let (Some(pid), Some(op)) = (get_u16(body, i * 6), get_u32(body, i * 6 + 2))
+ else {
+ break;
+ };
+ if pid & 0x3FFF == PID_GROUP_SHAPE && op & F_USE_HIDDEN != 0 {
+ hidden = op & F_HIDDEN != 0;
+ }
+ }
+ }
+ _ => {}
+ }
+ off += 8 + body.len();
+ }
+ anchor.map(|anchor| Shape { anchor, hidden })
+}
+
+/// The checked state of an OBJ record when its FtCmo names a checkbox and
+/// its FtCblsData carries a definite state (2 is mixed, which has no token).
+fn obj_checkbox(body: &[u8]) -> Option {
+ const FT_END: u16 = 0x0000;
+ const FT_CMO: u16 = 0x0015;
+ const FT_CBLS_DATA: u16 = 0x0012;
+ const OT_CHECKBOX: u16 = 0x000B;
+ let mut off = 0usize;
+ let mut is_checkbox = false;
+ let mut state = None;
+ while let (Some(ft), Some(cb)) = (get_u16(body, off), get_u16(body, off + 2)) {
+ if ft == FT_END {
+ break;
+ }
+ let data = body.get(off + 4..off + 4 + usize::from(cb))?;
+ match ft {
+ FT_CMO => is_checkbox = get_u16(data, 0) == Some(OT_CHECKBOX),
+ FT_CBLS_DATA => state = get_u16(data, 0),
+ _ => {}
+ }
+ off += 4 + usize::from(cb);
+ }
+ match (is_checkbox, state?) {
+ (true, 0) => Some(false),
+ (true, 1) => Some(true),
+ _ => None,
+ }
+}
+
+/// The text of a TXO record: `cchText` characters spread over the CONTINUE
+/// records after it, each opening with its own encoding flag byte.
+fn txo_text(segs: &[&[u8]]) -> Option {
+ let mut remaining = usize::from(get_u16(segs.first()?, 10)?);
+ let mut units: Vec = Vec::with_capacity(remaining);
+ for seg in segs.iter().skip(1) {
+ if remaining == 0 {
+ break;
+ }
+ let (&flags, chars) = seg.split_first()?;
+ if flags & 0x01 != 0 {
+ let take = (chars.len() / 2).min(remaining);
+ units.extend(utf16le_units(&chars[..take * 2]));
+ remaining -= take;
+ } else {
+ let take = chars.len().min(remaining);
+ units.extend(chars[..take].iter().map(|&b| u16::from(b)));
+ remaining -= take;
+ }
+ }
+ Some(String::from_utf16_lossy(&units))
+}
+
/// Record only non-empty cell text, like the xlsx reader.
fn put(out: &mut SheetContent, row: u32, col: u32, text: String) {
if !text.is_empty() {
@@ -1078,4 +1219,89 @@ mod tests {
let doc = parse(&ole_with("Book", &stream)).unwrap();
assert_eq!(texts(first_table(&doc)), vec![vec!["l\u{e9}gacy"]]);
}
+
+ /// An MSODRAWING body: one SpContainer holding a client anchor that
+ /// starts at (row, col), plus an OPT hiding the shape when asked.
+ fn msodrawing(row: u16, col: u16, hidden: bool) -> Vec {
+ let mut anchor = vec![0u8; 18];
+ anchor[2..4].copy_from_slice(&col.to_le_bytes());
+ anchor[6..8].copy_from_slice(&row.to_le_bytes());
+ anchor[10..12].copy_from_slice(&(col + 1).to_le_bytes());
+ anchor[14..16].copy_from_slice(&(row + 1).to_le_bytes());
+ let mut children = Vec::new();
+ if hidden {
+ children.extend_from_slice(&0x0013u16.to_le_bytes()); // one property
+ children.extend_from_slice(&0xF00Bu16.to_le_bytes());
+ children.extend_from_slice(&6u32.to_le_bytes());
+ children.extend_from_slice(&0x03BFu16.to_le_bytes());
+ children.extend_from_slice(&0x0002_0002u32.to_le_bytes());
+ }
+ children.extend_from_slice(&0u16.to_le_bytes());
+ children.extend_from_slice(&0xF010u16.to_le_bytes());
+ children.extend_from_slice(&(anchor.len() as u32).to_le_bytes());
+ children.extend(anchor);
+ let mut body = Vec::new();
+ body.extend_from_slice(&0x000Fu16.to_le_bytes());
+ body.extend_from_slice(&0xF004u16.to_le_bytes());
+ body.extend_from_slice(&(children.len() as u32).to_le_bytes());
+ body.extend(children);
+ rec(MSODRAWING, &body)
+ }
+
+ /// An OBJ record for a form control of type `ot` carrying `state`.
+ fn obj(ot: u16, state: u16) -> Vec {
+ let mut body = Vec::new();
+ body.extend_from_slice(&0x0015u16.to_le_bytes());
+ body.extend_from_slice(&0x0012u16.to_le_bytes());
+ body.extend_from_slice(&ot.to_le_bytes());
+ body.extend_from_slice(&1u16.to_le_bytes());
+ body.extend_from_slice(&[0u8; 14]);
+ body.extend_from_slice(&0x0012u16.to_le_bytes());
+ body.extend_from_slice(&8u16.to_le_bytes());
+ body.extend_from_slice(&state.to_le_bytes());
+ body.extend_from_slice(&[0u8; 6]);
+ body.extend_from_slice(&[0u8; 4]);
+ rec(OBJ, &body)
+ }
+
+ /// A TXO record with its text and formatting-run CONTINUE records.
+ fn txo(text: &str) -> Vec {
+ let mut body = vec![0u8; 18];
+ body[10..12].copy_from_slice(&(text.len() as u16).to_le_bytes());
+ body[12..14].copy_from_slice(&16u16.to_le_bytes());
+ let mut out = rec(TXO, &body);
+ let mut chars = vec![0x00];
+ chars.extend_from_slice(text.as_bytes());
+ out.extend(rec(CONTINUE, &chars));
+ out.extend(rec(CONTINUE, &[0u8; 16]));
+ out
+ }
+
+ /// Re-frame a record as its first `at` payload bytes plus a CONTINUE
+ /// carrying the rest.
+ fn split_at(record: &[u8], at: usize) -> Vec {
+ let rec_type = u16::from_le_bytes([record[0], record[1]]);
+ let payload = &record[4..];
+ let mut out = rec(rec_type, &payload[..at]);
+ out.extend(rec(CONTINUE, &payload[at..]));
+ out
+ }
+
+ #[test]
+ fn form_control_checkboxes_land_in_their_anchor_cell() {
+ let mut records = label(20, 0, 0, "14");
+ records.extend(split_at(&msodrawing(20, 3, false), 12));
+ records.extend(obj(0x0B, 1));
+ records.extend(txo("Roof"));
+ records.extend(msodrawing(20, 4, false));
+ records.extend(obj(0x0B, 0));
+ // A text box is a drawing object too, and must not be read as a box.
+ records.extend(msodrawing(20, 5, false));
+ records.extend(obj(0x06, 1));
+ records.extend(txo("note"));
+ records.extend(msodrawing(20, 6, true));
+ records.extend(obj(0x0B, 1));
+ let doc = parse(&one_sheet(records).build()).unwrap();
+ assert_eq!(texts(first_table(&doc)), vec![vec!["14", "", "", "[x] Roof", "[ ]"]]);
+ }
}
diff --git a/src/formats/sheet/xlsb.rs b/src/formats/sheet/xlsb.rs
index 4f1eb833..7bc042f0 100644
--- a/src/formats/sheet/xlsb.rs
+++ b/src/formats/sheet/xlsb.rs
@@ -4,6 +4,7 @@
//! materialization, format resolution and date rendering are shared with
//! the xlsx reader so both containers convert identically.
+use super::controls::read_vml_checkboxes;
use super::xlsx::{
CellFormat, MAX_COLS, MAX_ROWS, SHARED_STRINGS_REL, SheetContent, build_table, format_as_text,
render_number, resolve_format, sibling_part_name,
@@ -79,7 +80,7 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result c,
Some(Err(e)) if e.is_fatal() => return Err(e),
Some(Err(e)) => {
@@ -93,6 +94,7 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result>,
shared: Option>,
date1904: bool,
+ /// Further parts, verbatim: (name, body).
+ extra: Vec<(&'a str, Vec)>,
}
impl Wb<'_> {
@@ -633,6 +637,9 @@ mod tests {
if let Some(shared) = &self.shared {
add("xl/sharedStrings.bin", shared);
}
+ for (name, body) in &self.extra {
+ add(name, body);
+ }
zip.finish().unwrap().into_inner()
}
}
@@ -861,4 +868,32 @@ mod tests {
let doc = parse(&one_sheet(body).build()).unwrap();
assert_eq!(texts(first_table(&doc)), vec![vec!["7"]]);
}
+
+ #[test]
+ fn form_control_checkboxes_come_from_the_vml_part() {
+ const VML_REL: &str =
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing";
+ let rels = format!(
+ r#""#
+ );
+ let vml = r##"
+
+ Roof
+ 1, 5, 0, 2, 2, 10, 1, 11
+ "##;
+ let mut body = row_hdr(0, false);
+ let mut p = cell(0, 0);
+ p.push(1);
+ body.extend(rec(BRT_CELL_BOOL, &p));
+ let wb = Wb {
+ sheets: vec![("S", 0, body)],
+ extra: vec![
+ ("xl/worksheets/_rels/sheet1.bin.rels", rels.into_bytes()),
+ ("xl/drawings/vmlDrawing1.vml", vml.as_bytes().to_vec()),
+ ],
+ ..Wb::default()
+ };
+ let doc = parse(&wb.build()).unwrap();
+ assert_eq!(texts(first_table(&doc)), vec![vec!["TRUE", "[x] Roof"]]);
+ }
}
diff --git a/src/formats/sheet/xlsx.rs b/src/formats/sheet/xlsx.rs
index f4efbc9b..e0924562 100644
--- a/src/formats/sheet/xlsx.rs
+++ b/src/formats/sheet/xlsx.rs
@@ -3,6 +3,7 @@
//! merge regions. Rows, columns, and sheets the source hides are omitted,
//! and merge regions are remapped onto the surviving grid.
+use super::controls::{Checkboxes, cell_inlines, read_vml_checkboxes};
use super::numfmt::{DateParts, NumberFormat, Rendered, builtin_code};
use super::{format_duration_days, format_float, format_time_of_day};
use crate::error::ConvertError;
@@ -81,7 +82,8 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result) -> CellFormat
pub(super) struct SheetContent {
/// Rendered text by zero-based (row, col); empty results are absent.
pub(super) cells: HashMap<(u32, u32), String>,
+ /// Form control checkboxes by the cell they are anchored in.
+ pub(super) checkboxes: Checkboxes,
pub(super) hidden_rows: HashSet,
/// Inclusive zero-based column ranges hidden by `cols/col` entries.
pub(super) hidden_cols: Vec<(u32, u32)>,
@@ -400,10 +404,20 @@ pub(super) fn build_table(
let hidden_row = |r: u32| hidden_rows.binary_search(&r).is_ok();
let hidden_col = |c: u32| hidden_cols.binary_search(&c).is_ok();
+ // Cell text and anchored checkboxes become one inline map; the rest of
+ // the assembly no longer cares which was which.
+ let mut cells: HashMap<(u32, u32), Vec> = HashMap::new();
+ for (at, boxes) in sheet.checkboxes.drain() {
+ if at.0 < MAX_ROWS && at.1 < MAX_COLS {
+ cells.insert(at, cell_inlines(sheet.cells.remove(&at), &boxes));
+ }
+ }
+ for (at, text) in sheet.cells.drain() {
+ cells.insert(at, vec![Inline::plain(text)]);
+ }
// A merge with no surviving row or column disappears with its content.
// One whose origin is hidden keeps its content at the first surviving
// position it covers, so the value is not lost.
- let cells = &mut sheet.cells;
sheet.merges.retain(|&(r1, c1, r2, c2)| {
let vr = first_visible(&hidden_rows, r1, r2);
let vc = first_visible(&hidden_cols, c1, c2);
@@ -420,7 +434,7 @@ pub(super) fn build_table(
// Populated extent over visible cells only.
let mut bounds: Option<(u32, u32, u32, u32)> = None;
- for &(r, c) in sheet.cells.keys() {
+ for &(r, c) in cells.keys() {
if hidden_row(r) || hidden_col(c) {
continue;
}
@@ -500,8 +514,8 @@ pub(super) fn build_table(
builder.covered();
continue;
}
- let cell = match sheet.cells.remove(&(row, col)) {
- Some(text) => Cell::from_inlines(vec![Inline::plain(text)]),
+ let cell = match cells.remove(&(row, col)) {
+ Some(inlines) => Cell::from_inlines(inlines),
None => Cell::default(),
};
match origins.get(&(ri, ci)) {
@@ -700,6 +714,8 @@ mod tests {
styles: Option<&'a str>,
shared: Option<&'a str>,
date1904: bool,
+ /// Further parts, verbatim: (name, body).
+ extra: Vec<(&'a str, &'a str)>,
}
impl Wb<'_> {
@@ -762,6 +778,9 @@ mod tests {
&format!(r#"{shared}"#),
);
}
+ for (name, body) in &self.extra {
+ add(name, body);
+ }
zip.finish().unwrap().into_inner()
}
}
@@ -1013,4 +1032,50 @@ mod tests {
// A date-only format still has nothing to show but the number.
assert_eq!(render_serial(0.5, DATE_ONLY, false), "0.5");
}
+
+ const VML_REL: &str =
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing";
+
+ /// A legacy drawing with a checked captioned box over B1, an unchecked
+ /// bare one over C1, a hidden one over D1, and a cell note.
+ const VML: &str = r##"
+
+
+ Roof
+
+ 1, 5, 0, 2, 2, 10, 1, 11
+
+
+
+ 2, 5, 0, 2, 3, 10, 1, 1
+
+
+ 3, 5, 0, 2, 4, 10, 1, 11
+
+
+ a note
+ 4, 5, 0, 2, 5, 10, 1, 1
+
+ "##;
+
+ #[test]
+ fn form_control_checkboxes_land_in_their_anchor_cell() {
+ let rels = format!(
+ r#""#
+ );
+ let wb = Wb {
+ sheets: vec![(
+ "S",
+ "",
+ r#"14L/R
"#,
+ )],
+ extra: vec![
+ ("xl/worksheets/_rels/sheet1.xml.rels", &rels),
+ ("xl/drawings/vmlDrawing1.vml", VML),
+ ],
+ ..Wb::default()
+ };
+ let doc = parse(&wb.build()).unwrap();
+ assert_eq!(texts(first_table(&doc)), vec![vec!["14", "L/R [x] Roof", "[ ]"]]);
+ }
}
diff --git a/src/package/xml.rs b/src/package/xml.rs
index 87b66a46..32ff65d8 100644
--- a/src/package/xml.rs
+++ b/src/package/xml.rs
@@ -39,9 +39,11 @@ pub mod ns {
pub const XLINK: &str = "http://www.w3.org/1999/xlink";
pub const XML: &str = "http://www.w3.org/XML/1998/namespace";
pub const SVG_COMPAT: &str = "urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0";
+ pub const FORM: &str = "urn:oasis:names:tc:opendocument:xmlns:form:1.0";
pub const VML: &str = "urn:schemas-microsoft-com:vml";
pub const O_VML: &str = "urn:schemas-microsoft-com:office:office";
+ pub const X_VML: &str = "urn:schemas-microsoft-com:office:excel";
pub const WPS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingShape";
pub const WPG: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingGroup";
pub const A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main";