POSM Resize Tool · tài liệu kỹ thuật

Kiến trúc và luồng xử lý

Cách app đọc Excel, chọn mẫu trong file .ai, resize bằng Illustrator, báo kết quả; và cách key kích hoạt được tạo trên web rồi kiểm tra offline trong app. Mỗi mục ghi rõ file, hàm và cấu hình liên quan.

Kiến trúc

2 hệ tách rời, nối bằng 1 key
MÁY NGƯỜI DÙNG · APP DESKTOP VPS · DOCKER Giao diện licensing.py web_app.py job_builder.py service + runner Excel + template Illustrator data/output Admin Caddy license_server MongoDB pywebview · HTML HMAC, offline Flask 127.0.0.1 Excel → jobs osascript / VBS .xlsx · .ai resize_posm.jsx .tif · resize_log trình duyệt HTTPS tự động Flask + gunicorn SQLite · volume HTTP ① tạo jobs đọc Excel jobs.json JSX TIFF + log ② đọc resize_log → bảng kết quả chặn Export nếu key sai/hết hạn đăng nhập proxy lưu key key POSM1… admin gửi khách (Zalo/email) → dán vào mục "Key kích hoạt" trong app
App desktop chạy hoàn toàn trên máy người dùng; web key chạy trên VPS. Không có kết nối mạng giữa hai bên: chỉ có key (đường nét đứt màu cam) do admin gửi tay. Hai bên dùng chung một LICENSE_SECRET: server ký key, app kiểm tra chữ ký.

Luồng một lần bấm Export

POST /api/generate · run_export=true
  1. Giao diện gửi Excel, template, loại POSM, số dòng, định dạng, cách xuất. src/web_app.py · PAGE (JS) → api_generate()
  2. Đọc Excel: header 2 dòng, cột Ngang/Dọc theo loại POSM, dữ liệu từ dòng 3, lọc dòng thiếu hoặc ngoài khoảng validation. src/excel_parser.py · parse_excel()
  3. Tạo job: width_mm = width × mm_per_unit, tên file theo shop, size và chiến dịch; gắn nhãn catalog source_artboard (chỉ để tham khảo). src/job_builder.py · build_manifest() → src/naming.py · build_output_name()
  4. Ghi data/output/jobs.json kèm cấu hình export (scale, lề tên, ngưỡng méo, định dạng). src/job_builder.py · write_manifest_with_config()
  5. Chọn backend: file .ai hoặc TIFF thì dùng Illustrator; .svg thì dùng exporter Python. src/service.py · export_jobs() / resolve_export_backend()
  6. Gọi Illustrator chạy JSX. Trên Mac dùng osascript … do javascript, đợi chạy xong mới trả về. src/runner.py · launch_illustrator_script() → run_jsx_on_mac()
  7. JSX mở template, xử lý từng job: chọn mẫu, tạo document mới, copy, resize, thêm lề tên, xuất TIFF, ghi resize_log.txt. illustrator/resize_posm.jsx · processJobs() → exportJobBlankDoc()
  8. Flask đọc log (chỉ nhận log mới hơn lúc bấm), ghép với jobs, trả bảng kết quả cho giao diện. src/resize_log.py · parse_resize_log() → web_app._collect_export_result()

Thuật toán chọn mẫu

illustrator/resize_posm.jsx

1. Gom ứng viên · getCandidateItems()

  • Object top-level trên artboard được chọn, cộng mọi object không nằm trên artboard nào (thư viện BT-xx).
  • Bỏ object ẩn, bỏ TextFrame.
  • Bỏ object có cạnh ngắn < 20% cạnh ngắn lớn nhất (đường kẻ 0.7 mm, chấm, line 0 mm).

2. Chấm điểm · scoreItemForJob()

r_item = W / H        r_job = Ngang / Dọc
méo %  = (max(r_item/r_job, r_job/r_item) − 1) × 100
score  = méo × 1000 − diện tích × 1e-9

Nhỏ nhất thắng. Hoà thì lấy mẫu lớn hơn (nhiều pixel hơn).

3. Gom cụm · pickArtworkItems()

Thêm các object chồng lên mẫu thắng và cùng tỉ lệ (± 0.35), cho template nhiều lớp. Không gom 2 mẫu rời nhau, vd. hai BT-06 khác size.

4. Đặt và kéo · placeJobOnArtboard()

Duplicate sang document mới (CMYK), dời về góc, rồi fitItemsToArtboard kéo không đều cho khít content rect. Có layer BG/LOGO_LEFT/CTA/DECOR thì áp quy tắc từng layer (applyLayerTransformsOnItems). File tháng 9 chỉ có "Layer 1" nên kéo cả tấm.

Đã sửa: bản trước chỉ lấy object trên artboard (duy nhất BT-06 650×60) và có fallback relaxed-best lặng lẽ chọn mẫu sai tỉ lệ, nên 450×70 bị bóp 41%, 1300×60 bị kéo 100%. Nay quét cả thư viện ngoài artboard, bỏ fallback, và log WARN khi méo vượt max_distortion_pct.

Khi template có nhiều artboard, resolveJobArtboard() chọn artboard theo kích thước thật (exact rồi gần tỉ lệ); ứng viên = object trên artboard đó cộng thư viện ngoài artboard.

jobs.json

data/output/jobs.json · đầu vào của JSX
{
  "template": ".../Banner thường POSM tháng 9 đợt 2.ai",
  "export_format": "tif", "export_layout": "per_job",
  "export_scale": 0.1, "label_strip_mm": 2.0, "max_distortion_pct": 5.0,
  "mm_per_unit": 1000, "filename_display_multiplier": 100,
  "jobs": [{
    "row_number": 3, "shop_name": "LC HNI 01 Lê Văn Hiến, P. Đông Ngạc",
    "width": 4.5, "height": 0.7, "width_mm": 4500.0, "height_mm": 700.0,
    "output_name": "LC HNI 01 … - 450 x 70 - LC Tiêm chủng tháng 9",
    "source_artboard": "500x70", "match_reason": "near"   ← nhãn catalog, JSX không dùng
  }],
  "skipped_rows": [{ "row_number": 4, "reason": "Thiếu kích thước Ngang/Dọc" }]
}

Cấu hình ảnh hưởng tới file ra

config.yaml · config.release.yaml (bản build)
KeyMặc địnhTác dụng
unit, mm_per_unitm, 1000Đổi số Excel sang mm
filename_display_multiplier100Size hiển thị trong tên file và lề tên (m → cm)
campaign_nameLC Tiêm chủng tháng 9Hậu tố tên file
validation.*Ngang 0.5–30, Dọc 0.3–3Dòng ngoài khoảng bị bỏ qua
max_distortion_pct5Méo lớn hơn thì log WARN, GUI gắn "Méo nhiều"
export_scale0.1Artboard làm việc 1:10
label_strip_real_cm2Lề tên trái; trên artboard = cm × 10 × scale (mm)
default_export_formattifTIFF CMYK 500 ppi, LZW (saveDocumentAs)
export_layoutper_jobMỗi shop 1 file, hoặc 1 file .ai nhiều artboard
layers.*BG, LOGO_LEFT, CTA…Chỉ dùng khi template tách layer theo tên này
template_variants9 size ×70Chỉ còn là nhãn source_artboard; không quyết định mẫu

Log và giao diện kết quả

resize_log.txt → src/resize_log.py
INFO | <tên file> | pick BT-10.tif 2000x80 r=25.00 | job r=21.67 | méo 15.4% | 11 mẫu
WARN | <tên file> | méo 15.4% > 5% — thư viện không có mẫu gần tỉ lệ, cần kiểm tra / bổ sung mẫu
OK   | <tên file> | source BT-10.tif | méo 15.4% | items 1 | scale 1:10 | artboard 130.2x6.0cm | fitX 0.650 fitY 0.750
ERR  | <tên file> | <lỗi>          ·  ERR | FATAL | <lỗi toàn cục>
Success: 5 | Failed: 0 | Warn (méo): 2

parse_resize_log_text() gom theo tên file và trả về status ok | warn | error, mẫu dùng và % méo. api_generate ghép thêm dòng Excel, tên shop, size cm, và trả skipped_rows. Nếu log cũ hơn lúc bấm (Windows chạy bất đồng bộ), giao diện báo "đang chạy, xem log".

Giao diện: 3 bước (Chọn file, Tuỳ chọn xuất, Xuất file), cài đặt Illustrator thu gọn có pill trạng thái, bảng kết quả với chip tổng, nút "Mở thư mục kết quả" (POST /api/open-output). Hỗ trợ sáng/tối. App mở vào giao diện chính kể cả khi chưa có key; mục Key kích hoạt (đầu trang khi chưa kích hoạt, thu gọn cuối trang khi đã kích hoạt) để nhập, đổi, gia hạn key và copy Machine ID. Chỉ /api/generate và /api/export bị chặn (403 + license_required) khi chưa có key hợp lệ (LICENSED_PATHS trong web_app.py).

License và web quản lý key

src/licensing.py · license_server/ · deploy/local/ · deploy/vps/

Key

POSM1.<base64url payload>.<HMAC-SHA256>
payload = {v, sub: khách, iat, exp, lifetime?, mid?}
  • App nhúng secret lúc build (src/_license_secret.py), kiểm tra chữ ký, hạn, máy (mid = 16 hex từ hostname + MAC).
  • Lưu tại ~/Library/Application Support/POSM-Resize-Tool/license.json. Không gọi mạng.
  • Chưa có key: vẫn vào giao diện, chỉ bị chặn Export.
  • Không thu hồi được key đã phát. Muốn kiểm soát thì dùng key ngắn hạn.

Web quản lý key

  • Flask app factory create_app() tái dùng create_token / verify_token, MongoDB (customers, licenses, counters, GridFS releases).
  • Tạo khách, tạo key (30/90/180/365 ngày, tuỳ chỉnh, vĩnh viễn, gắn máy), copy, đánh dấu huỷ (chỉ theo dõi), kiểm tra key. Menu Bản cài đặt: upload zip theo hệ điều hành; khách tải ở /download (công khai, tự nhận OS).
  • Bảo mật: 1 tài khoản admin (ADMIN_USERNAME + hash werkzeug), sai 5 lần khoá 15 phút, CSRF mọi POST, cookie Secure/HttpOnly, ProxyFix sau Caddy.
  • Env: LICENSE_SECRET (phải trùng secret build app), ADMIN_USERNAME, ADMIN_PASSWORD_HASH (local: ADMIN_PASSWORD), FLASK_SECRET_KEY, MONGO_URI, MONGO_DB. Thiếu env thì không khởi động.
# Local (build & test): http://localhost:8800  admin / Password@123
cd deploy/local && docker compose up -d --build
docker compose --profile test run --rm --build test

# VPS (deploy)
cd deploy/vps && cp .env.example .env              # điền DOMAIN, LICENSE_SECRET, hash, FLASK_SECRET_KEY, MONGO_PASSWORD
docker compose run --rm --no-deps license-server python -m license_server.hash_password
docker compose up -d --build                          # Caddy tự lấy HTTPS cho DOMAIN
docker compose exec -T mongo sh -c 'mongodump -u "$MONGO_INITDB_ROOT_USERNAME" -p "$MONGO_INITDB_ROOT_PASSWORD" --authenticationDatabase admin --db posm_license --archive --gzip' > backup.archive.gz

Hướng dẫn đầy đủ: docs/LICENSE_SERVER.md.

Giới hạn đã biết

Kiểm thử

.venv/bin/python -m pytest -q          # 50 passed, 4 skipped
tests/test_license_gate.py             # vào UI không cần key, Export bị chặn tới khi kích hoạt
tests/test_resize_log.py               # parse OK/WARN/ERR/FATAL, log cũ bị bỏ qua
tests/test_license_server.py           # login, khoá, CSRF, tạo key → verify_token, huỷ = chỉ theo dõi
tests/test_export_formats.py           # manifest có max_distortion_pct

Đã chạy thật: export 5 dòng qua /api/generate với Illustrator (3 OK, 2 WARN, dòng 4 bỏ qua); Docker image license_server tạo key qua HTTP, kích hoạt app desktop bằng key đó thành công; dữ liệu SQLite còn sau restart container.