md2star.pptx_layout module

Template-intelligent PPTX layout catalog and per-slide AI selection.

Pandoc’s PPTX writer only ever reaches the ~7 named layouts its bundled template.pptx ships (Title Slide, Section Header, Title and Content, …); it has no notion of a designer template’s much richer layout vocabulary (a real marketing deck template routinely ships 20-40 named layouts: covers, section dividers, big-statement slides, photo heroes, multi-column splits, …). This module is the missing primitive: given any designer PPTX template, it builds a layout catalog (every distinct named layout, its placeholder geometry, and — when a vision model is available — a short caption of what kind of slide it is), then selects the best-fitting catalog layout for each slide of an authored Markdown deck.

Selection is two-staged, mirroring how a human designer would actually work:

  1. Text reasoning (LLM): read each slide’s Markdown content plus the catalog (names + captions) and choose the layout whose role best fits — a cover, a section divider, a photo hero, bulleted content, … This stage alone is cheap and works from captions only, never looking at a candidate render.

  2. Visual confirmation (VLM): the text stage never actually looks at the candidate layouts, so it can pick a layout whose caption sounds right but whose actual composition does not suit the slide (a “photo-hero” caption on a layout whose picture placeholder is tiny, say). This stage shows the vision model the slide’s short-listed candidate thumbnails (the text pick plus a couple of archetype-diverse alternates) side by side and asks which one actually carries the content best, overriding the text pick on disagreement. This is the dimension a pure-LLM, caption-only selector cannot provide.

Both stages, and the template-page renderer they rely on, are threaded through injectable seams (chat / render) so the whole pipeline is unit-testable offline with fakes, exactly like md2star.reverse_diagrams. Every call degrades gracefully: with no reachable engine the catalog still builds (deterministic layout/placeholder extraction never needs AI) and selection falls back to a safe, rule-based default layout per slide.

Author

[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)

md2star.pptx_layout.CATALOG_VERSION = 2

Bumped whenever what a cached catalog holds changes meaning, so an old cache file is never mistaken for this version’s.

md2star.pptx_layout.CLASS_HINTS: dict[str, tuple[str | None, tuple[str, ...]]] = {'agenda': (None, ('Agenda',)), 'big': ('big-statement', ('Statement',)), 'big-number': ('big-number', ('Big Number',)), 'bignumber': ('big-number', ('Big Number',)), 'closing': ('closing', ('Closing',)), 'cover': ('cover', ('Title Slide',)), 'photo': ('photo-hero', ('Photo',)), 'quote': ('quote', ('Quote',)), 'recap': (None, ('Recap',)), 'section': ('section-divider', ('Section Header', 'Section')), 'statement': ('big-statement', ('Statement',))}

an archetype, and the layout names that mean it in a template that names its layouts plainly (PowerPoint’s own vocabulary, and the course template’s). The name wins when the template has it; the archetype is the fallback.

Type:

What a {.class} on a slide heading asks for

class md2star.pptx_layout.LayoutInfo(name, example_pages=<factory>, placeholders=<factory>, archetype='bulleted-content', background='', caption='', is_content_layout=True)[source]

Bases: object

One distinct named layout the designer template exposes.

Parameters:
archetype: str = 'bulleted-content'
background: str = ''
caption: str = ''
example_pages: list[int]
has_kind(kind)[source]
Parameters:

kind (str)

Return type:

bool

is_content_layout: bool = True
name: str
placeholders: list[Placeholder]
property rep_page: int | None

The first example slide page this layout appears on, if any.

md2star.pptx_layout.PAGINATE_MARGIN = 1.1

Safety factor on the floor when deciding whether a page holds its text.

md2star.pptx_layout.PHOTO_SHARE = 0.8

Share of the canvas a picture box must cover for its layout to be a photo page.

class md2star.pptx_layout.Placeholder(idx, kind, emu=None)[source]

Bases: object

One placeholder shape on a layout: its role and its box in EMU.

Parameters:
emu: tuple[int, int, int, int] | None = None
idx: int
kind: str
md2star.pptx_layout.RenderLayoutFn

template path, layout name -> PNG bytes, or None. What the tie-break shows for a layout the template never demonstrates on an example page.

Type:

Renders one layout of a template on its own

alias of Callable[[Path, str], bytes | None]

class md2star.pptx_layout.Slide(index, title, body='', notes='', features=<factory>, chosen_layout=None, chosen_confidence=None, chosen_why='', title_only=False, overrides=<factory>, metrics=None, fit_ranked=<factory>, tried_layouts=<factory>, level=0, layout_hint=None, class_hint=None, subtitle='', pinned=False, lang='')[source]

Bases: object

One Markdown slide plus the cheap structural cues a selector keys on.

Parameters:
body: str = ''
chosen_confidence: float | None = None
chosen_layout: str | None = None
chosen_why: str = ''
class_hint: str | None = None

A role asked for by class ({.statement}, {.recap}, …), or implied by the title (“Key takeaways”); see CLASS_HINTS.

features: list[str]
fit_ranked: list

Layouts ranked by geometric fit, best first, as [(LayoutInfo, FitReport), ...]. Also filled by select_layouts.

index: int
lang: str = ''

The deck’s lang (front matter), for the “(suite)” / “(cont.)” label of a continuation slide.

layout_hint: str | None = None

the exact template layout the author asked for. Bypasses every model when the template has it.

Type:

{layout="Big Number"} on the heading

level: int = 0

1 for #, 2 for ##, 0 for a slide with no heading. An # past the cover opens a new part of the deck, which is a section divider, not another content slide.

Type:

Heading level that opened the slide

metrics: object | None = None

Measured content (md2star.pptx_fit.measure()), filled by select_layouts(). Kept on the slide so the visual stage and the assembler can reason about quantities instead of re-deriving them.

notes: str = ''

Speaker notes authored for this slide — the content of a Pandoc ::: notes fenced div, held off body on purpose. Notes are the presenter’s private script: anything left in body is drawn on the slide, where the audience reads it. The assembler puts this in the notes pane instead (md2star.pptx_assemble.assemble()).

overrides: dict
pinned: bool = False

Set when the layout was decided by an explicit hint rather than chosen: no model, tie-break or reselection moves a pinned slide.

subtitle: str = ''

its prose, or for the cover the front matter’s subtitle / author / date. Placed on the layout’s subtitle zone so a title-only slide keeps them.

Type:

The one or two lines under a cover’s / divider’s title

title: str
title_only: bool = False

A cover (slide 1) or a closing (slide n) carries its title and nothing else, whatever markup the source happens to have under it. Set by segment() so every stage agrees on it: the assembler enforces it, the fit model measures it as the big statement it is, and the visual tie-break is told what the slide actually contains. A hand-built Slide is taken at its word.

tried_layouts: list[str]

Layouts the eyeball loop has already moved this slide off, so a later pass does not send it back somewhere the vision model already rejected.

md2star.pptx_layout.VISUAL_VARIETY_SHARE = 0.5

A visual tie-break may not, on its own, make a deck monotonous. It is an arbitration between near-equals, and a designer who shipped twenty layouts did not draw them so that one could carry every slide — so once a layout already carries this share of the deck, a further override onto it is declined and the measured text pick (which weighed this slide’s own quantities against the layout’s capacity) stands. Overrides that move a slide away from a crowded layout are never affected.

md2star.pptx_layout.build_catalog(template, *, chat=None, render=None, use_vision=True, cache_path=None, refresh=False)[source]

Build the full layout catalog for template: geometry + (optional) captions.

Parameters:
  • template (Path) – The designer PPTX template to catalog.

  • chat (ChatFn, optional) – Vision transport for captioning; defaults to the real engine-backed one. Ignored when use_vision is False.

  • render (RenderFn, optional) – Template-page renderer; defaults to the real LibreOffice+poppler one.

  • use_vision (bool, default True) – When False (or when rendering/chat is unavailable), every layout gets a rule-based caption instead of a VLM one — the catalog still builds, just without visual captions.

  • cache_path (Path, optional) – Where to persist/read the catalog JSON. Defaults to a content-hashed path under md2star’s cache dir, so re-running on the same template is free.

  • refresh (bool, default False) – Force a rebuild even if a cache file exists.

Returns:

One entry per distinct designer layout name.

Return type:

list[LayoutInfo]

md2star.pptx_layout.code_layout(features, content)[source]

The layout with one big body box, for a slide that is a code listing.

None unless the slide is code and nothing else that wants a box of its own (columns, a table, a picture, a diagram).

Parameters:

features (list[str])

Return type:

str | None

md2star.pptx_layout.column_regions(info, n, canvas=None)[source]

Where n side-by-side columns go on info, in EMU, left to right.

The layout’s own body boxes when it has n of them (the n largest, so a “Comparison” layout’s small header boxes lose to the content boxes under them). Otherwise the stage under a full-width title, cut in n equal parts: that is how a “Title Only” layout holds two pictures. None when the layout has neither (its title sits beside the content, as on “Picture with Caption”), which is the layout’s way of saying it cannot hold columns.

The assembler places columns with this same geometry, so the layout chosen for a column slide is chosen for the boxes its columns will really get.

Parameters:
Return type:

list[tuple[int, int, int, int]] | None

md2star.pptx_layout.continuation_label(lang='', sample='')[source]

"(suite)" for a French deck, "(cont.)" otherwise.

The deck’s lang decides when it has one; failing that, a count of the commonest French and English function words in sample does.

Parameters:
Return type:

str

md2star.pptx_layout.default_chat(*, kind, model=None)[source]

Build the real engine-backed transport for kind ("llm" or "vlm").

Routes through best_engine_ai_helper.llm.chat using md2star’s resolved brief -> engine descriptor, exactly like md2star.reverse_diagrams. Returns None on any failure so callers degrade gracefully.

MD2STAR_LLM_MODEL / MD2STAR_VLM_MODEL in the environment name the model for that kind and override llm.engine.yaml: the resolved default (gemma3:12b on the reference machine) approves nearly every slide it is shown as a judge, and swapping it should not mean regenerating the engine file. An explicit model argument still wins over both.

Parameters:
  • kind (str)

  • model (str | None)

Return type:

Callable[[str, list[bytes] | None, dict | None], dict | str | None]

md2star.pptx_layout.default_render()[source]

Build the real page renderer, caching all pages per template (by hash).

Return type:

Callable[[Path, int], bytes | None]

md2star.pptx_layout.default_render_layout()[source]

Build the real one-layout renderer (python-pptx + LibreOffice + poppler).

Return type:

Callable[[Path, str], bytes | None]

md2star.pptx_layout.extract_layouts(template)[source]

Deterministically extract every distinct layout name and its geometry.

No AI, no rendering: pure zipfile + ElementTree over the template’s OOXML parts. Always available, and the first stage of build_catalog().

Each layout is described from the example page that demonstrates it when the template has one (_page_regions()), falling back to the layout part’s own placeholders otherwise. That is not a refinement but a correctness fix: a designer template exported from Google Slides types every placeholder body and sometimes declares nothing but an eyebrow label, which makes every layout measure the same and leaves layout selection with no signal at all.

Enumerates every ppt/slideLayouts/slideLayoutN.xml part directly (not only the layouts some slide happens to use) so a template distributed with no example slides at all — just its layouts, e.g. python-pptx’s own bundled default template — still yields a full catalog; example_pages is simply empty for a layout no slide currently demonstrates (no thumbnail/caption for it, but its geometry is still extracted and it can still be selected).

Parameters:

template (Path)

Return type:

list[LayoutInfo]

md2star.pptx_layout.is_photo_layout(info, canvas=None)[source]

Whether info is a full-bleed photo page (“Photo”): reserved for {.photo}.

Parameters:

canvas (tuple[int, int] | None)

Return type:

bool

md2star.pptx_layout.largest_columns_layout(body, content, *, base_dir=None, canvas=None)[source]

Name of the layout whose columns show this slide’s pictures largest, or None.

Applies only to a slide in columns where at least one column carries an image (two pictures side by side, or a picture beside its words). Each candidate’s columns (column_regions()) are scored by the area the smallest of the pictures takes once fitted to its own aspect inside its column, so neither of two pictures is sacrificed to the other. None leaves the slide to the ordinary ranking: no image in a column, an unreadable file, no layout able to hold the columns.

Parameters:
Return type:

str | None

md2star.pptx_layout.largest_picture_layout(metrics, content, *, base_dir=None)[source]

Name of the layout whose picture box shows a lone image largest, or None.

Applies only to a slide that is one image and few words, with a readable image file. Each candidate’s first picture region is scored by the area the image takes once fitted to its own aspect inside that box; the largest wins. None (no image, too much text, unreadable file, no picture box) leaves the slide to the ordinary ranking.

Parameters:

base_dir (Path | None)

Return type:

str | None

md2star.pptx_layout.layout_pages(template)[source]

Public wrapper over _layout_example_pages() (used by callers/tests).

Parameters:

template (Path)

Return type:

dict[str, list[int]]

md2star.pptx_layout.paginate(slides, catalog, *, min_pt, canvas=None)[source]

Split every slide no layout can hold at min_pt into continuations.

The alternative was what the assembler used to do: keep four bullets of ten words and drop the rest without a word. Here a slide that overruns even the roomiest content layout of the template becomes as many slides as it takes, each titled “<title> (suite)” (or “(cont.)”), split between list items or paragraphs. Content that cannot be split that way — a table, a code block, columns, an image — is kept whole and named in a warning.

Returns:

The deck, renumbered. Unchanged slides are the same objects.

Return type:

list of Slide

Parameters:
md2star.pptx_layout.parse_heading(line)[source]

Split a heading’s text from its {...} attributes.

Returns:

The title, its classes (without the dot), and a layout="..." attribute when one is given.

Return type:

tuple of (str, list of str, str or None)

Parameters:

line (str)

Examples

>>> parse_heading('Growth {.bignumber layout="Big Number"}')
('Growth', ['bignumber'], 'Big Number')
md2star.pptx_layout.parse_json_reply(raw)[source]

Recover the JSON object from whatever ChatFn actually returned.

A schema-constrained call usually comes back as a dict, and “usually” is the whole problem: llm.chat documents that it hands back the raw string when the model does not honour the schema, and on the vision side that is the common case rather than the exotic one — the strongest local VLMs are precisely the ones Ollama cannot hold to a schema. Those replies arrive as the right JSON wrapped in prose or a ``` fence.

Parsing that with a bare json.loads() raised json.JSONDecodeError straight out of whatever stage made the call, which turned “this one model reply was chatty” into “the catalog build / the tie-break / the whole eyeball loop died”. Every one of those stages is written to degrade on a bad reply, so the exception was the only thing standing between them and the fallback they already had.

So: a dict passes through, a string is searched for its first {...} span and parsed, and anything unusable is None — the value every caller here already treats as “no answer, use the fallback”. Same tolerance md2star.reverse_diagrams._parse_verdict() has always applied to the diagram loop’s verdicts; this is that stance, shared.

Parameters:

raw (object) – Whatever the transport returned: a parsed dict, a string that may or may not be pure JSON, or None from a failed call.

Returns:

The recovered object, or None when there is nothing usable in it.

Return type:

dict or None

md2star.pptx_layout.render_deck_pages(path, *, dpi=110)[source]

Render every page of a PPTX/PDF path to PNG bytes, keyed by 1-based page.

Public wrapper over _render_all_pages(), used by md2star.pptx_assemble’s eyeball loop to render its own assembled output (as opposed to default_render(), which caches per template page and is meant for the catalog/tie-break stages).

Parameters:
Return type:

dict[int, bytes]

md2star.pptx_layout.resolve_hint(s, catalog)[source]

The layout an explicit hint on s names in this template, or None.

{layout="Big Number"} must name a layout exactly (a case-insensitive match is accepted, and said so); a class (CLASS_HINTS) resolves to a layout of the conventional name first and to its archetype second. A hint the template cannot honour is reported and leaves the slide to the ordinary selection: a typo in a hint must not cost the slide its layout.

Parameters:
Return type:

str | None

md2star.pptx_layout.segment(markdown)[source]

Split markdown into slides.

Every horizontal rule (*** / --- / ___ on its own line) ends a slide and every # / ## heading starts one, as in Pandoc (see _chunks()). A leading YAML metadata block is removed first (its closing --- is a horizontal rule to every regex here), and each slide’s ::: notes div is lifted off its body into Slide.notes — a chunk holding only notes gives them to the slide before it.

Nothing is dropped on the way:

  • the cover (and the closing) are title-only only when they carry nothing to place; their prose becomes Slide.subtitle, and the cover’s subtitle falls back to the front matter’s subtitle, author and date;

  • a front-matter title that no slide carries becomes a cover of its own, as Pandoc’s title slide does;

  • an # heading past the cover with nothing to place is a section divider (Slide.class_hint "section").

Parameters:

markdown (str)

Return type:

list[Slide]

md2star.pptx_layout.select_layouts(slides, catalog, *, chat=None, batch=12, min_pt=30.0, strict=False, template=None, image_base_dir=None)[source]

Fill slide.chosen_layout for every slide via batched LLM text calls.

Only content layouts are offered (brand-reference pages are dropped), each tagged with its archetype so the model matches by role. On any transport failure, slides in that batch fall back to the best archetype-matching content layout (never left unassigned).

A slide with an explicit hint (resolve_hint()) is placed on that layout without asking anyone, and pinned there.

With strict, a layout that cannot hold the content at min_pt does not fit at all (md2star.pptx_fit.evaluate()), so an overflowing slide goes to a roomier layout instead of being shrunk on a cramped one. When nothing holds it, the lenient ranking is kept so the slide still lands on the best of what there is.

Parameters:
Return type:

None

md2star.pptx_layout.slide_size(template)[source]

The deck’s canvas in EMU, from ppt/presentation.xml’s <p:sldSz>.

Needed by the compositional checks in md2star.pptx_fit: “is this layout balanced?” and “does the focal element sit on a third?” are questions about where a box falls on the slide, so they cannot be answered from the box alone.

Returns None when the part is missing or unparseable, which every caller treats as “skip the compositional checks” rather than as an error.

Parameters:

template (Path)

Return type:

tuple[int, int] | None

md2star.pptx_layout.smart_layout_available()[source]

Return True when the full AI-assisted pipeline (LLM + VLM) can run.

Mirrors md2star.reverse_diagrams.diagrams_available(): a light “does the engine resolve and expose both model kinds?” probe, cheap enough to call before offering --smart-layout in the CLI. The deterministic catalog extraction and a rule-based selection fallback work regardless.

Return type:

bool

md2star.pptx_layout.split_columns(body)[source]

The bodies of a slide’s ::: {.column} divs, in order ([] if none).

Parameters:

body (str)

Return type:

list[str]

md2star.pptx_layout.split_front_matter(markdown)[source]

Separate a leading YAML metadata block from the deck body.

Parameters:

markdown (str) – The whole authored document.

Returns:

The document with any leading metadata block removed, and the flat scalar key: value pairs it declared (lower-cased keys; empty when there was no block).

Return type:

tuple of (str, dict)

Examples

>>> body, meta = split_front_matter("---\ntitle: Q3\n---\n\n# Q3\n")
>>> meta["title"], body.strip()
('Q3', '# Q3')

A deck that merely opens with a slide break keeps every slide:

>>> split_front_matter("---\n\n# First\n\n---\n\n# Second\n")[0].lstrip()
'# First\n\n---\n\n# Second\n'
md2star.pptx_layout.split_notes(chunk)[source]

Separate a slide’s visible content from its speaker notes.

Parameters:

chunk (str) – One slide’s Markdown, notes div included.

Returns:

The content to draw on the slide, and the notes to put in the notes pane (several ::: notes divs on one slide are joined).

Return type:

tuple of (str, str)

Examples

>>> body, notes = split_notes("## T\n\n::: notes\nsay this\n:::\n")
>>> notes
'say this'
>>> "say this" in body
False
md2star.pptx_layout.split_slide(s, groups, heading, label)[source]

Rebuild s as one slide per group of blocks, continuations labelled.

Parameters:
Return type:

list[Slide]

md2star.pptx_layout.stage_region(info, canvas=None)[source]

The area under a full-width title, down to the lowest content box, in EMU.

None when the layout has no title, a title beside the content rather than over it, or a title that fills the page and leaves no stage.

Parameters:
Return type:

tuple[int, int, int, int] | None

md2star.pptx_layout.visual_confirm(slides, catalog, *, chat=None, render=None, render_layout=None, template=None, k=3, only_low_confidence=False, confidence_threshold=0.7)[source]

Visually confirm (or override) each slide’s text-stage layout pick.

This is the VLM dimension the pure-text select_layouts() cannot provide: it renders each candidate’s example page, shows the vision model the actual thumbnails side by side with the slide’s content, and asks which one genuinely carries that content best. Disagreements override slide.chosen_layout; any transport/render failure leaves the text stage’s pick untouched (best-effort, never load-bearing).

Parameters:
Return type:

None

See also

VISUAL_VARIETY_SHARE

why some overrides are declined. Observed on a real

template

asked slide by slide, a vision model answered with the same layout six times out of six, and a deck of six identical compositions is not what any of those slides’ content asked for.