Compare commits
5 Commits
20403c5132
...
37715ef223
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37715ef223 | ||
|
|
b7e2ea12d0 | ||
|
|
933bd30cbd | ||
|
|
eb254da501 | ||
|
|
8786a33c8d |
@@ -86,7 +86,7 @@ jobs:
|
||||
# the generator's geometry. The provider suites use a fake transport: they
|
||||
# prove the documented contract, not the integration.
|
||||
- name: Print-file geometry and provider adapters
|
||||
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review tests.test_large_files -v
|
||||
run: $COMPOSE exec -T api python -m unittest tests.test_printfile tests.test_mercadopago tests.test_tiny tests.test_jadlog tests.test_quote_review tests.test_large_files tests.test_grade_check -v
|
||||
|
||||
- name: Runtime and retention regressions
|
||||
run: |
|
||||
|
||||
@@ -85,6 +85,16 @@ def intent(body: PaymentIntent, session_id=Depends(owner)):
|
||||
raise HTTPException(404 if 'not found' in str(refusal) else 409, str(refusal))
|
||||
if c.execute('SELECT 1 FROM dtf_local.orders WHERE quote_id=%s', (body.quote_id,)).fetchone():
|
||||
raise HTTPException(409, 'Quote is already paid')
|
||||
# Never charge for files that are gone: an unpaid cart's files are
|
||||
# removed after a while. A payment under way keeps them a day longer,
|
||||
# time enough for the provider's notice to become the order.
|
||||
uploads = payments.quote_uploads(quote['approved'])
|
||||
live = c.execute('''SELECT count(*) AS n FROM dtf_local.uploads WHERE id=ANY(%s)
|
||||
AND purged_at IS NULL AND expires_at>now()''', (uploads,)).fetchone()['n']
|
||||
if live != len(set(uploads)):
|
||||
raise HTTPException(410, 'Os arquivos deste pedido expiraram porque ele não foi pago a tempo. '
|
||||
'Monte o pedido de novo para pagar.')
|
||||
payments.hold_uploads(c, uploads, '1 day')
|
||||
# Never a second charge: an approved payment is waiting for its
|
||||
# notification to become the order, and a card in review may still be.
|
||||
# A card waiting for the bank's confirmation (3-D Secure) blocks only
|
||||
|
||||
@@ -7,12 +7,14 @@ from uuid import UUID, uuid4
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from .. import payments
|
||||
from ..core import db
|
||||
from ..core.auth import owner
|
||||
from ..core.models import QuoteRequest
|
||||
from ..core.pricing import price
|
||||
from .. import quote_review
|
||||
from ..runtime import freight, quote_view, require_delivery_available, upload_row
|
||||
from ..grade_check import Files, mismatch
|
||||
from ..runtime import freight, quote_view, require_delivery_available, storage, upload_row
|
||||
from ..scanning import require_clean
|
||||
|
||||
router = APIRouter()
|
||||
@@ -35,21 +37,30 @@ def create_quote(body: QuoteRequest, session_id=Depends(owner)):
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, str(exc))
|
||||
with db.connect() as c:
|
||||
rows = {}
|
||||
for item in body.items:
|
||||
for uid in item.uploads:
|
||||
row = upload_row(c, uid, session_id)
|
||||
if not row['complete']:
|
||||
raise HTTPException(409, 'Complete every upload before requesting a quote')
|
||||
require_clean(row)
|
||||
rows[str(uid)] = row
|
||||
# The grade sets the price and came from the browser: before a cart is
|
||||
# approved at checkout, the server works it out from the files.
|
||||
note = mismatch(Files(storage), draft['items'], rows) if reason is None else None
|
||||
reason = reason or note
|
||||
uid = uuid4()
|
||||
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft) VALUES(%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
|
||||
(uid, session_id, body.request_key, digest, Jsonb(draft)))
|
||||
c.execute('INSERT INTO dtf_local.quotes(id,owner,request_key,request_hash,draft,review_note) VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT(owner,request_key) DO NOTHING',
|
||||
(uid, session_id, body.request_key, digest, Jsonb(draft), note))
|
||||
row = c.execute('SELECT * FROM dtf_local.quotes WHERE owner=%s AND request_key=%s FOR UPDATE', (session_id, body.request_key)).fetchone()
|
||||
if row['request_hash'] != digest:
|
||||
raise HTTPException(409, 'Request key already used for a different cart')
|
||||
if row['id'] == uid and reason is None:
|
||||
quote_review.approve(c, row, body.items, quote_review.AUTO)
|
||||
return {'id': row['id'], 'status': 'approved'}
|
||||
if row['id'] == uid:
|
||||
# An operator reviews it first; the files wait for that review.
|
||||
payments.hold_uploads(c, payments.quote_uploads(draft), payments.REVIEW_HOLD)
|
||||
return {'id': row['id'], 'status': 'approved' if row['approved'] else 'pending_review'}
|
||||
|
||||
@router.get('/api/quotes/{uid}')
|
||||
|
||||
@@ -14,6 +14,7 @@ from ..core import db
|
||||
from ..core.auth import audit, owner, rate_limit
|
||||
from ..core.limits import upload_limit_bytes
|
||||
from ..core.models import UploadStart
|
||||
from ..payments import UNPAID_HOLD
|
||||
from ..runtime import PART_BYTES, storage, upload_row
|
||||
|
||||
router = APIRouter()
|
||||
@@ -87,7 +88,10 @@ def complete_upload(uid: UUID, session_id=Depends(owner)):
|
||||
existing_size = storage.size(row['object_key'])
|
||||
if existing_size != row['size']:
|
||||
raise HTTPException(409, 'Stored size differs from declared size')
|
||||
c.execute("UPDATE dtf_local.uploads SET complete=true,expires_at=now()+interval '30 days' WHERE id=%s", (uid,))
|
||||
# Held while the cart is unpaid: an abandoned cart's files go after
|
||||
# UNPAID_HOLD; a paid order keeps them for its 30 days (app/payments.py).
|
||||
c.execute('UPDATE dtf_local.uploads SET complete=true,expires_at=now()+%s::interval WHERE id=%s',
|
||||
(UNPAID_HOLD, uid))
|
||||
return {'id': uid, 'complete': True}
|
||||
|
||||
@router.delete('/api/uploads/{uid}')
|
||||
|
||||
@@ -67,7 +67,8 @@ def submit_files(c, order, body, identity, kind, actor):
|
||||
for ref in body.files:
|
||||
c.execute('INSERT INTO dtf_local.order_files(id,order_id,upload_id,item_index,kind,note,created_by) VALUES(%s,%s,%s,%s,%s,%s,%s)',
|
||||
(uuid4(),order['id'],ref.upload_id,ref.item_index,kind,body.note,actor))
|
||||
c.execute('UPDATE dtf_local.uploads SET expires_at=LEAST(expires_at,%s) WHERE id=%s', (expiry,ref.upload_id))
|
||||
# The order's window, whatever the upload's own hold was.
|
||||
c.execute('UPDATE dtf_local.uploads SET expires_at=%s WHERE id=%s', (expiry,ref.upload_id))
|
||||
c.execute('UPDATE dtf_local.orders SET version=version+1,updated_at=now() WHERE id=%s', (order['id'],))
|
||||
if kind == 'final':
|
||||
# Artwork approval, not commercial quote approval, starts original cleanup.
|
||||
|
||||
189
app/grade_check.py
Normal file
189
app/grade_check.py
Normal file
@@ -0,0 +1,189 @@
|
||||
"""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
|
||||
@@ -49,6 +49,26 @@ def is_test(order):
|
||||
return (order.get('payment') or {}).get('provider') == TEST_PROVIDER
|
||||
|
||||
|
||||
# How long a file is kept while nothing has been paid for it. A cart's files
|
||||
# are uploaded before payment, so the price and the security check are done on
|
||||
# the file itself; a cart that is never paid must not keep them for 30 days.
|
||||
UNPAID_HOLD = '2 days'
|
||||
# A quote waiting for an operator's review keeps its files this long.
|
||||
REVIEW_HOLD = '7 days'
|
||||
# Once paid, the order keeps its originals for this long from the upload.
|
||||
ORDER_RETENTION = '30 days'
|
||||
|
||||
|
||||
def quote_uploads(quote_or_draft):
|
||||
return [uid for item in quote_or_draft['items'] for uid in item['uploads']]
|
||||
|
||||
|
||||
def hold_uploads(c, upload_ids, interval):
|
||||
"""Keep these files at least `interval` from now; never shortens a hold."""
|
||||
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, now()+%s::interval)
|
||||
WHERE id=ANY(%s) AND purged_at IS NULL''', (interval, [str(u) for u in upload_ids]))
|
||||
|
||||
|
||||
def create_order(c, quote, payment):
|
||||
"""Create the order for a reviewed quote, or return the one already there.
|
||||
|
||||
@@ -68,6 +88,9 @@ def create_order(c, quote, payment):
|
||||
order = c.execute(
|
||||
'INSERT INTO dtf_local.orders(id,quote_id,owner,snapshot,payment) VALUES(%s,%s,%s,%s,%s) RETURNING *',
|
||||
(uuid4(), quote['id'], quote['owner'], Jsonb(approved), Jsonb(payment))).fetchone()
|
||||
# Paid: the files are kept for the order's retention, counted from upload.
|
||||
c.execute('''UPDATE dtf_local.uploads SET expires_at=GREATEST(expires_at, created_at+%s::interval)
|
||||
WHERE id=ANY(%s) AND purged_at IS NULL''', (ORDER_RETENTION, quote_uploads(approved)))
|
||||
queue_print_files(c, order['id'], len(approved['items']))
|
||||
if is_test(order):
|
||||
return order, True
|
||||
|
||||
@@ -6,11 +6,11 @@ moment it was created. The Site is a shop: a customer who can price the order
|
||||
should be able to pay for it straight away, at any hour, so only orders a
|
||||
person must look at before charging wait for the Kanban (review_reason).
|
||||
|
||||
Known limit: the grade (and so the discount) and the layout are still worked
|
||||
out in the customer's browser (roadmap 3.2, 3.9). The API checks the layout's
|
||||
geometry and that an unanalysed item carries no discount, but a customer who
|
||||
edits the page can claim a better grade. Operators see every order's grade
|
||||
and artwork at Arte recebida.
|
||||
The grade (and so the discount) is worked out in the customer's browser and
|
||||
checked against the files before an automatic approval (app/grade_check.py);
|
||||
a grade the files do not support waits for review. The layout is still the
|
||||
browser's (roadmap 3.9): the API checks its geometry. Operators see every
|
||||
order's grade and artwork at Arte recebida.
|
||||
"""
|
||||
import os
|
||||
from decimal import Decimal
|
||||
@@ -18,6 +18,7 @@ from decimal import Decimal
|
||||
from fastapi import HTTPException
|
||||
from psycopg.types.json import Jsonb
|
||||
|
||||
from . import payments
|
||||
from .core.pricing import price
|
||||
from .runtime import freight, upload_row
|
||||
from .scanning import require_clean
|
||||
@@ -83,4 +84,6 @@ def approve(c, row, items, reviewer):
|
||||
'total_cents': sum(i['total_cents'] for i in priced) + quoted_freight['total_cents']}
|
||||
c.execute('UPDATE dtf_local.quotes SET approved=%s, reviewed_by=%s, approved_at=now() WHERE id=%s',
|
||||
(Jsonb(approved), reviewer, row['id']))
|
||||
# Approved and payable now: the files wait for the payment, not for ever.
|
||||
payments.hold_uploads(c, payments.quote_uploads(approved), payments.UNPAID_HOLD)
|
||||
return approved
|
||||
|
||||
@@ -85,7 +85,7 @@ def quote_view(c, row):
|
||||
'draft': row['draft'], 'approved': row['approved'],
|
||||
'status': 'paid' if order else 'expired' if expired else 'approved' if row['approved'] else 'pending_review',
|
||||
'auto_approved': row.get('reviewed_by') == 'auto',
|
||||
'review_reason': None if row['approved'] else review_reason(row['draft']),
|
||||
'review_reason': None if row['approved'] else (row.get('review_note') or review_reason(row['draft'])),
|
||||
'payment': attempt,
|
||||
'order': order}
|
||||
|
||||
|
||||
@@ -142,6 +142,10 @@ CREATE TABLE IF NOT EXISTS dtf_local.backups (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS backups_finished ON dtf_local.backups(finished_at DESC);
|
||||
|
||||
-- Why a quote waits for review, when the reason came from its files (the grade
|
||||
-- the server recomputed) and cannot be worked out again from the draft alone.
|
||||
ALTER TABLE dtf_local.quotes ADD COLUMN IF NOT EXISTS review_note text;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS uploads_owner ON dtf_local.uploads(owner);
|
||||
|
||||
-- Indexes follow the queries the application actually issues. Only these; every
|
||||
|
||||
@@ -627,6 +627,13 @@ the site already did.
|
||||
generator, with the browser as preview only. This is the single largest gap between
|
||||
what was promised in the meeting and what exists.
|
||||
|
||||
**2026-09-30:** the grade (the resolution discount) is no longer taken on trust.
|
||||
Before an automatic approval the API recomputes it from the uploaded files by the
|
||||
Site's rules (`app/grade_check.py`): image headers for PNG/JPG/WebP, the placed
|
||||
images of a PDF up to 150 MB. A claim more than 2 points above the file's grade,
|
||||
or a discount on a file the server cannot grade, waits for review with the reason
|
||||
on the Kanban. The metres (the packing) are still the browser's.
|
||||
|
||||
### `[~]` 3.3 — The 5 GB problem is unsolved `(F19)`
|
||||
|
||||
Transport accepts 5 GiB; `SCAN_MAX_BYTES` / ClamAV `StreamMaxLength` release only
|
||||
|
||||
@@ -170,6 +170,17 @@ try{
|
||||
await evaluate(`folhas=[];pintaFolha()`);
|
||||
await evaluate(`pickTipo('avulsa')`);
|
||||
assert.equal(await evaluate('modo'),'avulsa');
|
||||
// A large JPG artwork cannot be read in parts: it is measured from its
|
||||
// header and placed as a full box, with the discount, instead of refused.
|
||||
await evaluate(`(()=>{const h=new Uint8Array(20);h.set([0xFF,0xD8,0xFF,0xC0,0,17,8,0x09,0x3A,0x09,0x3A,3]);
|
||||
const f=new File([h],'arte-grande.jpg');Object.defineProperty(f,'size',{value:400*1048576});sel([f]);})()`);
|
||||
await waitFor(()=>evaluate(`artes.length===1 && artes[0].semPrevia===true`),'large JPG artwork measured from its header');
|
||||
await fill('[data-cm]',20);
|
||||
await waitFor(()=>evaluate(`!!itemAtual?.production?.sources?.length`),'large JPG artwork packed');
|
||||
assert.deepEqual(await evaluate(`({dpi:Math.round(dpiDe(artes[0])),src:!!artes[0].src,
|
||||
measurement:itemAtual.production.sources[0].measurement,note:$('lista').textContent.includes('Sem prévia')})`),
|
||||
{dpi:300,src:false,measurement:'file',note:true});
|
||||
await evaluate(`artes=[];pintaArtes()`);
|
||||
await evaluate('sendImage()');
|
||||
await waitFor(()=>evaluate(`artes.length===1 && !!artes[0].src`),'JPG artwork model');
|
||||
assert.deepEqual(await evaluate(`({mode:modo,sheets:folhas.length,width:artes[0].cm,quantity:artes[0].q})`),{mode:'avulsa',sheets:0,width:0,quantity:1});
|
||||
|
||||
@@ -93,6 +93,12 @@ try{
|
||||
assert.deepEqual(await site.eval(`({items:pedido.length,empty:!$('carrVazio').hidden||document.documentElement.hasAttribute('data-sem-itens'),undo:!$('desfazer').hidden})`),{items:0,empty:true,undo:true});
|
||||
await site.click('#desfazerBtn');
|
||||
assert.equal(await site.eval('pedido.length===1 && pedido[0].total===21.89 && $("desfazer").hidden'),true);
|
||||
// The items come back after a reload; the customer's details do not.
|
||||
assert.deepEqual(await site.eval(`['fCnpj','fZap','fMail'].map(id=>$(id).value)`),['','','']);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),true);
|
||||
await site.fill('#fCnpj','11222333000181');
|
||||
await site.fill('#fZap','11999999999');
|
||||
await site.fill('#fMail','local-browser@example.test');
|
||||
assert.equal(await site.eval('pedido[0].localFiles[0].name'),'local-test.cdr');
|
||||
assert.equal(await site.eval('pedido[0].total'),21.89);
|
||||
assert.equal(await site.eval('document.getElementById("bPagar").disabled'),false);
|
||||
|
||||
@@ -155,6 +155,30 @@ def run():
|
||||
assert queued == 0 and jobs == 1, (queued, jobs)
|
||||
print('PASS: an operator test order reaches the board and the print queue, never Tiny or WhatsApp')
|
||||
|
||||
# Files are uploaded before payment. An unpaid cart keeps them briefly; a
|
||||
# paid order keeps them for its 30 days; a payment never starts for files
|
||||
# that are gone.
|
||||
def files_of(qid):
|
||||
with db.connect() as c:
|
||||
q = c.execute('SELECT approved,draft FROM dtf_local.quotes WHERE id=%s', (qid,)).fetchone()
|
||||
return [u for item in (q['approved'] or q['draft'])['items'] for u in item['uploads']]
|
||||
|
||||
def days(ids, since='now()'):
|
||||
with db.connect() as c:
|
||||
return [float(r['d']) for r in c.execute(
|
||||
f'SELECT extract(epoch FROM expires_at-{since})/86400 AS d FROM dtf_local.uploads WHERE id=ANY(%s)',
|
||||
(ids,)).fetchall()]
|
||||
fresh = upload_bytes(customer, b'UNPAID HOLD TEST')
|
||||
assert all(1.99 < d <= 2.0 for d in days([fresh])), days([fresh])
|
||||
assert all(abs(d - 30) < 0.01 for d in days(files_of(quote_id), 'created_at')), days(files_of(quote_id), 'created_at')
|
||||
late, late_quote, _ = reviewed_quote()
|
||||
assert all(d > 1.99 for d in days(files_of(late_quote))), days(files_of(late_quote))
|
||||
with db.connect() as c:
|
||||
c.execute("UPDATE dtf_local.uploads SET expires_at=now()-interval '1 second' WHERE id=ANY(%s)",
|
||||
(files_of(late_quote),))
|
||||
late.call('/payments/intent', {'quote_id': late_quote, 'method': {'type': 'pix'}}, expected=410)
|
||||
print('PASS: unpaid files are held 2 days, paid ones 30 days, and expired files are never charged for')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
|
||||
@@ -175,7 +175,11 @@ def run():
|
||||
assert not client.call('/quotes/'+qid)['auto_approved']
|
||||
# A cart the Site priced is approved at once and can be paid straight away,
|
||||
# at the server's own prices; no operator can then change it.
|
||||
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]}
|
||||
# The server works the grade out from the file: this PNG is 6059 px across
|
||||
# 57 cm, 270 DPI, which is grade 90.
|
||||
png=b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR'+(6059).to_bytes(4,'big')+(2000).to_bytes(4,'big')+b'\x08\x06\x00\x00\x00'+b'\x00'*4
|
||||
graded=upload_bytes(client,png,name='arte-270dpi.png')
|
||||
small={**draft,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,graded)]}
|
||||
auto=client.call('/quotes',small)
|
||||
assert auto['status']=='approved'
|
||||
seen=client.call('/quotes/'+auto['id'])
|
||||
@@ -188,6 +192,12 @@ def run():
|
||||
held=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[claimed]})
|
||||
assert held['status']=='pending_review'
|
||||
assert client.call('/quotes/'+held['id'])['review_reason']=='Nota informada sem análise da arte'
|
||||
# A better grade than the file supports, or one the server cannot check, waits too.
|
||||
inflated=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',100,graded)]})
|
||||
assert inflated['status']=='pending_review'
|
||||
assert client.call('/quotes/'+inflated['id'])['review_reason']=='Nota 100 maior que a do arquivo (90)'
|
||||
unchecked=client.call('/quotes',{**small,'request_key':str(uuid4()),'items':[item_spec('avulsa','2.75',90,uid)]})
|
||||
assert client.call('/quotes/'+unchecked['id'])['review_reason']=='Nota não conferida no servidor'
|
||||
print('PASS: priced carts are approved at checkout; large or inconsistent ones wait for review')
|
||||
client.call('/orders/dev-paid',{'quote_id':qid,'total_cents':1},expected=422)
|
||||
other.call('/orders/dev-paid',{'quote_id':qid},expected=404)
|
||||
|
||||
86
tests/test_grade_check.py
Normal file
86
tests/test_grade_check.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""The server's grade against real image and PDF files.
|
||||
|
||||
Runs where Pillow and pikepdf are installed (the API image).
|
||||
"""
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import pikepdf
|
||||
from PIL import Image
|
||||
|
||||
from app import grade_check
|
||||
|
||||
|
||||
class FakeFiles:
|
||||
def __init__(self, blobs):
|
||||
self.blobs = blobs
|
||||
|
||||
def head(self, row):
|
||||
return self.blobs[row['name']][:grade_check.HEAD_BYTES]
|
||||
|
||||
def pdf_dpi(self, row):
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
||||
handle.write(self.blobs[row['name']])
|
||||
handle.flush()
|
||||
return grade_check.pdf_dpi(handle.name)
|
||||
|
||||
|
||||
def encoded(fmt, size, **options):
|
||||
out = io.BytesIO()
|
||||
Image.new('RGBA' if fmt in ('PNG', 'WEBP') else 'RGB', size, (200, 30, 30)).save(out, fmt, **options)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def item(kind, grade, sources):
|
||||
return {'grade': grade, 'production': {'sources': [
|
||||
{'upload_id': name, 'kind': kind, 'width_cm': width, 'rotation_degrees': rotation}
|
||||
for name, width, rotation in sources]}}
|
||||
|
||||
|
||||
class GradeCheckTests(unittest.TestCase):
|
||||
def test_image_sizes_from_the_first_bytes(self):
|
||||
for fmt, options in (('PNG', {}), ('JPEG', {'quality': 80}), ('JPEG', {'progressive': True}),
|
||||
('WEBP', {'lossless': True}), ('WEBP', {'quality': 80})):
|
||||
self.assertEqual(grade_check.image_size(encoded(fmt, (1234, 567), **options)), (1234, 567), (fmt, options))
|
||||
# A JPEG with a large metadata block before the frame header.
|
||||
exif = Image.Exif()
|
||||
exif[0x010E] = 'x' * 60000
|
||||
self.assertEqual(grade_check.image_size(encoded('JPEG', (800, 600), exif=exif)), (800, 600))
|
||||
self.assertIsNone(grade_check.image_size(b'not an image at all'))
|
||||
|
||||
def test_pdf_dpi_is_the_images_area_weighted(self):
|
||||
out = io.BytesIO()
|
||||
Image.new('RGB', (1500, 750), 'white').save(out, 'PDF', resolution=150)
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
||||
handle.write(out.getvalue()); handle.flush()
|
||||
self.assertEqual(grade_check.pdf_dpi(handle.name), 150)
|
||||
vector = pikepdf.new()
|
||||
vector.add_blank_page(page_size=(1615, 850))
|
||||
with tempfile.NamedTemporaryFile(suffix='.pdf') as handle:
|
||||
vector.save(handle.name)
|
||||
self.assertEqual(grade_check.pdf_dpi(handle.name), 300)
|
||||
|
||||
def test_grades_follow_the_site(self):
|
||||
# 57 cm is 22.44 in: 6059 px is 270 DPI, grade 90; 3366 px is 150 DPI, grade 50.
|
||||
files = FakeFiles({'a.png': encoded('PNG', (6059, 100)), 'b.jpg': encoded('JPEG', (3366, 100)),
|
||||
'tall.png': encoded('PNG', (100, 2362)), 'x.cdr': b'CDR'})
|
||||
rows = {n: {'name': n, 'size': 1} for n in files.blobs}
|
||||
# A sheet takes the worst sheet's grade.
|
||||
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0)]), rows), 90)
|
||||
self.assertEqual(grade_check.item_grade(files, item('sheet', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 50)
|
||||
# Artworks average; a rotated one is measured along its height.
|
||||
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('a.png', 57, 0), ('b.jpg', 57, 0)]), rows), 70)
|
||||
self.assertEqual(grade_check.item_grade(files, item('artwork', 0, [('tall.png', 20, 90)]), rows), 100)
|
||||
self.assertIsNone(grade_check.item_grade(files, item('sheet', 0, [('x.cdr', 57, 0)]), rows))
|
||||
# What the cart claims is checked, with a point or two of rounding.
|
||||
claim = lambda g, src: grade_check.mismatch(files, [item('sheet', g, src)], rows)
|
||||
self.assertIsNone(claim(90, [('a.png', 57, 0)]))
|
||||
self.assertIsNone(claim(92, [('a.png', 57, 0)]))
|
||||
self.assertEqual(claim(100, [('a.png', 57, 0)]), 'Nota 100 maior que a do arquivo (90)')
|
||||
self.assertEqual(claim(40, [('x.cdr', 57, 0)]), 'Nota não conferida no servidor')
|
||||
self.assertIsNone(claim(0, [('x.cdr', 57, 0)]))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
20
web/cart.js
20
web/cart.js
@@ -1,5 +1,11 @@
|
||||
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h. */
|
||||
/* Browser-local cart recovery. Files are stored only for an unfinished cart, for 24h.
|
||||
Only the items are kept: the customer's details and address are typed again
|
||||
after a reload or a closed tab, so nothing personal stays in the browser. */
|
||||
(() => {
|
||||
// A browser may put back what was typed before the reload without telling
|
||||
// the page, which then shows values it does not know and cannot pay with.
|
||||
const CAMPOS=['fCnpj','fZap','fMail','cepIn','eNome','eRua','eNum','eComp','eBairro','eCidade','eUf'];
|
||||
for(const id of CAMPOS)if($(id))$(id).value='';
|
||||
let database,scope,timer,restoring=true;
|
||||
let signedOut=false;
|
||||
window.addEventListener('dtf-private-data-cleared',()=>{signedOut=true;clearTimeout(timer);pedido=[];itemAtual=null;folhas=[];artes=[];});
|
||||
@@ -11,7 +17,7 @@
|
||||
const items=[...pedido,...(itemAtual?[itemAtual]:[])];
|
||||
try{
|
||||
if(!items.length){await transaction('readwrite',store=>store.delete(scope));return;}
|
||||
await transaction('readwrite',store=>store.put({items,customer:{...cliente},delivery:{...entrega},expires:Date.now()+86400000},scope));
|
||||
await transaction('readwrite',store=>store.put({items,expires:Date.now()+86400000},scope));
|
||||
notice.textContent='Carrinho salvo neste navegador por 24 horas. Você pode remover itens e adicionar outros após recarregar.';
|
||||
}catch(error){notice.textContent='Não foi possível salvar o carrinho neste navegador. Mantenha esta página aberta até enviar o pedido.';}
|
||||
}
|
||||
@@ -30,16 +36,8 @@
|
||||
for(const key of records){const value=await transaction('readonly',store=>store.get(key));if(value.expires<Date.now())await transaction('readwrite',store=>store.delete(key));}
|
||||
const saved=await transaction('readonly',store=>store.get(scope));
|
||||
if(saved && !pedido.length && !itemAtual){
|
||||
pedido=saved.items;cliente=saved.customer;entrega=saved.delivery;
|
||||
// Freight must be quoted again; restored browser values are never final.
|
||||
if(entrega.tipo==='frete'){entrega.cotado=false;entrega.valor=0;entrega.dias=null;}
|
||||
for(const [id,key] of [['fCnpj','cnpj'],['fZap','zap'],['fMail','mail']])$(id).value=cliente[key];
|
||||
$('cepIn').value=fmtCep(entrega.cep);
|
||||
entrega.end=entrega.end||{};
|
||||
for(const [id,key] of [['eNome','nome'],['eRua','rua'],['eNum','num'],['eComp','comp'],
|
||||
['eBairro','bairro'],['eCidade','cidade'],['eUf','uf']])$(id).value=entrega.end[key]||'';
|
||||
pedido=saved.items;
|
||||
$('atual').style.display='none';pintaEntrega();
|
||||
if(entrega.tipo==='frete' && entrega.cep.length===8 && window.dtfFreight) window.dtfFreight();
|
||||
notice.textContent='Carrinho recuperado. Remova e adicione novamente um item se precisar alterar sua montagem.';
|
||||
}else{
|
||||
const account=await window.dtfApi('/account/me');
|
||||
|
||||
@@ -152,8 +152,10 @@
|
||||
if (!e) return '';
|
||||
if (e.fase==='pronto') return 'Arquivos enviados ✓';
|
||||
if (e.fase==='verificando') return 'Arquivos enviados · verificando a segurança…';
|
||||
return 'Enviando seus arquivos: '+e.pct+'% · '+mb(e.sent)+' de '+mb(e.total)+
|
||||
(e.restante!=null ? ' · '+tempo(e.restante) : '');
|
||||
// Until the speed is measured, the range from the file size.
|
||||
return 'Enviando seus arquivos: '+e.pct+'% · '+mb(e.sent)+' de '+mb(e.total)+' · '+
|
||||
(e.restante!=null ? tempo(e.restante) : 'estimativa '+faixaEnvio(e.total-e.sent))+
|
||||
'. Mantenha a página aberta.';
|
||||
}
|
||||
function pintaEnvio() {
|
||||
const e=estadoEnvio(), texto=textoEnvio(e);
|
||||
|
||||
@@ -917,6 +917,7 @@ footer a:hover{color:var(--laranja2)}
|
||||
<div class="trocouCam" id="avisoTipo" style="display:none"></div>
|
||||
<div class="recusa" id="recusa" style="display:none"></div>
|
||||
<div id="lista"></div>
|
||||
<p class="avisoEnvio" id="avisoEnvio" role="note" hidden></p>
|
||||
<div class="zona" id="zona" tabindex="0" role="button">
|
||||
<div class="ico">↑</div>
|
||||
<b id="zTit">Arraste aqui</b><span id="zSub"></span>
|
||||
@@ -1097,13 +1098,13 @@ footer a:hover{color:var(--laranja2)}
|
||||
e é por ele que suas artes ficam guardadas para o próximo pedido.</p>
|
||||
<div class="campos">
|
||||
<div class="cp"><label>CNPJ</label>
|
||||
<input id="fCnpj" inputmode="numeric" maxlength="18" placeholder="00.000.000/0000-00">
|
||||
<input id="fCnpj" autocomplete="off" inputmode="numeric" maxlength="18" placeholder="00.000.000/0000-00">
|
||||
<span class="err" id="eCnpj"></span></div>
|
||||
<div class="cp"><label>WhatsApp</label>
|
||||
<input id="fZap" inputmode="numeric" maxlength="15" placeholder="(16) 99999-9999">
|
||||
<input id="fZap" autocomplete="off" inputmode="numeric" maxlength="15" placeholder="(16) 99999-9999">
|
||||
<span class="err" id="eZap"></span></div>
|
||||
<div class="cp larga"><label>E-mail para a nota</label>
|
||||
<input id="fMail" type="email" placeholder="voce@empresa.com.br">
|
||||
<input id="fMail" autocomplete="off" type="email" placeholder="voce@empresa.com.br">
|
||||
<span class="err" id="eMail"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1131,24 +1132,24 @@ footer a:hover{color:var(--laranja2)}
|
||||
<div class="cep" id="cep">
|
||||
<label>CEP de entrega</label>
|
||||
<div class="cepL">
|
||||
<input id="cepIn" inputmode="numeric" maxlength="9" placeholder="00000-000" autocomplete="postal-code">
|
||||
<input id="cepIn" autocomplete="off" inputmode="numeric" maxlength="9" placeholder="00000-000">
|
||||
</div>
|
||||
<p id="cepMsg"></p>
|
||||
<div class="campos endereco" id="endereco">
|
||||
<div class="cp larga"><label>Quem recebe</label>
|
||||
<input id="eNome" maxlength="120" autocomplete="name" placeholder="Nome ou empresa"></div>
|
||||
<input id="eNome" autocomplete="off" maxlength="120" placeholder="Nome ou empresa"></div>
|
||||
<div class="cp larga"><label>Rua / avenida</label>
|
||||
<input id="eRua" maxlength="160" autocomplete="address-line1"></div>
|
||||
<input id="eRua" autocomplete="off" maxlength="160"></div>
|
||||
<div class="cp"><label>Número</label>
|
||||
<input id="eNum" maxlength="20" placeholder="123 ou S/N"></div>
|
||||
<input id="eNum" autocomplete="off" maxlength="20" placeholder="123 ou S/N"></div>
|
||||
<div class="cp"><label>Complemento (opcional)</label>
|
||||
<input id="eComp" maxlength="80" autocomplete="address-line2"></div>
|
||||
<input id="eComp" autocomplete="off" maxlength="80"></div>
|
||||
<div class="cp"><label>Bairro</label>
|
||||
<input id="eBairro" maxlength="80"></div>
|
||||
<input id="eBairro" autocomplete="off" maxlength="80"></div>
|
||||
<div class="cp"><label>Cidade</label>
|
||||
<input id="eCidade" maxlength="80" autocomplete="address-level2"></div>
|
||||
<input id="eCidade" autocomplete="off" maxlength="80"></div>
|
||||
<div class="cp"><label>UF</label>
|
||||
<input id="eUf" maxlength="2" autocomplete="address-level1" placeholder="SP"></div>
|
||||
<input id="eUf" autocomplete="off" maxlength="2" placeholder="SP"></div>
|
||||
</div>
|
||||
<p class="dicaEnd" id="eEnd"></p>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +91,7 @@ function carrinho(){
|
||||
measurement:x.med?'file':'customer'}
|
||||
: {kind:'artwork',width_cm:x.cm,length_cm:+(x.cm*propDe(x)).toFixed(4),
|
||||
copies:x.q,rotation_degrees:x.giro||0,mirrored:!!x.esp,
|
||||
measurement:x.src?'file':'customer'});
|
||||
measurement:(x.src||x.semPrevia)?'file':'customer'});
|
||||
let layout;
|
||||
if(ehFolha()){
|
||||
let y=0;
|
||||
|
||||
@@ -96,6 +96,25 @@ const destinoApi=()=>entrega.tipo!=='frete' ? null : {
|
||||
recipient:entrega.end.nome.trim(), street:entrega.end.rua.trim(), number:entrega.end.num.trim(),
|
||||
complement:(entrega.end.comp||'').trim(), district:entrega.end.bairro.trim(),
|
||||
city:entrega.end.cidade.trim(), state:entrega.end.uf.trim().toUpperCase(), postal_code:entrega.cep};
|
||||
// Tempo de envio antes de medir a internet de quem compra: uma faixa entre um
|
||||
// upload lento (10 Mbps) e um rápido (150 Mbps). O arquivo vai direto para o
|
||||
// armazenamento, então é o upload do cliente que manda.
|
||||
const ENVIO_LENTO=10e6/8, ENVIO_RAPIDO=150e6/8, ENVIO_AVISA_S=120;
|
||||
const duracao=s=>{
|
||||
const min=Math.ceil(s/60);
|
||||
return s<60 ? 'menos de 1 min' : min<60 ? min+' min' : Math.floor(min/60)+' h'+(min%60? ' '+min%60+' min' : '');
|
||||
};
|
||||
const faixaEnvio=bytes=>'de '+duracao(bytes/ENVIO_RAPIDO)+' a '+duracao(bytes/ENVIO_LENTO);
|
||||
function pintaAvisoEnvio(){
|
||||
const el=$('avisoEnvio'); if(!el) return;
|
||||
const total=(ehFolha()?folhas:artes).reduce((t,x)=>t+(x.f?.size||0),0);
|
||||
el.hidden = total/ENVIO_LENTO < ENVIO_AVISA_S;
|
||||
if(el.hidden) return;
|
||||
el.innerHTML='<b>O envio '+((ehFolha()?folhas:artes).length>1?'destes arquivos':'deste arquivo')+
|
||||
' ('+fmt(total)+') leva '+faixaEnvio(total)+'</b>, conforme a velocidade de upload da sua internet. '+
|
||||
'Ele começa quando você coloca o item no carrinho e continua enquanto você preenche os dados. '+
|
||||
'<b>Mantenha a página aberta até terminar.</b>';
|
||||
}
|
||||
const larguraFilme=()=>MODOS[modo].larg;
|
||||
// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
|
||||
const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);
|
||||
|
||||
@@ -337,7 +337,7 @@ function previaAoVivo(){
|
||||
tMont=setTimeout(()=>{
|
||||
// o produto pode ter sido fechado nesses 140 ms (item foi para o carrinho)
|
||||
if(!modo) return;
|
||||
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
|
||||
const prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
|
||||
const W=Math.round(300*ZOOMS[zoomI]);
|
||||
$('vArea').classList.toggle('ampliado', zoomI>0);
|
||||
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{
|
||||
|
||||
@@ -406,6 +406,7 @@ function detalhesDaFolha(a,i,aberto){
|
||||
}
|
||||
function pintaFolha(){
|
||||
invalidaItemAtual();
|
||||
pintaAvisoEnvio();
|
||||
const L=larguraFilme();
|
||||
pintaTipoEnvio(); // trava o seletor enquanto houver folha
|
||||
$('zona').classList.toggle('compacta', folhas.length>0);
|
||||
@@ -528,6 +529,7 @@ function pintaProgressoArte(a){
|
||||
}
|
||||
function pintaArtes(){
|
||||
invalidaItemAtual();
|
||||
pintaAvisoEnvio();
|
||||
$('zona').classList.toggle('compacta', artes.length>0);
|
||||
// The same card as a finished sheet: thumbnail, size, a DPI chip, then the
|
||||
// width (with the usual sizes), copies, rotation and mirroring.
|
||||
@@ -539,13 +541,11 @@ function pintaArtes(){
|
||||
if(a.lendo!=null){
|
||||
barra='<div class="prog"><i style="width:'+a.lendo+'%"></i></div>'+
|
||||
'<span class="progT">arquivo grande · gerando a prévia… '+a.lendo+'%</span>';
|
||||
}else if(a.decodeError==='grande'){
|
||||
aviso='<p class="avisoF"><b>Arquivo grande em JPG ou WebP.</b> Exporte em PNG para montar a folha.</p>';
|
||||
}else if(a.decodeError){
|
||||
aviso='<p class="avisoF"><b>Não conseguimos ler esta imagem.</b> Exporte novamente em PNG ou JPG.</p>';
|
||||
}else if(a.cm>larguraFilme()){
|
||||
aviso='<p class="avisoF"><b>Largura maior que o filme.</b> O filme aqui tem '+n1(larguraFilme())+' cm.</p>';
|
||||
}else if(a.cm>0 && a.src){
|
||||
}else if(a.cm>0 && (a.src||a.semPrevia)){
|
||||
const dpi=Math.round(dpiDe(a));
|
||||
medida='<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>';
|
||||
chips='<ul class="chips"><li class="chip '+(dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er')+'">'+dpi+' DPI</li>'+
|
||||
@@ -562,8 +562,11 @@ function pintaArtes(){
|
||||
n1(sug.cm)+' cm.<button data-usar="'+i+'" data-cm="'+sug.alvo+'">Usar '+n1(sug.alvo)+' cm</button></div>'
|
||||
: '';
|
||||
}
|
||||
if(a.semPrevia && !a.decodeError)
|
||||
aviso+='<p class="repTxt"><b>Sem prévia para '+escapeHTML(extDe(a.f.name))+' deste tamanho.</b> '+
|
||||
'A arte entra na folha pela medida, e a equipe confere antes de imprimir. Em PNG, a prévia aparece.</p>';
|
||||
const mini = a.miniSrc ? '<img src="'+escapeHTML(a.miniSrc)+'" alt="">' : '<span>'+escapeHTML(extDe(a.f.name))+'</span>';
|
||||
const sub = a.cm>0 && a.src ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
|
||||
const sub = a.cm>0 && (a.src||a.semPrevia) ? (a.q>1? a.q+' × ' : '')+'<b>'+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</b>' : '';
|
||||
return '<div class="rep art2'+(a===recemChegada?' nova':'')+'" data-arte="'+i+'">'+
|
||||
'<div class="repTopo"><div class="miniF">'+mini+'</div><div class="repInfo">'+
|
||||
'<div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+(a===recemChegada?'<em class="novaT">nova</em>':'')+
|
||||
@@ -620,7 +623,7 @@ function pintaArtes(){
|
||||
previaAoVivo();
|
||||
}
|
||||
function resumoArtes(){
|
||||
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && a.src);
|
||||
const r=$('rA'), prontas=artes.filter(a=>a.cm>0 && a.cm<=larguraFilme() && (a.src||a.semPrevia));
|
||||
if(!prontas.length){ r.style.display='none'; return; }
|
||||
const m=montagemCm/100; // a mesma folha que aparece ao lado
|
||||
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round(dpiDe(a)/300*100))));
|
||||
|
||||
@@ -64,9 +64,7 @@ function avaliar(){
|
||||
}
|
||||
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
|
||||
if(artes.some(a=>a.lendo!=null)) return {pronto:false, falta:'Gerando a prévia das artes grandes…'};
|
||||
if(artes.some(a=>a.decodeError==='grande'))
|
||||
return {pronto:false, falta:'Uma arte grande está em JPG ou WebP. Exporte-a em PNG para montar a folha.'};
|
||||
if(artes.some(a=>a.decodeError || !a.src || !(a.px>0) || !(a.py>0)))
|
||||
if(artes.some(a=>a.decodeError || !(a.src||a.semPrevia) || !(a.px>0) || !(a.py>0)))
|
||||
return {pronto:false, falta:'Não conseguimos ler uma das imagens. Exporte-a novamente em PNG ou JPG antes de continuar.'};
|
||||
if(artes.some(a=>!Number.isFinite(a.cm) || a.cm>larguraFilme()))
|
||||
return {pronto:false, falta:'A largura da arte precisa caber no filme de '+
|
||||
|
||||
@@ -128,7 +128,9 @@ function medir(a){
|
||||
return dimensoesImagem(a.f).then(async d=>{
|
||||
if(!d || !(d.w>0) || !(d.h>0)){ a.decodeError=true; return a; }
|
||||
a.px=d.w; a.py=d.h; a.prop=d.h/d.w; a.grande=true;
|
||||
if(!/\.png$/i.test(a.f.name)){ a.decodeError='grande'; return a; }
|
||||
// JPG and WebP cannot be read in parts: the art is placed by its
|
||||
// measurements, as a full box, and the team checks it before printing.
|
||||
if(!/\.png$/i.test(a.f.name)){ a.semPrevia=true; return a; }
|
||||
a.lendo=0; pintaArtes();
|
||||
let mostrado=0;
|
||||
const cv=await previaPngGrande(a.f, 1200, p=>{
|
||||
|
||||
@@ -332,6 +332,8 @@ section+section{border-top:0}
|
||||
/* The payment pages: paying on the left, the order summary on the right */
|
||||
html:is([data-rota="pagamento"],[data-rota="pix"]) .checkout{max-width:1040px;margin-top:8px}
|
||||
html:is([data-rota="pagamento"],[data-rota="pix"]) .pagGrid{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:32px;align-items:start}
|
||||
.avisoEnvio{margin:10px 0 0;padding:10px 12px;border-radius:8px;background:var(--aviso-fundo);color:#5A3A00;font-size:13px;line-height:1.5}
|
||||
.avisoEnvio[hidden]{display:none}
|
||||
.envioArq{font-size:13px;color:var(--texto2);margin-top:14px;line-height:1.5}
|
||||
.envioArq.ok{color:var(--verde)}
|
||||
.envioArq.erro{color:#B42318}
|
||||
|
||||
Reference in New Issue
Block a user