diff --git a/Examples/Document Configuration/report.yml b/Examples/Document Configuration/report.yml index 016bf59..0cadb24 100644 --- a/Examples/Document Configuration/report.yml +++ b/Examples/Document Configuration/report.yml @@ -24,13 +24,18 @@ Customizing your document configuration and styling: Using a combination of configuration levels, you can customize the style of your documents with a minimum amount of configuration text. - - The document template (_doc): + - The document template (_doc): - > - The template controls settings such as - - - Page size - - Margins + The `_doc` configuration controls the document-wide page size and orientation, along with + one or more named `page templates`. Each page template controls settings such as + - - Margins - PDF background file path - - And whether or not you have a separate set of the above settings applying to the first page of your document or not. + + - > + The first page template listed is the one your document starts on. Within your report you + can switch between page templates using the `_pagebreak` block (pass it the name or index of + the template to use for the following pages) or the `_nextpagetemplate` block (which arms the + template to switch to at the next page break). - > Currently, the document template is internally configured to only use a single content frame. This limitation would only affect you if you wanted to do something like a two-column layout (where text flows into the second column on the same page once the first one is full). diff --git a/Examples/PDF Backgrounds/report.yml b/Examples/PDF Backgrounds/report.yml index e1293e6..b71c507 100644 --- a/Examples/PDF Backgrounds/report.yml +++ b/Examples/PDF Backgrounds/report.yml @@ -1,5 +1,14 @@ _doc: - background: background.pdf + templates: + default: + margins: + top: 72.0 + left: 72.0 + right: 72.0 + bottom: 72.0 + background: + filepath: background.pdf + relative-to: source _vars: field_a: 1645 field_b: 99 Sycamore St, Canada diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 832b07a..2cc117e 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -8,7 +8,7 @@ above it. | Key | Category | Controls | | --- | --- | --- | -| `_doc` | Document template | Page size, orientation, margins, PDF background, first-page overrides. | +| `_doc` | Document template | Page size, orientation, and one or more named page templates (margins + PDF background). | | `_style` | Text styles | Body and heading fonts, colours, size, line spacing, bullet styling. | | `_tablestyle` | Table styles | Cell padding, header text, row colours and rule lines. | @@ -31,7 +31,7 @@ internal defaults ─► project config ─► document front matter :::{note} Currently the document template uses a **single content frame**. This only matters if you want something like a two-column layout where text flows into a second column on the same -page. Everything else — margins, backgrounds, first-page overrides — is fully configurable. +page. Everything else — page templates, margins, backgrounds — is fully configurable. ::: ## Inline (document) configuration @@ -70,34 +70,59 @@ inherits from the internal defaults. (cfg-doc)= ## Document template — `_doc` -Controls the page itself. +Controls the page itself. Page size and orientation are document-wide; margins and PDF +backgrounds are set per **named page template**. Declare one or more templates under +`templates` — the **first one listed is the template your document starts on**. ```yaml _doc: - page-size: a4 # a4, letter, etc. - landscape: false - margins: - top: 72.0 # points (72 pt = 1 inch) - left: 72.0 - right: 72.0 - bottom: 72.0 - background: null # path to a PDF to overlay onto — see PDF backgrounds - first-page: # optional: different margins / background for page 1 - margins: - top: 72.0 - left: 72.0 - right: 72.0 - bottom: 72.0 - background: null + page-size: a4 # a4, letter, etc. (document-wide) + landscape: false # document-wide + templates: + cover: # first listed → the document starts here + margins: + top: 144.0 # points (72 pt = 1 inch) + left: 72.0 + right: 72.0 + bottom: 72.0 + background: # path to a PDF to overlay onto — see PDF backgrounds + filepath: cover.pdf + relative-to: source + body: # switch to this with _pagebreak / _nextpagetemplate + margins: + top: 72.0 + left: 72.0 + right: 72.0 + bottom: 72.0 + background: null ``` | Key | Meaning | | --- | --- | -| `page-size` | Named page size, e.g. `a4`, `letter`. | -| `landscape` | `true` to rotate to landscape. | -| `margins` | Page margins in points (`top`, `left`, `right`, `bottom`). | -| `background` | Path to a PDF whose pages are used as a background. See [PDF backgrounds](pdf-backgrounds.md). | -| `first-page` | Optional block giving the first page its own `margins` and `background`. | +| `page-size` | Named page size, e.g. `a4`, `letter`. Document-wide. | +| `landscape` | `true` to rotate to landscape. Document-wide. | +| `templates` | Mapping of template name → page template. The first entry is the starting template. | +| `templates..margins` | Page margins in points (`top`, `left`, `right`, `bottom`). | +| `templates..background` | Path to a PDF whose pages are used as a background. See [PDF backgrounds](pdf-backgrounds.md). | + +### Switching page templates + +Within your report content, switch templates with two blocks: + +- `_pagebreak: ` — insert a page break and use the named (or 0-indexed) + template for the following pages. Called with no value (`_pagebreak:`), it just breaks the + page and keeps the current template. +- `_nextpagetemplate: ` — arm the template to switch to at the **next** page + break, without inserting a break itself. + +```yaml +Report: + - The cover page uses the "cover" template. + - _pagebreak: body # break, and use "body" from here on + - Now on the body template. + - _nextpagetemplate: 0 # arm a switch back to the first template (by index) + - _pagebreak: # the break applies the armed template +``` (cfg-style)= ## Text styles — `_style` diff --git a/docs/guide/pdf-backgrounds.md b/docs/guide/pdf-backgrounds.md index 65f1639..c5e2c33 100644 --- a/docs/guide/pdf-backgrounds.md +++ b/docs/guide/pdf-backgrounds.md @@ -7,11 +7,16 @@ illusion of a sophisticated page-layout workflow while you keep writing plain YA ## Using a background -Point `_doc.background` at a PDF file: +Give a [page template](#cfg-doc) a `background` pointing at a PDF file: ```yaml _doc: - background: background.pdf + templates: + default: + margins: {top: 72.0, left: 72.0, right: 72.0, bottom: 72.0} + background: + filepath: background.pdf + relative-to: source Custom styling with PDF backgrounds: - > @@ -19,13 +24,15 @@ Custom styling with PDF backgrounds: can carry your letterhead, borders, watermarks, and form fields. ``` -The path is resolved relative to the report file. Each page of your document is composited -over the corresponding page of the background. +`relative-to` may be `source` (resolve the path against the report file) or `config` +(against the project config directory). Each page rendered with a template is composited +over its background; when a background PDF has multiple pages, they are matched by page +number. :::{tip} -Give the first page its own background using `_doc.first-page.background` — useful for a -title page or a cover sheet that differs from the body pages. See -[Configuration → Document template](#cfg-doc). +Give a cover or title page its own background by defining a **separate page template** with +its own `background` and switching to the body template with `_pagebreak` — useful when the +first page differs from the rest. See [Configuration → Document template](#cfg-doc). ::: ## Auto-populated form fields @@ -37,7 +44,12 @@ the values look like normal, static PDF content. ```yaml _doc: - background: background.pdf + templates: + default: + margins: {top: 72.0, left: 72.0, right: 72.0, bottom: 72.0} + background: + filepath: background.pdf + relative-to: source _vars: field_a: 1645 field_b: 99 Sycamore St, Canada diff --git a/docs/reference/blocks.md b/docs/reference/blocks.md index 6c67f44..0c50800 100644 --- a/docs/reference/blocks.md +++ b/docs/reference/blocks.md @@ -38,7 +38,8 @@ The suffix does not affect how the block is executed. It is simply an optional i | [`_code`](#block-code) | A non-executable code block. | | [`_py`](#block-py) | Execute Python and optionally show the syntax-highlighted source. | | [`_loadjson`](#block-loadjson) | Load variables into the document from a JSON file. | -| [`_pagebreak`](#block-pagebreak) | Force a page break. | +| [`_pagebreak`](#block-pagebreak) | Force a page break, optionally switching page template. | +| [`_nextpagetemplate`](#block-nextpagetemplate) | Arm the page template to switch to at the next break. | | [`_hrule`](#block-hrule) | A customizable horizontal rule. | | [`_spacer`](#block-spacer) | Insert vertical whitespace. | @@ -251,15 +252,35 @@ _loadjson: (block-pagebreak)= ## `_pagebreak` — Page breaks -Force a page break. Takes `null` as its value (an empty block). +Force a page break. Called with no value, it just breaks the page and keeps the current +[page template](configuration.md#cfg-doc). Pass the **name or 0-based index** of a page +template to switch to it for the pages that follow. ```yaml Report: - > This content ends the page. - - _pagebreak: + - _pagebreak: # break, keep the current template - > This content starts a new page. + - _pagebreak: body # break AND switch to the "body" template + - _pagebreak: 1 # equivalently, by index +``` + +--- + +(block-nextpagetemplate)= +## `_nextpagetemplate` — Switch template at the next break + +Arm the [page template](configuration.md#cfg-doc) to switch to at the **next** page break, +without inserting a break itself. Useful when the break is produced elsewhere (for example, +by content overflowing the page). Takes the template **name or 0-based index**. + +```yaml +Report: + - _nextpagetemplate: body # the next page break will switch to "body" + - > + Long content that flows onto a second page, which will use the "body" template. ``` --- diff --git a/src/ymprint/blocks/nextpagetemplate_block.py b/src/ymprint/blocks/nextpagetemplate_block.py new file mode 100644 index 0000000..4512a9e --- /dev/null +++ b/src/ymprint/blocks/nextpagetemplate_block.py @@ -0,0 +1,22 @@ +from typing import Any + +from reportlab.platypus import NextPageTemplate + +from . import register_block +from ..exceptions import YMPrintSyntaxException + + +def convert_next_page_template(block_key: str, block_value: Any, context: dict) -> list: + """ + Sets the page template to switch to at the next page break, without inserting + a break itself. The value is the name or 0-based index of the page template. + """ + doctemplate = context['doctemplate']['ymprint'] + try: + template_id = doctemplate.resolve_template_id(block_value) + except ValueError as exc: + raise YMPrintSyntaxException(str(exc)) from exc + return [NextPageTemplate(template_id)] + + +register_block("_nextpagetemplate", convert_next_page_template) diff --git a/src/ymprint/blocks/page_break_block.py b/src/ymprint/blocks/page_break_block.py index 4f27e65..8f6e09e 100644 --- a/src/ymprint/blocks/page_break_block.py +++ b/src/ymprint/blocks/page_break_block.py @@ -1,10 +1,24 @@ -from reportlab.platypus import PageBreak -from . import register_block +from typing import Any + +from reportlab.platypus import PageBreak, NextPageTemplate +from . import register_block +from ..exceptions import YMPrintSyntaxException -def convert_page_break(block_key: str, block_value: Any, context: dict) -> list[PageBreak]: - return [PageBreak()] +def convert_page_break(block_key: str, block_value: Any, context: dict) -> list: + """ + Inserts a page break. If a value is supplied, it is the name or 0-based index + of the page template to use for the pages that follow the break. + """ + if block_value is None or block_value == "": + return [PageBreak()] + doctemplate = context['doctemplate']['ymprint'] + try: + template_id = doctemplate.resolve_template_id(block_value) + except ValueError as exc: + raise YMPrintSyntaxException(str(exc)) from exc + return [NextPageTemplate(template_id), PageBreak()] -register_block("_pagebreak", convert_page_break) \ No newline at end of file +register_block("_pagebreak", convert_page_break) diff --git a/src/ymprint/config/config_loaders.py b/src/ymprint/config/config_loaders.py index 70ce385..39198b0 100644 --- a/src/ymprint/config/config_loaders.py +++ b/src/ymprint/config/config_loaders.py @@ -96,6 +96,18 @@ def build_current_config(default_config: dict, config_data: DeepChainMap): """ style_map = {} for key in default_config: + # 'templates' is a user-extensible mapping whose keys are arbitrary + # template names, so it must not be merged key-by-key against the + # defaults (which would drop any user-named templates and inject the + # default one). Instead, take the whole mapping from the highest-priority + # layer that defines a non-empty 'templates'. + if key == 'templates': + for mapping in config_data.maps: + templates = mapping.get('templates') + if templates: + style_map['templates'] = templates + break + continue value = config_data[key] if isinstance(value, DeepChainMap): default_value = default_config[key] diff --git a/src/ymprint/config/defaults/defaults.ymprint.yml b/src/ymprint/config/defaults/defaults.ymprint.yml index 546e9e8..b7b4f39 100644 --- a/src/ymprint/config/defaults/defaults.ymprint.yml +++ b/src/ymprint/config/defaults/defaults.ymprint.yml @@ -1,19 +1,14 @@ _doc: page-size: a4 landscape: false - margins: - top: 72.0 - left: 72.0 - right: 72.0 - bottom: 72.0 - background: null - first-page: - margins: - top: 72.0 - left: 72.0 - right: 72.0 - bottom: 72.0 - background: null + templates: + default: + margins: + top: 72.0 + left: 72.0 + right: 72.0 + bottom: 72.0 + background: null _style: headings: diff --git a/src/ymprint/config/doctemplate.py b/src/ymprint/config/doctemplate.py index fb27b41..3022a23 100644 --- a/src/ymprint/config/doctemplate.py +++ b/src/ymprint/config/doctemplate.py @@ -1,7 +1,7 @@ from enum import StrEnum import pathlib from typing import Optional -from pydantic import BaseModel, Field, ConfigDict +from pydantic import BaseModel, Field from reportlab.platypus import BaseDocTemplate, Frame, PageTemplate import reportlab.lib.pagesizes as rl_pagesizes from .helpers import get_pagesize @@ -21,8 +21,8 @@ class PDFBackground(BaseModel): filepath: str relative_to: Optional[RelativeTo] = Field(alias='relative-to', default=None) -class PageConfig(BaseModel): - # model_config = ConfigDict(populate_by_name=True) +class TemplateConfig(BaseModel): + """A single named page template: its content margins and optional PDF background.""" margins: Margins background: Optional[PDFBackground] = None @@ -32,12 +32,9 @@ class PageSizeMixin: class LandscapeMixin: landscape: bool = Field(default = False) -class FirstPageMixin: - first_page: Optional[PageConfig] = Field(default = None, alias='first-page') - -class DocConfig(PageConfig, PageSizeMixin, LandscapeMixin, FirstPageMixin): - pass +class DocConfig(PageSizeMixin, LandscapeMixin, BaseModel): + templates: dict[str, TemplateConfig] @property def page_dims(self): @@ -48,97 +45,105 @@ def page_dims(self): else: final_page_dims = page_dims return final_page_dims - # page_dims = getattr(rl_pagesizes, self.page_size.upper()) else: raise ValueError(f"Page size of {self.page_size.upper()} not found. Page sizes available: {[attr for attr in dir(rl_pagesizes) if attr.isupper()]}") - def available_width(self, first_or_all: str): - if first_or_all == "first": - if getattr(self, 'first_page', None) is not None: - return self.page_dims[0] - self.first_page.margins.left - self.first_page.margins.right - else: - return self.page_dims[0] - self.margins.left - self.margins.right - else: - return self.page_dims[0] - self.margins.left - self.margins.right - - def available_height(self, first_or_all: str): - if first_or_all == "first": - if getattr(self, 'first_page', None) is not None: - return self.page_dims[1] - self.first_page.margins.top - self.first_page.margins.bottom - else: - return self.page_dims[1] - self.margins.top - self.margins.bottom - else: - return self.page_dims[1] - self.margins.top - self.margins.bottom - - def page_anchor(self, first_or_all: str): - if first_or_all == 'first': - if getattr(self, 'first_page', None) is not None: - return [self.first_page.margins.left, self.first_page.margins.bottom] - else: - return [] - else: - return [self.margins.left, self.margins.bottom] + @property + def template_names(self) -> list[str]: + """Template names in declaration order. The first is the starting template.""" + return list(self.templates.keys()) + + def resolve_template_id(self, name_or_index: str | int) -> str: + """ + Returns the template name (used as the ReportLab PageTemplate id) for a + user-supplied template reference, which may be a name or a 0-based index. + """ + names = self.template_names + # bool is an int subclass; reject it explicitly to avoid True/False -> index + if isinstance(name_or_index, bool): + raise ValueError(f"Invalid page template reference: {name_or_index!r}") + if isinstance(name_or_index, int): + try: + return names[name_or_index] + except IndexError: + raise ValueError( + f"Page template index {name_or_index} is out of range. " + f"Available templates (by index): {list(enumerate(names))}" + ) + if isinstance(name_or_index, str): + if name_or_index in self.templates: + return name_or_index + raise ValueError( + f"Page template {name_or_index!r} not found. Available templates: {names}" + ) + raise ValueError(f"Invalid page template reference: {name_or_index!r}") + + def available_width(self, template_name: str) -> float: + template = self.templates[template_name] + return self.page_dims[0] - template.margins.left - template.margins.right + + def available_height(self, template_name: str) -> float: + template = self.templates[template_name] + return self.page_dims[1] - template.margins.top - template.margins.bottom + + def page_anchor(self, template_name: str) -> list[float]: + template = self.templates[template_name] + return [template.margins.left, template.margins.bottom] + + def min_available_width(self) -> float: + """Smallest content width across all templates (safe for sizing flowables).""" + return min(self.available_width(name) for name in self.template_names) + + def min_available_height(self) -> float: + """Smallest content height across all templates (safe for sizing flowables).""" + return min(self.available_height(name) for name in self.template_names) - - def build(self, destination: str | pathlib.Path, title: str = "", author: str = ""): """ - Returns a rl object + Returns a tuple of (BaseDocTemplate, page_template_map). + + 'page_template_map' is an initially-empty dict that is populated during + the ReportLab build with {page_index (0-based): template_name}. Each + PageTemplate records the template used to render each page so that the + correct background can be overlaid in post-processing. """ page_width, page_height = self.page_dims - available_width = page_width - self.margins.left - self.margins.right - available_height = page_height - self.margins.top - self.margins.bottom - main_frame = Frame( - x1=self.margins.left, - y1=self.margins.bottom, - width = available_width, - height = available_height, - id='main', - leftPadding=0, - rightPadding=0, - topPadding=0, - bottomPadding=0 - ) - main_page_template = PageTemplate( - id='report', - pagesize=self.page_dims, - frames=[main_frame], - ) - page_templates = [main_page_template] - first_page_template = None - first_page_frame = None - if self.first_page is not None: - first_available_width = page_width - self.first_page.margins.left - self.first_page.margins.right - first_available_height = page_height - self.first_page.margins.top - self.first_page.margins.bottom - first_page_frame = Frame( - x1=self.first_page.margins.left, - y1=self.first_page.margins.bottom, - width=first_available_width, - height=first_available_height, - id='first', + page_template_map: dict[int, str] = {} + + def make_on_page(template_id: str): + def _on_page(canvas, doc): + page_template_map[canvas.getPageNumber() - 1] = template_id + return _on_page + + page_templates = [] + for name, template in self.templates.items(): + frame = Frame( + x1=template.margins.left, + y1=template.margins.bottom, + width=page_width - template.margins.left - template.margins.right, + height=page_height - template.margins.top - template.margins.bottom, + id=f'{name}_frame', leftPadding=0, rightPadding=0, topPadding=0, bottomPadding=0, ) - first_page_template = PageTemplate( - id='report_first_page', - pagesize=(page_width, page_height), - frames=[first_page_frame], + page_templates.append( + PageTemplate( + id=name, + pagesize=self.page_dims, + frames=[frame], + onPage=make_on_page(name), + ) ) - page_templates.insert(0, first_page_template) + doc = BaseDocTemplate( str(destination), - pagesize = self.page_dims, + pagesize=self.page_dims, pageTemplates=page_templates, title=title, author=author, allowSplitting=1 ) - return doc - - - -# # CRITICAL: This compiles the forward reference ("Doc") now that the class is defined. -# DocConfig.model_rebuild() \ No newline at end of file + return doc, page_template_map diff --git a/src/ymprint/config/pdf_fill_forms.py b/src/ymprint/config/pdf_fill_forms.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/ymprint/config/pdf_postprocessing.py b/src/ymprint/config/pdf_postprocessing.py index 94137bb..65dadc9 100644 --- a/src/ymprint/config/pdf_postprocessing.py +++ b/src/ymprint/config/pdf_postprocessing.py @@ -1,6 +1,5 @@ import io import pathlib -from typing import Optional import pymupdf as mu @@ -9,155 +8,122 @@ def overlay_pdf_background( pdf_background_streams: dict[str, io.BytesIO | None], destination_path: pathlib.Path | io.BytesIO, context: dict, + page_template_map: dict[int, str], ): """ - Overlays the pages from document_path onto the pages from - pdf_background_path and saves the new file to destination_path. - If first_page_background_path is provided, then that background - will be used for the first page and all other pages will be overlayed - onto pdf_background_path. - - If pdf_background_path is a PDF file with multiple pages, each page - in 'document_path' will be overlayed onto each page of 'pdf_background_path' - in sequence. If 'document_path' has more pages than 'pdf_background_path', - then the remaining pages of 'document_path' will not have a pdf background - applied. If 'pdf_background_path' has more pages than 'document_path', then - the final document will have the same number of pages as 'document_path' and - the remaining un-overlayed pages from 'pdf_background_path' will remain - unused in the final document. + Overlays the rendered document pages onto their page template backgrounds and + saves the result to 'destination_path'. + + 'pdf_background_streams' maps a page template name to its (form-filled) PDF + background stream, or None when that template has no background. + + 'page_template_map' maps a 0-based page index to the name of the page template + that rendered it, so each page is overlaid onto the correct template background. + + If a template's background PDF has multiple pages, each document page rendered + with that template is overlaid onto the background page at the same absolute + document index; document pages beyond the background's page count receive no + background. """ - first_data = pdf_background_streams['first'] - remaining_data = pdf_background_streams['remaining'] page_dims = context['doctemplate']['ymprint'].page_dims page_width, page_height = page_dims - first_bg = None - if first_data is not None: - first_bg = mu.open(stream=pdf_background_streams['first']) - remaining_bg = None - if remaining_data is not None: - remaining_bg = mu.open(stream=pdf_background_streams['remaining']) + + background_docs = { + name: (mu.open(stream=data) if data is not None else None) + for name, data in pdf_background_streams.items() + } + document = mu.open(stream=document_path) output = mu.open() for i in range(document.page_count): document_page = document.load_page(i) document_page.wrap_contents() out_page = output.new_page(width=page_width, height=page_height) - if i == 0 and first_bg is not None: - first_bg_page = first_bg.load_page(0) - first_bg_page.wrap_contents() - out_page.show_pdf_page(first_bg_page.rect, first_bg, pno=0) - out_page.show_pdf_page(document_page.rect, document, pno=i) - continue - if remaining_bg is not None: - if remaining_bg.page_count == 1: - background_page = remaining_bg.load_page(0) - background_page.wrap_contents() + + template_name = page_template_map.get(i) + background = background_docs.get(template_name) + if background is not None: + if background.page_count == 1: background_page_num = 0 + elif i < background.page_count: + background_page_num = i else: - try: - background_page = remaining_bg.load_page(i) - background_page.wrap_contents() - background_page_num = i - except IndexError: - background_page = None - out_page.show_pdf_page(background_page.rect, remaining_bg, pno=background_page_num) + background_page_num = None + if background_page_num is not None: + background_page = background.load_page(background_page_num) + background_page.wrap_contents() + out_page.show_pdf_page(background_page.rect, background, pno=background_page_num) + out_page.show_pdf_page(document_page.rect, document, pno=i) output.save(destination_path) - - -def fill_forms_and_bake(vars: dict, pdf_backgrounds: dict[str, io.BytesIO | None]) -> dict[str, io.BytesIO]: - first_data = pdf_backgrounds['first'] - remaining_data = pdf_backgrounds['remaining'] - first_bg = remaining_bg = None - if first_data is not None: - first_bg = mu.open(stream=first_data) - if remaining_data is not None: - remaining_bg = mu.open(stream=remaining_data) - - if first_bg is None and remaining_bg is None: - return pdf_backgrounds - docs = [first_bg, remaining_bg] - out_docs = {} - - indexes = ['first', 'remaining'] - for idx, doc in enumerate(docs): - if doc is None: - out_docs[indexes[idx]] = None + +def fill_forms_and_bake(vars: dict, pdf_backgrounds: dict[str, io.BytesIO | None]) -> dict[str, io.BytesIO | None]: + """ + For each background stream, populates any PDF form fields whose name matches a + document variable, then flattens ("bakes") the fields into the page content. + Returns a dict with the same keys, mapping to the baked stream (or None). + """ + out_docs: dict[str, io.BytesIO | None] = {} + for name, data in pdf_backgrounds.items(): + if data is None: + out_docs[name] = None continue + doc = mu.open(stream=data) for page in doc.pages(): widget = page.first_widget while widget is not None: - name = widget.field_name - widget_value = vars.get(name, None) - # THIS IS TRICKY + field_name = widget.field_name + widget_value = vars.get(field_name, None) if widget_value is not None: widget.field_value = str(widget_value) widget.update() widget = widget.next - doc.bake() doc_data = io.BytesIO() doc.save(filename=doc_data) doc_data.seek(0) - out_docs[indexes[idx]] = doc_data + out_docs[name] = doc_data return out_docs def load_pdf_backgrounds(context: dict) -> dict[str, io.BytesIO | None]: + """ + Returns a dict mapping each page template name to its background PDF as a + BytesIO stream (or None when the template has no background). Relative + background paths are resolved against the source or config directory according + to each background's 'relative-to' setting. + """ source_path = pathlib.Path(context['source_path']) source_parent = source_path.parent - print(f"{source_parent=}") if context['config_path'] is not None: - config_path = pathlib.Path(context['config_path']) - config_parent = config_path.parent + config_parent = pathlib.Path(context['config_path']).parent else: - config_path = source_path config_parent = source_parent - first_page_bg = context['doctemplate']['yaml']['_doc'].get('first-page', {}).get('background') - if isinstance(first_page_bg, dict): - print(f"{first_page.get('background', {})=}") - first_page_background_filepath = first_page_bg.get('filepath') - relative_to = first_page_bg.get('relative_to') - if relative_to == 'source': - first_page_background = source_parent / first_page_background_filepath - elif relative_to == 'config': - first_page_background = config_parent / first_page_background_filepath - else: - first_page_background = first_page_background_filepath - else: - first_page_background = None - - remaining = context['doctemplate']['yaml']['_doc'].get('background', {}) - first_page_pdf = remaining_pdf = None - first_page_data = remaining_page_data = None - if first_page_background is not None: - first_page_pdf = mu.open(first_page_background) - first_page_data = io.BytesIO() - first_page_pdf.save(first_page_data) - first_page_data.seek(0) - if remaining is not None: - relative_to = remaining.get('relative-to') - print(f"{relative_to=}") + doctemplate = context['doctemplate']['ymprint'] + backgrounds: dict[str, io.BytesIO | None] = {} + for name, template in doctemplate.templates.items(): + background = template.background + if background is None: + backgrounds[name] = None + continue + + relative_to = background.relative_to if relative_to == 'source': - remaining_page_background = source_parent / remaining.get('filepath') + background_path = source_parent / background.filepath elif relative_to == 'config': - remaining_page_background = config_parent / remaining.get('filepath') + background_path = config_parent / background.filepath else: - remaining_page_background = remaining.get('filepath') + background_path = background.filepath - print(f"{remaining_page_background=}") - remaining_pdf = mu.open(remaining_page_background) - remaining_page_data = io.BytesIO() - remaining_pdf.save(remaining_page_data) - remaining_page_data.seek(0) + pdf = mu.open(background_path) + data = io.BytesIO() + pdf.save(data) + data.seek(0) + backgrounds[name] = data - backgrounds = { - "first": first_page_data, - "remaining": remaining_page_data - } - return backgrounds \ No newline at end of file + return backgrounds diff --git a/src/ymprint/context_builder.py b/src/ymprint/context_builder.py index 1c459f7..7a224e7 100644 --- a/src/ymprint/context_builder.py +++ b/src/ymprint/context_builder.py @@ -20,7 +20,7 @@ def build_context( # This is not an appropriate merge. Need the nested chain map. combined_doctemplate = doctemplate_yaml# | inline_doctemplate doctemplate = DocConfig.model_validate(combined_doctemplate['_doc']) - rl_basedoctemplate = doctemplate.build(destination_path) + rl_basedoctemplate, _ = doctemplate.build(destination_path) report_tablestyles = TableStyle.model_validate(tablestyles_yaml['_tablestyle']) tablestyles = report_tablestyles.build() context = { @@ -49,21 +49,21 @@ def build_context( "vars": document_vars, "page_dims": doctemplate.page_dims, "frames": { - "first_page": { - "anchor": doctemplate.page_anchor('first'), - "width": doctemplate.available_width('first'), - "height": doctemplate.available_height('first'), - }, - "remaining_pages": { - "anchor": doctemplate.page_anchor('all'), - "width": doctemplate.available_width('all'), - "height": doctemplate.available_height('all'), + **{ + name: { + "anchor": doctemplate.page_anchor(name), + "width": doctemplate.available_width(name), + "height": doctemplate.available_height(name), + } + for name in doctemplate.template_names }, + # Conservative frame used by blocks to size flowables that could land + # on any page: the smallest content box across all templates. "all_pages": { - "anchor": doctemplate.page_anchor('all'), - "width": min(doctemplate.available_width('all'), doctemplate.available_width('first')), - "height": min(doctemplate.available_height('all'), doctemplate.available_height('first')), - } + "anchor": doctemplate.page_anchor(doctemplate.template_names[0]), + "width": doctemplate.min_available_width(), + "height": doctemplate.min_available_height(), + }, }, "source_path": source_path, "config_path": config_path, diff --git a/src/ymprint/report_reader.py b/src/ymprint/report_reader.py index 919a6de..5ba8ebc 100644 --- a/src/ymprint/report_reader.py +++ b/src/ymprint/report_reader.py @@ -5,6 +5,7 @@ from .blocks import admonition_block from .blocks import quote_block from .blocks import page_break_block +from .blocks import nextpagetemplate_block from .blocks import spacer_block from .blocks import hrule_block from .blocks import python_block @@ -19,7 +20,7 @@ from .config.pdf_postprocessing import load_pdf_backgrounds, fill_forms_and_bake, overlay_pdf_background -from reportlab.platypus import Spacer, NextPageTemplate +from reportlab.platypus import Spacer from reportlab.lib.units import mm from .config.font_registry import register_fonts @@ -57,20 +58,19 @@ def load_report(source_yaml: str | pathlib.Path, destination_pdf: str | pathlib. ) print(f"{doc_data=}") story = build_story(source_data, context) - if context['doctemplate']['yaml']['_doc'].get('first-page') is not None: - story = [NextPageTemplate(1)] + story - rl_doc = doctemplate.build(destination_pdf) + rl_doc, page_template_map = doctemplate.build(destination_pdf) rl_report_buffer = BytesIO() rl_doc.build(story, filename=rl_report_buffer) rl_report_buffer.seek(0) - # rl_doc.build(story, filename=str(pathlib.Path(destination_pdf).resolve())) + # page_template_map is now populated with {page_index: template_name} pdf_backgrounds = load_pdf_backgrounds(context) populated_backgrounds = fill_forms_and_bake(context['vars'], pdf_backgrounds) overlay_pdf_background( rl_report_buffer, populated_backgrounds, pathlib.Path(destination_pdf), - context + context, + page_template_map, ) diff --git a/tests/test-data/example_1_config/config.ymprint.yml b/tests/test-data/example_1_config/config.ymprint.yml index 3925ddc..f36dbe0 100644 --- a/tests/test-data/example_1_config/config.ymprint.yml +++ b/tests/test-data/example_1_config/config.ymprint.yml @@ -1,11 +1,13 @@ _doc: page-size: a4 landscape: false - margins: - top: 84 - left: 84 - right: 84 - bottom: 84 + templates: + default: + margins: + top: 84 + left: 84 + right: 84 + bottom: 84 _style: headings: diff --git a/tests/test-data/example_2_config/config.ymprint.yml b/tests/test-data/example_2_config/config.ymprint.yml index d000eaa..bfb84ce 100644 --- a/tests/test-data/example_2_config/config.ymprint.yml +++ b/tests/test-data/example_2_config/config.ymprint.yml @@ -1,23 +1,25 @@ _doc: page-size: letter landscape: true - margins: - top: 84 - left: 84 - right: 84 - bottom: 84 - background: - filepath: background_other_pages.pdf - relative-to: source - first-page: - margins: - top: 240 - left: 84 - right: 84 - bottom: 84 - background: - filepath: background_first_page.pdf - relative-to: source + templates: + first: + margins: + top: 240 + left: 84 + right: 84 + bottom: 84 + background: + filepath: background_first_page.pdf + relative-to: source + body: + margins: + top: 84 + left: 84 + right: 84 + bottom: 84 + background: + filepath: background_other_pages.pdf + relative-to: source _tablestyle: cell-padding: diff --git a/tests/test-data/example_output1.pdf b/tests/test-data/example_output1.pdf index 94418d2..aac4abd 100644 Binary files a/tests/test-data/example_output1.pdf and b/tests/test-data/example_output1.pdf differ diff --git a/tests/test-data/example_output2.pdf b/tests/test-data/example_output2.pdf index 0af00b7..826583f 100644 Binary files a/tests/test-data/example_output2.pdf and b/tests/test-data/example_output2.pdf differ diff --git a/tests/test-data/example_output3.pdf b/tests/test-data/example_output3.pdf index 6aeaefc..2865bb5 100644 Binary files a/tests/test-data/example_output3.pdf and b/tests/test-data/example_output3.pdf differ diff --git a/tests/test-data/filled_forms.pdf b/tests/test-data/filled_forms.pdf index fe5d2eb..fb44812 100644 Binary files a/tests/test-data/filled_forms.pdf and b/tests/test-data/filled_forms.pdf differ diff --git a/tests/test-data/report_example_1.pdf b/tests/test-data/report_example_1.pdf deleted file mode 100644 index e571a18..0000000 Binary files a/tests/test-data/report_example_1.pdf and /dev/null differ diff --git a/tests/test-data/report_example_2.pdf b/tests/test-data/report_example_2.pdf deleted file mode 100644 index 0b4b601..0000000 Binary files a/tests/test-data/report_example_2.pdf and /dev/null differ diff --git a/tests/test-data/report_example_2.yml b/tests/test-data/report_example_2.yml index 57af91a..5d5b7ef 100644 --- a/tests/test-data/report_example_2.yml +++ b/tests/test-data/report_example_2.yml @@ -6,11 +6,7 @@ _vars: date: 2026-06-12 time: 13:00 region: Level 1 columns -_doc: # TODO: this background is not loading - background: - filepath: background_other_pages.pdf - relative-to: source -title: +title: first topic: - > Here is the start of a new paragraph of {{region}}. Any text can be written Here @@ -27,7 +23,7 @@ title: - first bullet point - second bullet point - third bullet point - - _pagebreak: null + - _pagebreak: body - third topic: - First sub paragraph leading into subheading - Second sub paragraph leading into subheading diff --git a/tests/test_multipage_templates.py b/tests/test_multipage_templates.py new file mode 100644 index 0000000..3e28961 --- /dev/null +++ b/tests/test_multipage_templates.py @@ -0,0 +1,137 @@ +import pathlib + +import pytest +from reportlab.platypus import PageBreak, NextPageTemplate + +import ymprint.config as config +from ymprint.config.config_loaders import load_report_config +from ymprint.blocks.page_break_block import convert_page_break +from ymprint.blocks.nextpagetemplate_block import convert_next_page_template +from ymprint.exceptions import YMPrintSyntaxException + +TEST_DATA = pathlib.Path(__file__).parent / "test-data" + + +def make_doc_config(): + return config.DocConfig.model_validate( + { + "page-size": "a4", + "landscape": False, + "templates": { + "cover": {"margins": {"top": 200, "left": 72, "right": 72, "bottom": 72}}, + "body": {"margins": {"top": 72, "left": 72, "right": 72, "bottom": 72}}, + }, + } + ) + + +# --- DocConfig template resolution ------------------------------------------------- + +def test_template_names_preserve_order(): + doc = make_doc_config() + assert doc.template_names == ["cover", "body"] + + +def test_resolve_template_id_by_name_and_index(): + doc = make_doc_config() + assert doc.resolve_template_id("body") == "body" + assert doc.resolve_template_id(0) == "cover" + assert doc.resolve_template_id(1) == "body" + + +def test_resolve_template_id_rejects_unknown_name(): + doc = make_doc_config() + with pytest.raises(ValueError): + doc.resolve_template_id("nope") + + +def test_resolve_template_id_rejects_out_of_range_index(): + doc = make_doc_config() + with pytest.raises(ValueError): + doc.resolve_template_id(5) + + +def test_min_available_dims_use_smallest_template(): + doc = make_doc_config() + # cover has the larger top margin, so the smallest height comes from cover + assert doc.min_available_height() == doc.available_height("cover") + assert doc.min_available_width() == doc.available_width("body") + + +def test_build_records_page_template_map(): + from io import BytesIO + from reportlab.lib.styles import getSampleStyleSheet + from reportlab.platypus import Paragraph + + doc_cfg = make_doc_config() + styles = getSampleStyleSheet() + story = [ + Paragraph("p1", styles["Normal"]), + NextPageTemplate("body"), + PageBreak(), + Paragraph("p2", styles["Normal"]), + ] + doc, page_map = doc_cfg.build("/dev/null") + doc.build(story, filename=BytesIO()) + assert page_map == {0: "cover", 1: "body"} + + +# --- Block converters -------------------------------------------------------------- + +def make_context(): + return {"doctemplate": {"ymprint": make_doc_config()}} + + +def test_pagebreak_without_value_is_plain_break(): + result = convert_page_break("_pagebreak", None, make_context()) + assert len(result) == 1 + assert isinstance(result[0], PageBreak) + + +def test_pagebreak_with_name_switches_template(): + result = convert_page_break("_pagebreak", "body", make_context()) + assert isinstance(result[0], NextPageTemplate) + assert result[0].action == ("nextPageTemplate", "body") + assert isinstance(result[1], PageBreak) + + +def test_pagebreak_with_index_switches_template(): + result = convert_page_break("_pagebreak", 1, make_context()) + assert isinstance(result[0], NextPageTemplate) + assert result[0].action == ("nextPageTemplate", "body") + + +def test_pagebreak_unknown_template_raises_syntax_exception(): + with pytest.raises(YMPrintSyntaxException): + convert_page_break("_pagebreak", "missing", make_context()) + + +def test_nextpagetemplate_sets_template_without_break(): + result = convert_next_page_template("_nextpagetemplate", "body", make_context()) + assert len(result) == 1 + assert isinstance(result[0], NextPageTemplate) + assert result[0].action == ("nextPageTemplate", "body") + + +def test_nextpagetemplate_unknown_template_raises_syntax_exception(): + with pytest.raises(YMPrintSyntaxException): + convert_next_page_template("_nextpagetemplate", 9, make_context()) + + +# --- Config merge with named templates -------------------------------------------- + +def test_config_named_templates_override_default_template(): + # example_2_config defines 'first' and 'body' templates + _styles, _tablestyles, doc = load_report_config({}, TEST_DATA / "example_2_config") + templates = doc["_doc"]["templates"] + assert list(templates.keys()) == ["first", "body"] + # the default 'default' template must not leak in + assert "default" not in templates + + +def test_config_without_templates_falls_back_to_default(): + # example_1_config defines a single 'default' template + _styles, _tablestyles, doc = load_report_config({}, TEST_DATA / "example_1_config") + templates = doc["_doc"]["templates"] + assert list(templates.keys()) == ["default"] + assert templates["default"]["margins"]["top"] == 84