mattrguerra/meridian-skillsmainPrivate on GitHub, published here in full

build_template.py

Language Python11.8 KB306 lines

skills/artwork-template-rebuilder/scripts/build_template.py

  1. #!/usr/bin/env python3
  2. """
  3. build_template.py - Rebuild a vendor artwork template into the Meridian Display Co. house standard.
  4. Produces a print-ready PDF with FOUR real Illustrator/Acrobat layers (OCGs):
  5. Layout Area, Safe Line, Artwork Layer, Help Info
  6. Geometry is driven by explicit dimensions (the calling agent extracts these from the
  7. source template and passes them in). The script only renders; it does not guess.
  8. Usage:
  9. python build_template.py \
  10. --sku BS20 --name "Quick-Set Step and Repeat Backdrop, Large (up to 10 x 20 ft)" \
  11. --bleed-w 144 --bleed-h 120 \
  12. --visual-w 138 --visual-h 114 \
  13. --out /mnt/user-data/outputs/template.pdf
  14. All dimensions are in INCHES. --visual-w/h is the red safe-line size (from
  15. measure_template.py). If it is omitted, --safe-margin (a uniform inset in inches from
  16. the bleed edge to the red safe line) is used instead; it defaults to 2.0in (a sane
  17. backdrop default) - prefer passing the real measured value.
  18. """
  19. import argparse, sys
  20. from reportlab.pdfgen import canvas
  21. from reportlab.lib.colors import CMYKColor
  22. from reportlab.pdfbase import pdfmetrics
  23. from reportlab.pdfbase.ttfonts import TTFont
  24. import pikepdf
  25. # ---- House standard constants (extracted from confirmed-correct TD20 & BD08 templates) ----
  26. PT = 72.0 # points per inch
  27. BLACK = CMYKColor(0, 0, 0, 1)
  28. RED = CMYKColor(0, 1, 1, 0) # final warning line + stitching/safety callouts
  29. TEAL = CMYKColor(0.78, 0.45, 0.52, 0.05) # inline key-term accent (150dpi, CMYK, PMS, flatten, outline)
  30. LAYERS = ["Layout Area", "Safe Line", "Artwork Layer", "Help Info"]
  31. LIB = "/usr/share/fonts/truetype/liberation/"
  32. FONT_REG, FONT_BOLD = "Arial", "Arial-Bold" # Liberation Sans = metric-identical Arial
  33. # Canonical boilerplate. Each block is (kind, ...). Runs are (text, color, bold).
  34. NOTES_BULLETS = [
  35. [("- All graphic files must be built at ", BLACK, False), ("150dpi ", TEAL, False),
  36. ("at the overall graphic layout size.", BLACK, False)],
  37. [("- All graphic files must be created using ", BLACK, False), ("CMYK ", TEAL, False),
  38. ("mode to ensure the best color matching. If you would like a specific color matched, you "
  39. "MUST provide both ", BLACK, False), ("PMS ", TEAL, False), ("color and ", BLACK, False),
  40. ("CMYK ", TEAL, False), ("values or we cannot guarantee color matching.", BLACK, False)],
  41. [("- It is best to ", BLACK, False), ("flatten ", TEAL, False), ("all images and ", BLACK, False),
  42. ("outline ", TEAL, False), ("all text to ensure that nothing will be dropped out or changed "
  43. "during the file transfer.", BLACK, False)],
  44. [("- Graphics can be extended to the very edge but beware of the red visual line. Keep "
  45. "important images & text inside the red visual line. Images outside of the red visual line "
  46. "are subject to be trimmed off or covered by stitching.", BLACK, False)],
  47. [("- Remove the red visual line and help instructions upon submitting your artwork.", RED, False)],
  48. ]
  49. PREFERRED = [
  50. [(" - .PDF (Vector Format or 100% Sized, Text Outlined, Images Embedded)", BLACK, False)],
  51. [(" - .JPG / .JPEG (100% Sized, 150dpi)", BLACK, False)],
  52. ]
  53. ACCEPTED = [
  54. [(" - .AI or .EPS (Text Outlined, Images Embedded)", BLACK, False)],
  55. [(" - .PSD (100% Sized, 150dpi)", BLACK, False)],
  56. ]
  57. def register_fonts():
  58. try:
  59. pdfmetrics.registerFont(TTFont(FONT_REG, LIB + "LiberationSans-Regular.ttf"))
  60. pdfmetrics.registerFont(TTFont(FONT_BOLD, LIB + "LiberationSans-Bold.ttf"))
  61. return FONT_REG, FONT_BOLD
  62. except Exception:
  63. return "Helvetica", "Helvetica-Bold" # built-in Arial-metric fallback
  64. def sw(text, font, size):
  65. return pdfmetrics.stringWidth(text, font, size)
  66. class Info:
  67. """Top-down text engine on the canvas with colored runs, wrapping, bold + underline."""
  68. def __init__(self, c, x, y, width, body, fonts):
  69. self.c, self.x, self.y, self.width = c, x, y, width
  70. self.body = body
  71. self.reg, self.bold = fonts
  72. self.lead = body * 1.32
  73. def gap(self, mult=1.0):
  74. self.y -= self.lead * mult
  75. def heading(self, text, size=None, underline=True):
  76. size = size or self.body * 1.12
  77. self.c.setFillColor(BLACK)
  78. self.c.setFont(self.bold, size)
  79. self.c.drawString(self.x, self.y, text)
  80. if underline:
  81. w = sw(text, self.bold, size)
  82. uy = self.y - size * 0.14
  83. self.c.setStrokeColor(BLACK)
  84. self.c.setLineWidth(max(1.0, size * 0.045))
  85. self.c.line(self.x, uy, self.x + w, uy)
  86. self.y -= self.lead * 1.15
  87. def line_runs(self, runs, size=None, bold_all=False):
  88. """Draw one logical paragraph of mixed-color runs, word-wrapped to width."""
  89. size = size or self.body
  90. lead = size * 1.32
  91. font = self.bold if bold_all else self.reg
  92. # tokenize into (word, color, bold) keeping spaces
  93. tokens = []
  94. for text, color, bold in runs:
  95. parts = text.split(" ")
  96. for i, p in enumerate(parts):
  97. if p:
  98. tokens.append([p, color, bold or bold_all])
  99. if i < len(parts) - 1:
  100. tokens.append([" ", color, bold or bold_all])
  101. cx = self.x
  102. for tok, color, bold in tokens:
  103. f = self.bold if bold else self.reg
  104. wdt = sw(tok, f, size)
  105. if tok != " " and cx + wdt > self.x + self.width and cx > self.x:
  106. self.y -= lead
  107. cx = self.x
  108. if tok == " ":
  109. continue
  110. self.c.setFillColor(color)
  111. self.c.setFont(f, size)
  112. self.c.drawString(cx, self.y, tok)
  113. cx += wdt
  114. self.y -= lead
  115. def paras(self, blocks, size=None):
  116. for runs in blocks:
  117. self.line_runs(runs, size=size)
  118. def begin_oc(c, name):
  119. c.addLiteral(f"/OC /OC_{name.replace(' ', '_')} BDC")
  120. def end_oc(c):
  121. c.addLiteral("EMC")
  122. def build(sku, name, bleed_w, bleed_h, visual_w, visual_h, safe_margin, out):
  123. reg, bold = register_fonts()
  124. fonts = (reg, bold)
  125. bw, bh = bleed_w * PT, bleed_h * PT # artwork WITH bleed (solid black boundary)
  126. pb = max(bw, bh) * 0.03 # pasteboard margin so the boundary is visible
  127. PW, PH = bw + 2 * pb, bh + 2 * pb
  128. c = canvas.Canvas(out, pagesize=(PW, PH))
  129. c.setTitle(f"[{sku}] - {name}")
  130. unit = bh / 100.0 # scale everything to artwork height
  131. body = max(8.0, unit * 1.30)
  132. # safe/visual insets (non-uniform supported): derive from visual size if given
  133. if visual_w is not None and visual_h is not None:
  134. sm_x = max(0.0, (bleed_w - visual_w) / 2.0) * PT
  135. sm_y = max(0.0, (bleed_h - visual_h) / 2.0) * PT
  136. else:
  137. sm_x = sm_y = safe_margin * PT
  138. visual_w = bleed_w - 2 * safe_margin
  139. visual_h = bleed_h - 2 * safe_margin
  140. # black boundary = artwork-with-bleed edge, inset from page by pasteboard
  141. bx0, by0, bx1, by1 = pb, pb, pb + bw, pb + bh
  142. # red dashed safe line, inset from the black boundary
  143. sx0, sy0 = bx0 + sm_x, by0 + sm_y
  144. sx1, sy1 = bx1 - sm_x, by1 - sm_y
  145. # ---------------- Layer: Layout Area (solid black artwork-with-bleed boundary) ----------
  146. begin_oc(c, "Layout Area")
  147. c.setStrokeColor(BLACK)
  148. c.setLineWidth(max(1.5, unit * 0.06))
  149. c.rect(bx0, by0, bx1 - bx0, by1 - by0, stroke=1, fill=0)
  150. end_oc(c)
  151. # ---------------- Layer: Safe Line (red dashed) ----------------
  152. begin_oc(c, "Safe Line")
  153. c.setStrokeColor(RED)
  154. c.setLineWidth(max(1.5, unit * 0.09))
  155. dash = max(6.0, unit * 0.8)
  156. c.setDash(dash, dash * 0.7)
  157. c.rect(sx0, sy0, sx1 - sx0, sy1 - sy0, stroke=1, fill=0)
  158. c.setDash()
  159. end_oc(c)
  160. # ---------------- Layer: Artwork Layer (intentionally empty) ----------------
  161. begin_oc(c, "Artwork Layer")
  162. c.addLiteral("") # real OCG, no marks - designer drops art here
  163. end_oc(c)
  164. # ---------------- Layer: Help Info (all text + legend) ----------------
  165. begin_oc(c, "Help Info")
  166. pad = unit * 1.4
  167. safe_w = sx1 - sx0
  168. col_w = max(safe_w * 0.46, unit * 30) # narrow column like the standard
  169. col_w = min(col_w, safe_w - pad * 2)
  170. ix = sx0 + pad
  171. iy = sy1 - pad - unit * 2.0
  172. info = Info(c, ix, iy, col_w, body, fonts)
  173. # Title + subtitle
  174. c.setFillColor(BLACK); c.setFont(bold, unit * 2.0)
  175. c.drawString(ix, info.y, f"[{sku.upper()}] - {name}")
  176. info.y -= info.lead * 1.5
  177. c.setFillColor(BLACK); c.setFont(reg, unit * 1.30)
  178. c.drawString(ix, info.y, "Artwork Template")
  179. info.y -= info.lead * 1.6
  180. # Spec block
  181. info.heading("Graphic Size with Bleed:", size=unit * 1.35, underline=False)
  182. info.line_runs([(f"{fmt(bleed_w)} W x {fmt(bleed_h)} H inches", BLACK, False)])
  183. info.gap(0.5)
  184. info.heading("Visual / Safe Area:", size=unit * 1.35, underline=False)
  185. info.line_runs([(f"{fmt(visual_w)} W x {fmt(visual_h)} H inches", BLACK, False)])
  186. info.gap(0.4)
  187. info.line_runs([("Keep all critical content inside the red safe line.", BLACK, False)])
  188. info.gap(0.8)
  189. # Legend
  190. _legend_row(c, info, "Bleed / Layout Edge", BLACK, dashed=False, unit=unit, body=body)
  191. _legend_row(c, info, "Safe Visual Line", RED, dashed=True, unit=unit, body=body)
  192. info.gap(1.0)
  193. # Notes
  194. info.heading("Notes to Designers:")
  195. info.paras(NOTES_BULLETS)
  196. info.gap(1.0)
  197. info.heading("Preferred File Formats:")
  198. info.paras(PREFERRED)
  199. info.gap(0.8)
  200. info.heading("Accepted File Formats:")
  201. info.paras(ACCEPTED)
  202. end_oc(c)
  203. c.showPage()
  204. c.save()
  205. _inject_ocgs(out)
  206. return out
  207. def _legend_row(c, info, label, color, dashed, unit, body):
  208. y = info.y
  209. c.setFillColor(BLACK); c.setFont(info.reg, body)
  210. c.drawString(info.x, y, label)
  211. lx = info.x + sw(label, info.reg, body) + unit * 0.8
  212. lx_end = lx + unit * 6.0
  213. ly = y + body * 0.3
  214. c.setStrokeColor(color)
  215. c.setLineWidth(max(1.2, unit * 0.07))
  216. if dashed:
  217. d = max(5.0, unit * 0.6); c.setDash(d, d * 0.7)
  218. c.line(lx, ly, lx_end, ly)
  219. c.setDash()
  220. info.y -= info.lead
  221. def fmt(v):
  222. return str(int(v)) if abs(v - round(v)) < 1e-6 else f"{v:.2f}".rstrip("0").rstrip(".")
  223. def _inject_ocgs(path):
  224. """Attach real OCG dictionaries so the BDC/EMC markers become toggleable layers."""
  225. pdf = pikepdf.open(path, allow_overwriting_input=True)
  226. ocgs, name_to_ocg = [], {}
  227. for n in LAYERS:
  228. ocg = pdf.make_indirect(pikepdf.Dictionary(Type=pikepdf.Name("/OCG"), Name=n))
  229. ocgs.append(ocg); name_to_ocg[n] = ocg
  230. pdf.Root.OCProperties = pikepdf.Dictionary(
  231. OCGs=pikepdf.Array(ocgs),
  232. D=pikepdf.Dictionary(Order=pikepdf.Array(ocgs),
  233. ON=pikepdf.Array(ocgs),
  234. OFF=pikepdf.Array([])),
  235. )
  236. page = pdf.pages[0]
  237. props = pikepdf.Dictionary()
  238. for n in LAYERS:
  239. props[pikepdf.Name("/OC_" + n.replace(" ", "_"))] = name_to_ocg[n]
  240. page.Resources[pikepdf.Name("/Properties")] = props
  241. pdf.save(path)
  242. pdf.close()
  243. def main():
  244. ap = argparse.ArgumentParser()
  245. ap.add_argument("--sku", required=True)
  246. ap.add_argument("--name", required=True)
  247. ap.add_argument("--bleed-w", type=float, required=True, help="artwork-with-bleed width (in) = solid black boundary")
  248. ap.add_argument("--bleed-h", type=float, required=True, help="artwork-with-bleed height (in)")
  249. ap.add_argument("--visual-w", type=float, default=None, help="visual/safe area width (in) = red dashed line")
  250. ap.add_argument("--visual-h", type=float, default=None, help="visual/safe area height (in)")
  251. ap.add_argument("--safe-margin", type=float, default=2.0, help="uniform safe inset (in) used only when --visual-w/h omitted")
  252. ap.add_argument("--out", required=True)
  253. a = ap.parse_args()
  254. build(a.sku, a.name, a.bleed_w, a.bleed_h, a.visual_w, a.visual_h, a.safe_margin, a.out)
  255. print(f"Wrote {a.out}")
  256. if __name__ == "__main__":
  257. main()