diff --git a/.zenodo.json b/.zenodo.json index 8ce9fd22..8af7c331 100644 --- a/.zenodo.json +++ b/.zenodo.json @@ -12,7 +12,7 @@ "type": "ProjectMember" } ], - "description": "XLSX.jl is a Julia package to read and write Excel spreadsheet files in the XLSX format.", + "description": "XLSX.jl is a Julia package to read and write Excel spreadsheet files.", "license": "mit", "upload_type": "software", "keywords": ["julia", "excel", "xlsx", "spreadsheet"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca61d14..4925282c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - add `dependabot` support ([#130](https://github.com/JuliaData/XLSX.jl/issues/130)) - add ability to read native template (`.xltx`) files ([#293](https://github.com/JuliaData/XLSX.jl/issues/293)) - add `getDefinedNames` to mirror `addDefinedName` +- support adding png, jpg or gif images into a sheet ([#134](https://github.com/JuliaData/XLSX.jl/issues/134)) ## [v0.11.7](https://github.com/JuliaData/XLSX.jl/tree/v0.11.7) - 2026-05-07 - Fix issue [#368](https://github.com/JuliaData/XLSX.jl/issues/368) for both reading and writing diff --git a/docs/src/api/files.md b/docs/src/api/files.md index 6a2a7094..9c581ffe 100644 --- a/docs/src/api/files.md +++ b/docs/src/api/files.md @@ -24,4 +24,5 @@ XLSX.addsheet! XLSX.renamesheet! XLSX.copysheet! XLSX.deletesheet! +XLSX.addImage ``` diff --git a/src/XLSX.jl b/src/XLSX.jl index c00ac460..c35b9444 100644 --- a/src/XLSX.jl +++ b/src/XLSX.jl @@ -23,6 +23,7 @@ export writexlsx, savexlsx, Worksheet, sheetnames, sheetcount, hassheet, addsheet!, renamesheet!, copysheet!, deletesheet!, + addImage, # Cells & data CellRef, row_number, column_number, eachtablerow, readdata, getdata, gettable, readtable, readto, @@ -60,6 +61,7 @@ include("cellformat-helpers.jl") # must load before cellformats.jl include("cellformats.jl") include("conditional-format-helpers.jl") # must load before conditional-formats.jl include("conditional-formats.jl") +include("images.jl") include("write.jl") include("fileArray.jl") diff --git a/src/images.jl b/src/images.jl new file mode 100644 index 00000000..66eceb57 --- /dev/null +++ b/src/images.jl @@ -0,0 +1,547 @@ +# =========================================================================== +# Constants +# =========================================================================== + +const REL_DRAWING = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing" +const REL_IMAGE = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" +const NS_RELATIONSHIPS = + "http://schemas.openxmlformats.org/package/2006/relationships" +const NS_XDR = + "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing" +const NS_A = + "http://schemas.openxmlformats.org/drawingml/2006/main" +const NS_R = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + +const MIME_DRAWING = + "application/vnd.openxmlformats-officedocument.drawing+xml" +const EXT_MIME = Dict( + ".png" => "image/png", + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".gif" => "image/gif", +) + +const ImageInfo = NamedTuple{ + (:sheet, :media_name, :from, :to), + Tuple{String, String, String, String}, +} + +# =========================================================================== +# Traversal helpers (eliminate the repeated nodetype/tag/attributes pattern) +# =========================================================================== + +element_children(node::XML.Node) = + filter(n -> XML.nodetype(n) === XML.Element, something(XML.children(node), [])) + +# Match on local name only (ignores namespace prefix) +elements_with_tag(node::XML.Node, tag::String) = + filter(n -> localname(XML.tag(n)) == tag, element_children(node)) + +get_attr(node::XML.Node, key::AbstractString, default::AbstractString = "") = + something(get(XML.attributes(node), key, nothing), default) + +function root_element(doc::XML.Node)::XML.Node + children = something(XML.children(doc), []) + idx = findfirst(n -> XML.nodetype(n) === XML.Element, children) + idx !== nothing ? children[idx] : throw(XLSXError("Document has no root element")) +end + +function _text_value(node::XML.Node)::Union{Nothing,String} + for c in something(XML.children(node), []) + XML.nodetype(c) === XML.Text && return XML.value(c) + end + return nothing +end + +# Prepends prefix if non-empty: prefixed_tag("pkg", "Relationship") → "pkg:Relationship" +prefixed_tag(prefix::AbstractString, name::AbstractString) = + isempty(prefix) ? name : "$prefix:$name" + +# =========================================================================== +# Document templates +# =========================================================================== + +empty_rels_doc() = XML.Document( + XML.Declaration(; version="1.0", encoding="UTF-8"), + XML.Element("Relationships"; xmlns=NS_RELATIONSHIPS), +) + +empty_drawing_doc() = XML.Document( + XML.Declaration(; version="1.0", encoding="UTF-8"), + XML.Element("xdr:wsDr"; + var"xmlns:xdr" = NS_XDR, + var"xmlns:a" = NS_A, + var"xmlns:r" = NS_R, + ), +) + +# =========================================================================== +# addImage — public API +# =========================================================================== + +""" + addImage(s::Worksheet, ref::AbstractString, image::Union{AbstractString, IOBuffer}; size::Union{Nothing, Tuple{<:Integer, <:Integer}}=nothing) + addImage(s::Worksheet, row::Integer, col::Integer, image::Union{AbstractString, IOBuffer}; size::Union{Nothing, Tuple{<:Integer, <:Integer}}=nothing) + + +Insert an image into a worksheet at the given cell reference. The image "floats" above the +grid and does not affect cell contents or dimensions. In Excel, the image may be resized +and repositioned by the user as normal. +Supports file paths and `IOBuffer` sources. + +If multiple, overlapping images are added, newer images overly older ones. + +# Arguments + +- `s::Worksheet`: the target worksheet. +- `ref::AbstractString`: Either a valid cell reference (e.g. `"A1"`) or a valid cell range (e.g. `"B2:D4"`). +The image will be anchored to the top left of the reference and sized to fit within the reference bounds. +If a cell range is given, the `size` keyword argument is ignored. + +- `image::Union{AbstractString, IOBuffer}`: Specifies the image to be inserted. Either: + - a file path (`String`) + - an `IOBuffer` containing raw image bytes + +Supported formats (auto-detected): PNG, JPEG, GIF. + +# Keyword Arguments + +- `size`: provide the desired size of the image as a tuple of integers: `(width_px, height_px)`. Actual size +will snap to the nearest actual cell boundaries. If `nothing` (default), the image's native pixel size is used. +Ignored if `ref` is a cell range. + +# Return Value + +Returns a structured summary describing where and how the image was placed as a `NamedTuple` of `String` values: + +```julia +( + sheet = sheet name, + media_name = internal media file name, + from = Start cell (top left), + to = End cell (bottom right), +) +``` + +# Examples + +Insert from a file: + +```julia +info = XLSX.addImage(sheet, "B2", "photo.jpg") +``` + +Insert from an `IOBuffer`: + +```julia +buf = IOBuffer(read("logo.png")) +info = XLSX.addImage(sheet, "C5", buf) +``` + +Insert with explicit size: + +```julia +info = XLSX.addImage(sheet, "A1", "icon.png"; size=(128, 128)) +``` + +""" +addImage(s::Worksheet, row::Integer, col::Integer, image; kw...) = + addImage(s, CellRef(row, col), image; kw...) + +function addImage(s::Worksheet, ref::AbstractString, image; kw...) + if is_valid_cellname(ref) + addImage(s, CellRef(ref), image; kw...) + elseif is_valid_cellrange(ref) + addImage(s, CellRange(ref), image; kw...) + else + throw(ArgumentError("Invalid cell reference: $ref")) + end +end + +function addImage( + s::Worksheet, + cellref::Union{CellRef, CellRange}, + image::Union{AbstractString, IOBuffer}; + size::Union{Nothing, Tuple{<:Integer,<:Integer}} = nothing, +) + xf = get_xlsxfile(s) + sheet_path = get_relationship_target_by_id("xl", get_workbook(s), s.relationship_id) + + media_name = add_media!(xf, image) + drawing_path = ensure_drawing!(xf, sheet_path) + img_rid = add_image_rel!(xf, drawing_path, media_name) + col, row, col_to, row_to = + add_anchor!(xf, drawing_path, img_rid, media_name, cellref; size) + + return ( + sheet = s.name, + media_name = media_name, + from = string(CellRef(row, col)), + to = string(CellRef(row_to, col_to)), + ) +end + +# =========================================================================== +# Media +# =========================================================================== + +add_media!(xf::XLSXFile, path::AbstractString) = _add_media_bytes!(xf, read(path)) +add_media!(xf::XLSXFile, io::IOBuffer) = _add_media_bytes!(xf, take!(io)) + +function _add_media_bytes!(xf::XLSXFile, bytes::Vector{UInt8})::String + ext = detect_image_ext(bytes) + existing = count(k -> startswith(k, "xl/media/"), keys(xf.binary_data)) + name = "image$(existing + 1)$ext" + xf.binary_data["xl/media/$name"] = bytes + ext_no_dot = String(lstrip(ext, '.')) + register_content_type!(xf, "[Content_Types].xml"; + tag="Default", key="Extension", val=ext_no_dot, + content_type=get(EXT_MIME, ext, "image/$ext_no_dot")) + return name +end + +# =========================================================================== +# Drawing setup +# =========================================================================== + +function ensure_drawing!(xf::XLSXFile, sheet_path::String)::String + sheet_dir, sheet_file = rsplit(sheet_path, "/"; limit=2) + rels_path = "$sheet_dir/_rels/$sheet_file.rels" + + if !haskey(xf.data, rels_path) + xf.data[rels_path] = empty_rels_doc() + xf.files[rels_path] = true + end + rels_root = root_element(xf.data[rels_path]) + + # Return existing drawing path if already linked + for node in elements_with_tag(rels_root, "Relationship") + if get_attr(node, "Type") == REL_DRAWING + drawing_file = rsplit(get_attr(node, "Target"), "/"; limit=2)[2] + return "xl/drawings/$drawing_file" + end + end + + # Create a new drawing + i = 1 + while haskey(xf.data, "xl/drawings/drawing$i.xml"); i += 1; end + drawing_file = "drawing$i.xml" + drawing_path = "xl/drawings/$drawing_file" + + xf.data[drawing_path] = empty_drawing_doc() + xf.files[drawing_path] = true + + rid = new_relationship_id(rels_root) + pfx = get_prefix(rels_path, xf) + push!(rels_root, XML.Element(prefixed_tag(pfx, "Relationship"); + Id = rid, + Type = REL_DRAWING, + Target = "../drawings/$drawing_file", + )) + + ensure_drawing_element!(xf, xf.data[sheet_path], sheet_path, rid) + register_content_type!(xf, "[Content_Types].xml"; + tag="Override", key="PartName", val="/$drawing_path", + content_type=MIME_DRAWING) + return drawing_path +end + +function add_image_rel!(xf::XLSXFile, drawing_path::String, media_name::String)::String + drawing_file = rsplit(drawing_path, "/"; limit=2)[2] + rels_path = "xl/drawings/_rels/$drawing_file.rels" + + if !haskey(xf.data, rels_path) + xf.data[rels_path] = empty_rels_doc() + xf.files[rels_path] = true + end + rels_root = root_element(xf.data[rels_path]) + + # Reuse existing rel if the same media is already referenced + for node in elements_with_tag(rels_root, "Relationship") + get_attr(node, "Target") == "../media/$media_name" && return get_attr(node, "Id") + end + + rid = new_relationship_id(rels_root) + pfx = get_prefix(rels_path, xf) + push!(rels_root, XML.Element(prefixed_tag(pfx, "Relationship"); + Id = rid, + Type = REL_IMAGE, + Target = "../media/$media_name", + )) + return rid +end + +# =========================================================================== +# Anchor +# =========================================================================== + +function add_anchor!( + xf::XLSXFile, + drawing_path::String, + img_rid::String, + media_name::String, + cellref::Union{CellRef, CellRange}; + size::Union{Nothing,Tuple{<:Integer,<:Integer}} = nothing, +) + # Convention: col/row/col_to/row_to are 1-based inclusive throughout. + # build_two_cell_anchor takes 0-based (from inclusive, to exclusive). + # 1-based inclusive → 0-based inclusive: n - 1 + # 1-based inclusive → 0-based exclusive: n (unchanged, since excl = incl + 1 - 1) + + if cellref isa CellRef + col, row = column_number(cellref), row_number(cellref) + bytes = xf.binary_data["xl/media/$media_name"] + w_px, h_px = size !== nothing ? size : image_dimensions(bytes) + col_to = col + max(1, round(Int, w_px / 64)) - 1 + row_to = row + max(1, round(Int, h_px / 20)) - 1 + else + col, row = column_number(cellref.start), row_number(cellref.start) + col_to, row_to = column_number(cellref.stop), row_number(cellref.stop) + end + + root_el = root_element(xf.data[drawing_path]) + n_anchors = count(_ -> true, element_children(root_el)) + + push!(root_el, build_two_cell_anchor( + col - 1, row - 1, # 0-based inclusive from + col_to, row_to, # 0-based exclusive to + img_rid; + shape_id = n_anchors + 2, + )) + + return col, row, col_to, row_to +end + +# =========================================================================== +# Relationship / content-type helpers +# =========================================================================== + +function register_content_type!( + xf::XLSXFile, + path::AbstractString; + tag::AbstractString, key::AbstractString, val::AbstractString, content_type::AbstractString, +)::Nothing + ct_root = root_element(xf.data[path]) + pfx = get_prefix(path, xf) + any(n -> localname(XML.tag(n)) == tag && get_attr(n, key) == val, + element_children(ct_root)) && return nothing + push!(ct_root, XML.Element(prefixed_tag(pfx, tag); Symbol(key) => val, ContentType=content_type)) + return nothing +end + +function ensure_drawing_element!(xf::XLSXFile, sheet_doc::XML.Node, sheet_path::String, rid::String) + sheet_root = root_element(sheet_doc) + any(n -> localname(XML.tag(n)) == "drawing", element_children(sheet_root)) && return nothing + if !haskey(something(XML.attributes(sheet_root), Dict()), "xmlns:r") + sheet_root["xmlns:r"] = NS_R + end + pfx = get_prefix(sheet_path, xf) + el = XML.Element(prefixed_tag(pfx, "drawing")) + el["r:id"] = rid + push!(sheet_root, el) + return nothing +end + +# =========================================================================== +# Low-level XML builder +# =========================================================================== + +function build_two_cell_anchor( + col::Int, row::Int, # 0-based inclusive + col_to::Int, row_to::Int, # 0-based exclusive + img_rid::String; + shape_id::Int, +)::XML.Node + tel(tag, text) = XML.Element(tag, XML.Text(text)) + + function cell_marker(tag, c, r) + XML.Element(tag, + tel("xdr:col", string(c)), + tel("xdr:colOff", "0"), + tel("xdr:row", string(r)), + tel("xdr:rowOff", "0"), + ) + end + + blip = XML.Element("a:blip") + blip["r:embed"] = img_rid + + return XML.Element("xdr:twoCellAnchor", + cell_marker("xdr:from", col, row), + cell_marker("xdr:to", col_to, row_to), + XML.Element("xdr:pic", + XML.Element("xdr:nvPicPr", + XML.Element("xdr:cNvPr"; id=string(shape_id), name="Image $shape_id"), + XML.Element("xdr:cNvPicPr", + XML.Element("a:picLocks"; noChangeAspect="1"), + ), + ), + XML.Element("xdr:blipFill", + blip, + XML.Element("a:stretch", XML.Element("a:fillRect")), + ), + XML.Element("xdr:spPr", + XML.Element("a:xfrm", + XML.Element("a:off"; x="0", y="0"), + XML.Element("a:ext"; cx="0", cy="0"), + ), + XML.Element("a:prstGeom", XML.Element("a:avLst"); prst="rect"), + ), + ), + XML.Element("xdr:clientData"), + ) +end + +# =========================================================================== +# Image format detection +# =========================================================================== + +function detect_image_ext(bytes::Vector{UInt8})::String + length(bytes) ≥ 8 && + bytes[1:8] == UInt8[0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A] && return ".png" + length(bytes) ≥ 2 && + bytes[1] == 0xFF && bytes[2] == 0xD8 && return ".jpg" + length(bytes) ≥ 4 && + bytes[1:4] == UInt8[0x47,0x49,0x46,0x38] && return ".gif" + throw(XLSXError("Unsupported or unknown image format")) +end + +function image_dimensions(bytes::Vector{UInt8})::Tuple{Int,Int} + # PNG: width/height in bytes 17–20 and 21–24 + if length(bytes) ≥ 24 && + bytes[1:8] == UInt8[0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A] + w = Int(bytes[17]) << 24 | Int(bytes[18]) << 16 | + Int(bytes[19]) << 8 | Int(bytes[20]) + h = Int(bytes[21]) << 24 | Int(bytes[22]) << 16 | + Int(bytes[23]) << 8 | Int(bytes[24]) + return (w, h) + end + # GIF: little-endian 16-bit at bytes 7–10 + if length(bytes) ≥ 10 && bytes[1:4] == UInt8[0x47,0x49,0x46,0x38] + return (Int(bytes[7]) | Int(bytes[8]) << 8, + Int(bytes[9]) | Int(bytes[10]) << 8) + end + # JPEG: scan for SOF marker + if length(bytes) ≥ 2 && bytes[1] == 0xFF && bytes[2] == 0xD8 + i = 3 + while i + 8 ≤ length(bytes) + bytes[i] == 0xFF || break + marker = bytes[i+1] + if marker in 0xC0:0xC3 + h = Int(bytes[i+5]) << 8 | Int(bytes[i+6]) + w = Int(bytes[i+7]) << 8 | Int(bytes[i+8]) + return (w, h) + end + i += 2 + (Int(bytes[i+2]) << 8 | Int(bytes[i+3])) + end + throw(XLSXError("Could not find JPEG SOF marker")) + end + throw(XLSXError("Unsupported image format for dimension extraction")) +end + +# =========================================================================== +# getImages — public API +# =========================================================================== + +function getImages(s::Worksheet)::Vector{ImageInfo} + xf = get_xlsxfile(s) + sheet_path = get_relationship_target_by_id("xl", get_workbook(s), s.relationship_id) + return _images_for_sheet(xf, sheet_path, s.name) +end + +function getImages(xf::XLSXFile)::Vector{ImageInfo} + wb = get_workbook(xf) + return reduce(vcat, [ + _images_for_sheet(xf, + get_relationship_target_by_id("xl", wb, sheet.relationship_id), + sheet.name) + for sheet in wb.sheets + ]; init=ImageInfo[]) +end + +function _images_for_sheet(xf::XLSXFile, sheet_path::String, sheet_name::String)::Vector{ImageInfo} + drawing_path = _drawing_path_for_sheet(xf, sheet_path) + drawing_path === nothing && return ImageInfo[] + return _images_for_drawing(xf, drawing_path, sheet_name) +end + +function _drawing_path_for_sheet(xf::XLSXFile, sheet_path::String)::Union{Nothing,String} + sheet_dir, sheet_file = rsplit(sheet_path, "/"; limit=2) + rels_path = "$sheet_dir/_rels/$sheet_file.rels" + haskey(xf.data, rels_path) || return nothing + + for node in elements_with_tag(root_element(xf.data[rels_path]), "Relationship") + if get_attr(node, "Type") == REL_DRAWING + drawing_file = rsplit(get_attr(node, "Target"), "/"; limit=2)[2] + return "xl/drawings/$drawing_file" + end + end + return nothing +end + +function _images_for_drawing(xf::XLSXFile, drawing_path::String, sheet_name::String)::Vector{ImageInfo} + haskey(xf.data, drawing_path) || return ImageInfo[] + drawing_file = rsplit(drawing_path, "/"; limit=2)[2] + rels_path = "xl/drawings/_rels/$drawing_file.rels" + haskey(xf.data, rels_path) || return ImageInfo[] + + rid_to_media = _rid_to_media(xf.data[rels_path]) + return filter(!isnothing, [ + _parse_anchor(node, rid_to_media, sheet_name) + for node in elements_with_tag(root_element(xf.data[drawing_path]), "twoCellAnchor") + ]) +end + +function _rid_to_media(rels_doc::XML.Node)::Dict{String,String} + Dict( + get_attr(n, "Id") => rsplit(get_attr(n, "Target"), "/"; limit=2)[2] + for n in elements_with_tag(root_element(rels_doc), "Relationship") + if get_attr(n, "Type") == REL_IMAGE && !isempty(get_attr(n, "Id")) + ) +end + +function _parse_anchor( + anchor::XML.Node, + rid_to_media::Dict{String,String}, + sheet_name::String, +)::Union{Nothing,ImageInfo} + from_ref = _parse_cell_marker(anchor, "from"; is_to=false) + to_ref = _parse_cell_marker(anchor, "to"; is_to=true) + rid = _find_blip_rid(anchor) + media_name = rid !== nothing ? get(rid_to_media, rid, nothing) : nothing + (from_ref === nothing || to_ref === nothing || media_name === nothing) && return nothing + return (sheet=sheet_name, media_name=media_name, from=from_ref, to=to_ref) +end + +function _parse_cell_marker(anchor::XML.Node, tag::String; is_to::Bool)::Union{Nothing,String} + marker = nothing + for n in element_children(anchor) + localname(XML.tag(n)) == tag && (marker = n; break) + end + marker === nothing && return nothing + vals = Dict(localname(XML.tag(c)) => _text_value(c) for c in element_children(marker)) + col = get(vals, "col", nothing) + row = get(vals, "row", nothing) + (col === nothing || row === nothing) && return nothing + adj = is_to ? 0 : 1 + return string(CellRef(parse(Int, row) + adj, parse(Int, col) + adj)) +end + +function _find_blip_rid(node::XML.Node)::Union{Nothing,String} + XML.nodetype(node) === XML.Element || return nothing + if localname(XML.tag(node)) == "blip" + attrs = XML.attributes(node) + attrs === nothing && return nothing + return something(get(attrs, "r:embed", nothing), + get(attrs, "{$(NS_R)}embed", nothing), + nothing) + end + for child in something(XML.children(node), []) + rid = _find_blip_rid(child) + rid !== nothing && return rid + end + return nothing +end \ No newline at end of file diff --git a/src/relationship.jl b/src/relationship.jl index 4cd419b3..d4cb3ea8 100644 --- a/src/relationship.jl +++ b/src/relationship.jl @@ -75,38 +75,22 @@ function get_workbook_relationship_root(xf::XLSXFile)::XML.Node return xroot end +function new_relationship_id(rels_root::XML.Node)::String + ids = [parse(Int, m[1]) + for n in element_children(rels_root) + for m in [match(r"rId(\d+)", get_attr(n, "Id"))] + if m !== nothing] + return "rId$(isempty(ids) ? 1 : maximum(ids) + 1)" +end + # Adds new relationship. Returns new generated rId. function add_relationship!(wb::Workbook, target::String, _type::String)::String - xf = get_xlsxfile(wb) -# !is_writable(xf) && throw(XLSXError("XLSXFile instance is not writable.")) - local rId::String - - let - got_unique_id = false - id = 1 - - while !got_unique_id - got_unique_id = true - rId = string("rId", id) - for r in wb.relationships - if r.Id == rId - got_unique_id = false - id += 1 - break - end - end - end - end - - # adds to relationship vector - new_relationship = Relationship(rId, _type, target) - push!(wb.relationships, new_relationship) - - # adds to XML tree + xf = get_xlsxfile(wb) xroot = get_workbook_relationship_root(xf) - el = XML.Element("Relationship"; Id=rId, Type=_type, Target=target) - push!(xroot, el) + rId = new_relationship_id(xroot) + push!(wb.relationships, Relationship(rId, _type, target)) + push!(xroot, XML.Element("Relationship"; Id=rId, Type=_type, Target=target)) return rId end @@ -142,4 +126,5 @@ function is_chartsheet(wb::Workbook, sheetname::AbstractString)::Bool end end return false -end \ No newline at end of file +end + diff --git a/src/write.jl b/src/write.jl index d67cb781..978dcb18 100644 --- a/src/write.jl +++ b/src/write.jl @@ -1289,6 +1289,57 @@ function copysheet!(ws::Worksheet, name::AbstractString="")::Worksheet # insert the copied sheet into the workbook new_ws = insertsheet!(wb, xdoc, new_cache, ws.sst_count, pfx, name; dim) + # Copy images if the sheet has a drawing + sheet_path = get_relationship_target_by_id("xl", wb, ws.relationship_id) + drawing_path = _drawing_path_for_sheet(xl, sheet_path) + + if drawing_path !== nothing + src_drawing_file = rsplit(drawing_path, "/"; limit=2)[2] + src_drawing_rels = "xl/drawings/_rels/$src_drawing_file.rels" + + # Pick a fresh drawing file name + i = 1 + while haskey(xl.data, "xl/drawings/drawing$i.xml"); i += 1; end + new_drawing_file = "drawing$i.xml" + new_drawing_path = "xl/drawings/$new_drawing_file" + new_drawing_rels = "xl/drawings/_rels/$new_drawing_file.rels" + + # Copy drawing XML and rels verbatim + xl.data[new_drawing_path] = copynode(xl.data[drawing_path]) + xl.files[new_drawing_path] = true + xl.data[new_drawing_rels] = copynode(xl.data[src_drawing_rels]) + xl.files[new_drawing_rels] = true + + # Register content types for drawing and any media it references + register_content_type!(xl, "[Content_Types].xml"; + tag="Override", key="PartName", val="/$new_drawing_path", + content_type=MIME_DRAWING) + for img in _images_for_drawing(xl, drawing_path, ws.name) + ext = detect_image_ext(xl.binary_data["xl/media/$(img.media_name)"]) + ext_no_dot = String(lstrip(ext, '.')) + register_content_type!(xl, "[Content_Types].xml"; + tag="Default", key="Extension", val=ext_no_dot, + content_type=get(EXT_MIME, ext, "image/$ext_no_dot")) + end + + # Link drawing to new sheet using existing helpers + new_sheet_path = get_relationship_target_by_id("xl", wb, new_ws.relationship_id) + new_rels_path = let (d, f) = rsplit(new_sheet_path, "/"; limit=2) + "$d/_rels/$f.rels" + end + if !haskey(xl.data, new_rels_path) + xl.data[new_rels_path] = empty_rels_doc() + xl.files[new_rels_path] = true + end + new_rels_root = root_element(xl.data[new_rels_path]) + rid = new_relationship_id(new_rels_root) + pfx = get_prefix(new_rels_path, xl) + push!(new_rels_root, XML.Element(prefixed_tag(pfx, "Relationship"); + Id=rid, Type=REL_DRAWING, Target="../drawings/$new_drawing_file", + )) + ensure_drawing_element!(xl, xl.data[new_sheet_path], new_sheet_path, rid) + end + # copy defined names from the original worksheet to the new worksheet ws_keys = [x for x in keys(wb.worksheet_names) if first(x) == ws.sheetId] for k in ws_keys @@ -1568,6 +1619,46 @@ function deletesheet!(wb::Workbook, name::AbstractString)::XLSXFile delete!(wb.worksheet_names, oldkey) end + # Drawing and image cleanup + sheet_path = "xl/worksheets/sheet" * rId[4:end] * ".xml" + drawing_path = _drawing_path_for_sheet(xf, sheet_path) + + if drawing_path !== nothing + drawing_file = rsplit(drawing_path, "/"; limit=2)[2] + drawing_rels = "xl/drawings/_rels/$drawing_file.rels" + + # Remove media — but only if no other sheet references it + all_images = getImages(xf) + deleted_images = _images_for_drawing(xf, drawing_path, name) + deleted_names = Set(img.media_name for img in deleted_images) + still_used = Set(img.media_name for img in all_images + if img.sheet != name) + for media_name in deleted_names + if media_name ∉ still_used + delete!(xf.binary_data, "xl/media/$media_name") + end + end + + # Remove drawing XML and rels + for path in (drawing_path, drawing_rels) + delete!(xf.files, path) + delete!(xf.data, path) + end + + # Remove drawing Override from [Content_Types].xml + ctype_root = xmlroot(xf, "[Content_Types].xml")[end] + cont = XML.children(ctype_root) + idx = findfirst(i -> haskey(cont[i], "PartName") && + cont[i]["PartName"] == "/$drawing_path", eachindex(cont)) + idx !== nothing && deleteat!(cont, idx) + + # Remove sheet rels file (contains the drawing relationship) + sheet_dir, sheet_file = rsplit(sheet_path, "/"; limit=2) + sheet_rels = "$sheet_dir/_rels/$sheet_file.rels" + delete!(xf.files, sheet_rels) + delete!(xf.data, sheet_rels) + end + # Files xml_filename = "xl/worksheets/sheet" * rId[4:end] * ".xml" if in(xml_filename, keys(xf.files)) diff --git a/test/data/track_start.jpg b/test/data/track_start.jpg new file mode 100644 index 00000000..14dec238 Binary files /dev/null and b/test/data/track_start.jpg differ diff --git a/test/data/track_start.png b/test/data/track_start.png new file mode 100644 index 00000000..781d8395 Binary files /dev/null and b/test/data/track_start.png differ diff --git a/test/runtests.jl b/test/runtests.jl index dc060010..938c880d 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -6956,6 +6956,7 @@ end XLSX.mergeCells(f[2], "A4:C5") @test XLSX.setConditionalFormat(f[2], "H2:H15", :dataBar) == 0 XLSX.setFormula(f[2], "A16:N16", "=sum(A2:A15)") + XLSX.addImage(f[2], "B17", joinpath(data_directory, "track_start.jpg")) XLSX.copysheet!(f[2], "newSheet") XLSX.writexlsx("mytest.xlsx", f, overwrite=true) @@ -6981,6 +6982,7 @@ end @test XLSX.getFormula(f2[2], "M16") == "=sum(M2:M15)" @test XLSX.hassheet(f, "newSheet") + @test XLSX.getImages(f["newSheet"]) == [(sheet = "newSheet", media_name = "image1.jpg", from = "B17", to = "E27")] for row = 1:16 for col = 1:14 @@ -7533,3 +7535,147 @@ end end end end + + +@testset "Add Images" begin + REL_IMAGE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" + jpeg = joinpath(data_directory, "track_start.jpg") + png = joinpath(data_directory, "track_start.png") + bytes = read(jpeg) + + # Helper so each testset gets a fresh workbook + fresh() = (xf = XLSX.newxlsx(); (xf, xf["Sheet1"])) + + @testset "cell addressing variants" begin + cases = [ + ((1, 1), "A1", nothing), + (("B2",), "B2", nothing), + (("C3:D5",), "C3", "D5"), + ((CellRef("E7"),), "E7", nothing), + ((XLSX.CellRange("F4:H9"),), "F4", "H9"), + ] + for (args, exp_from, exp_to) in cases + xf, s = fresh() + info = XLSX.addImage(s, args..., png) + @test info.from == exp_from + exp_to === nothing || @test info.to == exp_to + @test haskey(xf.binary_data, "xl/media/" * info.media_name) + end + end + @testset "IOBuffer input" begin + xf, s = fresh() + info = XLSX.addImage(s, 3, 4, IOBuffer(copy(bytes))) + @test startswith(info.media_name, "image") + @test xf.binary_data["xl/media/" * info.media_name] == bytes + @test haskey(xf.data, "xl/drawings/drawing1.xml") + end + + @testset "drawing XML and relationships" begin + xf, s = fresh() + info = XLSX.addImage(s, "B2", jpeg; size=(128, 128)) + + # Relationships + rels_root = xf.data["xl/drawings/_rels/drawing1.xml.rels"][end] + rel_nodes = [n for n in XML.children(rels_root) if XML.tag(n) == "Relationship"] + @test !isempty(rel_nodes) + @test any(get(XML.attributes(n), "Type", "") == REL_IMAGE for n in rel_nodes) + + # Anchor geometry + drawing_root = xf.data["xl/drawings/drawing1.xml"][end] + anchors = [n for n in XML.children(drawing_root) if XML.tag(n) == "xdr:twoCellAnchor"] + @test length(anchors) == 1 + @test XLSX._parse_cell_marker(anchors[1], "from"; is_to=false) == info.from + @test XLSX._parse_cell_marker(anchors[1], "to"; is_to=true) == info.to + end + + @testset "multiple images" begin + xf, s = fresh() + info1 = XLSX.addImage(s, 1, 1, jpeg) + info2 = XLSX.addImage(s, 5, 5, jpeg) + + @test info1.media_name != info2.media_name + @test haskey(xf.binary_data, "xl/media/" * info1.media_name) + @test haskey(xf.binary_data, "xl/media/" * info2.media_name) + + rels_root = xf.data["xl/drawings/_rels/drawing1.xml.rels"][end] + rel_nodes = [n for n in XML.children(rels_root) if XML.tag(n) == "Relationship"] + @test length(rel_nodes) == 2 + @test all(get(XML.attributes(n), "Type", "") == REL_IMAGE for n in rel_nodes) + + drawing_root = xf.data["xl/drawings/drawing1.xml"][end] + anchors = [n for n in XML.children(drawing_root) if XML.tag(n) == "xdr:twoCellAnchor"] + @test length(anchors) == 2 + @test XLSX._parse_cell_marker(anchors[1], "from"; is_to=false) == info1.from + @test XLSX._parse_cell_marker(anchors[2], "from"; is_to=false) == info2.from + end + + @testset "round-trip (file and IOBuffer)" begin + for (label, src) in [("file path", jpeg), ("IOBuffer", IOBuffer(copy(bytes)))] + xf, s = fresh() + XLSX.addImage(s, 1, 1, src) + tmp = tempname() * ".xlsx" + XLSX.writexlsx(tmp, xf) + @test isfile(tmp) && filesize(tmp) > 0 + + xf2 = XLSX.readxlsx(tmp) + imgs = XLSX.getImages(xf2) + @test length(imgs) == 1 + @test imgs[1].sheet == "Sheet1" + @test startswith(imgs[1].media_name, "image") + end + end + + @testset "invalid cell reference" begin + xf, s = fresh() + @test_throws ArgumentError XLSX.addImage(s, "ZZZ9999", jpeg) + end + + @testset "image cleaned up when sheet deleted" begin + xf = XLSX.newxlsx() + s1 = xf["Sheet1"] + XLSX.addImage(s1, 1, 1, png) + info = XLSX.getImages(s1)[1] + + wb = XLSX.get_workbook(xf) + XLSX.addsheet!(wb, "Sheet2") # need a second sheet to allow deletion + XLSX.deletesheet!(wb, "Sheet1") + + # Media removed + @test !haskey(xf.binary_data, "xl/media/" * info.media_name) + + # Drawing XML and rels removed + @test !haskey(xf.data, "xl/drawings/drawing1.xml") + @test !haskey(xf.data, "xl/drawings/_rels/drawing1.xml.rels") + + # No images reported + @test isempty(XLSX.getImages(xf)) + end + + @testset "shared media preserved when only one sheet deleted" begin + xf = XLSX.newxlsx() + s1 = xf["Sheet1"] + wb = XLSX.get_workbook(xf) + + XLSX.addImage(s1, 1, 1, jpeg) + + XLSX.copysheet!(s1, "Sheet2") + s2 = xf["Sheet2"] + + info1 = XLSX.getImages(s1)[1] + info2 = XLSX.getImages(s2)[1] + + # Both sheets reference the same media file + @test info1.media_name == info2.media_name + media_key = "xl/media/" * info1.media_name + + XLSX.deletesheet!(wb, "Sheet1") + + # Media still present — Sheet2 still references it + @test haskey(xf.binary_data, media_key) + + # Sheet2 image still retrievable + imgs = XLSX.getImages(xf) + @test length(imgs) == 1 + @test imgs[1].sheet == "Sheet2" + end +end \ No newline at end of file