inkfold 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. inkfold/__init__.py +16 -0
  2. inkfold/brochure/__init__.py +43 -0
  3. inkfold/brochure/checks.py +126 -0
  4. inkfold/brochure/document.py +197 -0
  5. inkfold/brochure/fit.py +44 -0
  6. inkfold/brochure/fold.py +223 -0
  7. inkfold/brochure/imposition.py +89 -0
  8. inkfold/brochure/medium.py +40 -0
  9. inkfold/brochure/panel.py +266 -0
  10. inkfold/builder/__init__.py +307 -0
  11. inkfold/builder/apparatus.py +247 -0
  12. inkfold/builder/components.py +1990 -0
  13. inkfold/builder/composition.py +374 -0
  14. inkfold/builder/containers.py +1355 -0
  15. inkfold/builder/document.py +800 -0
  16. inkfold/builder/email.py +294 -0
  17. inkfold/builder/engine.py +445 -0
  18. inkfold/builder/enums.py +221 -0
  19. inkfold/builder/exceptions.py +61 -0
  20. inkfold/builder/exhibits.py +395 -0
  21. inkfold/builder/filters.py +243 -0
  22. inkfold/builder/finite.py +37 -0
  23. inkfold/builder/formats.py +246 -0
  24. inkfold/builder/glance.py +312 -0
  25. inkfold/builder/images.py +605 -0
  26. inkfold/builder/medium.py +176 -0
  27. inkfold/builder/models.py +1431 -0
  28. inkfold/builder/organising.py +436 -0
  29. inkfold/builder/prose.py +92 -0
  30. inkfold/builder/regions.py +843 -0
  31. inkfold/builder/research.py +348 -0
  32. inkfold/builder/sizing.py +1437 -0
  33. inkfold/builder/surfaces.py +419 -0
  34. inkfold/builder/templates/analysis/bar-list.html +42 -0
  35. inkfold/builder/templates/analysis/card-group.html +64 -0
  36. inkfold/builder/templates/analysis/cell-bar.html +12 -0
  37. inkfold/builder/templates/analysis/chart-block.html +59 -0
  38. inkfold/builder/templates/analysis/data-table.html +115 -0
  39. inkfold/builder/templates/analysis/figure-grid.html +71 -0
  40. inkfold/builder/templates/analysis/hero-stat.html +24 -0
  41. inkfold/builder/templates/analysis/legend.html +22 -0
  42. inkfold/builder/templates/analysis/sparkline-bars.html +22 -0
  43. inkfold/builder/templates/analysis/sparkline.html +20 -0
  44. inkfold/builder/templates/analysis/trend-arrow.html +26 -0
  45. inkfold/builder/templates/base.html +135 -0
  46. inkfold/builder/templates/brochure/base.html +102 -0
  47. inkfold/builder/templates/brochure/panel.html +37 -0
  48. inkfold/builder/templates/brochure/side.html +13 -0
  49. inkfold/builder/templates/common/badge.html +36 -0
  50. inkfold/builder/templates/common/containers/columns.html +179 -0
  51. inkfold/builder/templates/common/containers/full-width.html +84 -0
  52. inkfold/builder/templates/common/contents-list.html +24 -0
  53. inkfold/builder/templates/common/cta.html +33 -0
  54. inkfold/builder/templates/common/disclosure.html +28 -0
  55. inkfold/builder/templates/common/endnotes.html +19 -0
  56. inkfold/builder/templates/common/font-faces.html +6 -0
  57. inkfold/builder/templates/common/kicker.html +16 -0
  58. inkfold/builder/templates/common/nested-columns.html +63 -0
  59. inkfold/builder/templates/common/notes.html +38 -0
  60. inkfold/builder/templates/common/placed.html +25 -0
  61. inkfold/builder/templates/common/qualifier.html +7 -0
  62. inkfold/builder/templates/common/separated-columns.html +37 -0
  63. inkfold/builder/templates/common/share.html +27 -0
  64. inkfold/builder/templates/common/source-line.html +14 -0
  65. inkfold/builder/templates/common/stack.html +19 -0
  66. inkfold/builder/templates/common/stamp.html +14 -0
  67. inkfold/builder/templates/common/surface.html +17 -0
  68. inkfold/builder/templates/deck/bands.html +44 -0
  69. inkfold/builder/templates/deck/base.html +122 -0
  70. inkfold/builder/templates/deck/divider.html +41 -0
  71. inkfold/builder/templates/deck/regions/closing-slide.html +13 -0
  72. inkfold/builder/templates/deck/regions/title-slide.html +30 -0
  73. inkfold/builder/templates/deck/slide.html +42 -0
  74. inkfold/builder/templates/deck/statement.html +19 -0
  75. inkfold/builder/templates/document/base.html +181 -0
  76. inkfold/builder/templates/document/editorial.html +24 -0
  77. inkfold/builder/templates/document/page.html +25 -0
  78. inkfold/builder/templates/document/regions/back-matter.html +16 -0
  79. inkfold/builder/templates/document/regions/contents.html +16 -0
  80. inkfold/builder/templates/document/regions/cover.html +26 -0
  81. inkfold/builder/templates/document/regions/running-box.html +32 -0
  82. inkfold/builder/templates/media/image-block.html +79 -0
  83. inkfold/builder/templates/media/math-block.html +32 -0
  84. inkfold/builder/templates/media/qr-code.html +27 -0
  85. inkfold/builder/templates/regions/banner-minimal.html +90 -0
  86. inkfold/builder/templates/regions/banner.html +140 -0
  87. inkfold/builder/templates/regions/footer.html +67 -0
  88. inkfold/builder/templates/regions/header-bar.html +15 -0
  89. inkfold/builder/templates/text/author-block.html +24 -0
  90. inkfold/builder/templates/text/bibliography.html +23 -0
  91. inkfold/builder/templates/text/button.html +15 -0
  92. inkfold/builder/templates/text/callout.html +30 -0
  93. inkfold/builder/templates/text/contact-block.html +17 -0
  94. inkfold/builder/templates/text/contents.html +11 -0
  95. inkfold/builder/templates/text/divider.html +18 -0
  96. inkfold/builder/templates/text/fact-list.html +43 -0
  97. inkfold/builder/templates/text/glossary.html +25 -0
  98. inkfold/builder/templates/text/numbered-list.html +29 -0
  99. inkfold/builder/templates/text/prose-styles.html +20 -0
  100. inkfold/builder/templates/text/pull-quote.html +24 -0
  101. inkfold/builder/templates/text/tag-row.html +16 -0
  102. inkfold/builder/templates/text/teaser-list.html +81 -0
  103. inkfold/builder/templates/text/text-block.html +44 -0
  104. inkfold/builder/templates/text/timeline.html +58 -0
  105. inkfold/builder/textgen.py +400 -0
  106. inkfold/builder/theming.py +548 -0
  107. inkfold/builder/typography.py +398 -0
  108. inkfold/check/__init__.py +59 -0
  109. inkfold/check/__main__.py +70 -0
  110. inkfold/check/lint.py +1205 -0
  111. inkfold/check/target.py +83 -0
  112. inkfold/config.py +340 -0
  113. inkfold/data/__init__.py +28 -0
  114. inkfold/data/charts.py +109 -0
  115. inkfold/data/exceptions.py +20 -0
  116. inkfold/data/frames.py +193 -0
  117. inkfold/data/style.py +123 -0
  118. inkfold/deck/__init__.py +36 -0
  119. inkfold/deck/document.py +508 -0
  120. inkfold/deck/fit.py +136 -0
  121. inkfold/deck/medium.py +41 -0
  122. inkfold/deck/regions.py +202 -0
  123. inkfold/deck/slide.py +691 -0
  124. inkfold/delivery/__init__.py +33 -0
  125. inkfold/delivery/exceptions.py +44 -0
  126. inkfold/delivery/message.py +574 -0
  127. inkfold/delivery/retry.py +126 -0
  128. inkfold/document/__init__.py +46 -0
  129. inkfold/document/document.py +271 -0
  130. inkfold/document/medium.py +41 -0
  131. inkfold/document/page.py +176 -0
  132. inkfold/document/regions.py +436 -0
  133. inkfold/email/__init__.py +13 -0
  134. inkfold/email/medium.py +79 -0
  135. inkfold/gmail/__init__.py +30 -0
  136. inkfold/gmail/sender.py +269 -0
  137. inkfold/math/__init__.py +25 -0
  138. inkfold/math/adapter.py +104 -0
  139. inkfold/math/exceptions.py +19 -0
  140. inkfold/math/render.py +190 -0
  141. inkfold/outlook/__init__.py +39 -0
  142. inkfold/outlook/desktop.py +152 -0
  143. inkfold/outlook/sender.py +341 -0
  144. inkfold/pdf/__init__.py +63 -0
  145. inkfold/pdf/attachment.py +51 -0
  146. inkfold/pdf/exceptions.py +57 -0
  147. inkfold/pdf/exporter.py +335 -0
  148. inkfold/pdf/fetcher.py +83 -0
  149. inkfold/pdf/profile.py +121 -0
  150. inkfold/pdf/tagging.py +136 -0
  151. inkfold/py.typed +0 -0
  152. inkfold/qr/__init__.py +12 -0
  153. inkfold/qr/adapter.py +103 -0
  154. inkfold/qr/exceptions.py +15 -0
  155. inkfold-0.5.0.dist-info/METADATA +1280 -0
  156. inkfold-0.5.0.dist-info/RECORD +158 -0
  157. inkfold-0.5.0.dist-info/WHEEL +4 -0
  158. inkfold-0.5.0.dist-info/licenses/LICENSE +21 -0
inkfold/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """
2
+ inkfold service layer: ``builder`` -> ``delivery`` -> ``gmail`` / ``outlook``.
3
+
4
+ Two seams hold the chain apart, and both are load-bearing:
5
+
6
+ **Builder to delivery** — the builder *declares* CID embeds. For every
7
+ ``src="cid:X"`` in the HTML, :meth:`Email.assets` has the entry describing
8
+ what to attach as ``X``; delivery *performs* the attachment.
9
+
10
+ **Delivery to an adapter** — delivery assembles bytes, an adapter transmits
11
+ them. Adapters own their provider's wire contract and error semantics and
12
+ **never** authentication, so inkfold depends on no provider SDK.
13
+
14
+ This package re-exports nothing: import from ``inkfold.builder``,
15
+ ``inkfold.delivery``, ``inkfold.gmail`` or ``inkfold.outlook`` directly.
16
+ """
@@ -0,0 +1,43 @@
1
+ """
2
+ The brochure medium: one sheet folded into panels, printed on both sides.
3
+
4
+ ``Brochure`` is the product; ``Panel`` is the layout unit, and flattens in any
5
+ other medium as ``Page`` does; ``FoldFormat`` is the sheet and its fold, and
6
+ its presets are the shipped folds. Imposition, from the reader's order to the
7
+ printer's, is the medium's and never the caller's.
8
+ """
9
+
10
+ from .document import Brochure
11
+ from .fit import overflowing_panels
12
+ from .fold import (
13
+ BI_FOLD_LETTER,
14
+ FOLD_FORMATS,
15
+ GATE_FOLD_A4,
16
+ TRI_FOLD_LETTER,
17
+ Z_FOLD_LETTER,
18
+ FoldFormat,
19
+ FoldKind,
20
+ )
21
+ from .imposition import FACE_NAMES, IMPOSITION, face_name, impose
22
+ from .medium import BROCHURE_MEDIUM, brochure_medium
23
+ from .panel import Panel, PanelBox
24
+
25
+ __all__ = [
26
+ "BI_FOLD_LETTER",
27
+ "BROCHURE_MEDIUM",
28
+ "FACE_NAMES",
29
+ "FOLD_FORMATS",
30
+ "GATE_FOLD_A4",
31
+ "IMPOSITION",
32
+ "TRI_FOLD_LETTER",
33
+ "Z_FOLD_LETTER",
34
+ "Brochure",
35
+ "FoldFormat",
36
+ "FoldKind",
37
+ "Panel",
38
+ "PanelBox",
39
+ "brochure_medium",
40
+ "face_name",
41
+ "impose",
42
+ "overflowing_panels",
43
+ ]
@@ -0,0 +1,126 @@
1
+ """
2
+ What a print house rejects, checked before anything renders (#188).
3
+
4
+ Both checks read facts the brochure already holds at construction: the fold's
5
+ arithmetic and each image's own bytes. Neither is on the medium's
6
+ ``constraints``, because a ``Constraint`` sees only the composed markup, and
7
+ neither a panel's inset nor an image's pixel count is in it.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from collections.abc import Sequence
14
+
15
+ from inkfold.builder.exceptions import PrintQualityWarning, ValidationError, warn_caller
16
+ from inkfold.builder.images import EmailImage, pixel_size
17
+ from inkfold.config import get_config
18
+
19
+ from .fold import FoldFormat
20
+ from .imposition import face_name, impose
21
+ from .panel import Panel
22
+
23
+ #: CSS px per inch: every width in the package is px at 96 dpi.
24
+ CSS_DPI = 96
25
+
26
+
27
+ def panel_label(fold: FoldFormat, reader: int, panel: Panel) -> str:
28
+ """How an error names a panel: its face, and its title when it has one."""
29
+ named = f" ({panel.title!r})" if panel.title else ""
30
+ return f"the {face_name(fold, reader)}{named}"
31
+
32
+
33
+ def validate_safe_area(fold: FoldFormat, panels: Sequence[Panel]) -> None:
34
+ """
35
+ Every panel keeps its copy at least ``fold.safe`` from its edges.
36
+
37
+ Raises:
38
+ ValidationError: Naming the first panel that does not, and both distances.
39
+ """
40
+ for reader, panel in enumerate(panels, start=1):
41
+ inset = fold.inset if panel.inset is None else panel.inset
42
+ if inset < fold.safe:
43
+ raise ValidationError(
44
+ f"{panel_label(fold, reader, panel)} keeps its copy {inset}px from the "
45
+ f"trim, inside the {fold.safe}px safe distance, where a cut can reach it. "
46
+ f"Give it an inset of at least {fold.safe}."
47
+ )
48
+
49
+
50
+ def validate_inset_room(fold: FoldFormat, panels: Sequence[Panel]) -> None:
51
+ """
52
+ Every panel's inset leaves it a content width and a content height (#460).
53
+
54
+ The inset is taken from both edges of each axis, so it must stay under half
55
+ the panel's box. Without this the frame refuses the render, naming neither
56
+ the panel nor the inset.
57
+
58
+ Raises:
59
+ ValidationError: Naming the first panel with no room, its inset and its box.
60
+ """
61
+ for panel_box, (reader, panel) in zip(impose(fold), enumerate(panels, start=1), strict=True):
62
+ inset = fold.inset if panel.inset is None else panel.inset
63
+ for axis, length in (("width", panel_box.width), ("height", panel_box.height)):
64
+ if inset * 2 >= length:
65
+ raise ValidationError(
66
+ f"{panel_label(fold, reader, panel)} has an inset of {inset}px, which leaves "
67
+ f"no content {axis} inside its {length}px {axis}: an inset is taken from "
68
+ f"both edges. Give it an inset below {length / 2:g}."
69
+ )
70
+
71
+
72
+ def required_pixels(display_width: int | float, dpi: int | None = None) -> int:
73
+ """Source pixels an image displayed ``display_width`` px wide needs to print at ``dpi``."""
74
+ return math.ceil(display_width * (dpi or get_config().print_dpi) / CSS_DPI)
75
+
76
+
77
+ def validate_image_resolution(
78
+ images: Sequence[
79
+ EmailImage
80
+ | tuple[EmailImage, int | float | None]
81
+ | tuple[EmailImage, int | float | None, str]
82
+ ],
83
+ dpi: int | None = None,
84
+ ) -> None:
85
+ """
86
+ Every image carries enough pixels to print at ``Config.print_dpi``.
87
+
88
+ An entry is an image, displayed at its own ``width`` (or its pixel width
89
+ when it has none), or an ``(image, display width)`` pair for one that
90
+ fills a box instead, such as a panel's ground. Below the target warns with
91
+ a ``PrintQualityWarning``, as the email's 90 KB threshold does; below half of it raises. A
92
+ hosted image has no bytes to measure, and the PDF exporter refuses it.
93
+ ``dpi`` sets another target, as a deck sets a screen's (#348). A third
94
+ element says where the use sits, and the message ends with it (#458).
95
+
96
+ Raises:
97
+ ValidationError: Naming the image, its pixel width and the width it needs.
98
+ """
99
+ dpi = dpi or get_config().print_dpi
100
+ for entry in images:
101
+ fills: int | float | None = None
102
+ where: str | None = None
103
+ if isinstance(entry, EmailImage):
104
+ image = entry
105
+ else:
106
+ image, fills, *rest = entry
107
+ where = rest[0] if rest else None
108
+ used = f" It is {where}." if where else ""
109
+ size = pixel_size(image.data) if image.data else None
110
+ if size is None:
111
+ continue
112
+ pixels = size[0]
113
+ display = fills or image.width or pixels
114
+ needed = required_pixels(display, dpi)
115
+ if pixels * 2 < needed:
116
+ raise ValidationError(
117
+ f"the image {image.alt or image.filename!r} is {pixels}px wide and displayed "
118
+ f"at {display}px, which prints at {pixels * CSS_DPI / display:.0f} dpi. "
119
+ f"It needs {needed}px to print at {dpi} dpi.{used}"
120
+ )
121
+ if pixels < needed:
122
+ warn_caller(
123
+ f"the image {image.alt or image.filename!r} is {pixels}px wide and "
124
+ f"needs {needed}px to print at {dpi} dpi.{used}",
125
+ PrintQualityWarning,
126
+ )
@@ -0,0 +1,197 @@
1
+ """
2
+ ``Brochure`` — panels in the order a reader meets them, printed in the order a press needs.
3
+
4
+ The brochure counterpart to :class:`~inkfold.document.document.PagedDocument`.
5
+ The caller hands over panels in reader order; the fold's imposition decides
6
+ which side and position each one prints at, and the same three projections
7
+ follow.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ from pathlib import Path
14
+ from typing import Any, ClassVar, Self
15
+
16
+ from inkfold.builder.containers import Container
17
+ from inkfold.builder.document import Document
18
+ from inkfold.builder.engine import Renderer, TemplateOverlay
19
+ from inkfold.builder.exceptions import ValidationError
20
+ from inkfold.builder.images import EmailImage
21
+ from inkfold.builder.models import DocumentMetadata
22
+ from inkfold.config import Config
23
+
24
+ from .checks import (
25
+ panel_label,
26
+ validate_image_resolution,
27
+ validate_inset_room,
28
+ validate_safe_area,
29
+ )
30
+ from .fold import TRI_FOLD_LETTER, FoldFormat, _px
31
+ from .imposition import face_name, impose, sides
32
+ from .medium import brochure_medium
33
+ from .panel import Panel
34
+
35
+
36
+ class Brochure(Document):
37
+ """
38
+ One sheet, folded: its panels, its fold, and the print it becomes.
39
+
40
+ **Reader order is the only order the API speaks.** Panels are given as a
41
+ reader meets them, front cover first, and no argument takes a printer
42
+ position: imposition is a rendering fact the fold owns. The plain-text
43
+ projection is reader order too, because a text reader reads.
44
+
45
+ **The panels are fixed at construction**, and their count must be the
46
+ fold's: a five-panel tri-fold is an error, not a blank face.
47
+ :meth:`add_section` is refused.
48
+
49
+ **Footnotes are refused.** A note floats to the foot of a sheet, and a
50
+ side's foot runs under three panels that a reader meets separately. So is
51
+ a panel inset inside the safe distance, and an image too coarse to print.
52
+
53
+ Args:
54
+ metadata: Mapping of facts, or a ``DocumentMetadata``.
55
+ panels: Every face of the sheet, in reader order.
56
+ fold: The sheet and its fold. Defaults to a letter tri-fold.
57
+ template_dir: Root of the templates. Defaults to the packaged copy.
58
+ config, template_overlay: As ``Document`` takes them; print dpi is a limit.
59
+
60
+ Raises:
61
+ ValidationError: If the panel count is not the fold's, a panel is not
62
+ a :class:`~inkfold.brochure.panel.Panel`, or a component carries a
63
+ footnote.
64
+ """
65
+
66
+ METADATA: ClassVar[type[DocumentMetadata]] = DocumentMetadata
67
+
68
+ def __init__(
69
+ self,
70
+ metadata: dict[str, Any] | DocumentMetadata,
71
+ panels: list[Panel],
72
+ fold: FoldFormat = TRI_FOLD_LETTER,
73
+ template_dir: Path | None = None,
74
+ *,
75
+ config: Config | None = None,
76
+ template_overlay: TemplateOverlay = None,
77
+ ):
78
+ super().__init__(
79
+ metadata,
80
+ template_dir,
81
+ brochure_medium(fold),
82
+ config=config,
83
+ template_overlay=template_overlay,
84
+ )
85
+ self._fold = fold
86
+ self._proof = False
87
+ if len(panels) != fold.faces:
88
+ raise ValidationError(
89
+ f"a {fold.kind.value}-fold has {fold.faces} panels, "
90
+ f"{fold.panels} a side; got {len(panels)}. Pass every face in reader "
91
+ f"order: {', '.join(face_name(fold, n) for n in range(1, fold.faces + 1))}."
92
+ )
93
+ for reader, panel in enumerate(panels, start=1):
94
+ if not isinstance(panel, Panel):
95
+ raise ValidationError(
96
+ f"the {face_name(fold, reader)} must be a Panel, got: {type(panel).__name__}"
97
+ )
98
+ Document.add_section(self, panel)
99
+ if self._footnotes():
100
+ raise ValidationError(
101
+ "a brochure cannot carry footnotes: a note floats to the foot of a sheet, "
102
+ "and a side's foot runs under three panels. Put the note in the panel's copy."
103
+ )
104
+ validate_safe_area(fold, panels)
105
+ validate_inset_room(fold, panels)
106
+ # One image may ground two panels of different widths, so each use is
107
+ # measured at its own box and named by it (#458).
108
+ uses: list[tuple[EmailImage, int | float | None, str]] = []
109
+ for box, (reader, panel) in zip(impose(fold), enumerate(panels, start=1), strict=True):
110
+ label = panel_label(fold, reader, panel)
111
+ if panel.background_image is not None:
112
+ width = box.ground(fold.bleed, fold.panels)["width"]
113
+ uses.append((panel.background_image, width, f"the ground of {label}"))
114
+ uses.extend(
115
+ (image, None, f"in {label}")
116
+ for section in panel.sections
117
+ for image in section.images()
118
+ )
119
+ with self.configured():
120
+ validate_image_resolution(uses)
121
+
122
+ @property
123
+ def fold(self) -> FoldFormat:
124
+ """The sheet and its fold."""
125
+ return self._fold
126
+
127
+ @property
128
+ def panels(self) -> list[Panel]:
129
+ """Every panel, in reader order."""
130
+ return [section for section in self._sections if isinstance(section, Panel)]
131
+
132
+ def add_section(self, container: Container) -> Self:
133
+ """Refused: a brochure's panels are fixed by its fold, and given at construction."""
134
+ raise ValidationError(
135
+ "a brochure's panels are given at construction, all "
136
+ f"{self._fold.faces} of them; a folded sheet has no room for another section"
137
+ )
138
+
139
+ def render(self, proof: bool | None = None) -> str:
140
+ """
141
+ Render both sides of the sheet.
142
+
143
+ ``proof=True`` draws a light guide on every fold and labels each panel
144
+ with its reader index and name, so a designer can check the
145
+ imposition without folding paper. ``None`` takes this brochure's own
146
+ setting, which is off unless it came from :meth:`proof`.
147
+ """
148
+ standing = self._proof
149
+ self._proof = standing if proof is None else proof
150
+ try:
151
+ return super().render()
152
+ finally:
153
+ self._proof = standing
154
+
155
+ def proof(self) -> Brochure:
156
+ """
157
+ This brochure as a proof: the same panels, rendered with guides and labels.
158
+
159
+ A copy, so the brochure a printer is sent never carries the flag, and
160
+ ``render_pdf(brochure.proof())`` prints a proof through the unchanged
161
+ exporter.
162
+ """
163
+ proof = copy.copy(self)
164
+ proof._proof = True
165
+ return proof
166
+
167
+ def inset(self, panel: Panel) -> int | float:
168
+ """The distance ``panel`` keeps its copy from its edges: its own, or the fold's."""
169
+ return self._fold.inset if panel.inset is None else panel.inset
170
+
171
+ def _body_context(self, engine: Renderer, sections: list[Container]) -> dict[str, Any]:
172
+ """The two sides, each holding its panels in the printer's order, and the print marks."""
173
+ fold, panels, boxes = self._fold, self.panels, impose(self._fold)
174
+ rendered = {}
175
+ for box in boxes:
176
+ panel = panels[box.reader - 1]
177
+ rendered[box.reader] = panel.render_box(engine, box, self.inset(panel), fold)
178
+ sides_html = "\n".join(
179
+ engine.render(
180
+ "brochure/side.html",
181
+ {
182
+ "side": side,
183
+ "panels_html": "\n".join(rendered[reader] for reader in readers),
184
+ "proof": self._proof,
185
+ "folds": [_px(edge) for edge in fold.offsets(side)[1:]],
186
+ "labels": [
187
+ {"box": boxes[reader - 1], "name": face_name(fold, reader)}
188
+ for reader in readers
189
+ ],
190
+ },
191
+ )
192
+ for side, readers in enumerate(sides(fold), start=1)
193
+ )
194
+ # The print engine draws its marks inside the declared bleed, under
195
+ # whatever is painted there, so the declared bleed is the real bleed
196
+ # plus a slug the grounds never reach.
197
+ return {"sections_html": sides_html, "print_bleed": _px(fold.bleed + fold.slug)}
@@ -0,0 +1,44 @@
1
+ """
2
+ Whether every panel's copy fits inside its safe line, asked of the print engine.
3
+
4
+ A panel clips what overflows it (#186), and a clip is silent in the PDF. This
5
+ lays the brochure out and reads back where each panel's closing sentinel
6
+ landed. It needs the ``[pdf]`` extra, imported only on use.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from .document import Brochure
14
+ from .imposition import face_name, impose
15
+
16
+ if TYPE_CHECKING: # pragma: no cover - the exporter is imported only on use
17
+ from inkfold.pdf import Layout
18
+
19
+
20
+ def overflowing_panels(brochure: Brochure, laid_out: Layout | None = None) -> list[str]:
21
+ """
22
+ Every panel whose copy runs past its safe line, named by reader index and face.
23
+
24
+ A panel's sentinel lands below ``height - inset`` when its copy overflows
25
+ within the sheet, and nowhere at all when it overflows off it: the print
26
+ engine records no position for an element it did not place on a page.
27
+ Empty when everything fits. ``laid_out``, a :func:`~inkfold.pdf.layout`
28
+ of this brochure, is read rather than laying it out again.
29
+
30
+ Raises:
31
+ ValueError: If ``laid_out`` is a layout of another document.
32
+ inkfold.pdf.BackendMissingError: If WeasyPrint is not installed.
33
+ """
34
+ from inkfold.pdf import anchor_tops
35
+
36
+ if laid_out is not None and laid_out.document is not brochure:
37
+ raise ValueError("laid_out is a layout of another document, not this brochure")
38
+ landed = anchor_tops(brochure if laid_out is None else laid_out)
39
+ overflowing = []
40
+ for box, panel in zip(impose(brochure.fold), brochure.panels, strict=True):
41
+ y = landed.get(f"panel-{box.reader}-end")
42
+ if y is None or y > box.height - brochure.inset(panel):
43
+ overflowing.append(f"panel {box.reader} ({face_name(brochure.fold, box.reader)})")
44
+ return overflowing
@@ -0,0 +1,223 @@
1
+ """
2
+ Fold geometry: a sheet, a fold, and the panels the fold makes of it.
3
+
4
+ A brochure is laid out on panels, not on the sheet. This module turns a sheet
5
+ and a fold into panel widths, in px at 96 dpi like every size in the package,
6
+ and is the only place those widths are computed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+ from inkfold.builder.exceptions import ValidationError
15
+ from inkfold.builder.finite import require_finite
16
+ from inkfold.builder.sizing import PageFormat
17
+
18
+
19
+ class FoldKind(StrEnum):
20
+ """How a sheet is folded, which decides both its panel widths and its imposition."""
21
+
22
+ #: One fold down the middle: four faces, two a side.
23
+ BI = "bi"
24
+ #: A letter, or roll, fold: the right third folds in, the left third over
25
+ #: it. Six faces; the panel that folds inside is the narrow one.
26
+ C = "c"
27
+ #: An accordion: two folds in opposite directions, three equal panels.
28
+ Z = "z"
29
+ #: Two flaps fold in from the edges to meet over a double-width centre.
30
+ GATE = "gate"
31
+
32
+
33
+ #: Panels on each side of the sheet, per fold.
34
+ PANELS_PER_SIDE: dict[FoldKind, int] = {
35
+ FoldKind.BI: 2,
36
+ FoldKind.C: 3,
37
+ FoldKind.Z: 3,
38
+ FoldKind.GATE: 3,
39
+ }
40
+
41
+ #: The folds whose inner panels must be narrower to close flat. A Z-fold's
42
+ #: panels never nest and a bi-fold's two halves meet edge to edge.
43
+ _TUCKED = frozenset({FoldKind.C, FoldKind.GATE})
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class FoldFormat:
48
+ """
49
+ A sheet and how it folds: everything panel geometry is computed from.
50
+
51
+ **The tuck is a distance, not a ratio.** A panel that folds inside another
52
+ must be a few millimetres narrower or it buckles against the crease, and
53
+ that allowance does not grow with the sheet. So this is not
54
+ :func:`~inkfold.builder.sizing.column_layout`, which splits a content width by
55
+ weights with gutters between; `brochure.md` has why it was not widened.
56
+
57
+ ``panels`` is derived from ``kind``, as ``Medium.slots`` is from its
58
+ regions, so the two cannot disagree. Every width is px at 96 dpi, and an
59
+ ``int`` when whole: the size layers refuse ``356.0``.
60
+
61
+ Args:
62
+ sheet: The flat sheet, landscape, with no margin: a panel insets its
63
+ own copy, and a sheet margin would inset the edges twice.
64
+ kind: The fold.
65
+ tuck: How much narrower each inside panel is, in px. Zero for a
66
+ bi-fold and a Z-fold, which have no panel that nests.
67
+ inset: How far a panel keeps its copy from its edges unless the
68
+ panel says otherwise, in px. 24px is 1/4in (6.4mm).
69
+ safe: The least inset any panel may keep (#188): copy nearer the
70
+ trim than this risks being cut. 12px is 1/8in (3.2mm).
71
+ bleed: How far a panel's ground runs past the trim, so a cut that
72
+ lands a hair outside leaves no white sliver. 12px, 1/8in.
73
+ slug: Room past the bleed for the crop and registration marks.
74
+ 24px, 1/4in.
75
+ """
76
+
77
+ sheet: PageFormat
78
+ kind: FoldKind
79
+ tuck: int | float = 0
80
+ inset: int | float = 24
81
+ safe: int | float = 12
82
+ bleed: int | float = 12
83
+ slug: int | float = 24
84
+
85
+ def __post_init__(self) -> None:
86
+ try:
87
+ object.__setattr__(self, "kind", FoldKind(self.kind))
88
+ except ValueError:
89
+ raise ValidationError(
90
+ f"'fold.kind' must be one of {[k.value for k in FoldKind]}, got: {self.kind!r}"
91
+ ) from None
92
+ if self.sheet.height is None:
93
+ raise ValidationError("'fold.sheet' must have a height: a continuous page cannot fold")
94
+ if self.sheet.orientation != "landscape":
95
+ raise ValidationError(
96
+ f"'fold.sheet' must be landscape, got {self.sheet.orientation}: the panels "
97
+ "stand side by side across the sheet's width"
98
+ )
99
+ if any(
100
+ (
101
+ self.sheet.margin.top,
102
+ self.sheet.margin.right,
103
+ self.sheet.margin.bottom,
104
+ self.sheet.margin.left,
105
+ )
106
+ ):
107
+ raise ValidationError(
108
+ "'fold.sheet' must have no margin: a panel insets its own copy, so a sheet "
109
+ "margin would inset the outer panels twice"
110
+ )
111
+ if isinstance(self.tuck, bool) or not isinstance(self.tuck, (int, float)):
112
+ raise ValidationError(f"'fold.tuck' must be a number, got: {type(self.tuck).__name__}")
113
+ require_finite(self.tuck, "fold.tuck")
114
+ if self.tuck < 0:
115
+ raise ValidationError(f"'fold.tuck' must not be negative, got: {self.tuck}")
116
+ if self.tuck and self.kind not in _TUCKED:
117
+ raise ValidationError(
118
+ f"a {self.kind.value}-fold has no panel that folds inside another, so "
119
+ f"'fold.tuck' must be 0, got: {self.tuck}"
120
+ )
121
+ for name in ("inset", "safe", "bleed", "slug"):
122
+ value = getattr(self, name)
123
+ require_finite(value, f"fold.{name}")
124
+ if value < 0:
125
+ raise ValidationError(f"'fold.{name}' must be a number of px, got: {value!r}")
126
+ if self.inset < self.safe:
127
+ raise ValidationError(
128
+ f"'fold.inset' ({self.inset}) is inside the safe distance ({self.safe}): "
129
+ "every panel that took the default would be refused"
130
+ )
131
+ if min(self.widths) <= 0:
132
+ raise ValidationError(f"'fold.tuck' ({self.tuck}) leaves a panel with no width")
133
+
134
+ @property
135
+ def panels(self) -> int:
136
+ """Panels on each side of the sheet."""
137
+ return PANELS_PER_SIDE[self.kind]
138
+
139
+ @property
140
+ def faces(self) -> int:
141
+ """Panels a reader meets, both sides together: what a brochure is given."""
142
+ return 2 * self.panels
143
+
144
+ @property
145
+ def width(self) -> int | float:
146
+ """The sheet's width."""
147
+ return self.sheet.width
148
+
149
+ @property
150
+ def height(self) -> int | float:
151
+ """The sheet's height, which is every panel's height."""
152
+ assert self.sheet.height is not None # refused in __post_init__
153
+ return self.sheet.height
154
+
155
+ @property
156
+ def widths(self) -> tuple[int | float, ...]:
157
+ """
158
+ Side 1's panel widths, left to right. Sum exactly to the sheet.
159
+
160
+ Side 2 is the mirror: a sheet turned over puts side 1's left panel
161
+ behind side 2's right, and a panel is one width on both faces. See
162
+ :meth:`side_widths`.
163
+ """
164
+ width, tuck = self.width, self.tuck
165
+ if self.kind is FoldKind.BI:
166
+ half = _px(width / 2)
167
+ return (half, _px(width - half))
168
+ if self.kind is FoldKind.Z:
169
+ third = _px(width / 3)
170
+ return (third, third, _px(width - 2 * third))
171
+ if self.kind is FoldKind.C:
172
+ # Two full panels and the tuck panel, which is full less the tuck.
173
+ # It sits at side 1's left so it backs side 2's right, the panel
174
+ # that folds in first.
175
+ full = _px((width + tuck) / 3)
176
+ return (_px(width - 2 * full), full, full)
177
+ # GATE: two flaps meeting short of the middle by the tuck, over a
178
+ # centre panel that takes whatever the flaps leave.
179
+ flap = _px((width / 2 - tuck) / 2)
180
+ return (flap, _px(width - 2 * flap), flap)
181
+
182
+ def side_widths(self, side: int) -> tuple[int | float, ...]:
183
+ """One side's panel widths, left to right; ``side`` is 1 or 2."""
184
+ if side not in (1, 2):
185
+ raise ValidationError(f"a sheet has sides 1 and 2, got: {side!r}")
186
+ return self.widths if side == 1 else tuple(reversed(self.widths))
187
+
188
+ def offsets(self, side: int) -> tuple[int | float, ...]:
189
+ """Each panel's left edge on one side, from the sheet's left edge."""
190
+ edges: list[int | float] = []
191
+ running: int | float = 0
192
+ for width in self.side_widths(side):
193
+ edges.append(_px(running))
194
+ running += width
195
+ return tuple(edges)
196
+
197
+
198
+ def _px(value: int | float) -> int | float:
199
+ """A width as an ``int`` when whole, so it never prints as ``356.0px``."""
200
+ return int(value) if float(value).is_integer() else value
201
+
202
+
203
+ #: The flat sheets, landscape, in px at 96 dpi. Letter is 11 x 8.5in; A4 is
204
+ #: 297 x 210mm. No margin: see ``FoldFormat.sheet``.
205
+ LETTER_SHEET = PageFormat(width=1056, height=816)
206
+ A4_SHEET = PageFormat(width=1123, height=794)
207
+
208
+ #: The shipped folds. Every preset's widths are pinned by a test.
209
+ #:
210
+ #: The C-fold's tuck is 12px, 1/8in (3.2mm): 356 + 356 + 344. The gate's is
211
+ #: 7.5px, 2mm, which leaves each flap a whole 277px over a 569px centre.
212
+ BI_FOLD_LETTER = FoldFormat(sheet=LETTER_SHEET, kind=FoldKind.BI)
213
+ TRI_FOLD_LETTER = FoldFormat(sheet=LETTER_SHEET, kind=FoldKind.C, tuck=12)
214
+ Z_FOLD_LETTER = FoldFormat(sheet=LETTER_SHEET, kind=FoldKind.Z)
215
+ GATE_FOLD_A4 = FoldFormat(sheet=A4_SHEET, kind=FoldKind.GATE, tuck=7.5)
216
+
217
+ #: Every shipped fold by name, as ``PAGE_FORMATS`` names every page.
218
+ FOLD_FORMATS: dict[str, FoldFormat] = {
219
+ "bi_fold_letter": BI_FOLD_LETTER,
220
+ "tri_fold_letter": TRI_FOLD_LETTER,
221
+ "z_fold_letter": Z_FOLD_LETTER,
222
+ "gate_fold_a4": GATE_FOLD_A4,
223
+ }