diff --git a/src/formats/sheet/xlsx.rs b/src/formats/sheet/xlsx.rs index e0924562..eed04a01 100644 --- a/src/formats/sheet/xlsx.rs +++ b/src/formats/sheet/xlsx.rs @@ -7,9 +7,11 @@ 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; -use crate::model::{Block, Cell, Document, GridBuilder, Inline, Table, TableKind}; +use crate::model::{Block, Cell, Document, GridBuilder, Inline, LinkTarget, Table, TableKind}; use crate::package::limits; -use crate::package::relationships::{Relationships, read_rels, rel_type, rels_part_for}; +use crate::package::relationships::{ + Relationships, TargetMode, read_rels, rel_type, rels_part_for, +}; use crate::package::xml::{Element, ns}; use crate::package::{Package, path}; use crate::shared::header::resolve_header_rows; @@ -19,6 +21,8 @@ use std::rc::Rc; pub(super) const SHARED_STRINGS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"; +const HYPERLINK_REL: &str = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; /// The grid bounds the format defines; a reference outside them is not a /// real cell. @@ -71,6 +75,35 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result 1; + // Internal hyperlinks are rendered only when their target anchor exists. + // Resolve cell locations across the visible workbook before building any + // sheet tables so links to later sheets work too. + let mut anchors_by_sheet: HashMap>> = HashMap::new(); + for (source_name, part) in &sheets { + let worksheet = pkg.optional_xml_part(part)?; + let Some(worksheet) = worksheet.as_ref().and_then(|r| r.find(ns::SML, "worksheet")) else { + continue; + }; + for link in worksheet + .first_descendant(ns::SML, "hyperlinks") + .into_iter() + .flat_map(|links| links.find_all(ns::SML, "hyperlink")) + { + let Some(location) = link.attr_unqualified("location") else { + continue; + }; + let Some((target_sheet, at)) = cell_location_target(source_name, location) else { + continue; + }; + anchors_by_sheet + .entry(target_sheet.to_lowercase()) + .or_default() + .entry(at) + .or_default() + .push(location.to_string()); + } + } + let mut doc = Document::default(); let mut failed = 0usize; // One budget for the workbook, so sheets cannot multiply the cap. @@ -82,8 +115,10 @@ 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>, + /// Hyperlinks attached to individual worksheet cells. + pub(super) hyperlinks: HashMap<(u32, u32), LinkTarget>, + /// Internal links from workbook sheets that target these cells. + pub(super) anchors: HashMap<(u32, u32), Vec>, /// Form control checkboxes by the cell they are anchored in. pub(super) checkboxes: Checkboxes, pub(super) hidden_rows: HashSet, @@ -250,6 +289,7 @@ fn read_sheet( shared: &[String], styles: &Styles, date1904: bool, + rels: &Relationships, ) -> SheetContent { let mut out = SheetContent::default(); for cols in worksheet.find_all(ns::SML, "cols") { @@ -314,6 +354,31 @@ fn read_sheet( out.merges.push(region); } } + for link in worksheet + .first_descendant(ns::SML, "hyperlinks") + .into_iter() + .flat_map(|links| links.find_all(ns::SML, "hyperlink")) + { + let Some(at) = link.attr_unqualified("ref").and_then(parse_ref) else { + continue; + }; + let target = if let Some(location) = link.attr_unqualified("location") { + Some(LinkTarget::Anchor(location.to_string())) + } else { + link.attr_qualified(ns::R, "id") + .and_then(|id| rels.get(id)) + .filter(|rel| rel.rel_type == HYPERLINK_REL) + .map(|rel| { + crate::shared::fields::classify_rel_target( + rel.mode == TargetMode::External, + &rel.target, + ) + }) + }; + if let Some(target) = target { + out.hyperlinks.insert(at, target); + } + } out } @@ -415,6 +480,22 @@ pub(super) fn build_table( for (at, text) in sheet.cells.drain() { cells.insert(at, vec![Inline::plain(text)]); } + for (at, ids) in sheet.anchors.drain() { + if at.0 < MAX_ROWS + && at.1 < MAX_COLS + && let Some(content) = cells.get_mut(&at) + { + let mut anchored: Vec = ids.into_iter().map(Inline::Anchor).collect(); + anchored.append(content); + *content = anchored; + } + } + for (&at, target) in &sheet.hyperlinks { + if let Some(content) = cells.get_mut(&at) { + *content = + vec![Inline::Link { content: std::mem::take(content), target: target.clone() }]; + } + } // 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. @@ -672,6 +753,26 @@ fn parse_ref(r: &str) -> Option<(u32, u32)> { Some((row - 1, col - 1)) } +/// Resolve a worksheet location to a visible workbook sheet and cell. A +/// location without a sheet name refers to the source sheet; quoted sheet +/// names may escape apostrophes by doubling them. +fn cell_location_target(source_sheet: &str, location: &str) -> Option<(String, (u32, u32))> { + let (sheet, cell) = match location.rsplit_once('!') { + Some((sheet, cell)) => { + let sheet = sheet.trim(); + let sheet = sheet + .strip_prefix('\'') + .and_then(|s| s.strip_suffix('\'')) + .unwrap_or(sheet) + .replace("''", "'"); + (sheet, cell) + } + None => (source_sheet.to_string(), location), + }; + let cell = cell.replace('$', ""); + Some((sheet, parse_ref(&cell)?)) +} + /// A merge reference (`F1:O3`, or a single cell) as an inclusive normalized /// region. fn parse_region(r: &str) -> Option<(u32, u32, u32, u32)> { @@ -869,6 +970,56 @@ mod tests { ); } + #[test] + fn cell_hyperlinks_resolve_external_relationships_and_sheet_locations() { + let wb = Wb { + extra: vec![( + "xl/worksheets/_rels/sheet1.xml.rels", + r#""#, + )], + ..one_sheet( + r#"ExternalInternal"#, + ) + }; + let doc = parse(&wb.build()).unwrap(); + let table = first_table(&doc); + let link_at = |col| { + let CellSlot::Origin(cell) = &table.grid[0][col] else { panic!() }; + let Block::Paragraph(inlines) = &cell.blocks[0] else { panic!() }; + let Inline::Link { target, .. } = &inlines[0] else { + panic!("expected link at column {col}, got {inlines:?}") + }; + target + }; + assert_eq!(link_at(0), &LinkTarget::External("https://docs.example.test/".into())); + assert_eq!(link_at(1), &LinkTarget::Anchor("Sheet2!A1".into())); + } + + #[test] + fn sheet_location_hyperlinks_render_with_a_target_anchor() { + let wb = Wb { + sheets: vec![ + ( + "Sheet1", + "", + r#"Jump"#, + ), + ( + "Sheet2", + "", + r#"Target"#, + ), + ], + ..Wb::default() + }; + + let doc = parse(&wb.build()).unwrap(); + let markdown = crate::render::markdown::document_to_markdown(&doc); + + assert!(markdown.contains("[Jump](#sheet2-b2)"), "{markdown}"); + assert!(markdown.contains(""), "{markdown}"); + } + #[test] fn shared_strings_resolve_including_rich_text_runs() { let wb = Wb {