Repository navigation
Template packs
Français | English
MSB is the default pack. Additional packs can be
added under src/photoalbum/templates/ without changing the engine, GUI, or
packaging configuration. An album can mix templates from multiple packs.
Templates are executable Python modules, rather than HTML or Jinja files.
src/photoalbum/templates/my_pack/
__init__.py
manifest.json
photo/
__init__.py
layout.py
widget_renderer.py
settings.py
assets/ # optional; may contain subdirectories
screenshots/ # optional
i18n/en.json # optional pack catalog
docs/help.html # optional pack documentation
README.md # optional
The directory name must match the manifest's id. Python modules referenced
by the manifest must be importable; the ID itself is not used to construct imports. Template IDs must be unique across all packs; prefixing them with
the pack name avoids collisions.
{
"schema_version": 1,
"id": "my_pack",
"name": "My pack",
"version": "1.0",
"templates": [{
"id": "my-pack-photo",
"name": "One photo",
"localized_names": {"fr": "Une photo", "en": "One photo"},
"module": "photoalbum.templates.my_pack.photo",
"kinds": ["photo_page"],
"photo_capacity": 1
}]
}schema_version identifies the manifest format; version identifies the pack
release. Pack metadata also includes authors (names or objects with a name
field) and an optional description. These values are preserved by discovery,
independently of whether the UI displays them.
A pack can provide only some page types, with MSB providing the others. Available
types are photo_page, cover, special_page, body_special_page, day_divider,
month_divider, and year_divider. For covers, specify cover_positions: front, inside_front,
inside_back, and back. See the MSB manifest for a complete example.
The host owns named paper formats and orientation. It resolves these choices
into width and height in millimetres before checking a template or rendering it.
Templates do not declare format names or orientation whitelists. The targets field is rejected.
A template can optionally declare page_constraints with independently optional
min_width_mm, max_width_mm, min_height_mm, and max_height_mm fields.
For example, this declares a width limitation:
"page_constraints": {"min_width_mm": 180, "max_width_mm": 320}Bounds are inclusive, positive finite numbers; a minimum must not exceed its
corresponding maximum. Omitted or null bounds impose no restriction. Omitting
page_constraints entirely leaves the template geometrically unrestricted.
There is no aspect-ratio constraint. Most bundled templates omit bounds; add a restriction only after a real
limitation has been established. See the MSB pack documentation for its measured minima.
TemplateDefinition.is_compatible_with_page(width_mm, height_mm) delegates to
PageConstraints.accepts, the single geometric decision point. The registry
provides list_for_page and album_page_available using this same check.
Kinds and cover positions remain independent restrictions.
photo-album-cli templates lists one row per discovered template, including the
four bounds in millimetres and album usage. - denotes an absent bound or a
non-applicable cover position. --format json exposes the same collected data,
using null for absent bounds and non-applicable positions.
The opt-in body_special_page role allows an occurrence inside the chronological
body from Plan; special_page does not imply it. Built-ins opting in are MSB
dedication/blank and Simplex simplex-full-photo-cover. These occurrences use ordinary rendering and receive
their anchor's temporal context. See pagination internals.
Pack code imports host primitives from photoalbum.template_engine.api:
from photoalbum.template_engine.api import (
PageInstance, PageTemplateExtension, register_template_extension,
NormalizedRect, PageComposition, PhotoSlotComposition, TemplateLayout,
PageComposer, PreviewJob, TemplatePreviewBackend, PageTemplateSettingsWidget,
)This entry point exposes the generic instance/page models, layout and composition
primitives, preview backend contract, and settings editor base class. Authors do
not need internal modules such as album.composition, preview_backend, or
gui.template_settings.base. The exports refer to the actual host types; there
are no compatibility wrappers and no pack-specific implementations in this API.
Importing it does not activate packs or construct services/widgets.
Editors consume the host-provided self._render_service. The application normally
injects its shared service. When an editor is created standalone without a service,
the host's PageTemplateSettingsWidget provides one owned by the widget. A pack
must not construct PreviewRenderService itself. Workers implementing a pack's
preview calculation still belong in that pack.
The existing module field is an absolute, importable Python module name.
It is never inferred from the pack ID or its directory. External installed
packages can be referenced too. No Python code is imported during discovery.
Each module declared in the manifest exposes register(). Qt editor imports can
remain inside this function: discovering the catalog and layouts must not create
widgets.
# my_pack/photo/__init__.py
from photoalbum.template_engine.api import (
PageTemplateExtension, register_template_extension,
)
def validate(settings):
if not isinstance(settings.get("density"), int):
raise ValueError("density must be an integer")
def register():
from .widget_renderer import PhotoRenderer
from .settings import PhotoSettingsWidget
register_template_extension(PageTemplateExtension(
template_id="my-pack-photo",
widget_renderer=PhotoRenderer(),
settings_editor_type=PhotoSettingsWidget, # optional
settings_defaults=lambda: {"density": 17}, # optional; otherwise {}
validate_settings=validate, # optional callable(settings), raises ValueError
photo_scope="page", # or "album" for the canonical template photo pool
))
def register_layouts(registry):
from .layout import PhotoLayout
registry.register("my-pack-photo", PhotoLayout())register_layouts(registry) is required for templates with the photo_page role.
Other templates can also supply layouts. Each layout implements
compose(page, instance, page_numbers, *, page_width_mm, page_height_mm, reserved_caption_lines=None) and returns a PageComposition. instance.settings
is opaque to the engine. The pack owns all its geometry, content preparation and
caption rules. Generic rectangles, slots and page-number composition are available
from the public authoring API; there is no built-in pack-specific layout there.
A layout can optionally implement required_caption_lines(page, instance, *, page_width_mm, page_height_mm) and reserve_caption_lines(required). The composer
can then share measurements across a spread and report overflow; the pack decides
how text is measured and how much space is reserved. Other layouts need neither.
A module shared by several templates is processed only once per registration
pass, so it must register all their IDs. A missing required layout produces an
error identifying the template. A standalone occurrence uses
PageComposer.compose_instance(instance, photos, ...), including outside normal
pagination. This allows an optional layout to work on covers too.
PageRenderer.paint_template() is the single dispatch point for all template
renderers: settings previews, paginated pages, covers and PDF. There is no dispatch
branch based on a template ID or album role. The paint() wrapper handles page
numbers and the application's fallback display for pages without a renderer.
Every widget_renderer.paint() receives the same keyword context:
-
painter,target_rect,width,height, physical dimensions,font_pixel_size,pixel_rect; -
instance, its opaquesettings,template_pack_settings; -
photos(page or album according tophoto_scope),project_photos,album_pages,compositionandthumbnail_cache; - the pack translator,
render_service,set_waiting_key,show_empty_slots.
Declare the arguments used and accept **kwargs for the others. PDF sets render_service=None;
the renderer must be able to render synchronously too. An optional
TemplatePreviewBackend supplies expensive asynchronous jobs through PreviewJob.
Its worker and business logic belong in the pack, while the host schedules work
and caches results.
create_template_instance(template_id) calls the optional pack defaults factory,
copies its result, and invokes optional validation. Album validation invokes the
same validation callback before composition/export. Loading settings preserves
saved values; it never replaces them with fresh defaults. Renderers can also
interpret omitted options locally, as the built-in packs do.
Local settings are JSON-compatible dictionaries in PageInstance.settings.
Shared settings are in AlbumStructureSettings.template_pack_settings, keyed by
pack ID. Their meaning, validation and editors belong entirely to the pack.
To provide a shared settings dialog, declare its entry point in the manifest:
"settings_editor": "photoalbum.templates.my_pack.options:edit_settings"The module and callable name are chosen by the pack. Omit the field or use
null when there is no pack editor. The callback receives the complete template_pack_settings dictionary, not just its own pack settings, with this signature:
def edit_settings(settings, *, translator, parent=None):
# Open the pack's settings dialog.
# Return a new complete dictionary, preserving settings for other packs.
# Return None on cancellation; do not modify settings in place.
...A template editor can emit the Qt signal edit_theme_requested. The GUI then
looks up the selected template's pack and calls this hook. Without the declaration,
no pack settings dialog is opened.
For preview/PDF differences and cache invalidation signatures, see Rendering and caches.
discover_template_packs() and discover_templates() are read-only: local
catalog inspection never replaces active translations, editors or extensions.
register_discovered_template_extensions(packs) activates the supplied set
(or the discovered built-in set if omitted), replacing translations, editor
metadata and executable extensions. Removed packs leave no registered behavior.
Registration is collected before publication, so a missing module, missing
register(), duplicate extension or undeclared template ID leaves the previous
active set intact. Python import side effects are not rolled back.
replace_active_template_packs(packs) activates metadata/catalogs only and clears
previous extensions; use the registration function when executable behavior is
needed. An editor is imported lazily when requested. An invalid declared editor
raises an error; it is not treated as an absent editor.
Layouts use the same declared modules but remain local to each composer's registry. Existing composers retain their layouts; create a new composer after changing the installed set. Discovery, layout loading and metadata activation do not import an editor merely because it is declared in a manifest.
The application declares only DEFAULT_TEMPLATE_PACK = "msb". The selected
pack's optional default_templates manifest object maps album roles to its own
IDs: front_cover, inside_front_cover, inside_back_cover, back_cover,
photo_page, year_divider, month_divider, day_divider. The photo_page role can also refer to a pack automatic mode. These eight roles are required for
the default pack and validated against each template's supported uses. They are
not required for other packs. A missing default pack produces a clear error when
creating the album settings UI, never an import failure or an order-based choice.
Incompatible defaults still leave the first available GUI choice selected.
The manifest may declare automatic_photo_page_modes. Each entry contains id, name, module, selector (a module:callable reference), templates (allowed IDs), settings_template (one of those IDs), and optional localized_names.
The selector receives an AutomaticPhotoPageContext with upcoming photos and page_width_mm, page_height_mm. It returns a declared mode template ID. Pagination owns boundaries and photo consumption; the selector does not reorder content. The settings template supplies the editor for the mode’s shared options.
day_divider is an ordinary divider role with its own template and PageInstance.
Its planned items and rendered pages carry year, month, and day, available
to the renderer through composition.page. Packs that provide a day divider
declare it like the other divider roles and may select one in default_templates.
Renderers can receive temporal_context, which identifies year and month divider
levels actually materialized for the rendered period. This describes album
structure only; each pack remains responsible for deciding how that context
affects its presentation.
The public authoring API provides
format_date_parts(date, language=..., weekday=..., day=..., month=..., year=...)
for localized date titles. It handles localized names, component ordering,
punctuation and language-specific details such as French 1er, independently
of the system locale. The currently supported languages are English and French,
including their regional variants.
Put catalogs in i18n/<language>.json. Fallback is the selected pack language,
then English in that pack, then the application catalog, then the key. Renderers,
editors, labels and asynchronous previews use this routing, including PDF covers.
Packs never use another pack's catalogs. Global catalogs contain application text.
Declare documentation as a language/path mapping, for example
{"fr": "docs/help.fr.html", "en": "docs/help.en.html"}. A single path string is not accepted.
The help UI tries the requested language, English, then the first existing declared file.
Documentation paths must remain inside the pack directory.
Assets live under the pack directory and can be resolved relative to the pack's
own Python modules. No central resource list or central documentation change is
needed to make a new pack work.
Manifests, README.md, docs/*, i18n/*.json, screenshots/*, and assets/ (recursively) are included
automatically in Python distributions. PyInstaller already collects submodules
and data from all packs. Packs are added to the source tree: an application that
has already been distributed must be rebuilt to include a new pack.
Before distributing a pack, verify discovery, composition, settings, preview,
and PDF export. tests/fixtures/template_packs/testpack/ is an independent
example with opaque settings, editors, renderer, layout, async preview, catalogs,
asset and documentation. The lifecycle test copies only its directory into a
source tree, exercises the GUI, persistence and export, and then physically
removes packs. The distribution test also adds it before building the sdist and
wheel, installs the wheel, and runs the same lifecycle outside the checkout.