diff --git a/Project.toml b/Project.toml index 68be0ff..20ca214 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "PPTX" uuid = "14a86994-10a4-4a7d-b9ad-ef6f3b1fac6a" authors = ["Xander de Vries", "Matthijs Cox"] -version = "0.10.5" +version = "0.10.6" [deps] Colors = "5ae59095-9a9b-59fe-a467-6f913c188581" diff --git a/README.md b/README.md index 716b379..36dd21f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ [![Build Status](https://github.com/ASML-Labs/PPTX.jl/actions/workflows/CI.yml/badge.svg?branch=main)](https://github.com/ASML-Labs/PPTX.jl/actions/workflows/CI.yml?query=branch%3Amain) [![Coverage](https://codecov.io/gh/ASML-Labs/PPTX.jl/branch/main/graph/badge.svg)](https://codecov.io/gh/ASML-Labs/PPTX.jl) -[![Code Style: Blue](https://img.shields.io/badge/code%20style-blue-4495d1.svg)](https://github.com/invenia/BlueStyle) +[![Code Style: Blue](https://img.shields.io/badge/code%20style-blue-4495d1.svg)](https://github.com/JuliaDiff/BlueStyle) +[![](https://img.shields.io/badge/docs-stable-blue.svg)](https://asml-labs.github.io/PPTX.jl/stable/) [![](https://img.shields.io/badge/docs-dev-blue.svg)](https://asml-labs.github.io/PPTX.jl/dev/) Package to generate PowerPoint© .pptx files. @@ -22,7 +23,7 @@ julia> import Pkg; Pkg.add("PPTX") ## Documentation -Documentation and examples can be found on our [github page](https://asml-labs.github.io/PPTX.jl/dev/). +Documentation and examples can be found on our [github page](https://asml-labs.github.io/PPTX.jl/stable/). ## Contributing diff --git a/docs/make.jl b/docs/make.jl index 44b041f..12f3ec7 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -19,6 +19,7 @@ makedocs(; pages=[ "Home" => "index.md", "Table Styling" => "tablestyle.md", + "GridLayout" => "layout.md", "Plots" => "plots.md", "API Reference" => "api.md", ], diff --git a/docs/src/assets/images/gridlayout_example.png b/docs/src/assets/images/gridlayout_example.png new file mode 100644 index 0000000..edc04ac Binary files /dev/null and b/docs/src/assets/images/gridlayout_example.png differ diff --git a/docs/src/index.md b/docs/src/index.md index ecd5d31..adb0fd1 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -74,5 +74,5 @@ Presentation with 4 slides Finally you can write the PPTX file with `PPTX.write`: ```julia -PPTX.write("example.pptx", pres, overwrite = true, open_ppt=true) +write("example.pptx", pres, overwrite = true, open_ppt=true) ``` diff --git a/docs/src/layout.md b/docs/src/layout.md new file mode 100644 index 0000000..8ceea3b --- /dev/null +++ b/docs/src/layout.md @@ -0,0 +1,43 @@ +# GridLayout + +`GridLayout` lets you declare where shapes go on a slide without specifying absolute millimeter slide positions. +The layout computes cell sizes from the actual slide dimensions at write time. + +Note that by default TextBoxes and Tables are fully rescaled to fit the grid cells. Pictures and Videos are resized while keeping their ratio fixed. + +We do not yet supported nested layouts. + +```julia +using PPTX + +p = Presentation(title="GridLayout demo", author="PPTX.jl") +s = Slide(; title="Layout demo slide") + +text = TextBox( + content="welcome to layouting in PPTX.jl\n here we use a 2x2 grid", + textstyle=(align=:center, fontsize=30), + anchor=:center, + linecolor=:black, + wrap=true +) +table_cells = TableCell.( + reshape(1:9, 3,3); + textstyle=(fontsize=20, align=:center), + anchor=:center +) +table = Table(table_cells, bandrow=false) + +layout = GridLayout(2, 2) +layout[:, 1] = Picture(joinpath(PPTX.ASSETS_DIR, "julia_logo.emf")) +layout[1, 2] = text +layout[2, 2] = table + +push!(s, layout) +push!(p, s) + +write("gridlayout_example.pptx", p; overwrite=true) +``` + +```@raw html + +``` \ No newline at end of file diff --git a/src/AbstractShape.jl b/src/AbstractShape.jl index d884f40..6da9730 100644 --- a/src/AbstractShape.jl +++ b/src/AbstractShape.jl @@ -7,6 +7,40 @@ has_rid(s::AbstractShape) = false ## If AbstractShape does not have rId return 0 rid(s::AbstractShape) = 0 +# struct to hold common shape geometry properties +# (could be re-used in the future inside the shape) +struct Geometry + offset_x::Int + offset_y::Int + size_x::Int + size_y::Int +end + +# default do not change geometries (this is used for GridLayout, where the geometry is determined by the cell span, not the shape) +set_geometry(s::AbstractShape, geom::Geometry) = s +function get_geometry(s::AbstractShape) + return Geometry(s.offset_x, s.offset_y, s.size_x, s.size_y) +end + +# default no geometry rescaling (e.g. for TextBox, where the text should fill the cell span without changing the aspect ratio) +geometry_in_span(s::AbstractShape, geom::Geometry, keepratio::Bool) = geom + +# geometry rescaling re-used by Picture and Video, keeps the ratio constant +function geometry_in_span(shape_geom::Geometry, span::Geometry, keepratio::Bool) + if !keepratio || shape_geom.size_x <= 0 || shape_geom.size_y <= 0 || span.size_x <= 0 || span.size_y <= 0 + return span + end + + # look at the scaling factor for each dimension and use the smaller one to ensure the shape fits within the span + scale = min(span.size_x / shape_geom.size_x, span.size_y / shape_geom.size_y) + # rescale the shape dimensions by the calculated factor + scaled_x = Int(round(shape_geom.size_x * scale)) + scaled_y = Int(round(shape_geom.size_y * scale)) + centered_x = span.offset_x + Int(round((span.size_x - scaled_x) / 2)) + centered_y = span.offset_y + Int(round((span.size_y - scaled_y) / 2)) + return Geometry(centered_x, centered_y, scaled_x, scaled_y) +end + # default show used by Array show function Base.show(io::IO, shape::AbstractShape) compact = get(io, :compact, true) diff --git a/src/GridLayout.jl b/src/GridLayout.jl new file mode 100644 index 0000000..7f1a3d6 --- /dev/null +++ b/src/GridLayout.jl @@ -0,0 +1,184 @@ +struct GridMargins # in EMUs + left::Int + right::Int + top::Int + bottom::Int +end + +function Base.:(==)(m1::GridMargins, m2::GridMargins) + return m1.left == m2.left && m1.right == m2.right && m1.top == m2.top && m1.bottom == m2.bottom +end + +_grid_margin_mm(x::Real) = mm_to_emu(x) +_grid_margin_mm(::Nothing) = 0 + +function GridMargins(; left=0, right=0, top=0, bottom=0) + return GridMargins( + _grid_margin_mm(left), + _grid_margin_mm(right), + _grid_margin_mm(top), + _grid_margin_mm(bottom), + ) +end + +GridMargins(x::Real) = GridMargins(; left=x, right=x, top=x, bottom=x) +GridMargins(nt::NamedTuple) = GridMargins(; nt...) + +""" + GridLayout( + nrows::Int, + ncols::Int; + padding::Real=5.0, + margins=(left = 5, right = 5, top = 40, bottom = 5), + keepratio::Bool=true, + ) + +A declarative grid layout that distributes shapes evenly across a slide. + +Shapes are placed in cells via `setindex!` using integer indices, ranges or colons: + +```julia +layout = GridLayout(2, 2; margins=10) +layout[1, 1] = TextBox("Title") +layout[2, :] = Picture("plot.png") # spans both columns in row 2 +push!(slide, layout) +``` + +Cell positions and sizes are resolved at write time from the actual slide dimensions. +`padding` (in mm) is applied between cells and `margins` at the slide edges. +When `keepratio=true` (default), pictures/videos preserve their aspect ratio and are +centered within the assigned cell span. With `keepratio=false`, they are stretched +to fill the assigned span. + +Rules: +- Empty cells are silently skipped. +- Assigning to overlapping cell regions raises an `ArgumentError`. +- Cannot nest `GridLayout`s inside each other (not yet implemented, will throw an error if attempted). +""" +mutable struct GridLayout <: AbstractShape + nrows::Int + ncols::Int + padding::Float64 + margins::GridMargins + keepratio::Bool + _entries::Vector{Tuple{AbstractShape,UnitRange{Int},UnitRange{Int}}} +end + +function shapes(layout::GridLayout) + return getfield.(layout._entries, 1) +end + +function rid(layout::GridLayout) + return maximum(rid.(shapes(layout))) +end + +function GridLayout( + nrows::Int, + ncols::Int; + padding::Real=5.0, + # default bigger top margin to handle titles + margins=(left = 5, right = 5, top = 40, bottom = 5), + keepratio::Bool=true, +) + nrows > 0 || throw(ArgumentError("nrows must be positive")) + ncols > 0 || throw(ArgumentError("ncols must be positive")) + padding >= 0 || throw(ArgumentError("padding must be non-negative")) + grid_margins = GridMargins(margins) + min_margin = min(grid_margins.left, grid_margins.right, grid_margins.top, grid_margins.bottom) + min_margin >= 0 || throw(ArgumentError("margins must be non-negative")) + return GridLayout( + nrows, + ncols, + mm_to_emu(padding), + grid_margins, + keepratio, + Tuple{AbstractShape,UnitRange{Int},UnitRange{Int}}[], + ) +end + +function _to_range(idx, n::Int, dim::String)::UnitRange{Int} + if idx isa Colon + return 1:n + elseif idx isa Integer + i = Int(idx) + 1 <= i <= n || throw(BoundsError("$dim index $i is out of range 1:$n")) + return i:i + elseif idx isa UnitRange + r = UnitRange{Int}(idx) + (1 <= first(r) && last(r) <= n) || + throw(BoundsError("$dim range $r is out of bounds 1:$n")) + return r + else + throw(ArgumentError("unsupported index type $(typeof(idx)) for $dim")) + end +end + +function Base.setindex!(layout::GridLayout, shape::AbstractShape, row_idx, col_idx) + row_range = _to_range(row_idx, layout.nrows, "row") + col_range = _to_range(col_idx, layout.ncols, "col") + + # conflict detection: two rectangular spans conflict when they overlap in both dimensions + for (_, er, ec) in layout._entries + if !isempty(intersect(row_range, er)) && !isempty(intersect(col_range, ec)) + throw(ArgumentError( + "Cell span ($row_range, $col_range) conflicts with existing entry at ($er, $ec)" + )) + end + end + + push!(layout._entries, (shape, row_range, col_range)) + return shape +end + +function Base.setindex!(layout::GridLayout, shape::GridLayout, row_idx, col_idx) + error("we do not yet support nested GridLayouts") +end + +function gridlayout_geometry( + layout::GridLayout, + row_range::UnitRange{Int}, + col_range::UnitRange{Int}, + grid_size_x::Int, + grid_size_y::Int, +) + left_margin = layout.margins.left + right_margin = layout.margins.right + top_margin = layout.margins.top + bottom_margin = layout.margins.bottom + gap = Int(round(layout.padding)) + + usable_width = grid_size_x - left_margin - right_margin - (layout.ncols - 1) * gap + usable_height = grid_size_y - top_margin - bottom_margin - (layout.nrows - 1) * gap + usable_width > 0 || throw(ArgumentError("GridLayout has non-positive usable width")) + usable_height > 0 || throw(ArgumentError("GridLayout has non-positive usable height")) + + cell_width = usable_width / layout.ncols + cell_height = usable_height / layout.nrows + + first_col = first(col_range) + last_col = last(col_range) + first_row = first(row_range) + last_row = last(row_range) + + offset_x = Int(round(left_margin + (first_col - 1) * (cell_width + gap))) + offset_y = Int(round(top_margin + (first_row - 1) * (cell_height + gap))) + size_x = Int(round((last_col - first_col + 1) * cell_width + (length(col_range) - 1) * gap)) + size_y = Int(round((last_row - first_row + 1) * cell_height + (length(row_range) - 1) * gap)) + + return Geometry(offset_x, offset_y, size_x, size_y) +end + +function _show_string(layout::GridLayout, compact::Bool) + s = "GridLayout($(layout.nrows)×$(layout.ncols))" + if !compact + n = length(layout._entries) + s *= " with $n assigned cell$(n == 1 ? "" : "s")" + end + return s +end + +function copy_shape(w::ZipWriter, p::GridLayout) + for (nested_shape, _, _) in p._entries + copy_shape(w, nested_shape) + end +end diff --git a/src/PPTX.jl b/src/PPTX.jl index 0cbad9f..f4f14c1 100644 --- a/src/PPTX.jl +++ b/src/PPTX.jl @@ -13,13 +13,14 @@ import Tables: columns, columnnames, rows import Colors: Colorant, hex, @colorant_str -export Presentation, Slide, TextBox, TextStyle, Picture, Table, TableCell, list_layoutnames, Video +export Presentation, Slide, TextBox, TextStyle, Picture, Table, TableCell, list_layoutnames, Video, GridLayout include("AbstractShape.jl") include("constants.jl") include("TextBox.jl") include("Picture.jl") include("Tables.jl") +include("GridLayout.jl") include("Slide.jl") include("Presentation.jl") include("Video.jl") diff --git a/src/Picture.jl b/src/Picture.jl index f7ea146..c48146a 100644 --- a/src/Picture.jl +++ b/src/Picture.jl @@ -97,9 +97,18 @@ end function set_rid(s::Picture, i::Int) return Picture(s.source, s.offset_x, s.offset_y, s.size_x, s.size_y, i) end + rid(s::Picture) = s.rid has_rid(s::Picture) = true +function set_geometry(s::Picture, geom::Geometry) + return Picture(s.source, geom.offset_x, geom.offset_y, geom.size_x, geom.size_y, s.rid, s._uuid) +end + +function geometry_in_span(s::Picture, geom::Geometry, keepratio::Bool) + geometry_in_span(get_geometry(s), geom, keepratio) +end + function _show_string(p::Picture, compact::Bool) show_string = "Picture" if !compact diff --git a/src/Presentation.jl b/src/Presentation.jl index ab552cf..3d51a21 100644 --- a/src/Presentation.jl +++ b/src/Presentation.jl @@ -1,12 +1,7 @@ -struct PresentationSize - x::Int # EMUs - y::Int # EMUs -end - # presentation properties that are not for the user # these may be gathered from the .pptx template upon writing Base.@kwdef mutable struct PresentationState - size::Union{Nothing, PresentationSize} = nothing + size::Union{Nothing, SlideSize} = nothing end """ @@ -72,6 +67,12 @@ function Base.push!(pres::Presentation, slide::Slide) return push!(slides(pres), slide) end +function slide_size(p::Presentation) + p_sz = p._state.size + sz = isnothing(p_sz) ? SlideSize() : p_sz + return sz +end + # default show used by Array show function Base.show(io::IO, p::Presentation) compact = get(io, :compact, true) @@ -182,7 +183,7 @@ function update_presentation_state!(p::Presentation, template::ZipBufferReader) r = root(doc) n = findfirst("//p:sldSz", r) cx, cy = n["cx"], n["cy"] - sz = PresentationSize(parse(Int, cx), parse(Int, cy)) + sz = SlideSize(parse(Int, cx), parse(Int, cy)) p._state.size = sz return nothing end diff --git a/src/Slide.jl b/src/Slide.jl index 7eaec52..a95bee0 100644 --- a/src/Slide.jl +++ b/src/Slide.jl @@ -1,3 +1,14 @@ +# used in Presentation to set all slide sizes +struct SlideSize + x::Int # EMUs + y::Int # EMUs +end + +# default slide size +function SlideSize() + return SlideSize(inch_to_emu(13.333), inch_to_emu(7.5)) +end + """ ```julia Slide( @@ -91,14 +102,68 @@ function Base.push!(slide::Slide, shape::AbstractShape) end end -function make_slide(s::Slide, relationship_map::Dict = slide_relationship_map(s))::AbstractDict +function Base.push!(slide::Slide, layout::GridLayout) + next_rid = new_rid(slide) + updated_entries = Tuple{AbstractShape,UnitRange{Int},UnitRange{Int}}[] + for (shape, row_range, col_range) in layout._entries + if has_rid(shape) + shape = set_rid(shape, next_rid) + next_rid += 1 + end + push!(updated_entries, (shape, row_range, col_range)) + end + layout._entries = updated_entries + return push!(shapes(slide), layout) +end + +function update_relationship!(relationship_map::Dict, original_shape::AbstractShape, updated_shape::AbstractShape) + if has_rid(original_shape) && haskey(relationship_map, original_shape) + relationship_map[updated_shape] = relationship_map[original_shape] + end + return nothing +end + +function make_xml_shapes( + shape::AbstractShape, + start_id::Int, + relationship_map::Dict, + slide_size::SlideSize, +) + return Any[make_xml(shape, start_id, relationship_map)] +end + +function make_xml_shapes( + layout::GridLayout, + start_id::Int, + relationship_map::Dict, + slide_size::SlideSize +) + xml_nodes = Any[] + current_id = start_id + for (shape, row_range, col_range) in layout._entries + geom = gridlayout_geometry(layout, row_range, col_range, slide_size.x, slide_size.y) + geom = geometry_in_span(shape, geom, layout.keepratio) + shape_updated = set_geometry(shape, geom) + update_relationship!(relationship_map, shape, shape_updated) + push!(xml_nodes, make_xml(shape_updated, current_id, relationship_map)) + current_id += 1 + end + return xml_nodes +end + +function make_slide( + s::Slide, + relationship_map::Dict = slide_relationship_map(s); + slide_size::SlideSize = SlideSize(), +)::AbstractDict xml_slide = OrderedDict("p:sld" => main_attributes()) spTree = init_sptree() - initial_max_id = 1 - for (index, shape) in enumerate(shapes(s)) - id = index + initial_max_id - push!(spTree["p:spTree"], make_xml(shape, id, relationship_map)) + next_id = 2 + for shape in shapes(s) + xml_shapes = make_xml_shapes(shape, next_id, relationship_map, slide_size) + append!(spTree["p:spTree"], xml_shapes) + next_id += length(xml_shapes) end push!(xml_slide["p:sld"], OrderedDict("p:cSld" => [spTree])) @@ -174,16 +239,28 @@ function relationship_xml(url::AbstractString, r_id::Integer) ) end +get_nested_shapes(shape::AbstractShape) = (shape,) + +function get_nested_shapes(layout::GridLayout) + nested = AbstractShape[] + for (shape, _, _) in layout._entries + append!(nested, get_nested_shapes(shape)) + end + return nested +end + function slide_relationship_map(s::Slide) d = Dict{Union{Slide, AbstractShape, AbstractString}, Int}() r_id = 1 # first rid is reserved by slideLayout for shape in shapes(s) - if has_rid(shape) && !haskey(d, shape) - r_id += 1 - d[shape] = r_id - elseif has_hyperlink(shape) && !haskey(d, shape.hlink) - r_id += 1 - d[shape.hlink] = r_id + for nested_shape in get_nested_shapes(shape) + if has_rid(nested_shape) && !haskey(d, nested_shape) + r_id += 1 + d[nested_shape] = r_id + elseif has_hyperlink(nested_shape) && !haskey(d, nested_shape.hlink) + r_id += 1 + d[nested_shape.hlink] = r_id + end end end return d @@ -209,27 +286,29 @@ function make_slide_relationships(s::Slide, relationship_map::Dict = slide_relat ) used_r_ids = [1] for shape in shapes(s) - r_id = 1 - if has_rid(shape) - r_id = relationship_map[shape] - r_shape = shape - end - if has_hyperlink(shape) - r_id = relationship_map[shape.hlink] - r_shape = shape.hlink - end - if r_id ∉ used_r_ids - push!(xml_slide_rels["Relationships"], relationship_xml(r_shape, r_id)) - push!(used_r_ids, r_id) - end - # For a video 2 extra relationships are defined: an extra video link and a thumbnail - if shape isa Video - r_id += 1 - push!(xml_slide_rels["Relationships"], relationship_xml(shape, r_id; it = 1)) - push!(used_r_ids, r_id) - r_id += 1 - push!(xml_slide_rels["Relationships"], relationship_xml(picture_thumbnail(thumbnail_name(shape)), r_id)) - push!(used_r_ids, r_id) + for nested_shape in get_nested_shapes(shape) + r_id = 1 + if has_rid(nested_shape) + r_id = relationship_map[nested_shape] + r_shape = nested_shape + end + if has_hyperlink(nested_shape) + r_id = relationship_map[nested_shape.hlink] + r_shape = nested_shape.hlink + end + if r_id ∉ used_r_ids + push!(xml_slide_rels["Relationships"], relationship_xml(r_shape, r_id)) + push!(used_r_ids, r_id) + end + # For a video 2 extra relationships are defined: an extra video link and a thumbnail + if nested_shape isa Video + r_id += 1 + push!(xml_slide_rels["Relationships"], relationship_xml(nested_shape, r_id; it = 1)) + push!(used_r_ids, r_id) + r_id += 1 + push!(xml_slide_rels["Relationships"], relationship_xml(picture_thumbnail(thumbnail_name(nested_shape)), r_id)) + push!(used_r_ids, r_id) + end end end return xml_slide_rels diff --git a/src/Tables.jl b/src/Tables.jl index a489277..5fd1af8 100644 --- a/src/Tables.jl +++ b/src/Tables.jl @@ -64,24 +64,49 @@ struct Table <: AbstractShape row_heights::Union{Nothing, Vector{Int}} = nothing, header::Bool = true, bandrow::Bool = true, - style_id::String="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}", + style_id::String="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"; + convert_to_emu::Bool=true, ) # input is in mm + if convert_to_emu + offset_x = mm_to_emu(offset_x) + offset_y = mm_to_emu(offset_y) + size_x = mm_to_emu(size_x) + size_y = mm_to_emu(size_y) + column_widths = mm_to_emu(column_widths) + row_heights = mm_to_emu(row_heights) + end return new( content, - mm_to_emu(offset_x), - mm_to_emu(offset_y), - mm_to_emu(size_x), - mm_to_emu(size_y), - mm_to_emu(column_widths), - mm_to_emu(row_heights), + offset_x, + offset_y, + size_x, + size_y, + column_widths, + row_heights, header, bandrow, - style_id, + style_id ) end end +function set_geometry(t::Table, geom::Geometry) + return Table( + t.content, + geom.offset_x, + geom.offset_y, + geom.size_x, + geom.size_y, + t.column_widths, + t.row_heights, + t.header, + t.bandrow, + t.style_id; + convert_to_emu=false, + ) +end + # keyword argument constructor function Table(; content, @@ -246,8 +271,8 @@ TableCell( textstyle = TextStyle(), color = nothing, # background color of the table element anchor = nothing, # anchoring of text in the cell, can be "top", "bottom" or "center" - lines, - margins, + lines, # for example (bottom = (width=1, color=:white, dash = "solid"),) + margins, # for example (left=0.1, right=0.1, top=0.1, bottom=0.1) in mm ) ``` @@ -301,18 +326,15 @@ function TableCell(; end function has_tc_properties(c::TableCell) - return !isnothing(c.color) || has_lines(c.lines) + return !isnothing(c.color) || + !isnothing(c.anchor) || + !isnothing(c.direction) || + has_lines(c.lines) || + has_margins(c.margins) end has_margins(c::TableCell) = has_margins(c.margins) -anchor_string(::Nothing) = nothing -function anchor_string(x) - s = string(x) - @assert s in ("top", "bottom", "center") "unknown table cell anchor $s, must be top, bottom or center" - return s -end - text_direction(::Nothing) = nothing function text_direction(x) s = string(x) @@ -617,18 +639,8 @@ function solid_fill_color(color::Missing) end function make_anchor(t::TableCell) - if isnothing(t.anchor) - return nothing - elseif t.anchor == "center" - anchor = "ctr" - elseif t.anchor == "top" - anchor = "t" - elseif t.anchor == "bottom" - anchor = "b" - else - error("unknown table cell anchor \"$(t.anchor)\"") - end - return Dict("anchor" => anchor) + isnothing(t.anchor) && return nothing + return make_anchor_dict(t.anchor) end function make_single_val_extLst(uri::String, type::String, val::Integer = rand(UInt32)) diff --git a/src/TextBox.jl b/src/TextBox.jl index 5a7bec0..dfc06a2 100644 --- a/src/TextBox.jl +++ b/src/TextBox.jl @@ -92,6 +92,28 @@ function align_string(x) return s end +anchor_string(::Nothing) = nothing +function anchor_string(x) + s = string(x) + @assert s in ("top", "center", "middle", "bottom") "unknown anchor $s, must be top, center or bottom" + if s == "middle" + s = "center" + end + return s +end + +function make_anchor_dict(anchor::String) + if anchor == "center" + return Dict("anchor" => "ctr") + elseif anchor == "bottom" + return Dict("anchor" => "b") + elseif anchor == "top" + return Dict("anchor" => "t") + else + error("unknown anchor \"$anchor\"") + end +end + TextStyle(style::TextStyle) = style function TextStyle(style::AbstractDict{String}) @@ -148,6 +170,15 @@ struct Margins bottom::Union{Nothing, Int} end +function Base.:(==)(m1::Margins, m2::Margins) + return m1.left == m2.left && m1.right == m2.right && m1.top == m2.top && m1.bottom == m2.bottom +end + +function Margins(m::Real) + mar = margin(m) + return Margins(mar, mar, mar, mar) +end + function Margins(; left = nothing, right = nothing, @@ -172,6 +203,7 @@ Base.@kwdef struct TextBody style::TextStyle = TextStyle() margins::Margins = Margins() wrap::Bool = false + anchor::Union{Nothing, String} = nothing body_properties::Union{Nothing, AbstractVector} = default_body_properties() end @@ -245,6 +277,7 @@ function TextBox(; textstyle = (italic = false, bold = false, fontsize = nothing), margins = nothing, # e.g. (left=0.1, right=0.1, bottom=0.1, top=0.1) in millimeters wrap = false, # wrap text in shape or not + anchor = nothing, # "top", "center" or "bottom" ) ``` @@ -295,6 +328,32 @@ struct TextBox<: AbstractShape linecolor::Union{Nothing, String} linewidth::Union{Nothing, Int} rotation::Union{Nothing, Float64} + function TextBox( + content::TextBody, + offset_x::Int, + offset_y::Int, + size_x::Int, + size_y::Int, + hlink::Union{Nothing, Any} = nothing, + color::Union{Nothing, String} = nothing, + linecolor::Union{Nothing, String} = nothing, + linewidth::Union{Nothing, Int} = nothing, + rotation::Union{Nothing, Float64} = nothing, + ) + return new( + content, + offset_x, + offset_y, + size_x, + size_y, + hlink, + color, + linecolor, + linewidth, + rotation, + ) + end + function TextBox( content::AbstractString, offset_x::Real, # millimeters @@ -309,6 +368,7 @@ struct TextBox<: AbstractShape rotation::Union{Nothing, Real} = nothing, margins = Margins(), wrap = false, + anchor = nothing, ) # input is in mm return new( @@ -317,6 +377,7 @@ struct TextBox<: AbstractShape TextStyle(style), Margins(margins), wrap, + anchor_string(anchor), default_body_properties() ), mm_to_emu(offset_x), @@ -332,12 +393,20 @@ struct TextBox<: AbstractShape end end -mm_to_emu(::Nothing) = nothing -mm_to_emu(x) = Int(round(x * _EMUS_PER_MM)) -mm_to_emu(x::AbstractArray{<:Real}) = mm_to_emu.(x) - -points_to_emu(x::Nothing) = nothing -points_to_emu(x::Real) = Int(round(x * 12700)) +function set_geometry(t::TextBox, geom::Geometry) + return TextBox( + t.content, + geom.offset_x, + geom.offset_y, + geom.size_x, + geom.size_y, + t.hlink, + t.color, + t.linecolor, + t.linewidth, + t.rotation, + ) +end rotation_value(::Nothing) = nothing function rotation_value(x::Real) @@ -345,8 +414,9 @@ function rotation_value(x::Real) end # keyword argument constructor -function TextBox(; - content::AbstractString="", +function TextBox( + text::AbstractString=""; + content::AbstractString=text, offset=(50,50), offset_x::Real=offset[1], # millimeters offset_y::Real=offset[2], # millimeters @@ -363,6 +433,7 @@ function TextBox(; rotation::Union{Nothing, Real}=nothing, margins=Margins(), wrap=false, + anchor=nothing, ) return TextBox( content, @@ -378,11 +449,10 @@ function TextBox(; rotation, margins, wrap, + anchor, ) end -TextBox(content::String; kwargs...) = TextBox(;content=content, kwargs...) - function _show_string(p::TextBox, compact::Bool) show_string = "TextBox" if !compact @@ -537,6 +607,9 @@ function make_textbody_xml(t::TextBody, txBodyNameSpace="p") if has_margins(t) append!(bodyPr, make_body_properties_xml(t.margins)) end + if !isnothing(t.anchor) + push!(bodyPr, make_anchor_dict(t.anchor)) + end txBody = Dict( "$txBodyNameSpace:txBody" => [ diff --git a/src/Video.jl b/src/Video.jl index 0a48948..9276471 100644 --- a/src/Video.jl +++ b/src/Video.jl @@ -8,6 +8,18 @@ struct Video <: AbstractShape size_y::Int rid::Int _uuid::String + function Video( + source::String, + offset_x::Int, + offset_y::Int, + size_x::Int, + size_y::Int, + rid::Int, + uuid::String, + ) + new(source, offset_x, offset_y, size_x, size_y, rid, uuid) + end + function Video(source::String, offset_x::Int, offset_y::Int, size_x::Int, size_y::Int, rid::Int) new(source, offset_x, offset_y, size_x, size_y, rid, string(UUIDs.uuid4())) end @@ -40,9 +52,18 @@ end function set_rid(v::Video, i::Int) return Video(v.source, v.offset_x, v.offset_y, v.size_x, v.size_y, i) end + rid(v::Video) = v.rid has_rid(v::Video) = true +function set_geometry(v::Video, geom::Geometry) + return Video(v.source, geom.offset_x, geom.offset_y, geom.size_x, geom.size_y, v.rid, v._uuid) +end + +function geometry_in_span(s::Video, geom::Geometry, keepratio::Bool) + geometry_in_span(get_geometry(s), geom, keepratio) +end + function _show_string(v::Video, compact::Bool) show_string = "Video" if !compact diff --git a/src/constants.jl b/src/constants.jl index 84fc2ed..3eec15e 100644 --- a/src/constants.jl +++ b/src/constants.jl @@ -4,6 +4,16 @@ const _EMUS_PER_CM = 360000 const _EMUS_PER_MM = 36000 const _EMUS_PER_PT = 12700 +mm_to_emu(::Nothing) = nothing +mm_to_emu(x) = Int(round(x * _EMUS_PER_MM)) +mm_to_emu(x::AbstractArray{<:Real}) = mm_to_emu.(x) + +inch_to_emu(::Nothing) = nothing +inch_to_emu(x::Real) = Int(round(x * _EMUS_PER_INCH)) + +points_to_emu(::Nothing) = nothing +points_to_emu(x::Real) = Int(round(x * _EMUS_PER_PT)) + const TEMPLATE_DIR = abspath(joinpath(@__DIR__, "..", "templates")) const ASSETS_DIR = abspath(joinpath(@__DIR__, "..", "assets")) const TESTDATA_DIR = abspath(joinpath(@__DIR__, "..", "test/testdata")) diff --git a/src/write.jl b/src/write.jl index 682399b..424b22b 100644 --- a/src/write.jl +++ b/src/write.jl @@ -55,22 +55,29 @@ function write_slides!(w::ZipWriter, p::Presentation, template::ZipBufferReader) error("input template pptx already contains slides, please use an empty template") end layoutmap = get_layoutnamemap(template) + sz = slide_size(p) for (idx, slide) in enumerate(slides(p)) layoutnametoint!(slide, layoutmap) - xml = make_slide(slide) + + # write slide + xml = make_slide(slide; slide_size = sz) doc::EzXML.Document = xml_document(xml) add_title_shape!(doc, slide, template) - zip_newfile(w, "ppt/slides/slide$idx.xml"; compress=true) - print(w, doc) - zip_commitfile(w) + zip_write_doc(w, doc, "ppt/slides/slide$idx.xml") + + # write slide relationships xml = make_slide_relationships(slide) doc = xml_document(xml) - zip_newfile(w, "ppt/slides/_rels/slide$idx.xml.rels"; compress=true) - print(w, doc) - zip_commitfile(w) + zip_write_doc(w, doc, "ppt/slides/_rels/slide$idx.xml.rels") end end +function zip_write_doc(w::ZipWriter, doc::EzXML.Document, path::String) + zip_newfile(w, path; compress=true) + print(w, doc) + zip_commitfile(w) +end + function add_title_shape!(doc::EzXML.Document, slide::Slide, template::ZipBufferReader) # xpath to find something with an unregistered namespace spTree = findfirst("//*[local-name()='p:spTree']", root(doc)) @@ -87,12 +94,14 @@ function add_title_shape!(doc::EzXML.Document, slide::Slide, template::ZipBuffer nothing end +# default do nothing +copy_shape(w::ZipWriter, shape::AbstractShape) = nothing + +# some shapes, like Pictures, are media files that need to be copied/written into the pptx function write_shapes!(w::ZipWriter, pres::Presentation) for slide in slides(pres) for shape in shapes(slide) - if typeof(shape) ∈ [Picture, Video] - copy_shape(w::ZipWriter, shape) - end + copy_shape(w, shape) end end end diff --git a/test/runtests.jl b/test/runtests.jl index 837159e..a488016 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -5,11 +5,12 @@ using Test import PPTX: slides, shapes, rid include("testAbstractShape.jl") +include("testGridLayout.jl") include("testHyperlinks.jl") include("testLayout.jl") include("testPicture.jl") include("testPresentation.jl") -# include("testPresentationState.jl") # This testset seems outdated and or obsolete +include("testPresentationState.jl") include("testSlide.jl") include("testSlideXML.jl") include("testTables.jl") diff --git a/test/testGridLayout.jl b/test/testGridLayout.jl new file mode 100644 index 0000000..8d29b38 --- /dev/null +++ b/test/testGridLayout.jl @@ -0,0 +1,216 @@ +@testset "GridLayout" begin + @testset "constructor" begin + layout = GridLayout(2, 3) + @test layout.nrows == 2 + @test layout.ncols == 3 + @test layout.padding == PPTX.mm_to_emu(5) + @test layout.margins == PPTX.GridMargins(; left=5, right=5, top=40, bottom=5) + @test layout.keepratio == true + @test isempty(layout._entries) + + layout_p = GridLayout(3, 3; padding=10, margins = 10) + @test layout_p.padding == PPTX.mm_to_emu(10) + @test layout_p.margins == PPTX.GridMargins(10) + + @test_throws ArgumentError GridLayout(0, 2) + @test_throws ArgumentError GridLayout(2, 0) + @test_throws ArgumentError GridLayout(2, 2; padding=-1) + @test_throws ArgumentError GridLayout(2, 2; margins=(left=-1, right=0, top=0, bottom=0)) + end + + @testset "setindex! integer indices" begin + layout = GridLayout(2, 2) + box = TextBox("Hello") + layout[1, 1] = box + @test length(layout._entries) == 1 + shape, rows, cols = layout._entries[1] + @test shape === box + @test rows == 1:1 + @test cols == 1:1 + end + + @testset "setindex! colon spans" begin + layout = GridLayout(2, 3) + box = TextBox("Wide") + layout[2, :] = box + shape, rows, cols = layout._entries[1] + @test rows == 2:2 + @test cols == 1:3 + + layout2 = GridLayout(3, 2) + layout2[:, 1] = TextBox("Tall") + _, rows2, cols2 = layout2._entries[1] + @test rows2 == 1:3 + @test cols2 == 1:1 + end + + @testset "setindex! range spans" begin + layout = GridLayout(3, 4) + layout[1:2, 2:3] = TextBox("Block") + _, rows, cols = layout._entries[1] + @test rows == 1:2 + @test cols == 2:3 + end + + @testset "setindex! bounds checking" begin + layout = GridLayout(2, 2) + @test_throws BoundsError (layout[3, 1] = TextBox("")) + @test_throws BoundsError (layout[1, 0] = TextBox("")) + @test_throws BoundsError (layout[1:3, 1] = TextBox("")) + end + + @testset "setindex! conflict detection" begin + layout = GridLayout(3, 3) + layout[1, 1] = TextBox("A") + # exact overlap + @test_throws ArgumentError (layout[1, 1] = TextBox("B")) + # partial overlap + layout2 = GridLayout(3, 3) + layout2[1:2, 1:2] = TextBox("Block") + @test_throws ArgumentError (layout2[2, 2] = TextBox("Corner")) + # non-overlapping should succeed + layout3 = GridLayout(2, 2) + layout3[1, 1] = TextBox("A") + layout3[1, 2] = TextBox("B") + layout3[2, :] = TextBox("C") + @test length(layout3._entries) == 3 + end + + @testset "show" begin + layout = GridLayout(2, 2) + @test sprint(show, layout) == "GridLayout(2×2)" + layout[1, 1] = TextBox("x") + io = IOBuffer() + Base.show(io, MIME"text/plain"(), layout) + @test contains(String(take!(io)), "1 assigned cell") + end + + @testset "push! assigns nested rids" begin + slide = Slide() + layout = GridLayout(1, 2) + img1 = Picture(joinpath(PPTX.ASSETS_DIR, "julia_logo.emf"); size_x=10, size_y=10) + img2 = Picture(joinpath(PPTX.ASSETS_DIR, "julia_dots.wmf"); size_x=10, size_y=10) + layout[1, 1] = img1 + layout[1, 2] = img2 + + push!(slide, layout) + + @test length(PPTX.shapes(slide)) == 1 + pushed_layout = only(PPTX.shapes(slide)) + @test pushed_layout isa GridLayout + + pushed_entries = pushed_layout._entries + @test PPTX.rid(pushed_entries[1][1]) == 2 + @test PPTX.rid(pushed_entries[2][1]) == 3 + @test PPTX.rid(pushed_layout) == 3 + + @test PPTX.new_rid(slide) == 4 + end + + @testset "nested relationships" begin + target_slide = Slide() + slide = Slide() + layout = GridLayout(1, 2) + img = Picture(joinpath(PPTX.ASSETS_DIR, "julia_logo.emf"); size_x=10, size_y=10) + link_box = TextBox(content="jump", hlink=target_slide) + layout[1, 1] = img + layout[1, 2] = link_box + push!(slide, layout) + + rel_map = PPTX.slide_relationship_map(slide) + nested_img = only(filter(x -> x isa Picture, PPTX.shapes(layout))) + @test haskey(rel_map, nested_img) + @test haskey(rel_map, target_slide) + + rel_xml = PPTX.make_slide_relationships(slide) + relationships = rel_xml["Relationships"] + @test length(relationships) == 4 + @test relationships[3]["Relationship"][1]["Id"] == "rId2" + @test relationships[4]["Relationship"][1]["Id"] == "rId3" + end + + @testset "make_slide places grid shapes" begin + slide = Slide() + layout = GridLayout(2, 2; padding=10, margins=10) + layout[1, 1] = TextBox("Title") + pic = Picture(joinpath(PPTX.ASSETS_DIR, "julia_logo.emf")) + layout[2, :] = pic + # also test Video in layout + video_path = joinpath(PPTX.ASSETS_DIR, "sample_video.mp4") + layout[1, 2] = Video(video_path; size_x=10, size_y=10) + push!(slide, layout) + + sz = PPTX.SlideSize(PPTX.mm_to_emu(100), PPTX.mm_to_emu(50)) + xml = PPTX.make_slide( + slide; + slide_size = sz, + ) + + sp_tree = xml["p:sld"][end]["p:cSld"][1]["p:spTree"] + # spTree contains 2 group entries first, then our 2 layout shapes + text_sp = sp_tree[3]["p:sp"] + pic_sp = sp_tree[4]["p:pic"] + vid_sp = sp_tree[5]["p:pic"] + + # size of a grid cell for this slide size and layout dimensions, after accounting for padding and margins + cell_x_size = PPTX.mm_to_emu(35) + cell_y_size = PPTX.mm_to_emu(10) + + text_xfrm = text_sp[2]["p:spPr"][1]["a:xfrm"] + text_off = only(filter(x -> haskey(x, "a:off"), text_xfrm))["a:off"] + text_ext = only(filter(x -> haskey(x, "a:ext"), text_xfrm))["a:ext"] + @test text_ext[1]["cx"] == string(cell_x_size) + @test text_ext[2]["cy"] == string(cell_y_size) + @test text_off[1]["x"] == string(PPTX.mm_to_emu(10)) + @test text_off[2]["y"] == string(PPTX.mm_to_emu(10)) + + # default keepratio=true keeps ratio for Picture and centers it in span. + pic_xfrm = pic_sp[3]["p:spPr"][1]["a:xfrm"] + pic_off = pic_xfrm[1]["a:off"] + pic_ext = pic_xfrm[2]["a:ext"] + ratio = pic.size_x / pic.size_y + scaled_y = Int(round(cell_y_size * ratio)) + @test pic_ext[1]["cx"] == string(scaled_y) + @test pic_ext[2]["cy"] == string(cell_y_size) + centered_x = PPTX.mm_to_emu(45) + Int(round((cell_y_size - scaled_y) / 2)) + @test pic_off[1]["x"] == string(centered_x) + @test pic_off[2]["y"] == string(PPTX.mm_to_emu(30)) + + # Video should also be keepratio=true and centered in its span + vid_xfrm = vid_sp[3]["p:spPr"][1]["a:xfrm"] + vid_off = vid_xfrm[1]["a:off"] + @test vid_xfrm[2]["a:ext"][1]["cx"] == string(cell_y_size) + @test vid_xfrm[2]["a:ext"][2]["cy"] == string(cell_y_size) + @test vid_off[1]["x"] == string(PPTX.mm_to_emu(50 + 35/2)) + @test vid_off[2]["y"] == string(PPTX.mm_to_emu(10)) + end + + @testset "make_slide picture keepratio=false stretches" begin + slide = Slide() + layout = GridLayout(2, 2; padding=10, margins=10, keepratio=false) + layout[2, :] = Picture(joinpath(PPTX.ASSETS_DIR, "julia_logo.emf"); size_x=10, size_y=10) + push!(slide, layout) + + sz = PPTX.SlideSize(PPTX.mm_to_emu(100), PPTX.mm_to_emu(50)) + xml = PPTX.make_slide( + slide; + slide_size = sz, + ) + + sp_tree = xml["p:sld"][end]["p:cSld"][1]["p:spTree"] + pic_sp = sp_tree[3]["p:pic"] + pic_xfrm = pic_sp[3]["p:spPr"][1]["a:xfrm"] + pic_off = pic_xfrm[1]["a:off"] + pic_ext = pic_xfrm[2]["a:ext"] + @test pic_off[1]["x"] == string(PPTX.mm_to_emu(10)) + @test pic_off[2]["y"] == string(PPTX.mm_to_emu(30)) + @test pic_ext[1]["cx"] == string(PPTX.mm_to_emu(80)) + @test pic_ext[2]["cy"] == string(PPTX.mm_to_emu(10)) + end + + @testset "nested layouts not supported" begin + layout = GridLayout(2, 2) + nested_layout = GridLayout(1, 1) + @test_throws ErrorException (layout[1, 1] = nested_layout) + end +end diff --git a/test/testLayout.jl b/test/testLayout.jl index d40aec7..1f372fa 100644 --- a/test/testLayout.jl +++ b/test/testLayout.jl @@ -20,7 +20,7 @@ end mktempdir() do tmpdir filename = "testfile-layout" output_pptx = abspath(joinpath(tmpdir, "$filename.pptx")) - PPTX.write(output_pptx, pres) + PPTX.write(output_pptx, pres; open_ppt=false) end @test pres.slides[2].layout == 5 @@ -38,7 +38,7 @@ end filename = "testfile-layout_number_not_defined" output_pptx = abspath(joinpath(tmpdir, "$filename.pptx")) err_msg = "Slide layout number 12 not defined in the template" - @test_throws ErrorException(err_msg) PPTX.write(output_pptx, pres) + @test_throws ErrorException(err_msg) PPTX.write(output_pptx, pres; open_ppt=false) end end diff --git a/test/testPresentationState.jl b/test/testPresentationState.jl index 1128eab..5af4502 100644 --- a/test/testPresentationState.jl +++ b/test/testPresentationState.jl @@ -2,10 +2,9 @@ using Test using PPTX @testset "Presentation Size" begin - template_folder = abspath(joinpath(PPTX.TEMPLATE_DIR,"no-slides")) p = Presentation() - ppt_dir = joinpath(template_folder, "ppt") - PPTX.update_presentation_state!(p, ppt_dir) + template_reader = PPTX.ZipBufferReader(PPTX.read_template(PPTX.DEFAULT_TEMPLATE_DATA)) + PPTX.update_presentation_state!(p, template_reader) @test p._state.size.x == 12192000 @test p._state.size.y == 6858000 end \ No newline at end of file diff --git a/test/testTables.jl b/test/testTables.jl index 450e33e..9c1fcc8 100644 --- a/test/testTables.jl +++ b/test/testTables.jl @@ -7,6 +7,10 @@ using Colors @testset "PPTX Tables from a Matrix" begin t = Table(rand(3,2)) + @test t.size_x == PPTX.mm_to_emu(150) + @test t.size_y == PPTX.mm_to_emu(100) + @test t.offset_x == PPTX.mm_to_emu(50) + @test t.offset_y == PPTX.mm_to_emu(50) @test t.header == false @test PPTX.ncols(t) == 2 @@ -16,6 +20,24 @@ using Colors @test_throws AssertionError Table(rand(3,2); row_heights = [20,]) end +@testset "Table set geometry" begin + content = TableCell.(rand(3,2);anchor=:center) + t = Table(content, header=false, bandrow=false) + + # this is in EMUs + geom = PPTX.Geometry(100, 100, 200, 150) + t2 = PPTX.set_geometry(t, geom) + + @test t2.offset_x == 100 + @test t2.offset_y == 100 + @test t2.size_x == 200 + @test t2.size_y == 150 + @test t2.content === content + @test t2.header == t.header + @test t2.bandrow == t.bandrow + @test t2.style_id == t.style_id +end + @testset "PPTX Tables from a DataFrame" begin lines = PPTX.TableLines(left=(width=1,)) @test lines.left.width == 12700 # EMUs @@ -29,6 +51,7 @@ end t_margins = TableCell(3; margins=(bottom=0.1,)) @test PPTX.has_margins(t_margins) + @test PPTX.has_tc_properties(t_margins) @test t_margins.margins.bottom == 36000 @test t_margins.margins.left === nothing @@ -39,6 +62,9 @@ end @test PPTX.has_tc_properties(t4) @test !PPTX.has_margins(t4) + @test PPTX.has_tc_properties(TableCell(1; anchor=:center)) + @test PPTX.has_tc_properties(TableCell(1; direction=:vert)) + t3 = TableCell( 3; lines=(bottom=(width=3,color=:black),), diff --git a/test/testTextBox.jl b/test/testTextBox.jl index 48bf3c4..cadd491 100644 --- a/test/testTextBox.jl +++ b/test/testTextBox.jl @@ -50,4 +50,14 @@ @test b.content.margins.right == 36000 @test b.content.margins.top === nothing @test b.content.margins.bottom === nothing + + b = TextBox("bla", anchor = :center) + @test b.content.anchor == "center" + b = TextBox("bla", anchor = :middle) + @test b.content.anchor == "center" + b = TextBox("bla", anchor = "bottom") + @test b.content.anchor == "bottom" + b = TextBox("bla") + @test b.content.anchor === nothing + @test_throws AssertionError TextBox("bla", anchor = "banana") end \ No newline at end of file