build_template.py
Language Python11.8 KB306 lines
#!/usr/bin/env python3"""build_template.py - Rebuild a vendor artwork template into the Meridian Display Co. house standard.Produces a print-ready PDF with FOUR real Illustrator/Acrobat layers (OCGs):Layout Area, Safe Line, Artwork Layer, Help InfoGeometry is driven by explicit dimensions (the calling agent extracts these from thesource template and passes them in). The script only renders; it does not guess.Usage:python build_template.py \--sku BS20 --name "Quick-Set Step and Repeat Backdrop, Large (up to 10 x 20 ft)" \--bleed-w 144 --bleed-h 120 \--visual-w 138 --visual-h 114 \--out /mnt/user-data/outputs/template.pdfAll dimensions are in INCHES. --visual-w/h is the red safe-line size (frommeasure_template.py). If it is omitted, --safe-margin (a uniform inset in inches fromthe bleed edge to the red safe line) is used instead; it defaults to 2.0in (a sanebackdrop default) - prefer passing the real measured value."""import argparse, sysfrom reportlab.pdfgen import canvasfrom reportlab.lib.colors import CMYKColorfrom reportlab.pdfbase import pdfmetricsfrom reportlab.pdfbase.ttfonts import TTFontimport pikepdf# ---- House standard constants (extracted from confirmed-correct TD20 & BD08 templates) ----PT = 72.0 # points per inchBLACK = CMYKColor(0, 0, 0, 1)RED = CMYKColor(0, 1, 1, 0) # final warning line + stitching/safety calloutsTEAL = CMYKColor(0.78, 0.45, 0.52, 0.05) # inline key-term accent (150dpi, CMYK, PMS, flatten, outline)LAYERS = ["Layout Area", "Safe Line", "Artwork Layer", "Help Info"]LIB = "/usr/share/fonts/truetype/liberation/"FONT_REG, FONT_BOLD = "Arial", "Arial-Bold" # Liberation Sans = metric-identical Arial# Canonical boilerplate. Each block is (kind, ...). Runs are (text, color, bold).NOTES_BULLETS = [[("- All graphic files must be built at ", BLACK, False), ("150dpi ", TEAL, False),("at the overall graphic layout size.", BLACK, False)],[("- All graphic files must be created using ", BLACK, False), ("CMYK ", TEAL, False),("mode to ensure the best color matching. If you would like a specific color matched, you ""MUST provide both ", BLACK, False), ("PMS ", TEAL, False), ("color and ", BLACK, False),("CMYK ", TEAL, False), ("values or we cannot guarantee color matching.", BLACK, False)],[("- It is best to ", BLACK, False), ("flatten ", TEAL, False), ("all images and ", BLACK, False),("outline ", TEAL, False), ("all text to ensure that nothing will be dropped out or changed ""during the file transfer.", BLACK, False)],[("- Graphics can be extended to the very edge but beware of the red visual line. Keep ""important images & text inside the red visual line. Images outside of the red visual line ""are subject to be trimmed off or covered by stitching.", BLACK, False)],[("- Remove the red visual line and help instructions upon submitting your artwork.", RED, False)],]PREFERRED = [[(" - .PDF (Vector Format or 100% Sized, Text Outlined, Images Embedded)", BLACK, False)],[(" - .JPG / .JPEG (100% Sized, 150dpi)", BLACK, False)],]ACCEPTED = [[(" - .AI or .EPS (Text Outlined, Images Embedded)", BLACK, False)],[(" - .PSD (100% Sized, 150dpi)", BLACK, False)],]def register_fonts():try:pdfmetrics.registerFont(TTFont(FONT_REG, LIB + "LiberationSans-Regular.ttf"))pdfmetrics.registerFont(TTFont(FONT_BOLD, LIB + "LiberationSans-Bold.ttf"))return FONT_REG, FONT_BOLDexcept Exception:return "Helvetica", "Helvetica-Bold" # built-in Arial-metric fallbackdef sw(text, font, size):return pdfmetrics.stringWidth(text, font, size)class Info:"""Top-down text engine on the canvas with colored runs, wrapping, bold + underline."""def __init__(self, c, x, y, width, body, fonts):self.c, self.x, self.y, self.width = c, x, y, widthself.body = bodyself.reg, self.bold = fontsself.lead = body * 1.32def gap(self, mult=1.0):self.y -= self.lead * multdef heading(self, text, size=None, underline=True):size = size or self.body * 1.12self.c.setFillColor(BLACK)self.c.setFont(self.bold, size)self.c.drawString(self.x, self.y, text)if underline:w = sw(text, self.bold, size)uy = self.y - size * 0.14self.c.setStrokeColor(BLACK)self.c.setLineWidth(max(1.0, size * 0.045))self.c.line(self.x, uy, self.x + w, uy)self.y -= self.lead * 1.15def line_runs(self, runs, size=None, bold_all=False):"""Draw one logical paragraph of mixed-color runs, word-wrapped to width."""size = size or self.bodylead = size * 1.32font = self.bold if bold_all else self.reg# tokenize into (word, color, bold) keeping spacestokens = []for text, color, bold in runs:parts = text.split(" ")for i, p in enumerate(parts):if p:tokens.append([p, color, bold or bold_all])if i < len(parts) - 1:tokens.append([" ", color, bold or bold_all])cx = self.xfor tok, color, bold in tokens:f = self.bold if bold else self.regwdt = sw(tok, f, size)if tok != " " and cx + wdt > self.x + self.width and cx > self.x:self.y -= leadcx = self.xif tok == " ":continueself.c.setFillColor(color)self.c.setFont(f, size)self.c.drawString(cx, self.y, tok)cx += wdtself.y -= leaddef paras(self, blocks, size=None):for runs in blocks:self.line_runs(runs, size=size)def begin_oc(c, name):c.addLiteral(f"/OC /OC_{name.replace(' ', '_')} BDC")def end_oc(c):c.addLiteral("EMC")def build(sku, name, bleed_w, bleed_h, visual_w, visual_h, safe_margin, out):reg, bold = register_fonts()fonts = (reg, bold)bw, bh = bleed_w * PT, bleed_h * PT # artwork WITH bleed (solid black boundary)pb = max(bw, bh) * 0.03 # pasteboard margin so the boundary is visiblePW, PH = bw + 2 * pb, bh + 2 * pbc = canvas.Canvas(out, pagesize=(PW, PH))c.setTitle(f"[{sku}] - {name}")unit = bh / 100.0 # scale everything to artwork heightbody = max(8.0, unit * 1.30)# safe/visual insets (non-uniform supported): derive from visual size if givenif visual_w is not None and visual_h is not None:sm_x = max(0.0, (bleed_w - visual_w) / 2.0) * PTsm_y = max(0.0, (bleed_h - visual_h) / 2.0) * PTelse:sm_x = sm_y = safe_margin * PTvisual_w = bleed_w - 2 * safe_marginvisual_h = bleed_h - 2 * safe_margin# black boundary = artwork-with-bleed edge, inset from page by pasteboardbx0, by0, bx1, by1 = pb, pb, pb + bw, pb + bh# red dashed safe line, inset from the black boundarysx0, sy0 = bx0 + sm_x, by0 + sm_ysx1, sy1 = bx1 - sm_x, by1 - sm_y# ---------------- Layer: Layout Area (solid black artwork-with-bleed boundary) ----------begin_oc(c, "Layout Area")c.setStrokeColor(BLACK)c.setLineWidth(max(1.5, unit * 0.06))c.rect(bx0, by0, bx1 - bx0, by1 - by0, stroke=1, fill=0)end_oc(c)# ---------------- Layer: Safe Line (red dashed) ----------------begin_oc(c, "Safe Line")c.setStrokeColor(RED)c.setLineWidth(max(1.5, unit * 0.09))dash = max(6.0, unit * 0.8)c.setDash(dash, dash * 0.7)c.rect(sx0, sy0, sx1 - sx0, sy1 - sy0, stroke=1, fill=0)c.setDash()end_oc(c)# ---------------- Layer: Artwork Layer (intentionally empty) ----------------begin_oc(c, "Artwork Layer")c.addLiteral("") # real OCG, no marks - designer drops art hereend_oc(c)# ---------------- Layer: Help Info (all text + legend) ----------------begin_oc(c, "Help Info")pad = unit * 1.4safe_w = sx1 - sx0col_w = max(safe_w * 0.46, unit * 30) # narrow column like the standardcol_w = min(col_w, safe_w - pad * 2)ix = sx0 + padiy = sy1 - pad - unit * 2.0info = Info(c, ix, iy, col_w, body, fonts)# Title + subtitlec.setFillColor(BLACK); c.setFont(bold, unit * 2.0)c.drawString(ix, info.y, f"[{sku.upper()}] - {name}")info.y -= info.lead * 1.5c.setFillColor(BLACK); c.setFont(reg, unit * 1.30)c.drawString(ix, info.y, "Artwork Template")info.y -= info.lead * 1.6# Spec blockinfo.heading("Graphic Size with Bleed:", size=unit * 1.35, underline=False)info.line_runs([(f"{fmt(bleed_w)} W x {fmt(bleed_h)} H inches", BLACK, False)])info.gap(0.5)info.heading("Visual / Safe Area:", size=unit * 1.35, underline=False)info.line_runs([(f"{fmt(visual_w)} W x {fmt(visual_h)} H inches", BLACK, False)])info.gap(0.4)info.line_runs([("Keep all critical content inside the red safe line.", BLACK, False)])info.gap(0.8)# Legend_legend_row(c, info, "Bleed / Layout Edge", BLACK, dashed=False, unit=unit, body=body)_legend_row(c, info, "Safe Visual Line", RED, dashed=True, unit=unit, body=body)info.gap(1.0)# Notesinfo.heading("Notes to Designers:")info.paras(NOTES_BULLETS)info.gap(1.0)info.heading("Preferred File Formats:")info.paras(PREFERRED)info.gap(0.8)info.heading("Accepted File Formats:")info.paras(ACCEPTED)end_oc(c)c.showPage()c.save()_inject_ocgs(out)return outdef _legend_row(c, info, label, color, dashed, unit, body):y = info.yc.setFillColor(BLACK); c.setFont(info.reg, body)c.drawString(info.x, y, label)lx = info.x + sw(label, info.reg, body) + unit * 0.8lx_end = lx + unit * 6.0ly = y + body * 0.3c.setStrokeColor(color)c.setLineWidth(max(1.2, unit * 0.07))if dashed:d = max(5.0, unit * 0.6); c.setDash(d, d * 0.7)c.line(lx, ly, lx_end, ly)c.setDash()info.y -= info.leaddef fmt(v):return str(int(v)) if abs(v - round(v)) < 1e-6 else f"{v:.2f}".rstrip("0").rstrip(".")def _inject_ocgs(path):"""Attach real OCG dictionaries so the BDC/EMC markers become toggleable layers."""pdf = pikepdf.open(path, allow_overwriting_input=True)ocgs, name_to_ocg = [], {}for n in LAYERS:ocg = pdf.make_indirect(pikepdf.Dictionary(Type=pikepdf.Name("/OCG"), Name=n))ocgs.append(ocg); name_to_ocg[n] = ocgpdf.Root.OCProperties = pikepdf.Dictionary(OCGs=pikepdf.Array(ocgs),D=pikepdf.Dictionary(Order=pikepdf.Array(ocgs),ON=pikepdf.Array(ocgs),OFF=pikepdf.Array([])),)page = pdf.pages[0]props = pikepdf.Dictionary()for n in LAYERS:props[pikepdf.Name("/OC_" + n.replace(" ", "_"))] = name_to_ocg[n]page.Resources[pikepdf.Name("/Properties")] = propspdf.save(path)pdf.close()def main():ap = argparse.ArgumentParser()ap.add_argument("--sku", required=True)ap.add_argument("--name", required=True)ap.add_argument("--bleed-w", type=float, required=True, help="artwork-with-bleed width (in) = solid black boundary")ap.add_argument("--bleed-h", type=float, required=True, help="artwork-with-bleed height (in)")ap.add_argument("--visual-w", type=float, default=None, help="visual/safe area width (in) = red dashed line")ap.add_argument("--visual-h", type=float, default=None, help="visual/safe area height (in)")ap.add_argument("--safe-margin", type=float, default=2.0, help="uniform safe inset (in) used only when --visual-w/h omitted")ap.add_argument("--out", required=True)a = ap.parse_args()build(a.sku, a.name, a.bleed_w, a.bleed_h, a.visual_w, a.visual_h, a.safe_margin, a.out)print(f"Wrote {a.out}")if __name__ == "__main__":main()