Repository navigation
fix(xlsx): preserve cell hyperlinks #198
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Document, Conver | |
| } | ||
|
|
||
| let multi_sheet = sheets.len() > 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<String, HashMap<(u32, u32), Vec<String>>> = 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<Document, Conver | |
| failed += 1; | ||
| continue; | ||
| }; | ||
| let mut content = read_sheet(worksheet, &shared, &styles, date1904); | ||
| let sheet_rels = read_rels(pkg, &rels_part_for(part))?; | ||
| let mut content = read_sheet(worksheet, &shared, &styles, date1904, &sheet_rels); | ||
| content.checkboxes = read_vml_checkboxes(pkg, part)?; | ||
| content.anchors = anchors_by_sheet.remove(&name.to_lowercase()).unwrap_or_default(); | ||
| let Some(table) = build_table(content, &mut slots)? else { | ||
| continue; | ||
| }; | ||
|
|
@@ -236,6 +271,10 @@ pub(super) fn resolve_format(id: u32, custom: &HashMap<u32, &str>) -> 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<String>>, | ||
| /// Form control checkboxes by the cell they are anchored in. | ||
| pub(super) checkboxes: Checkboxes, | ||
| pub(super) hidden_rows: HashSet<u32>, | ||
|
|
@@ -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 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Range-valued Prompt for AI agents |
||
| continue; | ||
| }; | ||
| let target = if let Some(location) = link.attr_unqualified("location") { | ||
| Some(LinkTarget::Anchor(location.to_string())) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: This branch discards Prompt for AI agents |
||
| } 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<Inline> = 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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdLink" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://docs.example.test/" TargetMode="External"/></Relationships>"#, | ||
| )], | ||
| ..one_sheet( | ||
| r#"<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>External</t></is></c><c r="B1" t="inlineStr"><is><t>Internal</t></is></c></row></sheetData><hyperlinks><hyperlink ref="A1" r:id="rIdLink" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/><hyperlink ref="B1" location="Sheet2!A1"/></hyperlinks>"#, | ||
| ) | ||
| }; | ||
| 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#"<sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>Jump</t></is></c></row></sheetData><hyperlinks><hyperlink ref="A1" location="Sheet2!B2"/></hyperlinks>"#, | ||
| ), | ||
| ( | ||
| "Sheet2", | ||
| "", | ||
| r#"<sheetData><row r="2"><c r="B2" t="inlineStr"><is><t>Target</t></is></c></row></sheetData>"#, | ||
| ), | ||
| ], | ||
| ..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("<a id=\"sheet2-b2\"></a>"), "{markdown}"); | ||
| } | ||
|
|
||
| #[test] | ||
| fn shared_strings_resolve_including_rich_text_runs() { | ||
| let wb = Wb { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3: Repeated links to one target cell create duplicate
<a id>attributes. Deduplicate each target location before converting the IDs toInline::Anchornodes.Prompt for AI agents