///|
fn parse_comment_authors(xml : StringView) -> Array[String] raise XlsxError {
let authors : Array[String] = []
let body = match extract_tag_body(xml, "authors") {
Some(value) => value
None => return authors
}
let mut first = true
for chunk in body.split("") {
if first {
first = false
continue
}
let text = chunk.to_owned()
let end = match text.find("") {
Some(pos) => pos
None => continue
}
let name = unescape_xml_text(text[:end])
authors.push(name)
}
authors
}
///|
/// Older writers emitted a plain `` prefix before rich `` elements.
/// `parse_text_nodes` returns the complete visible text while
/// `parse_rich_text_runs` returns only the rich suffix. Convert that retained
/// prefix into an unformatted run so the in-memory representation has one
/// authoritative run sequence and can be written again without loss.
fn normalize_parsed_comment_runs(
text : String,
runs : Array[RichTextRun],
) -> Array[RichTextRun] {
if runs.length() == 0 {
return runs
}
let rich_text_builder = StringBuilder::new()
for run in runs {
rich_text_builder.write_view(run.text)
}
let rich_text = rich_text_builder.to_string()
if text == rich_text {
return runs
}
match text.strip_suffix(rich_text) {
Some(prefix) =>
if prefix.length() > 0 {
let normalized : Array[RichTextRun] = [
{ text: prefix.to_owned(), font: None },
]
for run in runs {
normalized.push(run)
}
normalized
} else {
runs
}
None => runs
}
}
///|
fn parse_comments_xml(xml : StringView) -> Array[Comment] raise XlsxError {
let comments : Array[Comment] = []
let authors = parse_comment_authors(xml)
let body = match extract_tag_body(xml, "commentList") {
Some(value) => value
None => return comments
}
let mut first = true
for chunk in body.split("") {
Some(pos) => pos
None => raise InvalidXml(msg="comment tag not closed")
}
let tag = text[:end]
let ref_value = match attr_value(tag, "ref") {
Some(value) => value
None => raise InvalidXml(msg="comment ref missing")
}
let (row, col) = cell_ref_to_rc(ref_value)
let cell = cell_ref_from(row, col)
let author_id = match attr_value(tag, "authorId") {
Some(value) => @string.parse_int(value, base=10) catch { _ => 0 }
None => 0
}
let author = if author_id >= 0 && author_id < authors.length() {
authors[author_id]
} else {
""
}
let rest = text[end + 1:]
let text_body = match extract_tag_body_from(rest, "text") {
Some(value) => value
None => ""
}
let parsed_runs = if text_body.contains(" []
}
} else {
[]
}
let text_value = parse_text_nodes(text_body) catch { _ => "" }
let paragraph = normalize_parsed_comment_runs(text_value, parsed_runs)
comments.push({
cell,
author,
author_id: Some(author_id),
text: text_value,
paragraph,
width: None,
height: None,
})
}
comments
}
///|
fn parse_table_part_ids(xml : StringView) -> Array[String] raise XlsxError {
let ids : Array[String] = []
let body = match extract_tag_body(xml, "tableParts") {
Some(value) => value
None => return ids
}
let mut first = true
for chunk in body.split("") {
Some(pos) => pos
None =>
match text.find(">") {
Some(pos) => pos
None => raise InvalidXml(msg="tablePart tag not closed")
}
}
let tag = text[:end]
let id = match attr_value(tag, "r:id") {
Some(value) => value
None => raise InvalidXml(msg="tablePart id missing")
}
ids.push(id)
}
ids
}
///|
fn parse_pivot_table_part_ids(
xml : StringView,
) -> Array[String] raise XlsxError {
let ids : Array[String] = []
let body = match extract_tag_body(xml, "pivotTableParts") {
Some(value) => value
None => return ids
}
let mut first = true
for chunk in body.split("") {
Some(pos) => pos
None =>
match text.find(">") {
Some(pos) => pos
None => raise InvalidXml(msg="pivotTablePart tag not closed")
}
}
let tag = text[:end]
let id = match attr_value(tag, "r:id") {
Some(value) => value
None => raise InvalidXml(msg="pivotTablePart id missing")
}
ids.push(id)
}
ids
}
///|
fn parse_sheet_slicer_rel_ids(
xml : StringView,
) -> Array[String] raise XlsxError {
let ids : Array[String] = []
let xml_str = xml.to_owned()
let mut first = true
for chunk in xml_str.split("") {
Some(pos) => pos
None =>
match text.find(">") {
Some(pos) => pos
None => raise InvalidXml(msg="x14:slicer tag not closed")
}
}
let tag = text[:end]
let id = match attr_value(tag, "r:id") {
Some(value) => value
None => raise InvalidXml(msg="x14:slicer id missing")
}
ids.push(id)
}
ids
}
///|
priv struct ParsedSlicerPartEntry {
name : String
cache : String
caption : String
display_header : Bool?
}
///|
fn parse_slicer_part_entries(
xml : StringView,
) -> Array[ParsedSlicerPartEntry] raise XlsxError {
let entries : Array[ParsedSlicerPartEntry] = []
let xml_str = xml.to_owned()
let mut first = true
for chunk in xml_str.split("") {
Some(pos) => pos
None =>
match text.find(">") {
Some(pos) => pos
None => raise InvalidXml(msg="slicer tag not closed")
}
}
let tag = text[:end]
let name = match attr_value(tag, "name") {
Some(value) => unescape_xml_text(value)
None => raise InvalidXml(msg="slicer name missing")
}
let cache = match attr_value(tag, "cache") {
Some(value) => unescape_xml_text(value)
None => raise InvalidXml(msg="slicer cache missing")
}
let caption = match attr_value(tag, "caption") {
Some(value) => unescape_xml_text(value)
None => ""
}
let display_header = match attr_value(tag, "showCaption") {
Some(value) => Some(parse_bool_attr(value))
None => None
}
entries.push({ name, cache, caption, display_header })
}
entries
}
///|
priv struct SlicerCacheDef {
name : String
source_name : String
item_desc : Bool
table_id : Int?
table_column : Int?
pivot_tab_id : Int?
pivot_table_name : String?
}
///|
fn parse_slicer_cache_definition(
xml : StringView,
) -> SlicerCacheDef raise XlsxError {
let tag = match tag_attributes_in(xml, "slicerCacheDefinition") {
Some(value) => value
None => raise InvalidXml(msg="slicerCacheDefinition missing")
}
let name = match attr_value(tag, "name") {
Some(value) => unescape_xml_text(value)
None => raise InvalidXml(msg="slicer cache name missing")
}
let source_name = match attr_value(tag, "sourceName") {
Some(value) => unescape_xml_text(value)
None => ""
}
let mut item_desc = false
let mut table_id : Int? = None
let mut table_column : Int? = None
let mut pivot_tab_id : Int? = None
let mut pivot_table_name : String? = None
match tag_attributes_in(xml, "x15:tableSlicerCache") {
Some(cache_tag) => {
match attr_value(cache_tag, "tableId") {
Some(value) =>
try @string.parse_int(value, base=10) catch {
_ => raise InvalidXml(msg="slicer cache tableId invalid")
} noraise {
val => table_id = Some(val)
}
None => ()
}
match attr_value(cache_tag, "column") {
Some(value) =>
try @string.parse_int(value, base=10) catch {
_ => raise InvalidXml(msg="slicer cache column invalid")
} noraise {
val => table_column = Some(val)
}
None => ()
}
match attr_value(cache_tag, "sortOrder") {
Some(value) =>
if unescape_xml_text(value) == "descending" {
item_desc = true
}
None => ()
}
}
None => ()
}
match tag_attributes_in(xml, "tabular") {
Some(tabular_tag) =>
match attr_value(tabular_tag, "sortOrder") {
Some(value) =>
if unescape_xml_text(value) == "descending" {
item_desc = true
}
None => ()
}
None => ()
}
match extract_tag_body_from(xml, "pivotTables") {
Some(pivot_tables_body) =>
match tag_attributes_in(pivot_tables_body, "pivotTable") {
Some(pivot_tag) => {
match attr_value(pivot_tag, "tabId") {
Some(value) =>
try @string.parse_int(value, base=10) catch {
_ => raise InvalidXml(msg="slicer cache tabId invalid")
} noraise {
val => pivot_tab_id = Some(val)
}
None => ()
}
match attr_value(pivot_tag, "name") {
Some(value) => pivot_table_name = Some(unescape_xml_text(value))
None => ()
}
}
None => ()
}
None => ()
}
{
name,
source_name,
item_desc,
table_id,
table_column,
pivot_tab_id,
pivot_table_name,
}
}
///|
fn parse_slicer_cache_definitions(
workbook_rels_xml : StringView,
workbook_part : StringView,
part_names : Map[String, String],
content_types : @ooxml.PackageContentTypes,
archive : @zip.Archive,
decode : (BytesView) -> String raise XlsxError,
budget? : ReadBudget,
cancelled? : () -> Bool = () => false,
) -> Map[String, SlicerCacheDef] raise XlsxError {
let defs : Map[String, SlicerCacheDef] = Map([])
let targets = parse_internal_relationship_targets(
workbook_rels_xml,
rel_slicer_cache,
budget?,
cancelled~,
)
for _rel_id, target in targets {
let cache_path = actual_archive_part_path(
part_names,
resolve_part_rel_target(
logical_archive_part_path(workbook_part),
target,
cancelled~,
),
)
require_part_content_type(
content_types,
logical_archive_part_path(cache_path),
[ct_slicer_cache],
"slicer cache",
cancelled~,
)
let cache_bytes = match archive.get(cache_path) {
Some(value) => value
None => raise MissingPart(path=cache_path)
}
let cache_xml = decode(cache_bytes)
let def = parse_slicer_cache_definition(cache_xml)
defs[def.name] = def
}
defs
}
///|
fn resolve_slicer_sources_after_read(
workbook : Workbook,
slicer_cache_defs : Map[String, SlicerCacheDef],
) -> Unit {
fn worksheet_name_from_tab_id(workbook : Workbook, tab_id : Int) -> String? {
let count = workbook.sheets.length()
if count == 0 {
return None
}
let candidates : Array[Int] = []
candidates.push(tab_id)
candidates.push(tab_id + 1)
candidates.push(tab_id - 1)
for id in candidates {
if id >= 1 && id <= count {
return Some(workbook.sheets[id - 1].name)
}
}
None
}
let table_by_id : Map[Int, (String, Table)] = Map([])
for sheet in workbook.sheets {
for table in sheet.tables() {
if table.id > 0 {
table_by_id[table.id] = (sheet.name, table)
}
}
}
for sheet in workbook.sheets {
let slicers = sheet.slicers
let mut i = 0
while i < slicers.length() {
let slicer = slicers[i]
match slicer_cache_defs.get(slicer.cache) {
Some(def) => {
let mut table_sheet = slicer.table_sheet
let mut table_name = slicer.table_name
let mut source_name = slicer.source_name
let mut item_desc = slicer.item_desc
if def.source_name != "" {
source_name = def.source_name
}
item_desc = def.item_desc
match def.table_id {
Some(table_id) =>
match table_by_id.get(table_id) {
Some((sheet_name, table)) => {
table_sheet = sheet_name
table_name = table.name
match def.table_column {
Some(col) => {
let idx = col - 1
if idx >= 0 && idx < table.columns.length() {
source_name = table.columns[idx]
}
}
None => ()
}
}
None => ()
}
None => ()
}
match def.pivot_table_name {
Some(pivot_name) => if table_name == "" { table_name = pivot_name }
None => ()
}
match def.pivot_tab_id {
Some(tab_id) =>
match worksheet_name_from_tab_id(workbook, tab_id) {
Some(sheet_name) =>
if table_sheet == "" {
table_sheet = sheet_name
}
None => ()
}
None => ()
}
if table_sheet != slicer.table_sheet ||
table_name != slicer.table_name ||
source_name != slicer.source_name ||
item_desc != slicer.item_desc {
slicers[i] = {
name: slicer.name,
cache: slicer.cache,
source_name,
cell: slicer.cell,
table_sheet,
table_name,
caption: slicer.caption,
macro_name: slicer.macro_name,
width: slicer.width,
height: slicer.height,
display_header: slicer.display_header,
item_desc,
format: slicer.format,
drawing_offset_x_emu: slicer.drawing_offset_x_emu,
drawing_offset_y_emu: slicer.drawing_offset_y_emu,
drawing_width_emu: slicer.drawing_width_emu,
drawing_height_emu: slicer.drawing_height_emu,
drawing_order: slicer.drawing_order,
}
}
}
None => ()
}
i = i + 1
}
}
}
///|
test "read_sheet_rel_parts wb: parse_comment_authors handles missing and malformed entries" {
debug_inspect(parse_comment_authors(""), content="[]")
let xml =
#|
#|
#| Alice
#| Bob
#|
#|
debug_inspect(parse_comment_authors(xml), content="[\"Bob\"]")
}
///|
test "read_sheet_rel_parts wb: parse_comments_xml edge branches and errors" {
let no_list =
#|
#| A
#|
inspect(parse_comments_xml(no_list).length(), content="0")
let malformed_tag =
#|
#|
#|
#|
let malformed_result : Result[Array[Comment], Error] = Ok(
parse_comments_xml(malformed_tag),
) catch {
e => Err(e)
}
match malformed_result {
Err(XlsxError::InvalidXml(msg~)) =>
inspect(msg, content="comment tag not closed")
_ => fail("expected InvalidXml comment tag not closed")
}
let missing_ref =
#|
#|
#| x
#|
#|
let missing_ref_result : Result[Array[Comment], Error] = Ok(
parse_comments_xml(missing_ref),
) catch {
e => Err(e)
}
match missing_ref_result {
Err(XlsxError::InvalidXml(msg~)) =>
inspect(msg, content="comment ref missing")
_ => fail("expected InvalidXml comment ref missing")
}
let mixed =
#|
#| A
#|
#|
#| bad
#|
#|
let comments = parse_comments_xml(mixed)
inspect(comments.length(), content="2")
inspect(comments[0].author, content="A")
inspect(comments[0].text, content="")
inspect(comments[1].author, content="")
inspect(comments[1].paragraph.length(), content="0")
}
///|
test "read_sheet_rel_parts wb: parse_table_part_ids supports expanded tags and validates id" {
let xml =
#|
#|
#|
#|
#|
debug_inspect(parse_table_part_ids(xml), content="[\"rIdTable\"]")
let missing_id =
#|
#|
#|
#|
#|
let result : Result[Array[String], Error] = Ok(
parse_table_part_ids(missing_id),
) catch {
e => Err(e)
}
match result {
Err(XlsxError::InvalidXml(msg~)) =>
inspect(msg, content="tablePart id missing")
_ => fail("expected InvalidXml tablePart id missing")
}
}
///|
test "read_sheet_rel_parts wb: parse_pivot_table_part_ids supports expanded tags and validates id" {
let xml =
#|
#|
#|
#|
#|
debug_inspect(parse_pivot_table_part_ids(xml), content="[\"rIdPivot\"]")
let missing_id =
#|
#|
#|
#|
#|
let result : Result[Array[String], Error] = Ok(
parse_pivot_table_part_ids(missing_id),
) catch {
e => Err(e)
}
match result {
Err(XlsxError::InvalidXml(msg~)) =>
inspect(msg, content="pivotTablePart id missing")
_ => fail("expected InvalidXml pivotTablePart id missing")
}
}