Skip to content
Open
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
157 changes: 154 additions & 3 deletions src/formats/sheet/xlsx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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

@cubic-dev-ai cubic-dev-ai Bot Oct 21, 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.

P3: Repeated links to one target cell create duplicate <a id> attributes. Deduplicate each target location before converting the IDs to Inline::Anchor nodes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/formats/sheet/xlsx.rs, line 98:

<comment>Repeated links to one target cell create duplicate `<a id>` attributes. Deduplicate each target location before converting the IDs to `Inline::Anchor` nodes.</comment>

<file context>
@@ -75,6 +75,35 @@ pub(super) fn parse(pkg: &mut Package, wb_part: &str) -> Result<Document, Conver
+            let Some((target_sheet, at)) = cell_location_target(source_name, location) else {
+                continue;
+            };
+            anchors_by_sheet
+                .entry(target_sheet.to_lowercase())
+                .or_default()
</file context>
Fix with cubic

.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.
Expand All @@ -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;
};
Expand Down Expand Up @@ -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>,
Expand All @@ -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") {
Expand Down Expand Up @@ -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 {

@cubic-dev-ai cubic-dev-ai Bot Oct 18, 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.

P2: Range-valued hyperlink@ref attributes are discarded because this path accepts only a single cell reference. Parse the range and apply the target to each populated cell it covers without materializing unbounded blank ranges.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/formats/sheet/xlsx.rs, line 330:

<comment>Range-valued `hyperlink@ref` attributes are discarded because this path accepts only a single cell reference. Parse the range and apply the target to each populated cell it covers without materializing unbounded blank ranges.</comment>

<file context>
@@ -314,6 +322,31 @@ fn read_sheet(
+        .into_iter()
+        .flat_map(|links| links.find_all(ns::SML, "hyperlink"))
+    {
+        let Some(at) = link.attr_unqualified("ref").and_then(parse_ref) else {
+            continue;
+        };
</file context>
Fix with cubic

continue;
};
let target = if let Some(location) = link.attr_unqualified("location") {
Some(LinkTarget::Anchor(location.to_string()))
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.

@cubic-dev-ai cubic-dev-ai Bot Oct 18, 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.

P2: This branch discards r:id whenever location is present, so external HTML links with a fragment become workbook anchors and lose their external URL. Combine the relationship target with location when the relationship is external.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/formats/sheet/xlsx.rs, line 334:

<comment>This branch discards `r:id` whenever `location` is present, so external HTML links with a fragment become workbook anchors and lose their external URL. Combine the relationship target with `location` when the relationship is external.</comment>

<file context>
@@ -314,6 +322,31 @@ fn read_sheet(
+            continue;
+        };
+        let target = if let Some(location) = link.attr_unqualified("location") {
+            Some(LinkTarget::Anchor(location.to_string()))
+        } else {
+            link.attr_qualified(ns::R, "id")
</file context>
Fix with cubic

} 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
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)> {
Expand Down Expand Up @@ -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 {
Expand Down