///|
fn parse_header_footer_shape_id(
id : StringView,
) -> (HeaderFooterImagePosition, Bool, Bool)? {
let id_norm = id.to_owned().to_upper()
if id_norm.length() < 2 {
return None
}
let pos_char = id_norm.get_char(0)
let hf_char = id_norm.get_char(1)
let position = match pos_char {
Some('L') => HeaderFooterImagePosition::Left
Some('C') => Center
Some('R') => Right
_ => return None
}
let is_footer = match hf_char {
Some('H') => false
Some('F') => true
_ => return None
}
let suffix = if id_norm.length() > 2 {
let view = id_norm[2:]
view.to_owned()
} else {
""
}
let first_page = match suffix {
"" => false
"FIRST" => true
_ => return None
}
Some((position, is_footer, first_page))
}
///|
fn parse_style_value(style : StringView, key : StringView) -> String? {
let style_str = style.to_owned()
let needle = "\{key.to_owned()}:"
let start = match style_str.find(needle) {
Some(pos) => pos + needle.length()
None => return None
}
let rest = style_str[start:]
let end = match rest.find(";") {
Some(pos) => pos
None => rest.length()
}
let slice = rest[:end]
let trimmed = slice.to_owned().trim()
let value = trimmed.to_owned()
if value == "" {
None
} else {
Some(value)
}
}
///|
fn extension_from_path(path : StringView) -> String? {
let path_str = path.to_owned()
let dot = match path_str.rev_find(".") {
Some(pos) => pos
None => return None
}
let start = dot + 1
if start >= path_str.length() {
return None
}
let slice = path_str[start:]
let value = slice.to_owned().to_lower()
if value == "" {
None
} else {
Some(value)
}
}
///|
fn parse_header_footer_images_from_vml(
vml_xml : StringView,
vml_rels_xml : String?,
vml_part : StringView,
part_names : Map[String, String],
content_types : @ooxml.PackageContentTypes,
archive : @zip.Archive,
budget? : ReadBudget,
cancelled? : () -> Bool = () => false,
) -> Array[HeaderFooterImage] raise XlsxError {
let shapes = extract_vml_shapes(vml_xml)
let rel_targets = match vml_rels_xml {
Some(xml) =>
parse_internal_relationship_targets(xml, rel_image, budget?, cancelled~)
None => Map([])
}
let images : Array[HeaderFooterImage] = []
let index_by_id : Map[String, Int] = Map([])
for shape in shapes {
let shape_attrs = match tag_attributes_in(shape, "v:shape") {
Some(value) => value
None => continue
}
let shape_id = match attr_value(shape_attrs, "id") {
Some(value) => value
None => continue
}
let info = match parse_header_footer_shape_id(shape_id) {
Some(value) => value
None => continue
}
let (position, is_footer, first_page) = info
let style = match attr_value(shape_attrs, "style") {
Some(value) => value
None => ""
}
let width = match parse_style_value(style, "width") {
Some(value) => value
None => continue
}
let height = match parse_style_value(style, "height") {
Some(value) => value
None => continue
}
let imagedata_attrs = match tag_attributes_in(shape, "v:imagedata") {
Some(value) => value
None => continue
}
let rel_id = match attr_value(imagedata_attrs, "o:relid") {
Some(value) => value
None =>
match attr_value(imagedata_attrs, "r:id") {
Some(value) => value
None => continue
}
}
let target = match rel_targets.get(rel_id) {
Some(value) => value
None => raise InvalidXml(msg="header/footer image relationship missing")
}
let image_path = actual_relationship_target_path(
vml_part,
target,
part_names,
cancelled~,
)
let (image_bytes, identity) = load_image_part(
archive,
content_types,
image_path,
"header/footer image",
cancelled~,
)
let image : HeaderFooterImage = {
position,
data: image_bytes.to_owned(),
extension: identity.extension,
content_type: identity.content_type,
is_footer,
first_page,
width,
height,
}
let key = header_footer_image_shape_id(position, is_footer, first_page)
match index_by_id.get(key) {
Some(idx) => images[idx] = image
None => {
index_by_id[key] = images.length()
images.push(image)
}
}
}
images
}
///|
test "header/footer image read wb: shape id parser handles invalid cases" {
debug_inspect(parse_header_footer_shape_id("L"), content="None")
debug_inspect(parse_header_footer_shape_id("XH"), content="None")
debug_inspect(parse_header_footer_shape_id("LA"), content="None")
debug_inspect(parse_header_footer_shape_id("LHX"), content="None")
debug_inspect(
parse_header_footer_shape_id("LHFIRST"),
content="Some((Left, false, true))",
)
}
///|
test "header/footer image read wb: style and extension parsing edge cases" {
debug_inspect(parse_style_value("height:20pt", "width"), content="None")
debug_inspect(
parse_style_value("width: ;height:20pt", "width"),
content="None",
)
debug_inspect(
parse_style_value("width:10pt", "width"),
content="Some(\"10pt\")",
)
debug_inspect(extension_from_path("xl/media/image."), content="None")
debug_inspect(
extension_from_path("xl/media/image1.png"),
content="Some(\"png\")",
)
}
///|
test "header/footer image read wb: parse images with r:id fallback and duplicate overwrite" {
let archive = @zip.Archive::new()
let image1 = @encoding/utf8.encode("img1")
let image2 = @encoding/utf8.encode("img2")
archive.add("xl/media/image1.png", image1)
archive.add("xl/media/image2.png", image2)
let rels_xml =
#|
#|
#|
#|
let vml_xml =
#|
#|
#|
#|
#|
#|
#|
#|
#|
#|
#|
let images = parse_header_footer_images_from_vml(
vml_xml,
Some(rels_xml),
"xl/drawings/vmlDrawing1.vml",
Map([]),
image_reader_test_content_types(),
archive,
)
inspect(images.length(), content="1")
debug_inspect(images[0].position, content="Left")
inspect(images[0].is_footer, content="false")
inspect(images[0].first_page, content="false")
inspect(images[0].width, content="30pt")
inspect(images[0].height, content="40pt")
inspect(images[0].data == image2, content="true")
}
///|
test "header/footer image read wb: missing target part raises MissingPart" {
let archive = @zip.Archive::new()
let rels_xml =
#|
#|
#|
let vml_xml =
#|
#|
#|
#|
#|
let result : Result[Array[HeaderFooterImage], Error] = Ok(
parse_header_footer_images_from_vml(
vml_xml,
Some(rels_xml),
"xl/drawings/vmlDrawing1.vml",
Map([]),
image_reader_test_content_types(),
archive,
),
) catch {
e => Err(e)
}
match result {
Err(XlsxError::MissingPart(path~)) =>
inspect(path, content="xl/media/missing.png")
_ => fail("expected MissingPart for missing header/footer image target")
}
}