Upload renderer.py with huggingface_hub
Browse files- renderer.py +89 -32
renderer.py
CHANGED
|
@@ -495,14 +495,16 @@ def render_layout_to_pdf(layout: Dict[str, Any],
|
|
| 495 |
cover_bytes: Optional[bytes],
|
| 496 |
page_count: int = 1,
|
| 497 |
page_size_in: Tuple[float, float] = (PAGE_W_IN, PAGE_H_IN),
|
| 498 |
-
output_path: Optional[Path] = None) -> bytes:
|
| 499 |
"""Render a layout JSON to a print-ready PDF.
|
| 500 |
|
| 501 |
The cover is stamped on the FIRST page only (drawn as a raster
|
| 502 |
image on top of the vector design). All subsequent pages are
|
| 503 |
pure vector with the same layout.
|
| 504 |
|
| 505 |
-
Returns
|
|
|
|
|
|
|
| 506 |
"""
|
| 507 |
page_w_in, page_h_in = page_size_in
|
| 508 |
|
|
@@ -515,6 +517,8 @@ def render_layout_to_pdf(layout: Dict[str, Any],
|
|
| 515 |
buf = io.BytesIO()
|
| 516 |
c = Canvas(buf, pagesize=(_in_to_pt(page_w_in), _in_to_pt(page_h_in)))
|
| 517 |
|
|
|
|
|
|
|
| 518 |
for page_idx in range(page_count):
|
| 519 |
# Start design below the safe zone
|
| 520 |
y_pt = _in_to_pt(page_h_in - SAFE_TOP_IN)
|
|
@@ -523,13 +527,22 @@ def render_layout_to_pdf(layout: Dict[str, Any],
|
|
| 523 |
c.setFillColor(HexColor(theme["bg"]))
|
| 524 |
c.rect(0, 0, _in_to_pt(page_w_in), _in_to_pt(page_h_in), stroke=0, fill=1)
|
| 525 |
|
| 526 |
-
# Cover band only on page 1
|
| 527 |
-
|
| 528 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
|
| 530 |
# Subtle horizontal divider between cover band and design area
|
| 531 |
# (acts as a hairline that ties the cover to the content).
|
| 532 |
-
if
|
|
|
|
| 533 |
c.setStrokeColor(HexColor(theme["primary"]))
|
| 534 |
c.setLineWidth(0.5)
|
| 535 |
divider_y_pt = _in_to_pt(page_h_in - SAFE_TOP_IN + 0.05)
|
|
@@ -574,51 +587,89 @@ def render_layout_to_pdf(layout: Dict[str, Any],
|
|
| 574 |
pdf_bytes = buf.getvalue()
|
| 575 |
if output_path is not None:
|
| 576 |
output_path.write_bytes(pdf_bytes)
|
| 577 |
-
return pdf_bytes
|
| 578 |
|
| 579 |
|
| 580 |
def _stamp_cover_band(canvas: Canvas, cover_bytes: bytes,
|
| 581 |
-
page_w_in: float, page_h_in: float) ->
|
| 582 |
"""Stamp the cover image into the top ``TOP_BANNER_INCHES`` inches.
|
| 583 |
|
| 584 |
-
The cover is cropped to its visible *content* (
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
if not cover_bytes:
|
| 591 |
-
return
|
| 592 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
if cover_img.mode != "RGBA":
|
| 594 |
cover_img = cover_img.convert("RGBA")
|
| 595 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 596 |
# Content-aware crop: find the bounding box of pixels that are
|
| 597 |
# actually drawn. Works for both transparent and white backgrounds.
|
| 598 |
alpha = cover_img.split()[-1]
|
| 599 |
-
src_w, src_h = cover_img.size
|
| 600 |
-
# Threshold: any pixel with alpha > 32 is "solid content".
|
| 601 |
solid = alpha.point(lambda a: 255 if a > 32 else 0)
|
| 602 |
bbox = solid.getbbox()
|
| 603 |
-
if bbox is
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
|
|
|
| 615 |
|
| 616 |
src_w, src_h = cover_img.size
|
| 617 |
if src_w == 0 or src_h == 0:
|
| 618 |
-
|
| 619 |
-
return
|
| 620 |
|
| 621 |
-
band_h_in = TOP_BANNER_INCHES
|
| 622 |
src_aspect = src_w / src_h
|
| 623 |
|
| 624 |
# Decide target size in INCHES. Use 95% of band height as the
|
|
@@ -633,6 +684,11 @@ def _stamp_cover_band(canvas: Canvas, cover_bytes: bytes,
|
|
| 633 |
target_w_in = max_w_in
|
| 634 |
target_h_in = target_w_in / src_aspect
|
| 635 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
# Resize the raster to a high pixel density (450 DPI) so the
|
| 637 |
# embedded image stays crisp in print.
|
| 638 |
target_h_px = max(1, int(target_h_in * 450))
|
|
@@ -661,6 +717,7 @@ def _stamp_cover_band(canvas: Canvas, cover_bytes: bytes,
|
|
| 661 |
"[cover] stamped %dx%d px (%.2fx%.2f in) at (%.2f, %.2f in)",
|
| 662 |
target_w_px, target_h_px, target_w_in, target_h_in, x_in, y_in_bottom,
|
| 663 |
)
|
|
|
|
| 664 |
|
| 665 |
|
| 666 |
# ---------------------------------------------------------------------------
|
|
|
|
| 495 |
cover_bytes: Optional[bytes],
|
| 496 |
page_count: int = 1,
|
| 497 |
page_size_in: Tuple[float, float] = (PAGE_W_IN, PAGE_H_IN),
|
| 498 |
+
output_path: Optional[Path] = None) -> Tuple[bytes, Dict[str, Any]]:
|
| 499 |
"""Render a layout JSON to a print-ready PDF.
|
| 500 |
|
| 501 |
The cover is stamped on the FIRST page only (drawn as a raster
|
| 502 |
image on top of the vector design). All subsequent pages are
|
| 503 |
pure vector with the same layout.
|
| 504 |
|
| 505 |
+
Returns ``(pdf_bytes, info)`` where ``info`` is a dict with at
|
| 506 |
+
least ``{"cover_stamped": bool}`` so the caller can surface
|
| 507 |
+
cover-stamp failures to the user.
|
| 508 |
"""
|
| 509 |
page_w_in, page_h_in = page_size_in
|
| 510 |
|
|
|
|
| 517 |
buf = io.BytesIO()
|
| 518 |
c = Canvas(buf, pagesize=(_in_to_pt(page_w_in), _in_to_pt(page_h_in)))
|
| 519 |
|
| 520 |
+
info: Dict[str, Any] = {"cover_stamped": False, "cover_warnings": []}
|
| 521 |
+
|
| 522 |
for page_idx in range(page_count):
|
| 523 |
# Start design below the safe zone
|
| 524 |
y_pt = _in_to_pt(page_h_in - SAFE_TOP_IN)
|
|
|
|
| 527 |
c.setFillColor(HexColor(theme["bg"]))
|
| 528 |
c.rect(0, 0, _in_to_pt(page_w_in), _in_to_pt(page_h_in), stroke=0, fill=1)
|
| 529 |
|
| 530 |
+
# Cover band only on page 1. Always call the stamper on
|
| 531 |
+
# page 0 so a placeholder is drawn when the cover is
|
| 532 |
+
# missing/corrupt (rather than a silent blank top).
|
| 533 |
+
if page_idx == 0:
|
| 534 |
+
ok = _stamp_cover_band(c, cover_bytes or b"", page_w_in, page_h_in)
|
| 535 |
+
if page_idx == 0:
|
| 536 |
+
info["cover_stamped"] = bool(ok)
|
| 537 |
+
if not ok:
|
| 538 |
+
info["cover_warnings"].append(
|
| 539 |
+
"Cover stamp could not be drawn — a placeholder was rendered instead."
|
| 540 |
+
)
|
| 541 |
|
| 542 |
# Subtle horizontal divider between cover band and design area
|
| 543 |
# (acts as a hairline that ties the cover to the content).
|
| 544 |
+
# Only draw if a real cover (not a placeholder) was stamped.
|
| 545 |
+
if page_idx == 0 and info["cover_stamped"]:
|
| 546 |
c.setStrokeColor(HexColor(theme["primary"]))
|
| 547 |
c.setLineWidth(0.5)
|
| 548 |
divider_y_pt = _in_to_pt(page_h_in - SAFE_TOP_IN + 0.05)
|
|
|
|
| 587 |
pdf_bytes = buf.getvalue()
|
| 588 |
if output_path is not None:
|
| 589 |
output_path.write_bytes(pdf_bytes)
|
| 590 |
+
return pdf_bytes, info
|
| 591 |
|
| 592 |
|
| 593 |
def _stamp_cover_band(canvas: Canvas, cover_bytes: bytes,
|
| 594 |
+
page_w_in: float, page_h_in: float) -> bool:
|
| 595 |
"""Stamp the cover image into the top ``TOP_BANNER_INCHES`` inches.
|
| 596 |
|
| 597 |
+
The cover is cropped to its visible *content* (alpha-based bbox)
|
| 598 |
+
and scaled to fill as much of the band as possible while
|
| 599 |
+
preserving aspect ratio. The resized raster is drawn into the
|
| 600 |
+
PDF; everything else on the page stays vector.
|
| 601 |
+
|
| 602 |
+
Returns ``True`` if a real cover image was stamped, ``False``
|
| 603 |
+
if a placeholder was drawn instead (or nothing was drawn).
|
| 604 |
+
Every early-return path logs a warning so the failure is visible
|
| 605 |
+
in the engine logs.
|
| 606 |
"""
|
| 607 |
+
band_h_in = TOP_BANNER_INCHES
|
| 608 |
+
|
| 609 |
+
def _draw_placeholder(reason: str) -> bool:
|
| 610 |
+
"""Draw a thin dashed rectangle with a 'COVER' label so the
|
| 611 |
+
user can see the design intent instead of a blank top."""
|
| 612 |
+
x_in = SIDE_MARGIN_IN
|
| 613 |
+
w_in = page_w_in - 2 * SIDE_MARGIN_IN
|
| 614 |
+
h_in = band_h_in * 0.6
|
| 615 |
+
y_in = page_h_in - band_h_in + (band_h_in - h_in) / 2
|
| 616 |
+
x_pt = _in_to_pt(x_in)
|
| 617 |
+
y_pt = _in_to_pt(y_in)
|
| 618 |
+
w_pt = _in_to_pt(w_in)
|
| 619 |
+
h_pt = _in_to_pt(h_in)
|
| 620 |
+
canvas.setStrokeColor(HexColor("#9AA5A5"))
|
| 621 |
+
canvas.setLineWidth(0.6)
|
| 622 |
+
canvas.setDash(3, 3)
|
| 623 |
+
canvas.rect(x_pt, y_pt, w_pt, h_pt, stroke=1, fill=0)
|
| 624 |
+
canvas.setDash()
|
| 625 |
+
canvas.setFillColor(HexColor("#9AA5A5"))
|
| 626 |
+
canvas.setFont(DEFAULT_FONT_OBLIQUE, 10)
|
| 627 |
+
canvas.drawCentredString(
|
| 628 |
+
x_pt + w_pt / 2,
|
| 629 |
+
y_pt + h_pt / 2 - 3,
|
| 630 |
+
"[ cover unavailable — placeholder ]",
|
| 631 |
+
)
|
| 632 |
+
log.warning("[cover] placeholder drawn: %s", reason)
|
| 633 |
+
return False
|
| 634 |
+
|
| 635 |
if not cover_bytes:
|
| 636 |
+
return _draw_placeholder("no cover bytes provided")
|
| 637 |
+
|
| 638 |
+
try:
|
| 639 |
+
cover_img = Image.open(io.BytesIO(cover_bytes))
|
| 640 |
+
except Exception as exc:
|
| 641 |
+
return _draw_placeholder(f"cannot open image: {exc}")
|
| 642 |
if cover_img.mode != "RGBA":
|
| 643 |
cover_img = cover_img.convert("RGBA")
|
| 644 |
|
| 645 |
+
src_w, src_h = cover_img.size
|
| 646 |
+
log.info("[cover] input: %dx%d px, mode=%s", src_w, src_h, cover_img.mode)
|
| 647 |
+
if src_w == 0 or src_h == 0:
|
| 648 |
+
return _draw_placeholder(f"zero-dimension image ({src_w}x{src_h})")
|
| 649 |
+
|
| 650 |
# Content-aware crop: find the bounding box of pixels that are
|
| 651 |
# actually drawn. Works for both transparent and white backgrounds.
|
| 652 |
alpha = cover_img.split()[-1]
|
|
|
|
|
|
|
| 653 |
solid = alpha.point(lambda a: 255 if a > 32 else 0)
|
| 654 |
bbox = solid.getbbox()
|
| 655 |
+
if bbox is None:
|
| 656 |
+
return _draw_placeholder("no opaque content (alpha > 32) found in image")
|
| 657 |
+
x0, y0, x1, y1 = bbox
|
| 658 |
+
# Add a small breathing margin so the subject isn't flush
|
| 659 |
+
# against the stamp edge.
|
| 660 |
+
pad = max(2, int(min(x1 - x0, y1 - y0) * 0.01))
|
| 661 |
+
x0 = max(0, x0 - pad)
|
| 662 |
+
y0 = max(0, y0 - pad)
|
| 663 |
+
x1 = min(src_w, x1 + pad)
|
| 664 |
+
y1 = min(src_h, y1 + pad)
|
| 665 |
+
cover_img = cover_img.crop((x0, y0, x1, y1))
|
| 666 |
+
log.info("[cover] alpha bbox: %dx%d -> %dx%d (orig %dx%d)",
|
| 667 |
+
x0, y0, x1, y1, src_w, src_h)
|
| 668 |
|
| 669 |
src_w, src_h = cover_img.size
|
| 670 |
if src_w == 0 or src_h == 0:
|
| 671 |
+
return _draw_placeholder(f"zero-dim after crop ({src_w}x{src_h})")
|
|
|
|
| 672 |
|
|
|
|
| 673 |
src_aspect = src_w / src_h
|
| 674 |
|
| 675 |
# Decide target size in INCHES. Use 95% of band height as the
|
|
|
|
| 684 |
target_w_in = max_w_in
|
| 685 |
target_h_in = target_w_in / src_aspect
|
| 686 |
|
| 687 |
+
if target_h_in < 0.1 or target_w_in < 0.1:
|
| 688 |
+
return _draw_placeholder(
|
| 689 |
+
f"computed stamp too small ({target_w_in:.2f}x{target_h_in:.2f} in)"
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
# Resize the raster to a high pixel density (450 DPI) so the
|
| 693 |
# embedded image stays crisp in print.
|
| 694 |
target_h_px = max(1, int(target_h_in * 450))
|
|
|
|
| 717 |
"[cover] stamped %dx%d px (%.2fx%.2f in) at (%.2f, %.2f in)",
|
| 718 |
target_w_px, target_h_px, target_w_in, target_h_in, x_in, y_in_bottom,
|
| 719 |
)
|
| 720 |
+
return True
|
| 721 |
|
| 722 |
|
| 723 |
# ---------------------------------------------------------------------------
|