pptxfill 0.2.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pptxfill/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ from .loader import load_template
2
+ from .picture import replace_image, replace_image_by_name
3
+ from .slides import copy_slide_content, delete_slide, insert_slide, multiply_slide
4
+ from .table import fill_table, fill_table_by_name
5
+ from .text import format_text_on_slide
6
+ from .walker import find_shape_by_name, walk_shapes
7
+
8
+ __all__ = [
9
+ "load_template",
10
+ "replace_image",
11
+ "replace_image_by_name",
12
+ "copy_slide_content",
13
+ "delete_slide",
14
+ "insert_slide",
15
+ "multiply_slide",
16
+ "fill_table",
17
+ "fill_table_by_name",
18
+ "format_text_on_slide",
19
+ "walk_shapes",
20
+ "find_shape_by_name",
21
+ ]
pptxfill/eq.py ADDED
@@ -0,0 +1,66 @@
1
+ from typing import Callable, Optional, cast
2
+
3
+ from lxml.etree import Element, QName
4
+ from lxml.etree import tostring as lxml_tostring
5
+ from pptx.oxml.action import CT_Hyperlink
6
+ from pptx.oxml.text import CT_TextCharacterProperties, CT_TextFont
7
+
8
+
9
+ def optional_eq[T](v1: Optional[T], v2: Optional[T], eq: Callable[[T, T], bool]):
10
+ if v1 is None or v2 is None:
11
+ return (v1 is None) == (v2 is None)
12
+
13
+ return eq(v1, v2)
14
+
15
+
16
+ def get_fill_properties(rpr: CT_TextCharacterProperties):
17
+ """rPr.eg_fillProperties doesn't cover all fill-related tags, so using a custom method"""
18
+ return next(
19
+ filter(
20
+ lambda el: not isinstance(el.tag, bytearray)
21
+ and QName(el.tag).localname
22
+ in {
23
+ "noFill",
24
+ "solidFill",
25
+ "gradFill",
26
+ "blipFill",
27
+ "pattFill",
28
+ "grpFill",
29
+ "effectLst",
30
+ "effectDag",
31
+ "highlight",
32
+ "uLnTx",
33
+ "uLn",
34
+ "uFillTx",
35
+ "uFill",
36
+ },
37
+ cast(Element, rpr),
38
+ ),
39
+ None,
40
+ )
41
+
42
+
43
+ def elements_eq(el1: Element, el2: Element):
44
+ return lxml_tostring(el1) == lxml_tostring(el2)
45
+
46
+
47
+ def text_font_eq(t1: CT_TextFont, t2: CT_TextFont):
48
+ return t1.typeface == t2.typeface
49
+
50
+
51
+ def hyperlink_eq(h1: CT_Hyperlink, h2: CT_Hyperlink):
52
+ return h1.rId == h2.rId and h1.action == h2.action
53
+
54
+
55
+ def text_character_properties_eq(
56
+ rpr1: CT_TextCharacterProperties, rpr2: CT_TextCharacterProperties
57
+ ):
58
+ return (
59
+ optional_eq(get_fill_properties(rpr1), get_fill_properties(rpr2), elements_eq)
60
+ and optional_eq(rpr1.latin, rpr2.latin, text_font_eq)
61
+ and optional_eq(rpr1.hlinkClick, rpr2.hlinkClick, hyperlink_eq)
62
+ and rpr1.sz == rpr2.sz
63
+ and rpr1.b == rpr2.b
64
+ and rpr1.i == rpr2.i
65
+ and rpr1.u == rpr2.u
66
+ )
pptxfill/loader.py ADDED
@@ -0,0 +1,57 @@
1
+ from typing import IO
2
+
3
+ from lxml.etree import Element
4
+ from pptx import Presentation as new_presentation
5
+ from pptx.oxml.text import CT_RegularTextRun
6
+ from pptx.shapes.autoshape import Shape
7
+
8
+ from .eq import optional_eq, text_character_properties_eq
9
+ from .walker import walk_shapes
10
+
11
+
12
+ def merge_spans_with_similar_style(parent: Element):
13
+ children = list(parent)
14
+
15
+ merged = [children[0]]
16
+ for i in range(1, len(children)):
17
+ last_part = merged[-1]
18
+ next_part = children[i]
19
+
20
+ if (
21
+ isinstance(last_part, CT_RegularTextRun)
22
+ and isinstance(next_part, CT_RegularTextRun)
23
+ and optional_eq(last_part.rPr, next_part.rPr, text_character_properties_eq)
24
+ ):
25
+ last_part.text += next_part.text
26
+ else:
27
+ merged.append(next_part)
28
+
29
+ parent.clear()
30
+ parent.extend(merged)
31
+
32
+
33
+ def load_template(pptx: str | IO[bytes]):
34
+ """Loads a presentation similarly to :func:`pptx.Presentation`,
35
+
36
+ fixing split tags such as:
37
+
38
+ .. code-block:: xml
39
+
40
+ <a:r>
41
+ <a:t>{uni_full</a:t>
42
+ </a:r>
43
+ <a:r>
44
+ <a:t>_name}</a:t>
45
+ </a:r>
46
+
47
+ to make future formatting easier
48
+ """
49
+
50
+ template = new_presentation(pptx)
51
+
52
+ for slide in template.slides:
53
+ for shape in walk_shapes(slide, Shape):
54
+ for paragraph in shape.text_frame.paragraphs:
55
+ merge_spans_with_similar_style(paragraph._element)
56
+
57
+ return template
pptxfill/picture.py ADDED
@@ -0,0 +1,73 @@
1
+ from pathlib import Path
2
+ from typing import IO, cast
3
+
4
+ from lxml.etree import Element
5
+ from pptx.shapes.base import BaseShape
6
+ from pptx.shapes.picture import Picture
7
+ from pptx.slide import Slide
8
+
9
+ from .walker import find_shape_by_name
10
+
11
+
12
+ def replace_image(
13
+ slide: Slide,
14
+ old_pic: BaseShape,
15
+ image: Path | str | IO[bytes],
16
+ full_width: bool = True,
17
+ full_height: bool = True,
18
+ ) -> Picture:
19
+ """Replace contents of a :class:`Picture <pptx.shapes.picture.Picture>` shape
20
+
21
+ Args:
22
+ slide: target slide
23
+ old_pic: shape to replace
24
+ image: new image file
25
+ full_width: stretch to original shape's width
26
+ full_height: stretch to original shape's height
27
+ """
28
+
29
+ if isinstance(image, Path):
30
+ image = str(image)
31
+
32
+ new_pic = slide.shapes.add_picture(
33
+ image,
34
+ left=old_pic.left,
35
+ top=old_pic.top,
36
+ width=old_pic.width if full_width else None,
37
+ height=old_pic.height if full_height else None,
38
+ )
39
+
40
+ old_pic_element = cast(Element, old_pic._element)
41
+ old_pic_element.addnext(new_pic._element)
42
+ parent = old_pic_element.getparent()
43
+ if parent is not None:
44
+ parent.remove(old_pic_element)
45
+
46
+ new_pic.name = old_pic.name
47
+ return new_pic
48
+
49
+
50
+ def replace_image_by_name(
51
+ slide: Slide,
52
+ name: str,
53
+ image: Path | str | IO[bytes],
54
+ full_width: bool = True,
55
+ full_height: bool = True,
56
+ ):
57
+ """Shorthand for :func:`pptxfill.find_shape_by_name` + :func:`pptxfill.replace_image`
58
+
59
+ Args:
60
+ slide: target slide
61
+ name: target shape name
62
+ image: new image file
63
+ full_width: stretch to original shape's width
64
+ full_height: stretch to original shape's height
65
+ """
66
+
67
+ return replace_image(
68
+ slide,
69
+ find_shape_by_name(slide, name),
70
+ image,
71
+ full_width=full_width,
72
+ full_height=full_height,
73
+ )
pptxfill/slides.py ADDED
@@ -0,0 +1,93 @@
1
+ import io
2
+ from copy import deepcopy
3
+
4
+ from pptx.shapes.picture import Picture
5
+ from pptx.slide import Slide, SlideLayout, Slides
6
+
7
+
8
+ def insert_slide(slides: Slides, layout: SlideLayout, position: int) -> Slide:
9
+ """Insert a new slide into the slide list
10
+
11
+ Args:
12
+ slides: the slide list (usually :attr:`pptx.presentation.Presentation.slides`)
13
+ layout: layout to base the new slide off
14
+ position: index where the new slide will be inserted
15
+
16
+ Returns:
17
+ the new slide
18
+ """
19
+
20
+ new_slide = slides.add_slide(layout)
21
+
22
+ slide_ids = slides._sldIdLst
23
+ new_id = slide_ids[-1]
24
+ slide_ids.remove(new_id)
25
+ slide_ids.insert(position, new_id)
26
+
27
+ return new_slide
28
+
29
+
30
+ def copy_slide_content(source: Slide, target: Slide):
31
+ """Copy XML tree and images to the new slide"""
32
+
33
+ for shape in target.shapes:
34
+ target.shapes.element.remove(shape.element)
35
+
36
+ for shape in source.shapes:
37
+ if isinstance(shape, Picture):
38
+ img = io.BytesIO(shape.image.blob)
39
+ new_shape = target.shapes.add_picture(
40
+ img,
41
+ left=shape.left,
42
+ top=shape.top,
43
+ width=shape.width,
44
+ height=shape.height,
45
+ )
46
+ new_shape.name = shape.name
47
+ else:
48
+ target.shapes._spTree.insert_element_before(
49
+ deepcopy(shape.element), "p:extList"
50
+ )
51
+
52
+
53
+ def delete_slide(slides: Slides, index: int):
54
+ """Delete slide at index
55
+
56
+ Args:
57
+ slides: the slide list (usually :attr:`pptx.presentation.Presentation.slides`)
58
+ index: target index
59
+ """
60
+ slides._sldIdLst.remove(slides._sldIdLst[index])
61
+
62
+
63
+ def multiply_slide(slides: Slides, source: Slide, n: int) -> list[Slide]:
64
+ """Replace a single slide with N copies of itself
65
+
66
+ Useful for templating where you need a certain slide to repeat.
67
+ The new slides will be inserted at the original slide's index
68
+ and will have the original slides's content copied into them.
69
+
70
+ Args:
71
+ slides: the slide list (usually :attr:`pptx.presentation.Presentation.slides`)
72
+ source: template slide
73
+ n: number of new slides
74
+
75
+ Returns:
76
+ list of slides with length N
77
+ """
78
+
79
+ source_index = slides.index(source)
80
+
81
+ if n == 0:
82
+ delete_slide(slides, source_index)
83
+ return []
84
+ else:
85
+ slide_list = [source]
86
+
87
+ for _ in range(n - 1):
88
+ new_slide = insert_slide(slides, source.slide_layout, source_index)
89
+ copy_slide_content(source, new_slide)
90
+ slide_list.append(new_slide)
91
+
92
+ slide_list.reverse()
93
+ return slide_list
pptxfill/table.py ADDED
@@ -0,0 +1,105 @@
1
+ from copy import deepcopy
2
+ from itertools import islice
3
+ from typing import Any, cast
4
+
5
+ from lxml.etree import Element
6
+ from pandas import DataFrame
7
+ from pptx.oxml.text import CT_RegularTextRun
8
+ from pptx.shapes.graphfrm import GraphicFrame
9
+ from pptx.slide import Slide
10
+ from pptx.table import Table, _Cell
11
+
12
+ from .loader import merge_spans_with_similar_style
13
+ from .walker import find_shape_by_name
14
+
15
+
16
+ def get_cell_template(cell: _Cell):
17
+ for paragraph in cell.text_frame.paragraphs:
18
+ merge_spans_with_similar_style(paragraph._element)
19
+
20
+ return list(cast(Element, cell.text_frame._txBody))
21
+
22
+
23
+ def set_cell_value(cell: _Cell, template: list[Element], value: Any):
24
+ text_body = cast(Element, cell.text_frame._txBody)
25
+ text_body.clear()
26
+
27
+ for template_element in template:
28
+ text_body.append(deepcopy(template_element))
29
+
30
+ for paragraph in cell.text_frame.paragraphs:
31
+ for tag in cast(Element, paragraph._element):
32
+ if isinstance(tag, CT_RegularTextRun):
33
+ tag.text = tag.text.format(value)
34
+
35
+
36
+ def fill_table(
37
+ table: Table,
38
+ data: DataFrame,
39
+ skip_header: bool = True,
40
+ rownum: bool = False,
41
+ ):
42
+ """Fill :class:`pptx.table.Table` object with data from a :class:`pandas.DataFrame`
43
+
44
+ Does not expand the table, data will be cut off where the table ends.
45
+
46
+ In the template, the first row must be filled with format strings, for example:
47
+
48
+ .. list-table::
49
+
50
+ * - Name
51
+ - Number
52
+ - Percent
53
+ * - {}
54
+ - {:.2f}
55
+ - {}%
56
+
57
+ Args:
58
+ table: table in the presentation
59
+ data: fill with this data
60
+ skip_header: consider the first row the header, begin filling table from the second row
61
+ rownum: fill the first column with row numbers, data begins from the second column
62
+ """
63
+
64
+ row_start = 1 if skip_header else 0
65
+ height = min(len(table.rows) - row_start, len(data))
66
+
67
+ template = [
68
+ get_cell_template(table.cell(row_start, i)) for i in range(len(table.columns))
69
+ ]
70
+
71
+ if rownum:
72
+ for y, index in enumerate(islice(data.index, height)):
73
+ set_cell_value(table.cell(row_start + y, 0), template[0], str(index))
74
+
75
+ col_start = 1 if rownum else 0
76
+ width = min(len(table.columns) - col_start, len(data.columns))
77
+
78
+ for x, (_, column) in enumerate(islice(data.items(), width)):
79
+ for y, value in enumerate(islice(column, height)):
80
+ set_cell_value(
81
+ table.cell(row_start + y, col_start + x),
82
+ template[col_start + x],
83
+ value,
84
+ )
85
+
86
+
87
+ def fill_table_by_name(
88
+ slide: Slide,
89
+ name: str,
90
+ data: DataFrame,
91
+ skip_header: bool = True,
92
+ rownum: bool = False,
93
+ ):
94
+ """Shorthand for :func:`pptxfill.find_shape_by_name` + :func:`pptxfill.fill_table`
95
+
96
+ Args:
97
+ slide: target slide
98
+ name: target shape's name
99
+ data: fill with this data
100
+ skip_header: consider the first row the header, begin filling table from the second row
101
+ rownum: fill the first column with row numbers, data begins from the second column
102
+ """
103
+ table = find_shape_by_name(slide, name, GraphicFrame).table
104
+ fill_table(table, data, skip_header=skip_header, rownum=rownum)
105
+ return table
pptxfill/text.py ADDED
@@ -0,0 +1,23 @@
1
+ from typing import cast
2
+
3
+ from lxml.etree import Element
4
+ from pptx.oxml.text import CT_RegularTextRun
5
+ from pptx.shapes.autoshape import Shape
6
+ from pptx.slide import Slide
7
+
8
+ from .walker import walk_shapes
9
+
10
+
11
+ def format_text_on_slide(slide: Slide, **format_args):
12
+ """Walk the slides's shape tree, applying :meth:`str.format` to each text frame
13
+
14
+ Args:
15
+ slide: the target slide
16
+ *format_args: identical to :meth:`str.format` arguments
17
+ """
18
+
19
+ for shape in walk_shapes(slide, Shape):
20
+ for paragraph in shape.text_frame.paragraphs:
21
+ for tag in cast(Element, paragraph._element):
22
+ if isinstance(tag, CT_RegularTextRun):
23
+ tag.text = tag.text.format(**format_args)
pptxfill/walker.py ADDED
@@ -0,0 +1,48 @@
1
+ from typing import Iterator
2
+
3
+ from pptx.shapes.base import BaseShape
4
+ from pptx.shapes.group import GroupShape
5
+ from pptx.shapes.shapetree import _BaseGroupShapes
6
+ from pptx.slide import Slide
7
+
8
+
9
+ def walk_group_shapes[T: BaseShape](
10
+ group: _BaseGroupShapes, t: type[T] = BaseShape
11
+ ) -> Iterator[T]:
12
+ for shape in group:
13
+ if isinstance(shape, t):
14
+ yield shape
15
+
16
+ if isinstance(shape, GroupShape):
17
+ yield from walk_group_shapes(shape.shapes, t)
18
+
19
+
20
+ def walk_shapes[T: BaseShape](slide: Slide, t: type[T] = BaseShape) -> Iterator[T]:
21
+ """Walks the shape tree of the slide, visiting each :class:`GroupShape <pptx.shapes.group.GroupShape>` contents
22
+
23
+ Args:
24
+ slide: target slide
25
+ t: filter by shape type (if omitted, will visit all shapes)
26
+ """
27
+
28
+ yield from walk_group_shapes(slide.shapes, t)
29
+
30
+
31
+ def find_shape_by_name[T: BaseShape](
32
+ slide: Slide, name: str, t: type[T] = BaseShape
33
+ ) -> T:
34
+ """Walk the slide's shape tree and find shape with the given name
35
+
36
+ Args:
37
+ slide: target slide
38
+ name: target shape name
39
+ t: target shape's type
40
+
41
+ Raises:
42
+ RuntimeError: If shape wasn't found
43
+ """
44
+ for shape in walk_shapes(slide, t):
45
+ if shape.name == name:
46
+ return shape
47
+
48
+ raise RuntimeError(f"Shape '{name}' of type {t} not found")
@@ -0,0 +1,34 @@
1
+ Metadata-Version: 2.5
2
+ Name: pptxfill
3
+ Version: 0.2.5
4
+ Summary: Add your description here
5
+ Project-URL: Repository, https://gitlab.com/csml-tools/pptxfill/
6
+ Project-URL: Documentation, https://csml-tools.gitlab.io/pptxfill/
7
+ Requires-Python: >=3.13
8
+ Requires-Dist: pandas>=2.3.3
9
+ Requires-Dist: python-pptx>=1.0.2
10
+ Requires-Dist: types-lxml>=2026.1.1
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pptxfill
14
+
15
+ **pptxfill** is a templating library for PowerPoint presentations.
16
+
17
+ Unlike other similar template engines, this one does not rely on Slide Layouts,
18
+ instead using a more flexible system similar to ones used for Word documents,
19
+ where you can substitute variables in text blocks using string formatting,
20
+ replace arbitrary images and fill tables with data.
21
+
22
+ [Documentation](https://csml-tools.gitlab.io/pptxfill/)
23
+
24
+ ## Installation:
25
+
26
+ ```
27
+ uv add pptxfill
28
+ ```
29
+
30
+ or
31
+
32
+ ```
33
+ pip install pptxfill
34
+ ```
@@ -0,0 +1,11 @@
1
+ pptxfill/__init__.py,sha256=T-OZylf9hyoqJ9WiKqSEg8ZYYbnrS141uF2spkmWgbU,607
2
+ pptxfill/eq.py,sha256=JlZ1f5tzKpgtrWistRY_8E5MzWLtOt8m5Jrc57CSJ-o,1927
3
+ pptxfill/loader.py,sha256=K7vaBHaJQ6gqtWblmgmE_TG3EgGRqgJGd079TL5KsMo,1462
4
+ pptxfill/picture.py,sha256=-FQMNlczPgw0fP3mvaQknuUdWG7s83ZnMpkAeEjavEA,1886
5
+ pptxfill/slides.py,sha256=bqLtTaQaAmwS0Y8aP2Mz7DgUqMiG77W6bFaRMwmCMoo,2643
6
+ pptxfill/table.py,sha256=KNrAN6t2lL7Zmvu6Q1SnIRpoItpzwOg6yG8UBSvjeGs,3248
7
+ pptxfill/text.py,sha256=5saIUGwm58y2HjeOTHNE0GWcPFkgPCu1Xdp1OvgfjwQ,733
8
+ pptxfill/walker.py,sha256=uXYo7SDXJ4rRaK2sJg0I47bp44AacpdmL9U4ownyOuc,1346
9
+ pptxfill-0.2.5.dist-info/METADATA,sha256=YUqmgQlaxZkC6Q3loD1S7xxS4qnfunVkP8jrhHbCSmc,883
10
+ pptxfill-0.2.5.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
11
+ pptxfill-0.2.5.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any