from typing import TYPE_CHECKING, Iterator, Tuple
from nbformat import NotebookNode
from pydantic import Field, field_validator, model_validator
from nbprint.config.base import BaseModel, Role, _append_or_extend
from nbprint.config.common import PageOrientation, PageSize, Style
from nbprint.config.exceptions import NBPrintNullCellError
from .content import Section
def _iter_page_boxes(content) -> "Iterator":
"""Yield every ``ContentPageBox`` in a ``ContentMarshall``, including nested ones."""
from nbprint.config.content.page_box import ContentPageBox
from .content import SECTION_ORDER
def walk(node) -> "Iterator":
if isinstance(node, ContentPageBox):
yield node
children = getattr(node, "content", None)
if isinstance(children, list):
for child in children:
yield from walk(child)
for section in SECTION_ORDER:
for item in getattr(content, section, None) or []:
yield from walk(item)
PAGE_REGION_ATTRS = (
"top",
"top_left",
"top_left_corner",
"top_right",
"top_right_corner",
"bottom",
"bottom_left",
"bottom_left_corner",
"bottom_right",
"bottom_right_corner",
"left",
"left_top",
"left_bottom",
"right",
"right_top",
"right_bottom",
)
# The four edge regions whose CSS name is not their attribute name.
_REGION_CSS_NAMES = {
"top": "top-center",
"bottom": "bottom-center",
"left": "left-center",
"right": "right-center",
}
def _region_css_name(attr: str) -> str:
return _REGION_CSS_NAMES.get(attr, attr.replace("_", "-"))
if TYPE_CHECKING:
from .config import Configuration
_FIRST_PAGE_SUPPRESSION_MARKER = ".pagedjs_first_page .pagedjs_margin-"
__all__ = (
"Page",
"PageGlobal",
"PageNumber",
"PageRegion",
"PageRegionContent",
"RunningElement",
)
class PageRegionContent(BaseModel):
content: str | None = ""
# common
tags: list[str] = Field(default_factory=list)
role: Role = Role.PAGE
ignore: bool = True
def __str__(self) -> str:
if self.content:
return f"content: {self.content};"
return ""
@field_validator("tags", mode="after")
@classmethod
def _ensure_tags(cls, v: list[str]) -> list[str]:
if "nbprint:page" not in v:
v.append("nbprint:page")
return v
def region_css(self) -> str:
"""CSS this content needs *outside* its ``@page`` block.
Most region content is self-contained: a string, a counter. Content
sourced from the document body is not, because the source element has
to be styled where it lives.
"""
return ""
class PageNumber(PageRegionContent):
content: str | None = "counter(page)"
class RunningElement(PageRegionContent):
"""Region content taken from an element that lives in the document body.
Paged.js lifts an element out of the flow when it carries
``position: running(<name>)``, and a margin box pulls it back in with
``content: element(<name>)``. Nothing here was expressible before, so every
consumer hand-rolled the pair -- and each one also had to rediscover that
the source element must be hidden when the document is *not* paginated,
since ``position: running()`` means nothing outside Paged.js and the element
would otherwise render inline in the body.
``selector`` addresses the source element, e.g. ``.footer-logo``.
"""
name: str
selector: str
@model_validator(mode="after")
def _default_content_to_the_element(self) -> "RunningElement":
if not self.content:
self.content = f"element({self.name})"
return self
def region_css(self) -> str:
# The `position: running()` rule has to be matchable against the parsed
# content fragment, because that is where Paged.js hides the source
# element (it sets an inline `display: none` on everything the rule
# selects). The fragment holds the body's *children*, so any selector
# rooted at `body` matches nothing there, the element is never hidden,
# and it paints in the normal flow while its clone appears in the margin
# box. For the same reason the rule must not force the element visible:
# a `display: block !important` here would outrank the inline
# `display: none` Paged.js relies on. Hiding for the unpaginated case is
# left to a `body`-rooted rule, which is only ever matched against the
# live document.
return f"\n{self.selector} {{ position: running({self.name}); }}\nbody:not(.pagedjs) {self.selector} {{ display: none !important; }}"
class PageRegion(BaseModel):
_region: str = ""
content: PageRegionContent | None = Field(default_factory=PageNumber)
style: Style | None = None
css: str = ""
# common
tags: list[str] = Field(default_factory=list)
role: Role = Role.PAGE
ignore: bool = True
@field_validator("tags", mode="after")
@classmethod
def _ensure_tags(cls, v: list[str]) -> list[str]:
if "nbprint:page" not in v:
v.append("nbprint:page")
return v
def generate(self, metadata: dict, config: "Configuration", parent: "BaseModel", attr: str, **_) -> NotebookNode:
cell = super().generate(metadata=metadata, config=config, parent=parent, attr=attr)
cell.metadata.tags.append(f"nbprint:page:{attr}")
return cell
[docs]
class Page(BaseModel):
top: PageRegion | None = None
top_left: PageRegion | None = None
top_left_corner: PageRegion | None = None
top_right: PageRegion | None = None
top_right_corner: PageRegion | None = None
bottom: PageRegion | None = None
bottom_left: PageRegion | None = None
bottom_left_corner: PageRegion | None = None
bottom_right: PageRegion | None = None
bottom_right_corner: PageRegion | None = None
left: PageRegion | None = None
left_top: PageRegion | None = None
left_bottom: PageRegion | None = None
right: PageRegion | None = None
right_top: PageRegion | None = None
right_bottom: PageRegion | None = None
counter_reset: bool = False
counter_style: str | None = None
# The first page of a report is nearly always a cover, and a running footer
# or header across a cover looks like a mistake. Suppressing it is otherwise
# a hand-written rule against Paged.js' internal margin-box class names.
suppress_first_page_regions: bool = False
size: PageSize | Tuple[float, float] | None = Field(default=PageSize.letter)
orientation: PageOrientation | None = Field(default=PageOrientation.portrait)
css: str = ""
@model_validator(mode="after")
def _assemble_page_css(self) -> "Page":
"""Collect the CSS that has to sit outside any ``@page`` block or ``@scope``.
Two things land here rather than on the region itself. A region's CSS is
emitted as *cell* CSS, which the template wraps in ``@scope``, so a rule
addressing ``body`` or an element elsewhere in the document can never
match from there. And ``Configuration.page`` is typed as ``Page``, so a
consumer subclassing ``Page`` never reaches ``PageGlobal.render`` --
anything emitted only from there would silently do nothing for them.
"""
for attr in PAGE_REGION_ATTRS:
region = getattr(self, attr, None)
if region is None or region.content is None:
continue
# A region handed over as a field default -- `bottom_left: PageRegion
# = Field(default=Footer())` on a Page subclass -- never passes
# through the `mode="before"` validator that names it and attaches
# its margin-box rule, because Pydantic does not validate defaults.
# Without that rule the box is never declared, so nothing consumes
# the region's content: a running element in particular is then left
# in the flow with no margin box to be lifted into. This validator
# does run for defaults, so it is the one place that can repair them.
region_name = _region_css_name(attr)
if not region._region:
region._region = region_name
if f"@{region_name} {{" not in region.css:
region.css += f"@page {{ @{region_name} {{ {region.content} }} }}"
extra = region.content.region_css()
if extra and extra not in self.css:
self.css += extra
if self.suppress_first_page_regions and _FIRST_PAGE_SUPPRESSION_MARKER not in self.css:
selectors = ", ".join(f".pagedjs_first_page .pagedjs_margin-{edge}" for edge in ("top", "bottom", "left", "right"))
self.css += f"\n{selectors} {{ display: none !important; }}"
return self
[docs]
@classmethod
def convert_region_from_obj(cls, v, region) -> PageRegion:
ret = BaseModel._to_type(v, PageRegion)
ret._region = region
base_css_scope = "@page {{ @{region} {{ {content} }} }}".format(region=region, content=str(ret.content or ""))
ret.css += base_css_scope
return ret
@field_validator("top", mode="before")
@classmethod
def convert_top_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "top-center")
@field_validator("top_left", mode="before")
@classmethod
def convert_top_left_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "top-left")
@field_validator("top_left_corner", mode="before")
@classmethod
def convert_top_left_corner_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "top-left-corner")
@field_validator("top_right", mode="before")
@classmethod
def convert_top_right_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "top-right")
@field_validator("top_right_corner", mode="before")
@classmethod
def convert_top_right_corner_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "top-right-corner")
@field_validator("bottom", mode="before")
@classmethod
def convert_bottom_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "bottom-center")
@field_validator("bottom_left", mode="before")
@classmethod
def convert_bottom_left_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "bottom-left")
@field_validator("bottom_left_corner", mode="before")
@classmethod
def convert_bottom_left_corner_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "bottom-left-corner")
@field_validator("bottom_right", mode="before")
@classmethod
def convert_bottom_right_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "bottom-right")
@field_validator("bottom_right_corner", mode="before")
@classmethod
def convert_bottom_right_corner_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "bottom-right-corner")
@field_validator("left", mode="before")
@classmethod
def convert_left_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "left-center")
@field_validator("left_top", mode="before")
@classmethod
def convert_left_top_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "left-top")
@field_validator("left_bottom", mode="before")
@classmethod
def convert_left_bottom_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "left-bottom")
@field_validator("right", mode="before")
@classmethod
def convert_right_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "right-center")
@field_validator("right_top", mode="before")
@classmethod
def convert_right_top_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "right-top")
@field_validator("right_bottom", mode="before")
@classmethod
def convert_right_bottom_from_obj(cls, v) -> PageRegion:
return Page.convert_region_from_obj(v, "right-bottom")
[docs]
def generate(self, metadata: dict, config: "Configuration", parent: "BaseModel", attr: str = "page", **_) -> NotebookNode | list[NotebookNode] | None:
cells = []
# parent and config should be equal for the global page layout
assert parent == config
main_cell = super()._base_generate(metadata=metadata, config=config, parent=config, attr=attr or "page")
main_cell.metadata.tags.append("nbprint:page")
main_cell.metadata.nbprint.role = "page"
main_cell.metadata.nbprint.ignore = True
cells.append(main_cell)
for set_attr in PAGE_REGION_ATTRS:
if getattr(self, set_attr) is not None:
# pass in `self` as `parent here`
_append_or_extend(cells, getattr(self, set_attr).generate(metadata=metadata, config=config, parent=self, attr=set_attr))
for cell in cells:
if cell is None:
raise NBPrintNullCellError
return cells
class PageGlobal(Page):
pages: dict[Section, "Page"] | None = Field(default_factory=dict)
css: str = ""
def render(self, config: "Configuration" = None, **_) -> None:
if "@page { size:" not in self.css:
if isinstance(self.size, tuple):
self.css += f"\n@page {{ size: {self.size[0]}in {self.size[1]}in; }}"
else:
self.css += f"\n@page {{ size: {self.size.value} {self.orientation.value}; }}"
# Generate per-section named page CSS
for section_name, section_page in (self.pages or {}).items():
marker = f"@page {section_name}"
if marker in self.css:
continue
inner_rules = []
for region_attr in PAGE_REGION_ATTRS:
region = getattr(section_page, region_attr, None)
if region is not None and region.content:
inner_rules.append(f"@{region._region} {{ {region.content} }}")
if section_page.counter_reset:
inner_rules.append("counter-reset: page 1;")
if section_page.counter_style:
inner_rules.append(f"@bottom-center {{ content: counter(page, {section_page.counter_style}); }}")
if section_page.css:
inner_rules.append(section_page.css)
if inner_rules:
self.css += f"\n@page {section_name} {{ {' '.join(inner_rules)} }}"
self.css += f'\n[data-nbprint-section="{section_name}"] {{ page: {section_name}; }}'
self._render_page_box_rules(config)
def _render_page_box_rules(self, config: "Configuration" = None) -> None:
"""Emit a named ``@page`` rule for every page-box carrying per-page overrides.
A page-box cannot emit this itself: cell CSS is wrapped in ``@scope`` by the template, and an
``@page`` rule inside a scope does nothing. The page model is the one place that emits unscoped
CSS, and it renders before content, so it reaches into the already-built content tree the same way
the per-section rules above work off ``pages``.
"""
content = getattr(config, "content", None) if config is not None else None
if content is None:
return
for box in _iter_page_boxes(content):
if not (box.page_size or box.page_orientation or box.page_margins):
continue
name = f"nbprint-page-box-{box._id}"
if f"@page {name}" in self.css:
continue
size = box.page_size or self.size
orientation = box.page_orientation or self.orientation
rules = [f"size: {size[0]}in {size[1]}in;" if isinstance(size, tuple) else f"size: {size.value} {orientation.value};"]
if box.page_margins:
rules.append(f"margin: {box.page_margins};")
self.css += f"\n@page {name} {{ {' '.join(rules)} }}"
self.css += f'\n[data-nbprint-page-box="{box._id}"] {{ page: {name}; }}'
@field_validator("pages", mode="before")
@classmethod
def convert_pages_from_obj(cls, v) -> "Page":
if v is None:
return []
if isinstance(v, dict):
for k, element in v.items():
if isinstance(element, str):
v[k] = Page(type_=element)
elif isinstance(element, dict):
v[k] = BaseModel._to_type(element)
elif element is None:
v[k] = Page()
return v