
This tech pack Python script turns a product photo, a line drawing and a small style.json into a two-page PDF: page one puts the photo next to the drawing with numbered callout pins, page two holds the bill of materials, points of measure with tolerances, and a revision log. The script is 175 lines of Python built on reportlab and Pillow, and every number in this post comes from running it.
AI helped draft this post; I ran every code block and copied the printed output from those runs. This account belongs to Virse, and Virse's line art tool comes up once below as one way to get the drawing.
What does this tech pack Python script produce?
It produces a landscape A4 PDF with a header block on every page (style number, name, season, revision, date, page count) and two pages of content. Techpacker lists seven core components of a tech pack: "Cover page and style information," "technical flat sketches," "bill of materials," "measurement specifications and grading chart," "construction notes and stitching details," "cost sheet," and "revision history and version log" (source). Here is how the script maps to that list:
| Component | In this script | Where |
|---|---|---|
| Cover page and style information | Yes, as a header on every page | header() |
| Technical flat sketch | Yes, line drawing with numbered pins | page 1 |
| Construction notes | Partly, one spec line per callout | page 1 legend |
| Bill of materials | Yes | page 2 |
| Measurements | Yes, one size with tolerances | page 2 |
| Grading chart | No | extend pom
|
| Cost sheet | No | out of scope |
| Revision log | Yes | page 2 |
The point is not to replace a PLM system. It is for the common case where a small hardware or apparel team keeps specs in a spreadsheet, drawings in a folder, and rebuilds the PDF by hand every time a supplier asks for "the latest version".

Page two: BOM, points of measure with tolerances, and the revision log, all read from style.json.
What do you need before running it?
Python 3 and three packages. I tested with Python 3.13.3, reportlab 4.5.1, Pillow 12.2.0 and PyMuPDF 1.27.2.3 (PyMuPDF is only used for the preview step at the end).
pip install reportlab pillow pymupdf
Each style gets its own folder with up to three files:
drill/
photo.jpg # optional reference photo
lineart.png # black lines on white
style.json # everything the tables and callouts need
style.json holds the header fields, callouts, BOM, points of measure and revisions. The drill below is a hypothetical example; the materials and dimensions are placeholders, not a real product's spec.
{
"style_no": "DR-18",
"name": "Cordless drill driver (example)",
"season": "SS27",
"revision": "B",
"date": "2026-09-23",
"callouts": [
{"x": 0.25, "y": 0.14, "label": "Keyless chuck", "spec": "Knurl pattern as sample; no printed marks"},
{"x": 0.92, "y": 0.16, "label": "Motor vents", "spec": "4 slots each side, keep open, no mesh"}
],
"bom": [
{"component": "Main housing", "material": "PC/ABS", "finish": "Yellow, light texture", "supplier": "TBD", "qty": 2}
],
"pom": [
{"code": "A", "point": "Overall length, chuck tip to rear vent", "target": 185, "tol": 1.5, "unit": "mm"}
],
"revisions": [
{"rev": "A", "date": "2026-09-10", "change": "First issue"},
{"rev": "B", "date": "2026-09-23", "change": "Vent slots 5 -> 4; grip overmold 1.5 -> 2 mm proud"}
]
}
That listing is trimmed for space; the file I ran has seven callouts, five BOM rows and four points of measure.
Where does the line drawing come from?
Any flat that is black lines on a white background will work: an Illustrator export, a scanned hand drawing, or a drawing traced from a sample photo. The script only cares about two things. The background has to be white, because Step 1 finds the drawing by looking for dark pixels. And if you include a reference photo, the drawing should show the product from the same angle, or the callouts on the drawing will not match what people see in the photo next to it.
If all you have is a photo of the sample, you need a photo to line drawing for a tech pack step before this script runs. Virse's AI Line Art Generator takes one product photo, removes the background, and returns black lines on white in one of three styles (Detailed line art, Clean line art or Bold outline), plus an optional one-line note such as which seams to keep or which reflections to drop. It does not return a transparent PNG, which is fine here because the script expects white. The drill photo and drawing pair and the hoodie flat used in this post are example images published on that tool's page; I did not generate new ones for this tutorial.

The photo to line drawing step: one product photo, a line style, and an optional note on what to keep.
Step 1: clean and trim the line drawing
Both drawings I used arrived with a wide white margin around the product and gray anti-aliased pixels along every line. The margin shrinks the drawing once it is scaled into a column on the page. The gray pixels stop the image from being stored as 1-bit black and white, which is what keeps it small inside the PDF.
"""Assemble a tech pack PDF from a product photo, a line drawing and a style.json."""
import io
import json
import sys
from pathlib import Path
from PIL import Image, ImageOps
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.utils import ImageReader
from reportlab.pdfgen import canvas
from reportlab.platypus import Table, TableStyle
PAGE_W, PAGE_H = landscape(A4) # 842 x 595 pt
MARGIN = 28
HEADER_H = 46
def prep_line_drawing(path, threshold=200, pad=12):
"""Grayscale, snap to pure black/white, trim the empty margin."""
img = Image.open(path).convert("L")
bw = img.point(lambda v: 255 if v > threshold else 0, mode="1")
bbox = ImageOps.invert(bw.convert("L")).getbbox() # box around the ink
left, top, right, bottom = bbox
bbox = (max(left - pad, 0), max(top - pad, 0),
min(right + pad, bw.width), min(bottom + pad, bw.height))
trimmed = bw.crop(bbox)
print(f"line drawing {img.size} -> trimmed {trimmed.size}, crop box {bbox}")
return ImageReader(trimmed)
getbbox() returns the box around non-zero pixels, so the function inverts the 1-bit image first to make the ink the non-zero part. On the drill drawing it printed:
line drawing (766, 1024) -> trimmed (572, 752), crop box (92, 137, 664, 889)
The trim matters more than it looks. On page one the drawing column has an image area 332 pt tall. Fitted into it untrimmed, the drill itself would be drawn 244 pt tall; trimmed, the drill is 332 pt tall and uses the whole area. A threshold of 200 keeps thin stitch lines on the drawings I tried. If faint lines disappear on yours, raise it; if gray noise survives, lower it.

prep_line_drawing() on the drill: the crop box it printed, and the trimmed 1-bit drawing that goes into the PDF.
Step 2: embed the photo without bloating the PDF
This one surprised me. If you hand reportlab a decoded Pillow image, it stores the raw pixels with Flate compression. A photo compresses badly that way. When I passed the photo and the untrimmed RGB drawing straight to ImageReader, the two-page PDF came out at 1,235 KB. With the two prep functions it is 122 KB, about a tenth of the size.
def prep_photo(path, max_side=1400, quality=85):
"""Downscale and re-encode as JPEG so the PDF embeds it with DCT, not Flate."""
photo = Image.open(path).convert("RGB")
photo.thumbnail((max_side, max_side))
buf = io.BytesIO()
photo.save(buf, "JPEG", quality=quality)
buf.seek(0)
return ImageReader(buf) # JPEG bytes go into the PDF as-is
def fit(img, box_w, box_h):
"""Scale an image into a box without distortion; return drawn w, h."""
w, h = img.getSize()
scale = min(box_w / w, box_h / h)
return w * scale, h * scale
Giving ImageReader a file object that contains JPEG bytes lets reportlab copy those bytes into the PDF unchanged. Opening the finished file with PyMuPDF confirms it: the photo is stored as a 75,769-byte JPEG and the 1-bit drawing as a 21,087-byte Flate image. A tech pack usually travels as an email attachment, so cutting the file to a tenth is worth six lines of code.
To reproduce the comparison, this swaps both prep functions for the naive version and rebuilds into a copy of the folder:
import shutil
from pathlib import Path
from PIL import Image
from reportlab.lib.utils import ImageReader
import build_techpack as bt
naive = Path("naive")
shutil.copytree("drill", naive, dirs_exist_ok=True)
bt.prep_photo = lambda p: ImageReader(Image.open(p).convert("RGB"))
bt.prep_line_drawing = lambda p: ImageReader(Image.open(p).convert("RGB"))
bt.build(naive)
Step 3: pin numbered callouts to the drawing
Callouts are where a tech pack earns its keep. A dev.to post on what factories want puts it as "Construction Callouts That Map Directly to the Sketches" (source). The question for code is what coordinates to store.
Page coordinates break as soon as the layout changes. Pixel coordinates break when someone re-exports the drawing at a different resolution. So style.json stores each pin as a fraction of the trimmed drawing, with (0, 0) at the top-left and (1, 1) at the bottom-right. The page code converts that into points after it knows where the drawing landed.
def header(c, style, page_no, page_total, title):
top = PAGE_H - MARGIN
c.setLineWidth(0.8)
c.rect(MARGIN, top - HEADER_H, PAGE_W - 2 * MARGIN, HEADER_H)
c.setFont("Helvetica-Bold", 14)
c.drawString(MARGIN + 10, top - 20, f"{style['style_no']} {style['name']}")
c.setFont("Helvetica", 9)
c.drawString(MARGIN + 10, top - 36,
f"Season {style['season']} Rev {style['revision']} {style['date']}")
c.setFont("Helvetica-Bold", 11)
c.drawRightString(PAGE_W - MARGIN - 10, top - 20, title)
c.setFont("Helvetica", 9)
c.drawRightString(PAGE_W - MARGIN - 10, top - 36, f"Page {page_no} of {page_total}")
def make_table(c, rows, col_widths):
"""Build a styled table and measure it; returns (table, height in pt)."""
t = Table(rows, colWidths=col_widths)
t.setStyle(TableStyle([
("FONT", (0, 0), (-1, 0), "Helvetica-Bold", 8),
("FONT", (0, 1), (-1, -1), "Helvetica", 8),
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e9ecef")),
("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
]))
_, h = t.wrapOn(c, sum(col_widths), PAGE_H)
return t, h
def sketch_page(c, style, photo, drawing):
# measure the callout legend first, then give the images whatever is left
rows = [["#", "Callout", "Spec"]] + [
[str(i), co["label"], co["spec"]] for i, co in enumerate(style["callouts"], 1)]
legend, legend_h = make_table(c, rows, [24, 200, PAGE_W - 2 * MARGIN - 224])
legend.drawOn(c, MARGIN, MARGIN)
body_top = PAGE_H - MARGIN - HEADER_H - 12
body_h = body_top - (MARGIN + legend_h + 24)
if photo is not None:
col_w = (PAGE_W - 2 * MARGIN - 20) / 2
pw, ph = fit(photo, col_w, body_h)
c.drawImage(photo, MARGIN + (col_w - pw) / 2, body_top - ph, width=pw, height=ph)
c.setFont("Helvetica-Oblique", 8)
c.drawCentredString(MARGIN + col_w / 2, body_top - ph - 11, "Reference photo, not to scale")
draw_x0 = MARGIN + col_w + 20
else:
col_w = PAGE_W - 2 * MARGIN
draw_x0 = MARGIN
dw, dh = fit(drawing, col_w, body_h)
dx = draw_x0 + (col_w - dw) / 2
dy = body_top - dh
c.drawImage(drawing, dx, dy, width=dw, height=dh)
# callouts are stored as fractions of the trimmed drawing (0..1, origin top-left)
for i, co in enumerate(style["callouts"], start=1):
px = dx + co["x"] * dw
py = dy + (1 - co["y"]) * dh
c.setFillColor(colors.HexColor("#d62828"))
c.circle(px, py, 8, stroke=0, fill=1)
c.setFillColor(colors.white)
c.setFont("Helvetica-Bold", 9)
c.drawCentredString(px, py - 3, str(i))
c.setFillColor(colors.black)
Two details here came from bugs in my first version:
-
Measure the legend before placing the images. I first reserved a fixed 110 pt for the callout table. With seven callouts the table is 124.8 pt tall, so it drew over the bottom of the photo and the drawing.
Table.wrapOn()returns the real height before anything is drawn, so the image area is now whatever is left. -
Flip the y axis once. PDF coordinates start at the bottom-left, image coordinates at the top-left. The
1 - co["y"]is the only place that conversion happens.
Because pins are relative to the trimmed drawing, re-exporting the drawing at double resolution keeps them in place. A new drawing with a different pose or crop does not; for that, re-check the pins in the preview (below) and adjust the fractions.
Step 4: BOM, points of measure and revision log
Page two is three tables stacked from the top. Each block draws a title, measures its table, draws it, and returns the y position for the next one.
def spec_page(c, style):
width = PAGE_W - 2 * MARGIN
top = PAGE_H - MARGIN - HEADER_H - 16
def block(title, rows, col_widths, top):
c.setFont("Helvetica-Bold", 10)
c.drawString(MARGIN, top, title)
t, h = make_table(c, rows, col_widths)
t.drawOn(c, MARGIN, top - 8 - h)
return top - 8 - h - 24
bom = [["Component", "Material", "Color / finish", "Supplier ref", "Qty"]]
bom += [[b["component"], b["material"], b["finish"], b["supplier"], str(b["qty"])]
for b in style["bom"]]
top = block("Bill of materials", bom, [150, 190, 170, 170, width - 680], top)
pom = [["Code", "Point of measure", "Target", "Tol +/-", "Unit"]]
pom += [[p["code"], p["point"], f"{p['target']:g}", f"{p['tol']:g}", p["unit"]]
for p in style["pom"]]
top = block("Points of measure", pom, [50, 330, 90, 90, width - 560], top)
rev = [["Rev", "Date", "Change"]] + [[r["rev"], r["date"], r["change"]]
for r in style["revisions"]]
block("Revision log", rev, [50, 90, width - 140], top)
The :g format prints 1.5 as 1.5 and 2.0 as 2, so the tolerance column does not fill up with trailing zeros. The column widths add up to the roughly 786 pt between the margins; if you add a column, take the width from "Point of measure".
This version assumes a style fits on two pages. If your BOM runs to forty rows, switch page two to reportlab's SimpleDocTemplate, let the table split across pages, and call header() from the template's onPage callback.
Step 5: fail the build when the spec is incomplete
The same dev.to post lists "Measurements With Tolerances, Not Just Target Numbers" as one of the things factories want. A script can enforce that: refuse to build a PDF that has a measurement without a tolerance, or a pin that falls off the drawing.
def validate(style):
errors = []
for i, co in enumerate(style["callouts"], 1):
if not (0 <= co["x"] <= 1 and 0 <= co["y"] <= 1):
errors.append(f"callout {i} is outside the drawing: {co['x']}, {co['y']}")
for p in style["pom"]:
if p.get("tol") in (None, 0):
errors.append(f"POM {p['code']} has no tolerance")
if errors:
sys.exit("style.json problems:\n " + "\n ".join(errors))
def build(folder):
folder = Path(folder)
style = json.loads((folder / "style.json").read_text(encoding="utf-8"))
validate(style)
drawing = prep_line_drawing(folder / "lineart.png")
photo_path = folder / "photo.jpg"
photo = prep_photo(photo_path) if photo_path.exists() else None
out = folder / f"{style['style_no']}_rev{style['revision']}_techpack.pdf"
c = canvas.Canvas(str(out), pagesize=(PAGE_W, PAGE_H))
c.setTitle(f"{style['style_no']} tech pack rev {style['revision']}")
header(c, style, 1, 2, "Sketch and callouts")
sketch_page(c, style, photo, drawing)
c.showPage()
header(c, style, 2, 2, "Materials and measurements")
spec_page(c, style)
c.showPage()
c.save()
print(f"wrote {out} ({out.stat().st_size / 1024:.0f} KB)")
if __name__ == "__main__":
build(sys.argv[1])
Save all the blocks from Step 1 to here, in order, as build_techpack.py. With a copy of the drill folder where I pushed one pin to x = 1.08 and set one tolerance to 0, the run stops before writing anything and exits with status 1:
$ python build_techpack.py broken
style.json problems:
callout 3 is outside the drawing: 1.08, 0.16
POM D has no tolerance
With the real files:
$ python build_techpack.py drill
line drawing (766, 1024) -> trimmed (572, 752), crop box (92, 137, 664, 889)
wrote drill\DR-18_revB_techpack.pdf (122 KB)
The file name carries the style number and revision, so "rev B" in an email subject and the attachment name can't drift apart.
How do you check the PDF before sending it?
Render it to images and look. A pin that lands one feature off is the most likely error, and no validation rule catches it.
import sys
import fitz # PyMuPDF
doc = fitz.open(sys.argv[1])
print(f"{doc.page_count} pages, {doc[0].rect.width:.0f} x {doc[0].rect.height:.0f} pt")
for page in doc:
page.get_pixmap(dpi=110).save(f"{sys.argv[1][:-4]}_p{page.number + 1}.png")
$ python preview.py drill/DR-18_revB_techpack.pdf
2 pages, 842 x 595 pt
The same script handles apparel. For a hoodie there is no photo, only a flat, so photo.jpg is left out and the drawing takes the full width. The run printed line drawing (1254, 1254) -> trimmed (860, 1186) and wrote a 40 KB PDF.
If you put these PNGs in a pull request or a shared folder, reviewers can comment on pin placement without opening a PDF viewer.
What this script does not do
Keeping the scope honest saves a supplier email later:
-
No grading. Points of measure cover one size. For apparel, add a
sizeslist and one column per size inspec_page(). -
One view. Techpacker notes that "For garment sketches, it is typical to have just a front and back view" (source). To add a back view, give each callout a
viewkey and draw a second drawing in the photo column when there is no photo. -
Built-in fonts only. Helvetica covers English specs. For notes in other writing systems, register a TTF font with
pdfmetrics.registerFont(TTFont(...))first. - No cost sheet, and nothing about compliance labels. Those usually live in a separate document anyway.
The parts that are worth copying even if you rebuild everything else: trim and threshold the drawing before layout, store pins as fractions of the trimmed drawing, hand reportlab JPEG bytes for photos, measure tables before placing images, and let a missing tolerance stop the build.

Top comments (0)