Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/formats/odf/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office: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:form="urn:oasis:names:tc:opendocument:xmlns:form:1.0"
xmlns:xml="http://www.w3.org/XML/1998/namespace">
<office:body><office:spreadsheet><table:table table:name="S">
<office:forms><form:form>
<form:checkbox xml:id="c1" form:label="Roof" form:current-state="checked"/>
<form:checkbox xml:id="c2" form:label="Wall"/>
<form:checkbox xml:id="c3" form:current-state="unknown"/>
</form:form></office:forms>
<table:table-row>
<table:table-cell><text:p>14</text:p></table:table-cell>
<table:table-cell><text:p>L/R</text:p><draw:control draw:control="c1"/></table:table-cell>
<table:table-cell><draw:control draw:control="c2"/><draw:control draw:control="c3"/></table:table-cell>
</table:table-row>
</table:table></office:spreadsheet></office:body>
</office:document-content>"#;
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");
}
}
65 changes: 62 additions & 3 deletions src/formats/odf/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<Block>, ConvertError> {
let mut state = TableState {
Expand All @@ -23,6 +25,7 @@ pub fn parse_table(elem: &Element, ctx: &Ctx) -> Result<Vec<Block>, ConvertError
pending_rows: 0,
header_rows: 0,
rows_emitted: 0,
checkboxes: read_checkboxes(elem),

@cubic-dev-ai cubic-dev-ai Bot Aug 20, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Standard ODS places office:forms under office:spreadsheet, alongside table:table. Because parse_table receives only the table, read_checkboxes(elem) misses those forms and drops every anchored control. Collect forms at spreadsheet scope and pass them into each table parser.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/formats/odf/table.rs, line 28:

<comment>Standard ODS places `office:forms` under `office:spreadsheet`, alongside `table:table`. Because `parse_table` receives only the table, `read_checkboxes(elem)` misses those forms and drops every anchored control. Collect forms at spreadsheet scope and pass them into each table parser.</comment>

<file context>
@@ -23,6 +25,7 @@ pub fn parse_table(elem: &Element, ctx: &Ctx) -> Result<Vec<Block>, ConvertError
         pending_rows: 0,
         header_rows: 0,
         rows_emitted: 0,
+        checkboxes: read_checkboxes(elem),
     };
     walk_rows(elem, ctx, &mut state, true)?;
</file context>
Fix with cubic

};
walk_rows(elem, ctx, &mut state, true)?;
let mut table = state.builder.finish(TableKind::Data);
Expand All @@ -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<String, Vec<Inline>>,
}

/// 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<String, Vec<Inline>> {
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<String, Vec<Inline>>,
blocks: &mut Vec<Block>,
) {
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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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<Vec<RowCell>, ConvertError> {
fn parse_row_cells(
row: &Element,
ctx: &Ctx,
checkboxes: &HashMap<String, Vec<Inline>>,
) -> Result<Vec<RowCell>, ConvertError> {
let mut out = Vec::new();
for cell in row.child_elems() {
let repeat: u64 = cell
Expand All @@ -223,7 +281,8 @@ fn parse_row_cells(row: &Element, ctx: &Ctx) -> Result<Vec<RowCell>, 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 });
}
Expand Down
107 changes: 107 additions & 0 deletions src/formats/sheet/controls.rs
Original file line number Diff line number Diff line change
@@ -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<Checkbox>>;

/// A cell's inlines: its own text, then each checkbox with its caption.
pub(super) fn cell_inlines(text: Option<String>, boxes: &[Checkbox]) -> Vec<Inline> {
let mut out: Vec<Inline> = 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<Checkboxes, ConvertError> {
let rels = read_rels(pkg, &rels_part_for(sheet_part))?;
let mut out = Checkboxes::new();
let mut targets: Vec<String> = 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::<u32>().ok());
let col = parts.next()??;
parts.next()??;
let row = parts.next()??;
Some((row, col))
}
1 change: 1 addition & 0 deletions src/formats/sheet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading