Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
1 change: 1 addition & 0 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ makedocs(;
pages=[
"Home" => "index.md",
"Table Styling" => "tablestyle.md",
"GridLayout" => "layout.md",
"Plots" => "plots.md",
"API Reference" => "api.md",
],
Expand Down
Binary file added docs/src/assets/images/gridlayout_example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/src/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
43 changes: 43 additions & 0 deletions docs/src/layout.md
Original file line number Diff line number Diff line change
@@ -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
<img src="../assets/images/gridlayout_example.png"/>
```
34 changes: 34 additions & 0 deletions src/AbstractShape.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
184 changes: 184 additions & 0 deletions src/GridLayout.jl
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion src/PPTX.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions src/Picture.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 8 additions & 7 deletions src/Presentation.jl
Original file line number Diff line number Diff line change
@@ -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

"""
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading