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 +
+ + +
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";