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:
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.
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.
- 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:
objectOne distinct named layout the designer template exposes.
- Parameters:
- placeholders: list[Placeholder]
- 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:
objectOne placeholder shape on a layout: its role and its box in EMU.
- 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
- 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:
objectOne Markdown slide plus the cheap structural cues a selector keys on.
- Parameters:
index (int)
title (str)
body (str)
notes (str)
chosen_layout (str | None)
chosen_confidence (float | None)
chosen_why (str)
title_only (bool)
overrides (dict)
metrics (object | None)
fit_ranked (list)
level (int)
layout_hint (str | None)
class_hint (str | None)
subtitle (str)
pinned (bool)
lang (str)
- class_hint: str | None = None
A role asked for by class (
{.statement},{.recap}, …), or implied by the title (“Key takeaways”); seeCLASS_HINTS.
- fit_ranked: list
Layouts ranked by geometric fit, best first, as
[(LayoutInfo, FitReport), ...]. Also filled by select_layouts.
- 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 byselect_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
::: notesfenced div, held offbodyon purpose. Notes are the presenter’s private script: anything left inbodyis drawn on the slide, where the audience reads it. The assembler puts this in the notes pane instead (md2star.pptx_assemble.assemble()).
- 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_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-builtSlideis taken at its word.
- 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:
- md2star.pptx_layout.code_layout(features, content)[source]
The layout with one big body box, for a slide that is a code listing.
Noneunless the slide is code and nothing else that wants a box of its own (columns, a table, a picture, a diagram).
- 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.
Nonewhen 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.
- md2star.pptx_layout.continuation_label(lang='', sample='')[source]
"(suite)"for a French deck,"(cont.)"otherwise.The deck’s
langdecides when it has one; failing that, a count of the commonest French and English function words in sample does.
- 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.chatusing md2star’s resolved brief -> engine descriptor, exactly likemd2star.reverse_diagrams. ReturnsNoneon any failure so callers degrade gracefully.MD2STAR_LLM_MODEL/MD2STAR_VLM_MODELin the environment name the model for that kind and overridellm.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.
- md2star.pptx_layout.default_render()[source]
Build the real page renderer, caching all pages per template (by hash).
- md2star.pptx_layout.default_render_layout()[source]
Build the real one-layout renderer (python-pptx + LibreOffice + poppler).
- md2star.pptx_layout.extract_layouts(template)[source]
Deterministically extract every distinct layout name and its geometry.
No AI, no rendering: pure
zipfile+ElementTreeover the template’s OOXML parts. Always available, and the first stage ofbuild_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 placeholderbodyand 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.xmlpart 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_pagesis 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:
- md2star.pptx_layout.is_photo_layout(info, canvas=None)[source]
Whether info is a full-bleed photo page (“Photo”): reserved for
{.photo}.
- 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.Noneleaves the slide to the ordinary ranking: no image in a column, an unreadable file, no layout able to hold the columns.
- 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.
- md2star.pptx_layout.layout_pages(template)[source]
Public wrapper over
_layout_example_pages()(used by callers/tests).
- 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.
- 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:
- 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
ChatFnactually returned.A schema-constrained call usually comes back as a
dict, and “usually” is the whole problem:llm.chatdocuments 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()raisedjson.JSONDecodeErrorstraight 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
dictpasses through, a string is searched for its first{...}span and parsed, and anything unusable isNone— the value every caller here already treats as “no answer, use the fallback”. Same tolerancemd2star.reverse_diagrams._parse_verdict()has always applied to the diagram loop’s verdicts; this is that stance, shared.
- 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 bymd2star.pptx_assemble’s eyeball loop to render its own assembled output (as opposed todefault_render(), which caches per template page and is meant for the catalog/tie-break stages).
- 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:
s (Slide)
catalog (list[LayoutInfo])
- 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::: notesdiv is lifted off its body intoSlide.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’ssubtitle,authoranddate;a front-matter
titlethat 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").
- 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_layoutfor 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.
- 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
Nonewhen the part is missing or unparseable, which every caller treats as “skip the compositional checks” rather than as an error.
- md2star.pptx_layout.smart_layout_available()[source]
Return
Truewhen 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-layoutin the CLI. The deterministic catalog extraction and a rule-based selection fallback work regardless.- Return type:
- md2star.pptx_layout.split_columns(body)[source]
The bodies of a slide’s
::: {.column}divs, in order ([]if none).
- 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: valuepairs it declared (lower-cased keys; empty when there was no block).- Return type:
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
::: notesdivs on one slide are joined).- Return type:
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.
- md2star.pptx_layout.stage_region(info, canvas=None)[source]
The area under a full-width title, down to the lowest content box, in EMU.
Nonewhen 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.
- 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 overrideslide.chosen_layout; any transport/render failure leaves the text stage’s pick untouched (best-effort, never load-bearing).- Parameters:
only_low_confidence (bool, default False) – When
True, only re-check slides whose text-stage confidence is missing or below confidence_threshold (cheaper, fewer VLM calls); whenFalse(default) every slide gets a visual check.catalog (list[LayoutInfo])
chat (Callable[[str, list[bytes] | None, dict | None], dict | str | None] | None)
template (Path | None)
k (int)
confidence_threshold (float)
- Return type:
None
See also
VISUAL_VARIETY_SHAREwhy some overrides are declined. Observed on a real
templateasked 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.