feat: place PDF artwork in print files, add card payment, count only failed logins
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
All checks were successful
Build and deploy / Validate source (push) Successful in 6s
Build and deploy / Integration suite on a real stack (push) Successful in 2m23s
Build and deploy / Secret scan and release gate (push) Successful in 5s
Build and deploy / Publish images and notify Portainer (push) Has been skipped
PDF artwork: a single-page PDF source is placed in the print file as a vector form through pikepdf, never rasterised, using the CropBox and inherited /Rotate the Site measured with pdf.js. Multi-page and protected PDFs go to hand preparation. PyMuPDF was not used because of its AGPL licence. Raster tests cover crop, page rotation, placement rotation and mirroring, and fail when the rotation or crop handling is broken. Card payment: Mercado Pago's Card Payment Brick on the Site when MP_PUBLIC_KEY is set; the card becomes a one-time token in Mercado Pago's secure fields. Each card attempt has its own idempotency key, and the intent route refuses new attempts once a payment is approved or a card is in review, so a quote cannot be charged twice. The Site CSP admits Mercado Pago's origins only through PAYMENT_CSP_SOURCES, empty by default. Logins: every attempt counts against the source address, only failures against the account. Counting successful sign-ins let ordinary use lock an operator out and made CI's final browser sign-in fail. No new required settings; production behaviour is unchanged until the provider credentials are configured. Verified with the full CI integration sequence locally. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
180
app/printfile.py
180
app/printfile.py
@@ -12,13 +12,15 @@ in the file, a JPEG keeps its original bytes, and transparency survives as a
|
||||
soft mask. The output is therefore about the size of the artwork, not of a
|
||||
57 cm x 20 m raster, and it never needs that raster in memory.
|
||||
|
||||
Only formats whose pixels can be read here are generated: JPEG, PNG, WebP and
|
||||
TIFF. Anything else (PDF, PSD, AI, CDR), or a file whose proportions do not
|
||||
match the size it was quoted at, is refused with a reason, and the operator
|
||||
prepares that item by hand exactly as before.
|
||||
Raster sources are JPEG, PNG, WebP and TIFF. A single-page PDF is placed as a
|
||||
vector form, never rasterised. Anything else (PSD, AI, CDR, multi-page or
|
||||
protected PDFs), or a file whose proportions do not match the size it was
|
||||
quoted at, is refused with a reason, and the operator prepares that item by
|
||||
hand exactly as before.
|
||||
"""
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import zlib
|
||||
from decimal import Decimal
|
||||
|
||||
@@ -288,8 +290,85 @@ def placement_matrix(placement, page_height_pt):
|
||||
return [ax - ox, ay - oy, cx - ox, cy - oy, ox, oy]
|
||||
|
||||
|
||||
def check_layout(item, sizes):
|
||||
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest DPI."""
|
||||
class PdfPage:
|
||||
"""One page of a customer PDF, placed as a vector form: nothing is rasterised.
|
||||
|
||||
The box and orientation are the ones the Site measured with pdf.js: the
|
||||
CropBox (which pikepdf uses for the form's BBox), turned by the page's
|
||||
/Rotate, inherited or not.
|
||||
"""
|
||||
|
||||
def __init__(self, path, name):
|
||||
import pikepdf
|
||||
self.name = name
|
||||
try:
|
||||
self.pdf = pikepdf.open(path)
|
||||
except pikepdf.PasswordError as exc:
|
||||
raise Unsupported(f'"{name}" is password-protected') from exc
|
||||
except Exception as exc:
|
||||
raise Unsupported(f'"{name}" is not a PDF this generator can read') from exc
|
||||
try:
|
||||
pages = len(self.pdf.pages)
|
||||
if pages != 1:
|
||||
raise Unsupported(f'"{name}" has {pages} pages; only single-page PDFs are generated automatically')
|
||||
self.page = self.pdf.pages[0]
|
||||
self.rotation = int(self.page.rotation) % 360
|
||||
if self.rotation not in (0, 90, 180, 270):
|
||||
raise Unsupported(f'"{name}" has an unsupported page rotation')
|
||||
box = [float(v) for v in self.page.cropbox]
|
||||
except Unsupported:
|
||||
self.pdf.close()
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.pdf.close()
|
||||
raise Unsupported(f'"{name}" has a page this generator cannot read') from exc
|
||||
self.x0, self.y0 = min(box[0], box[2]), min(box[1], box[3])
|
||||
self.w, self.h = abs(box[2] - box[0]), abs(box[3] - box[1])
|
||||
if self.w <= 0 or self.h <= 0:
|
||||
self.pdf.close()
|
||||
raise Unsupported(f'"{name}" has an empty page')
|
||||
# As displayed, which is what the proportions are checked against.
|
||||
self.size = (self.h, self.w) if self.rotation in (90, 270) else (self.w, self.h)
|
||||
|
||||
def normalise(self):
|
||||
"""Matrix from the page's box, as displayed, onto the unit square."""
|
||||
a, d = 1 / self.w, 1 / self.h
|
||||
scale = [a, 0, 0, d, -self.x0 * a, -self.y0 * d]
|
||||
turn = {0: [1, 0, 0, 1, 0, 0], 90: [0, -1, 1, 0, 0, 1],
|
||||
180: [-1, 0, 0, -1, 1, 1], 270: [0, 1, -1, 0, 1, 0]}[self.rotation]
|
||||
return multiply(scale, turn)
|
||||
|
||||
def form(self, target):
|
||||
"""The page as a form XObject copied into the target document."""
|
||||
form = self.page.as_form_xobject(handle_transformations=False)
|
||||
group = self.page.obj.get('/Group')
|
||||
if group is not None and '/Group' not in form:
|
||||
form.Group = group
|
||||
return target.copy_foreign(form)
|
||||
|
||||
def close(self):
|
||||
self.pdf.close()
|
||||
|
||||
|
||||
def multiply(first, then):
|
||||
"""PDF matrices: a point transformed by `first`, then by `then`."""
|
||||
a1, b1, c1, d1, e1, f1 = first
|
||||
a2, b2, c2, d2, e2, f2 = then
|
||||
return [a1 * a2 + b1 * c2, a1 * b2 + b1 * d2, c1 * a2 + d1 * c2, c1 * b2 + d1 * d2,
|
||||
e1 * a2 + f1 * c2 + e2, e1 * b2 + f1 * d2 + f2]
|
||||
|
||||
|
||||
def open_source(path, name):
|
||||
with open(path, 'rb') as handle:
|
||||
head = handle.read(1024)
|
||||
if b'%PDF-' in head:
|
||||
return PdfPage(path, name)
|
||||
return SourceImage(path, name)
|
||||
|
||||
|
||||
def check_layout(item, sizes, vector=()):
|
||||
"""Refuse a layout the file cannot reproduce faithfully. Returns the lowest
|
||||
DPI of the raster sources (vector pages have none)."""
|
||||
production = item['production']
|
||||
height = Decimal(str(production['height_cm']))
|
||||
billed = Decimal(str(item['billed_metres'])) * 100
|
||||
@@ -307,6 +386,8 @@ def check_layout(item, sizes):
|
||||
source = production['sources'][placement['source_index']]
|
||||
raise Unsupported(f'file {placement["source_index"] + 1} has proportions that do not match '
|
||||
f'the quoted {source["width_cm"]} x {source["length_cm"]} cm')
|
||||
if placement['source_index'] in vector:
|
||||
continue
|
||||
dpi = width_px / (width_cm / 2.54)
|
||||
lowest = dpi if lowest is None else min(lowest, dpi)
|
||||
return lowest
|
||||
@@ -317,6 +398,10 @@ def render(item, files, out, title):
|
||||
|
||||
`files` maps each source index to (local path, original name). Returns the
|
||||
evidence the Kanban shows: page size, what was billed, and the lowest DPI.
|
||||
|
||||
Raster sources are written by the streaming writer above. PDF sources are
|
||||
then added as vector forms by a second pass through pikepdf, so a sheet
|
||||
exported as PDF keeps its vectors, fonts and transparency.
|
||||
"""
|
||||
production = item['production']
|
||||
if production.get('version') != 2:
|
||||
@@ -325,33 +410,23 @@ def render(item, files, out, title):
|
||||
try:
|
||||
for index in range(len(production['sources'])):
|
||||
path, name = files[index]
|
||||
sources.append(SourceImage(path, name))
|
||||
lowest_dpi = check_layout(item, [source.size for source in sources])
|
||||
sources.append(open_source(path, name))
|
||||
vector = {index for index, source in enumerate(sources) if isinstance(source, PdfPage)}
|
||||
lowest_dpi = check_layout(item, [source.size for source in sources], vector)
|
||||
|
||||
width_pt = float(production['film_width_cm']) * PT_PER_CM
|
||||
height_pt = float(production['height_cm']) * PT_PER_CM
|
||||
unit = max(1, math.ceil(max(width_pt, height_pt) / MAX_PAGE_PT))
|
||||
|
||||
pdf = PdfWriter(out)
|
||||
images = [source.embed(pdf) for source in sources]
|
||||
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
|
||||
for placement in production['placements']:
|
||||
matrix = placement_matrix(placement, height_pt)
|
||||
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
|
||||
b' cm /Im%d Do Q\n' % placement['source_index'])
|
||||
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
|
||||
pages = pdf.alloc()
|
||||
page_box = [0, 0, width_pt / unit, height_pt / unit]
|
||||
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
|
||||
'Resources': {'XObject': {f'Im{index}': ref for index, ref in enumerate(images)}},
|
||||
'Contents': content}
|
||||
if unit > 1:
|
||||
page['UserUnit'] = unit
|
||||
page_ref = pdf.obj(page)
|
||||
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
|
||||
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
|
||||
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
|
||||
pdf.finish(root, info)
|
||||
first = tempfile.TemporaryFile() if vector else out
|
||||
try:
|
||||
write_rasters(production, sources, first, title, width_pt, height_pt, unit)
|
||||
if vector:
|
||||
first.seek(0)
|
||||
add_vector_pages(production, sources, first, out, height_pt)
|
||||
finally:
|
||||
if vector:
|
||||
first.close()
|
||||
finally:
|
||||
for source in sources:
|
||||
source.close()
|
||||
@@ -360,5 +435,54 @@ def render(item, files, out, title):
|
||||
'billed_metres': str(item['billed_metres']),
|
||||
'placements': len(production['placements']),
|
||||
'sources': len(sources),
|
||||
'vector_sources': len(vector),
|
||||
'min_dpi': round(lowest_dpi) if lowest_dpi else None,
|
||||
'user_unit': unit}
|
||||
|
||||
|
||||
def write_rasters(production, sources, out, title, width_pt, height_pt, unit):
|
||||
pdf = PdfWriter(out)
|
||||
images = {index: source.embed(pdf) for index, source in enumerate(sources)
|
||||
if isinstance(source, SourceImage)}
|
||||
# The user-space scale is not wrapped in q/Q, so it also applies to the
|
||||
# vector placements appended by the second pass.
|
||||
commands = [b'%s 0 0 %s 0 0 cm\n' % (serialize(1 / unit), serialize(1 / unit))] if unit > 1 else []
|
||||
for placement in production['placements']:
|
||||
if placement['source_index'] not in images:
|
||||
continue
|
||||
matrix = placement_matrix(placement, height_pt)
|
||||
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
|
||||
b' cm /Im%d Do Q\n' % placement['source_index'])
|
||||
content = pdf.stream({'Filter': Name('FlateDecode')}, deflate(commands))
|
||||
pages = pdf.alloc()
|
||||
page_box = [0, 0, width_pt / unit, height_pt / unit]
|
||||
page = {'Type': Name('Page'), 'Parent': pages, 'MediaBox': page_box, 'TrimBox': page_box,
|
||||
'Resources': {'XObject': {f'Im{index}': ref for index, ref in images.items()}},
|
||||
'Contents': content}
|
||||
if unit > 1:
|
||||
page['UserUnit'] = unit
|
||||
page_ref = pdf.obj(page)
|
||||
pdf.obj({'Type': Name('Pages'), 'Kids': [page_ref], 'Count': 1}, pages)
|
||||
root = pdf.obj({'Type': Name('Catalog'), 'Pages': pages})
|
||||
info = pdf.obj({'Title': title, 'Producer': 'DTF System print-file generator'})
|
||||
pdf.finish(root, info)
|
||||
|
||||
|
||||
def add_vector_pages(production, sources, first, out, height_pt):
|
||||
import pikepdf
|
||||
with pikepdf.open(first) as document:
|
||||
page = document.pages[0]
|
||||
xobjects = page.obj.Resources.XObject
|
||||
for index, source in enumerate(sources):
|
||||
if isinstance(source, PdfPage):
|
||||
xobjects[f'/Pdf{index}'] = source.form(document)
|
||||
commands = []
|
||||
for placement in production['placements']:
|
||||
source = sources[placement['source_index']]
|
||||
if not isinstance(source, PdfPage):
|
||||
continue
|
||||
matrix = multiply(source.normalise(), placement_matrix(placement, height_pt))
|
||||
commands.append(b'q ' + b' '.join(serialize(v) for v in matrix) +
|
||||
b' cm /Pdf%d Do Q\n' % placement['source_index'])
|
||||
page.contents_add(pikepdf.Stream(document, b''.join(commands)), prepend=False)
|
||||
document.save(out, min_version='1.6')
|
||||
|
||||
Reference in New Issue
Block a user