python-pptx2 3.1.0__py3-none-any.whl → 3.2.0__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.
pptx2/__init__.py CHANGED
@@ -8,15 +8,16 @@ from typing import TYPE_CHECKING
8
8
  import pptx2.exc as exceptions
9
9
  from pptx2.api import Presentation
10
10
  from pptx2.audit import AuditReport, audit
11
- from pptx2.geometry import BBox
12
11
  from pptx2.design.blocks import (
12
+ CODE_THEMES,
13
13
  Card,
14
14
  FittedPicture,
15
15
  add_bullets,
16
16
  add_card,
17
+ add_code,
17
18
  add_picture_fit,
19
+ code_shape_name,
18
20
  )
19
- from pptx2.design.palettes import PALETTES, Palette, palette
20
21
  from pptx2.design.components import (
21
22
  ArticleCard,
22
23
  Gauge,
@@ -38,6 +39,8 @@ from pptx2.design.figures import (
38
39
  add_plotly_figure,
39
40
  add_svg_figure,
40
41
  )
42
+ from pptx2.design.palettes import PALETTES, Palette, palette
43
+ from pptx2.geometry import BBox
41
44
  from pptx2.math import MathBackendUnavailable
42
45
  from pptx2.opc.constants import CONTENT_TYPE as CT
43
46
  from pptx2.opc.package import PartFactory
@@ -65,7 +68,7 @@ from pptx2.parts.slide import (
65
68
  if TYPE_CHECKING:
66
69
  from pptx2.opc.package import Part
67
70
 
68
- __version__ = "3.1.0"
71
+ __version__ = "3.2.0"
69
72
 
70
73
  sys.modules["pptx2.exceptions"] = exceptions
71
74
  del sys
@@ -103,6 +106,9 @@ __all__ = [
103
106
  "add_card",
104
107
  "add_bullets",
105
108
  "add_picture_fit",
109
+ "add_code",
110
+ "code_shape_name",
111
+ "CODE_THEMES",
106
112
  "Card",
107
113
  "FittedPicture",
108
114
  # Curated colour sets.
pptx2/design/blocks.py CHANGED
@@ -14,6 +14,11 @@ driven by plain hex colours and points so a script needs no token setup:
14
14
  * :func:`add_picture_fit` — a picture placed *inside* a box, either
15
15
  letter-boxed (``mode="contain"``) or cropped to fill (``mode="cover"``),
16
16
  centred, with an optional caption underneath.
17
+ * :func:`add_code` — a source listing: monospace, one paragraph per line,
18
+ no wrapping, on a rounded editor-coloured surface. The shape is *named*
19
+ ``fika:code:<language>:<theme>:<0|1>`` so an editor that has a native
20
+ code block (Fika) restores a syntax-highlighted element from it instead
21
+ of a plain text box; PowerPoint just sees a tidy monospace box.
17
22
 
18
23
  Every block tags the shapes it stacks with ``lint_group`` so the linter
19
24
  treats a card and the text on it as one deliberate cluster, and every
@@ -36,11 +41,14 @@ if TYPE_CHECKING:
36
41
  from pptx2.slide import Slide
37
42
 
38
43
  __all__ = (
44
+ "CODE_THEMES",
39
45
  "Card",
40
46
  "FittedPicture",
41
47
  "add_card",
42
48
  "add_bullets",
49
+ "add_code",
43
50
  "add_picture_fit",
51
+ "code_shape_name",
44
52
  )
45
53
 
46
54
 
@@ -99,7 +107,7 @@ def add_bullets(
99
107
  align: str = "left",
100
108
  anchor: str = "top",
101
109
  margin_pt: float = 0.0,
102
- min_size_pt: float = 12.0,
110
+ min_size_pt: float = 18.0,
103
111
  ) -> "Shape":
104
112
  """Add a bulleted (or numbered) list that fits its box.
105
113
 
@@ -199,7 +207,7 @@ def add_card(
199
207
  align: str = "left",
200
208
  anchor: str = "top",
201
209
  title_gap_pt: float = 6.0,
202
- body_min_size_pt: float = 12.0,
210
+ body_min_size_pt: float = 16.0,
203
211
  numbered: bool = False,
204
212
  ) -> Card:
205
213
  """Add a card: one surface, padded title and body, nothing else.
@@ -260,7 +268,7 @@ def add_card(
260
268
  run.font.size = Pt(title_size_pt)
261
269
  run.font.bold = True
262
270
  run.font.color.rgb = coerce_color(title_color)
263
- _fit(tf, font=font, max_pt=title_size_pt, min_pt=max(12.0, title_size_pt * 0.7), bold=True)
271
+ _fit(tf, font=font, max_pt=title_size_pt, min_pt=max(18.0, title_size_pt * 0.7), bold=True)
264
272
  cursor_top += title_h + int(Pt(title_gap_pt))
265
273
  remaining = int(inner.bottom) - cursor_top
266
274
 
@@ -431,3 +439,154 @@ def add_picture_fit(
431
439
 
432
440
  _tag([picture, caption_box], f"picture@{int(bb.left)},{int(bb.top)}")
433
441
  return FittedPicture(picture=picture, caption_box=caption_box, frame=frame)
442
+
443
+
444
+ # ----------------------------------------------------------------------------- code
445
+
446
+ CODE_SHAPE_NAME_PREFIX = "fika:code"
447
+
448
+ #: Editor themes a listing can be painted in: ``theme -> (background, foreground)``.
449
+ #: The ids are Shiki's, so the tag round-trips into the editor's highlighter;
450
+ #: PowerPoint only sees the two flat colours.
451
+ CODE_THEMES: dict[str, tuple[str, str]] = {
452
+ "github-dark": ("#24292E", "#E1E4E8"),
453
+ "github-light": ("#FFFFFF", "#24292E"),
454
+ "one-dark-pro": ("#282C34", "#ABB2BF"),
455
+ "one-light": ("#FAFAFA", "#383A42"),
456
+ "dracula": ("#282A36", "#F8F8F2"),
457
+ "monokai": ("#272822", "#F8F8F2"),
458
+ "nord": ("#2E3440", "#D8DEE9"),
459
+ "tokyo-night": ("#1A1B26", "#A9B1D6"),
460
+ "night-owl": ("#011627", "#D6DEEB"),
461
+ "catppuccin-mocha": ("#1E1E2E", "#CDD6F4"),
462
+ "catppuccin-latte": ("#EFF1F5", "#4C4F69"),
463
+ "vitesse-dark": ("#121212", "#DBD7CA"),
464
+ "vitesse-light": ("#FFFFFF", "#393A34"),
465
+ "min-dark": ("#1F1F1F", "#B392F0"),
466
+ "min-light": ("#FFFFFF", "#24292E"),
467
+ }
468
+
469
+ _CODE_LANGUAGE_ALIASES = {
470
+ "js": "javascript",
471
+ "ts": "typescript",
472
+ "py": "python",
473
+ "sh": "bash",
474
+ "shell": "bash",
475
+ "zsh": "bash",
476
+ "yml": "yaml",
477
+ "md": "markdown",
478
+ "c++": "cpp",
479
+ "c#": "csharp",
480
+ "cs": "csharp",
481
+ "text": "plaintext",
482
+ "txt": "plaintext",
483
+ "plain": "plaintext",
484
+ }
485
+
486
+
487
+ def _code_language(language: str) -> str:
488
+ key = (language or "plaintext").strip().lower()
489
+ return _CODE_LANGUAGE_ALIASES.get(key, key) or "plaintext"
490
+
491
+
492
+ def code_shape_name(language: str, theme: str = "github-dark", line_numbers: bool = False) -> str:
493
+ """The shape name that marks a text box as a code block for the editor.
494
+
495
+ ``fika:code:<language>:<theme>:<0|1>`` — the same tag Fika writes when it
496
+ exports one of its native code elements, so a deck authored here and a
497
+ deck exported from the editor import identically.
498
+ """
499
+ lang = _code_language(language)
500
+ if ":" in lang or ":" in theme:
501
+ raise ValueError("language and theme must not contain ':'")
502
+ return f"{CODE_SHAPE_NAME_PREFIX}:{lang}:{theme}:{1 if line_numbers else 0}"
503
+
504
+
505
+ def add_code(
506
+ slide: "Slide",
507
+ *bbox_or_positional,
508
+ code: str,
509
+ language: str = "plaintext",
510
+ theme: str = "github-dark",
511
+ size_pt: float = 16.0,
512
+ line_numbers: bool = False,
513
+ font: str = "Consolas",
514
+ pad_pt: float = 12.0,
515
+ radius_pt: float = 10.0,
516
+ line_spacing: float = 1.2,
517
+ tab_width: int = 4,
518
+ min_size_pt: float = 12.0,
519
+ ) -> "Shape":
520
+ """Add a source-code listing that the editor recognises as a code block.
521
+
522
+ ``code`` is written verbatim — one paragraph per line, leading spaces and
523
+ blank lines kept, tabs expanded to ``tab_width`` spaces, never wrapped —
524
+ in a monospace ``font`` on a rounded surface coloured after ``theme``
525
+ (a key of :data:`CODE_THEMES`; unknown names fall back to a dark
526
+ surface). ``language`` is a Shiki id or common alias (``py``, ``cs``,
527
+ ``c#``, ``ts`` …). When the listing is taller than the box the type
528
+ shrinks, never below ``min_size_pt`` — beyond that, split the listing
529
+ across slides instead of squeezing it.
530
+
531
+ The shape is named with :func:`code_shape_name`; an editor with native
532
+ code blocks (Fika) turns it into a highlighted, editable code element
533
+ on import. PowerPoint shows a plain monospace box in the theme colours.
534
+
535
+ Returns the :class:`Shape`.
536
+ """
537
+ from pptx2._color import coerce_color
538
+ from pptx2.enum.text import MSO_ANCHOR, MSO_AUTO_SIZE, PP_ALIGN
539
+
540
+ bb = _as_bbox(bbox_or_positional)
541
+ lines = code.replace("\r\n", "\n").replace("\r", "\n").expandtabs(tab_width).split("\n")
542
+ while lines and not lines[-1].strip():
543
+ lines.pop()
544
+ while lines and not lines[0].strip():
545
+ lines.pop(0)
546
+ if not lines:
547
+ raise ValueError("code must not be empty")
548
+
549
+ bg_hex, fg_hex = CODE_THEMES.get(theme, CODE_THEMES["github-dark"])
550
+ bg, fg = coerce_color(bg_hex), coerce_color(fg_hex)
551
+
552
+ box = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, *bb)
553
+ box.name = code_shape_name(language, theme, line_numbers)
554
+ box.fill.solid()
555
+ box.fill.fore_color.rgb = bg
556
+ box.line.fill.background()
557
+ box.shadow.clear()
558
+ short_edge = min(int(bb.width), int(bb.height))
559
+ box.corner_radius = Emu(min(int(Pt(radius_pt)), short_edge // 2))
560
+
561
+ tf = box.text_frame
562
+ tf.word_wrap = False
563
+ tf.vertical_anchor = MSO_ANCHOR.TOP
564
+ pad = Pt(pad_pt)
565
+ tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = pad
566
+
567
+ width = len(str(len(lines)))
568
+ for i, line in enumerate(lines):
569
+ para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
570
+ para.alignment = PP_ALIGN.LEFT
571
+ para.line_spacing = float(line_spacing)
572
+ text = f"{i + 1:>{width}} {line}" if line_numbers else line
573
+ run = para.add_run()
574
+ run.text = text if text else " "
575
+ run.font.name = font
576
+ run.font.size = Pt(size_pt)
577
+ run.font.color.rgb = fg
578
+
579
+ # Fit by height only: a listing must never wrap, so shrink the type until
580
+ # every line has room, then let PowerPoint clip the rest rather than reflow.
581
+ avail_h = int(bb.height) - 2 * int(pad)
582
+ per_line = size_pt * 1.2 * line_spacing
583
+ needed_h = int(Pt(per_line * len(lines)))
584
+ if needed_h > avail_h > 0:
585
+ fitted = max(min_size_pt, size_pt * avail_h / needed_h)
586
+ for para in tf.paragraphs:
587
+ for run in para.runs:
588
+ run.font.size = Pt(fitted)
589
+ tf.auto_size = MSO_AUTO_SIZE.NONE
590
+
591
+ _tag([box], f"code@{int(bb.left)},{int(bb.top)}")
592
+ return box
pptx2/lint.py CHANGED
@@ -293,7 +293,7 @@ class MinFontSize(LintIssue):
293
293
  """A text run uses a font size below the configured legibility threshold."""
294
294
 
295
295
  pt: float = 0.0
296
- threshold_pt: float = 9.0
296
+ threshold_pt: float = 12.0
297
297
 
298
298
  def __init__(self, shape: BaseShape, pt: float, threshold_pt: float):
299
299
  super().__init__(
@@ -1819,10 +1819,11 @@ def _check_collisions(
1819
1819
 
1820
1820
 
1821
1821
  # ---------------------------------------------------------------------------
1822
- # Min font size — flag any run below the legibility threshold (default 9pt).
1822
+ # Min font size — flag any run below the legibility threshold (default 12pt:
1823
+ # projected slides are read from the back of a room, not from a desk).
1823
1824
  # ---------------------------------------------------------------------------
1824
1825
 
1825
- _DEFAULT_MIN_FONT_PT = 9.0
1826
+ _DEFAULT_MIN_FONT_PT = 12.0
1826
1827
  _PT_TO_EMU = 12700
1827
1828
 
1828
1829
 
pptx2/render.py CHANGED
@@ -324,6 +324,17 @@ def _render_via_pdf(
324
324
  pass
325
325
 
326
326
 
327
+ def _pdf_splitter_available() -> bool:
328
+ """True when the PDF→PNG step can run (Poppler's pdftoppm or pypdfium2)."""
329
+ if shutil.which("pdftoppm") is not None:
330
+ return True
331
+ try:
332
+ import pypdfium2 # type: ignore[import-not-found] # noqa: F401
333
+ except ImportError:
334
+ return False
335
+ return True
336
+
337
+
327
338
  def _pdf_to_pngs(pdf_path: Path, out_dir: Path, *, dpi: int) -> List[Path]:
328
339
  """Split a PDF into one PNG per page in *out_dir* and return the paths.
329
340
 
@@ -615,13 +626,18 @@ def render_contact_sheet(
615
626
 
616
627
  work_dir = Path(tempfile.mkdtemp(prefix="pptx-sheet-"))
617
628
  try:
629
+ # Stock LibreOffice 7+ writes only the first slide through the PNG
630
+ # filter, so for a multi-slide deck "auto" pays for a wasted soffice
631
+ # start before falling back to PDF. Go straight to PDF whenever a
632
+ # page splitter is available; "auto" remains the portable fallback.
633
+ strategy = "pdf" if _pdf_splitter_available() else "auto"
618
634
  paths = render_slide_thumbnails(
619
635
  prs,
620
636
  out_dir=work_dir,
621
637
  slide_indexes=slides,
622
638
  soffice_bin=soffice_bin,
623
639
  timeout=timeout,
624
- strategy="auto",
640
+ strategy=strategy,
625
641
  dpi=dpi,
626
642
  )
627
643
  if not paths:
pptx2/skill/SKILL.md CHANGED
@@ -38,14 +38,22 @@ helpers that implement it:
38
38
  - one **palette** (`PALETTES["slate"]` → `P.paper / P.ink / P.accent /
39
39
  P.surface / P.muted`; `P.dark()` for title and section slides),
40
40
  - one **grid** (`TITLE` band + `BODY` region; `BODY.columns(n, gap=…)`),
41
- - a **type scale** (title 32, body 2024, card body 16–18, caption 12),
41
+ - a **type scale** read from the back row (title 32–36, body 2226, card
42
+ body 18–20, caption 14 — nothing under 14 pt),
42
43
  - **surfaces done well** — `add_card` is one tinted rounded rectangle with
43
44
  padded text inside, nothing attached to it,
44
45
  - real **bullets** with hanging indents (`add_bullets`), pictures that
45
- keep their aspect (`add_picture_fit`), palette-driven diagrams and
46
+ keep their aspect (`add_picture_fit`), **code listings** the editor
47
+ recognises as code blocks (`add_code`), palette-driven diagrams and
46
48
  tables,
47
- - a catalog of **slide archetypes** (title, section, statement, bullets +
48
- picture, three cards, process, comparison, quote, question, summary),
49
+ - a catalog of **slide archetypes** (title, section, statement, big
50
+ number, bullets + picture, three cards, process, comparison, quote,
51
+ full-bleed photo, question, summary) — planned as a sequence before
52
+ slide 2, consecutive slides never sharing one, dense alternating with
53
+ light,
54
+ - a **visual per slide** chosen for its content: a photo for a real
55
+ thing, a chart for numbers, a diagram for a process, a table for a
56
+ comparison, typeset math for formulas,
49
57
  - and a **visual check**: `prs.render_contact_sheet("preview.png")` — one
50
58
  PNG of every slide, looked at once before shipping.
51
59
 
@@ -61,7 +69,7 @@ working set:
61
69
 
62
70
  ```python
63
71
  from pptx2 import Presentation, BBox, audit, PALETTES
64
- from pptx2 import add_card, add_bullets, add_picture_fit
72
+ from pptx2 import add_card, add_bullets, add_picture_fit, add_code
65
73
  from pptx2.diagrams import horizontal_pipeline, hub_and_spoke, cycle
66
74
  from pptx2.enum.shapes import MSO_SHAPE
67
75
  from pptx2.util import Inches, Pt
@@ -104,6 +112,12 @@ add_bullets(slide, left, items=["First point", "Second point"],
104
112
  add_picture_fit(slide, "photo.jpg", right, mode="contain", # or "cover" to fill + crop
105
113
  caption="Source: Wikimedia", caption_color=P.muted)
106
114
 
115
+ # --- source code: monospace listing, never a plain text box ---
116
+ add_code(slide, right, code='for i in range(3):\n print(i)', language="python",
117
+ size_pt=18) # theme="github-dark" | "github-light" | …; line_numbers=True
118
+ # the shape is named fika:code:<lang>:<theme>:<0|1> — editors with a native
119
+ # code block (Fika) turn it into a highlighted, editable element on import
120
+
107
121
  # --- native equation from LaTeX (pip install "python-pptx2[math]") ---
108
122
  slide.shapes.add_equation(bb, latex=r"\frac{a}{b}", size_pt=28)
109
123
  para = slide.shapes.add_text(bb, text="Euler: ").text_frame.paragraphs[0]