All checks were successful
Build and deploy / Validate source (push) Successful in 1m23s
Build and deploy / Integration suite on a real stack (push) Successful in 3m13s
Build and deploy / Secret scan and release gate (push) Successful in 10s
Build and deploy / Publish images (push) Successful in 1m25s
The price depends on the grade, which the browser worked out and the API took on trust. Before a cart is approved at checkout the API now recomputes it from the uploaded files by the Site's own rules: the pixel size in a PNG, JPG or WebP header across the printed width (rotation included), and the area-weighted DPI of the images placed in a PDF of up to 150 MB, 300 for vectors. Sheets take the worst grade, artworks the average. A claim more than 2 points above the file's grade, or a discount on a file the server cannot grade, waits for an operator, with the reason on the Kanban. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
7.7 KiB
Python
190 lines
7.7 KiB
Python
"""The grade (the resolution discount) recomputed from the uploaded files.
|
|
|
|
The Site grades the art in the customer's browser and sends the grade with the
|
|
cart, and the price depends on it. A customer who edits the page could claim a
|
|
better grade, so before a cart is approved at checkout the server reads the
|
|
files it already has and works the grade out again, by the Site's own rules:
|
|
|
|
- a finished sheet: its pixel width over the sheet width is its DPI, and the
|
|
item's grade comes from the worst sheet (web/site-quality.js);
|
|
- artworks: each one's DPI along the width it is printed at, rotation
|
|
included; the item's grade is the average of the artworks' grades;
|
|
- a PDF: the area-weighted DPI of the images drawn on its page, 300 when it
|
|
is only vectors (web/site-pdf.js, dpiDasImagens and resumoDpi).
|
|
|
|
Images are measured from their first bytes, never decoded. A PDF is opened
|
|
only up to PDF_MAX_BYTES, the size the Site itself analyses. A grade that
|
|
cannot be recomputed is None; the caller decides what that means.
|
|
"""
|
|
import math
|
|
import os
|
|
import struct
|
|
import tempfile
|
|
|
|
DPI_IDEAL = 300
|
|
# The Site analyses PDFs up to 150 MB (GRANDE_BYTES); above that it does not
|
|
# grade them, so neither does this.
|
|
PDF_MAX_BYTES = 150 * 1048576
|
|
HEAD_BYTES = 1048576
|
|
# Rounding in the browser and here can differ by a point on the same file.
|
|
TOLERANCE = 2
|
|
|
|
|
|
def js_round(value):
|
|
"""Math.round, which rounds halves up; Python's round() does not."""
|
|
return math.floor(value + 0.5)
|
|
|
|
|
|
def grade_of(dpi):
|
|
return max(6, min(100, js_round(dpi / DPI_IDEAL * 100)))
|
|
|
|
|
|
def image_size(head):
|
|
"""(width, height) in pixels from a PNG, JPEG or WebP's first bytes."""
|
|
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
|
|
return struct.unpack('>II', head[16:24])
|
|
if head[:2] == b'\xff\xd8':
|
|
i = 2
|
|
while i + 9 < len(head):
|
|
if head[i] != 0xFF:
|
|
i += 1
|
|
continue
|
|
marker = head[i + 1]
|
|
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7 or marker == 0xFF:
|
|
i += 1 if marker == 0xFF else 2
|
|
continue
|
|
length = struct.unpack('>H', head[i + 2:i + 4])[0]
|
|
if 0xC0 <= marker <= 0xCF and marker not in (0xC4, 0xC8, 0xCC):
|
|
h, w = struct.unpack('>HH', head[i + 5:i + 9])
|
|
return w, h
|
|
i += 2 + length
|
|
return None
|
|
if head[:4] == b'RIFF' and head[8:12] == b'WEBP':
|
|
chunk = head[12:16]
|
|
if chunk == b'VP8X':
|
|
return (int.from_bytes(head[24:27], 'little') + 1, int.from_bytes(head[27:30], 'little') + 1)
|
|
if chunk == b'VP8L' and head[20] == 0x2F:
|
|
bits = int.from_bytes(head[21:25], 'little')
|
|
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
if chunk == b'VP8 ' and head[23:26] == b'\x9d\x01\x2a':
|
|
w, h = struct.unpack('<HH', head[26:30])
|
|
return w & 0x3FFF, h & 0x3FFF
|
|
return None
|
|
|
|
|
|
def _multiply(a, b):
|
|
return [a[0] * b[0] + a[2] * b[1], a[1] * b[0] + a[3] * b[1],
|
|
a[0] * b[2] + a[2] * b[3], a[1] * b[2] + a[3] * b[3],
|
|
a[0] * b[4] + a[2] * b[5] + a[4], a[1] * b[4] + a[3] * b[5] + a[5]]
|
|
|
|
|
|
def pdf_dpi(path):
|
|
"""The page's area-weighted image DPI, 300 for vectors only, None if the
|
|
file is not a one-page PDF this can read."""
|
|
import pikepdf
|
|
try:
|
|
with pikepdf.open(path) as pdf:
|
|
if len(pdf.pages) != 1:
|
|
return None
|
|
page = pdf.pages[0]
|
|
unit = float(page.obj.get('/UserUnit', 1))
|
|
found = []
|
|
|
|
def walk(resources, instructions, ctm, depth):
|
|
stack = []
|
|
xobjects = resources.get('/XObject', {}) if resources is not None else {}
|
|
for operands, operator in instructions:
|
|
op = str(operator)
|
|
if op == 'q':
|
|
stack.append(ctm)
|
|
elif op == 'Q':
|
|
ctm = stack.pop() if stack else [1, 0, 0, 1, 0, 0]
|
|
elif op == 'cm':
|
|
ctm = _multiply(ctm, [float(v) for v in operands])
|
|
elif op == 'Do' and operands:
|
|
xobject = xobjects.get(str(operands[0]))
|
|
if xobject is None:
|
|
continue
|
|
subtype = xobject.get('/Subtype')
|
|
if subtype == '/Image':
|
|
width_pt = math.hypot(ctm[0], ctm[1])
|
|
if width_pt >= 1:
|
|
found.append((int(xobject.get('/Width', 0)) / (width_pt * unit / 72),
|
|
abs(ctm[0] * ctm[3] - ctm[1] * ctm[2])))
|
|
elif subtype == '/Form' and depth < 8:
|
|
matrix = [float(v) for v in xobject.get('/Matrix', [1, 0, 0, 1, 0, 0])]
|
|
walk(xobject.get('/Resources', resources),
|
|
pikepdf.parse_content_stream(xobject), _multiply(ctm, matrix), depth + 1)
|
|
|
|
walk(page.obj.get('/Resources'), pikepdf.parse_content_stream(page), [1, 0, 0, 1, 0, 0], 0)
|
|
except Exception:
|
|
return None
|
|
found = [(dpi, area) for dpi, area in found if dpi > 0]
|
|
if not found:
|
|
return DPI_IDEAL
|
|
total = sum(area for _, area in found) or 1
|
|
return js_round(sum(dpi * area for dpi, area in found) / total)
|
|
|
|
|
|
class Files:
|
|
"""The uploaded files, read from storage as little as needed."""
|
|
|
|
def __init__(self, storage):
|
|
self.storage = storage
|
|
|
|
def head(self, row):
|
|
return self.storage.client.get_object(Bucket=self.storage.bucket, Key=row['object_key'],
|
|
Range=f'bytes=0-{HEAD_BYTES - 1}')['Body'].read()
|
|
|
|
def pdf_dpi(self, row):
|
|
if row['size'] > PDF_MAX_BYTES:
|
|
return None
|
|
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
|
self.storage.client.download_fileobj(self.storage.bucket, row['object_key'], handle)
|
|
handle.flush()
|
|
return pdf_dpi(handle.name)
|
|
|
|
|
|
def source_dpi(files, row, source):
|
|
"""One source's DPI across the width it is printed at, or None."""
|
|
name = row['name'].lower()
|
|
width_in = float(source['width_cm']) / 2.54
|
|
if name.endswith('.pdf'):
|
|
return files.pdf_dpi(row) if source['kind'] == 'sheet' else None
|
|
if not name.endswith(('.png', '.jpg', '.jpeg', '.webp')):
|
|
return None
|
|
size = image_size(files.head(row))
|
|
if not size or not all(size):
|
|
return None
|
|
across = size[1] if source['rotation_degrees'] in (90, 270) else size[0]
|
|
return across / width_in
|
|
|
|
|
|
def item_grade(files, item, rows):
|
|
"""The grade the Site gives this item, recomputed; None if it cannot be."""
|
|
sources = item['production']['sources']
|
|
dpis = []
|
|
for source in sources:
|
|
row = rows.get(str(source['upload_id']))
|
|
dpi = source_dpi(files, row, source) if row else None
|
|
if dpi is None:
|
|
return None
|
|
dpis.append(dpi)
|
|
if all(source['kind'] == 'sheet' for source in sources):
|
|
# A sheet's DPI is shown and compared as a whole number.
|
|
return grade_of(min(js_round(d) for d in dpis))
|
|
return js_round(sum(grade_of(d) for d in dpis) / len(dpis))
|
|
|
|
|
|
def mismatch(files, items, rows):
|
|
"""Why a cart's grades cannot be approved at checkout, or None."""
|
|
for item in items:
|
|
if item['grade'] == 0:
|
|
continue # full price: nothing to verify
|
|
verified = item_grade(files, item, rows)
|
|
if verified is None:
|
|
return 'Nota não conferida no servidor'
|
|
if item['grade'] > verified + TOLERANCE:
|
|
return f"Nota {item['grade']} maior que a do arquivo ({verified})"
|
|
return None
|