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.
- inkfold/__init__.py +16 -0
- inkfold/brochure/__init__.py +43 -0
- inkfold/brochure/checks.py +126 -0
- inkfold/brochure/document.py +197 -0
- inkfold/brochure/fit.py +44 -0
- inkfold/brochure/fold.py +223 -0
- inkfold/brochure/imposition.py +89 -0
- inkfold/brochure/medium.py +40 -0
- inkfold/brochure/panel.py +266 -0
- inkfold/builder/__init__.py +307 -0
- inkfold/builder/apparatus.py +247 -0
- inkfold/builder/components.py +1990 -0
- inkfold/builder/composition.py +374 -0
- inkfold/builder/containers.py +1355 -0
- inkfold/builder/document.py +800 -0
- inkfold/builder/email.py +294 -0
- inkfold/builder/engine.py +445 -0
- inkfold/builder/enums.py +221 -0
- inkfold/builder/exceptions.py +61 -0
- inkfold/builder/exhibits.py +395 -0
- inkfold/builder/filters.py +243 -0
- inkfold/builder/finite.py +37 -0
- inkfold/builder/formats.py +246 -0
- inkfold/builder/glance.py +312 -0
- inkfold/builder/images.py +605 -0
- inkfold/builder/medium.py +176 -0
- inkfold/builder/models.py +1431 -0
- inkfold/builder/organising.py +436 -0
- inkfold/builder/prose.py +92 -0
- inkfold/builder/regions.py +843 -0
- inkfold/builder/research.py +348 -0
- inkfold/builder/sizing.py +1437 -0
- inkfold/builder/surfaces.py +419 -0
- inkfold/builder/templates/analysis/bar-list.html +42 -0
- inkfold/builder/templates/analysis/card-group.html +64 -0
- inkfold/builder/templates/analysis/cell-bar.html +12 -0
- inkfold/builder/templates/analysis/chart-block.html +59 -0
- inkfold/builder/templates/analysis/data-table.html +115 -0
- inkfold/builder/templates/analysis/figure-grid.html +71 -0
- inkfold/builder/templates/analysis/hero-stat.html +24 -0
- inkfold/builder/templates/analysis/legend.html +22 -0
- inkfold/builder/templates/analysis/sparkline-bars.html +22 -0
- inkfold/builder/templates/analysis/sparkline.html +20 -0
- inkfold/builder/templates/analysis/trend-arrow.html +26 -0
- inkfold/builder/templates/base.html +135 -0
- inkfold/builder/templates/brochure/base.html +102 -0
- inkfold/builder/templates/brochure/panel.html +37 -0
- inkfold/builder/templates/brochure/side.html +13 -0
- inkfold/builder/templates/common/badge.html +36 -0
- inkfold/builder/templates/common/containers/columns.html +179 -0
- inkfold/builder/templates/common/containers/full-width.html +84 -0
- inkfold/builder/templates/common/contents-list.html +24 -0
- inkfold/builder/templates/common/cta.html +33 -0
- inkfold/builder/templates/common/disclosure.html +28 -0
- inkfold/builder/templates/common/endnotes.html +19 -0
- inkfold/builder/templates/common/font-faces.html +6 -0
- inkfold/builder/templates/common/kicker.html +16 -0
- inkfold/builder/templates/common/nested-columns.html +63 -0
- inkfold/builder/templates/common/notes.html +38 -0
- inkfold/builder/templates/common/placed.html +25 -0
- inkfold/builder/templates/common/qualifier.html +7 -0
- inkfold/builder/templates/common/separated-columns.html +37 -0
- inkfold/builder/templates/common/share.html +27 -0
- inkfold/builder/templates/common/source-line.html +14 -0
- inkfold/builder/templates/common/stack.html +19 -0
- inkfold/builder/templates/common/stamp.html +14 -0
- inkfold/builder/templates/common/surface.html +17 -0
- inkfold/builder/templates/deck/bands.html +44 -0
- inkfold/builder/templates/deck/base.html +122 -0
- inkfold/builder/templates/deck/divider.html +41 -0
- inkfold/builder/templates/deck/regions/closing-slide.html +13 -0
- inkfold/builder/templates/deck/regions/title-slide.html +30 -0
- inkfold/builder/templates/deck/slide.html +42 -0
- inkfold/builder/templates/deck/statement.html +19 -0
- inkfold/builder/templates/document/base.html +181 -0
- inkfold/builder/templates/document/editorial.html +24 -0
- inkfold/builder/templates/document/page.html +25 -0
- inkfold/builder/templates/document/regions/back-matter.html +16 -0
- inkfold/builder/templates/document/regions/contents.html +16 -0
- inkfold/builder/templates/document/regions/cover.html +26 -0
- inkfold/builder/templates/document/regions/running-box.html +32 -0
- inkfold/builder/templates/media/image-block.html +79 -0
- inkfold/builder/templates/media/math-block.html +32 -0
- inkfold/builder/templates/media/qr-code.html +27 -0
- inkfold/builder/templates/regions/banner-minimal.html +90 -0
- inkfold/builder/templates/regions/banner.html +140 -0
- inkfold/builder/templates/regions/footer.html +67 -0
- inkfold/builder/templates/regions/header-bar.html +15 -0
- inkfold/builder/templates/text/author-block.html +24 -0
- inkfold/builder/templates/text/bibliography.html +23 -0
- inkfold/builder/templates/text/button.html +15 -0
- inkfold/builder/templates/text/callout.html +30 -0
- inkfold/builder/templates/text/contact-block.html +17 -0
- inkfold/builder/templates/text/contents.html +11 -0
- inkfold/builder/templates/text/divider.html +18 -0
- inkfold/builder/templates/text/fact-list.html +43 -0
- inkfold/builder/templates/text/glossary.html +25 -0
- inkfold/builder/templates/text/numbered-list.html +29 -0
- inkfold/builder/templates/text/prose-styles.html +20 -0
- inkfold/builder/templates/text/pull-quote.html +24 -0
- inkfold/builder/templates/text/tag-row.html +16 -0
- inkfold/builder/templates/text/teaser-list.html +81 -0
- inkfold/builder/templates/text/text-block.html +44 -0
- inkfold/builder/templates/text/timeline.html +58 -0
- inkfold/builder/textgen.py +400 -0
- inkfold/builder/theming.py +548 -0
- inkfold/builder/typography.py +398 -0
- inkfold/check/__init__.py +59 -0
- inkfold/check/__main__.py +70 -0
- inkfold/check/lint.py +1205 -0
- inkfold/check/target.py +83 -0
- inkfold/config.py +340 -0
- inkfold/data/__init__.py +28 -0
- inkfold/data/charts.py +109 -0
- inkfold/data/exceptions.py +20 -0
- inkfold/data/frames.py +193 -0
- inkfold/data/style.py +123 -0
- inkfold/deck/__init__.py +36 -0
- inkfold/deck/document.py +508 -0
- inkfold/deck/fit.py +136 -0
- inkfold/deck/medium.py +41 -0
- inkfold/deck/regions.py +202 -0
- inkfold/deck/slide.py +691 -0
- inkfold/delivery/__init__.py +33 -0
- inkfold/delivery/exceptions.py +44 -0
- inkfold/delivery/message.py +574 -0
- inkfold/delivery/retry.py +126 -0
- inkfold/document/__init__.py +46 -0
- inkfold/document/document.py +271 -0
- inkfold/document/medium.py +41 -0
- inkfold/document/page.py +176 -0
- inkfold/document/regions.py +436 -0
- inkfold/email/__init__.py +13 -0
- inkfold/email/medium.py +79 -0
- inkfold/gmail/__init__.py +30 -0
- inkfold/gmail/sender.py +269 -0
- inkfold/math/__init__.py +25 -0
- inkfold/math/adapter.py +104 -0
- inkfold/math/exceptions.py +19 -0
- inkfold/math/render.py +190 -0
- inkfold/outlook/__init__.py +39 -0
- inkfold/outlook/desktop.py +152 -0
- inkfold/outlook/sender.py +341 -0
- inkfold/pdf/__init__.py +63 -0
- inkfold/pdf/attachment.py +51 -0
- inkfold/pdf/exceptions.py +57 -0
- inkfold/pdf/exporter.py +335 -0
- inkfold/pdf/fetcher.py +83 -0
- inkfold/pdf/profile.py +121 -0
- inkfold/pdf/tagging.py +136 -0
- inkfold/py.typed +0 -0
- inkfold/qr/__init__.py +12 -0
- inkfold/qr/adapter.py +103 -0
- inkfold/qr/exceptions.py +15 -0
- inkfold-0.5.0.dist-info/METADATA +1280 -0
- inkfold-0.5.0.dist-info/RECORD +158 -0
- inkfold-0.5.0.dist-info/WHEEL +4 -0
- 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)}
|
inkfold/brochure/fit.py
ADDED
|
@@ -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
|
inkfold/brochure/fold.py
ADDED
|
@@ -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
|
+
}
|