docs(handover): add Indonesian scope summary + its generator

PFM-Scanner-Ringkasan-Scope.pdf/.odt — 20 pages covering the flow, folder map,
ports, how to run each part and from which directory, how login and upload move
data, and what is built versus what isn't.

src/build_ringkasan.py draws its four diagrams as native shapes (same approach as
build_deck.py), crops them to img/ring-*.png, and builds the document in one run.
This commit is contained in:
fhanyuh committed 2026-08-27 10:40:55 +07:00
1 parent caf8e98378
commit ef08ff0527
24 files changed
+4218

No files matched your search

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
# Handover materials
Two deliverables, same content, different jobs.
| File | What it's for |
|---|---|
| `PFM-Scanner-Knowledge-Transfer.odp` | 30-slide deck (LibreOffice Impress). For the walkthrough session. Diagrams are native draw shapes, so it also opens in Draw. |
| `PFM-Scanner-Handover-Document.odt` | 42-page document (LibreOffice Writer). For the person working alone, later. Adds an operational runbook, a troubleshooting guide, a glossary, and a full screenshot appendix. |
| `PFM-Scanner-Ringkasan-Scope.odt` | 20-page Indonesian summary (LibreOffice Writer). The one to read first: the flow, the folder map, the ports, how to run each part and from which directory, how login and upload actually move data, then what is built versus what isn't. |
| `*.pdf` | Same files, for sharing with people who won't edit them. |
All three are built entirely from what is committed in this repo. Nothing is invented.
Screenshots are the real captures from `screenshots/v2/` and `screenshots/`, taken on
a real device against the live backend.
## Before handing them over — fill these in
Three things the repo cannot know are marked `[ TO FILL IN ]`:
- **Deck slide 1 / document cover** — presenter name, handover date, successors.
- **Deck slide 24 / document §15** — the access & credentials checklist is a
*checklist*, deliberately unfilled. Don't type secrets into either file; use it as
the agenda for a session where the successor logs into each one themselves.
- **Document §12.5, §12.7, §15** — how the APK reaches store devices, whether any DB
backup runs, and the current deployment state (which stores are live, on what
hardware, since when).
## Contents
**Deck (30 slides)** — 1–2 intro · 3–8 what exists + architecture and sequence
diagrams · 9–11 the app on screen (shell, DO scan ×2) · 12–13 gateway + internal web
tooling · 14–16 accuracy · 17–18 Product Scan + its screens · 19–21 data, ops, repo
map · 22–24 gotchas + credentials · 25–30 roadmap, scope, first 30 days, index.
**Document (42 pages)** — §1–9 what exists · §10 accuracy · §11–13 setup, runbook,
troubleshooting · §14–18 gotchas, credentials, roadmap, out-of-scope, first 30 days ·
Appendix A glossary · B command reference · C doc index · D the app screen by screen.
## Regenerating
`src/` holds the generators. Both emit flat-ODF XML that LibreOffice converts.
```bash
cd docs/handover/src
python3 prepare_assets.py # once — builds shots_small/ from ../../../screenshots/
# (needs Pillow; nothing else here does)
python3 build_deck.py # -> pfm-handover.fodp
libreoffice --headless --convert-to odp pfm-handover.fodp
python3 build_doc.py # -> pfm-handover-doc.fodt
libreoffice --headless --convert-to odt pfm-handover-doc.fodt
```
`shots_small/` is gitignored — it is display-resolution copies of screenshots already
committed elsewhere in the repo, and embedding the originals at full size makes the
`.odp` roughly 10MB for no visible gain.
The Indonesian summary has its own generator, same two-stage shape as the deck:
```bash
cd docs/handover/src
python3 build_ringkasan.py # draws diagrams, crops them, builds the doc,
# writes ../PFM-Scanner-Ringkasan-Scope.{pdf,odt}
```
Its four diagrams are native shapes like the deck's, drawn in the same file that
builds the document, cropped to `img/ring-*.png`. Edit the shape coordinates in
`build_ringkasan.py` and re-run — there is no separate re-crop step.
Two layout constraints it works around, worth keeping if you edit it. Writer glues a
heading to a full-width image below it, so a section opening with a diagram jumps to
the next page and strands a half-empty one — `newpage()` makes that jump deliberate.
And a sequence diagram wider than about 20cm shrinks below readable size once scaled
into the A4 text column, so keep the crop narrow rather than the font large.
`build_deck.py` prints a text-overflow report for any frame whose content is
estimated to exceed its box — anything over ~0.15cm is worth fixing before shipping.
### If you change a deck diagram
The document embeds the deck's diagrams as PNGs from `src/img/`, cropped out of the
deck PDF. Re-crop after any diagram change, or the two will disagree. The crop
rectangles are in centimetres `(deck page, x, y, w, h)`; `build_doc.py` resolves them
by filename prefix, so the page number in the filename can change freely.
```bash
# after rebuilding the deck PDF
python3 - <<'PY'
import subprocess
DPI=200; K=DPI/2.54
crops={"flow":(3,1.35,4.40,22.70,2.90),"arch":(4,1.30,3.10,22.90,5.05),
"sequence":(6,1.00,3.25,23.20,9.15),"layers":(14,1.35,4.25,22.70,7.00),
"product":(17,1.35,4.25,22.70,2.20),"phases":(27,1.35,8.75,22.70,2.05)}
for n,(p,x,y,w,h) in crops.items():
subprocess.run(["pdftoppm","-r",str(DPI),"-f",str(p),"-l",str(p),"-png",
"-x",str(int(x*K)),"-y",str(int(y*K)),"-W",str(int(w*K)),"-H",str(int(h*K)),
"pfm-handover.pdf","img/"+n],check=True)
PY
```
Note: LibreOffice installed from snap cannot read paths outside `$HOME`, so run the
conversions somewhere under your home directory.
Editing directly in Impress/Writer is fine, but then the generators are stale — pick
one as the source of truth rather than maintaining both.
+3
View File
@@ -0,0 +1,3 @@
# Generated by prepare_assets.py from the repo's screenshots/ — don't commit a
# second copy of the client screenshots.
shots_small/
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+886
View File
@@ -0,0 +1,886 @@
#!/usr/bin/env python3
"""Builds the Indonesian scope summary (.fodt -> .odt/.pdf).
Two stages, same convention as build_deck.py / build_doc.py:
1. diagrams are drawn as native shapes on throwaway .fodp pages, rendered to
PDF, and cropped to PNG in img/ (prefix "ring-")
2. the document embeds those crops
Run it from anywhere; it stages into $HOME because snap LibreOffice cannot read
paths outside it.
"""
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
IMG = os.path.join(HERE, "img")
WORK = os.path.expanduser("~/pfm-ringkasan-build") # not a dot-dir: snap
# LibreOffice cannot read those
SOFFICE = "/snap/bin/libreoffice" if os.path.exists("/snap/bin/libreoffice") else "libreoffice"
DPI = 200
K = DPI / 2.54
import odf_engine as E # noqa: E402
from odt_engine import * # noqa: E402,F403
# ─────────────────────────────────────────────────────────── diagram helpers
def box(pg, x, y, w, h, label, sub=None, stroke=E.BORDER, fill=E.WHITE,
tc=E.INK, size=10, subsize=8.0):
pg.rect(x, y, w, h, fill=fill, stroke=stroke, sw=0.032, radius=0.14)
if sub:
paras = [(label, {"align": "center", "space_after": 0.10, "bold": True,
"size": size, "color": tc})]
for ln in sub.split("\n"):
paras.append((ln, {"align": "center", "size": subsize, "color": E.MUTED,
"line": 118}))
pg.text(x + 0.14, y, w - 0.28, h, paras, valign="middle")
else:
pg.text(x + 0.14, y, w - 0.28, h, label, size=size, bold=True, color=tc,
align="center", valign="middle")
def lane(pg, x, y, w, h, title, color=E.FAINT, fill="#F8FAFC", stroke=E.BORDER):
pg.rect(x, y, w, h, fill=fill, stroke=stroke, sw=0.022, radius=0.18)
pg.text(x, y + 0.14, w, 0.44, title, size=7.6, bold=True, color=color,
align="center", caps=True, spacing=0.05)
def tag(pg, x, y, w, txt, color=E.MUTED, size=7.6, align="center"):
pg.text(x, y, w, 0.42, txt, size=size, color=color, align=align)
DIAGRAMS = {} # name -> (page index, x, y, w, h) in cm
def diagram(name, bbox):
"""Register a diagram page; bbox is the crop rect in cm."""
pg = deck.page(name)
pg.rect(0, 0, E.W, E.H, fill=E.WHITE)
DIAGRAMS[name] = (len(deck.pages), ) + bbox
return pg
deck = E.Deck()
# ══════════════════════════════════════════════ 1. ALUR BESAR (arsitektur)
pg = diagram("alur", (0.35, 0.55, 24.7, 8.55))
box(pg, 0.6, 3.15, 3.1, 2.5, "HP Petugas", "Flutter\nfoto · GPS · antrean", stroke=E.BLUE)
box(pg, 0.6, 6.55, 3.1, 1.7, "Admin", "browser", stroke=E.BORDER, tc=E.MUTED,
fill="#FBFBFC", size=9)
lane(pg, 4.45, 0.85, 20.45, 8.0, "Server on-premise · satu stack Docker")
box(pg, 5.05, 3.30, 2.85, 2.2, "Nginx", ":8000\n:8001", stroke=E.INK, fill=E.SURFACE)
box(pg, 8.95, 3.30, 4.55, 2.2, "Next.js", ":3000\nAPI + web admin", stroke=E.BLUE)
box(pg, 8.95, 6.45, 4.55, 1.75, "backend/uploads/", "foto .jpg + hasil .json",
stroke=E.AMBER, size=9)
box(pg, 14.95, 1.55, 4.35, 1.95, "Pipeline OCR", ":8090 PaddleOCR", stroke=E.TEAL)
box(pg, 20.10, 1.55, 4.20, 1.95, "vLLM", ":8118 PaddleOCR-VL", stroke=E.TEAL)
box(pg, 14.95, 5.15, 4.35, 1.95, "PostgreSQL", ":5432 documents\nocr_items · accounts",
stroke=E.PURPLE, size=9.6, subsize=7.6)
# client -> nginx
pg.line(3.70, 4.05, 5.00, 4.05, color=E.BLUE, sw=0.042, arrow=True)
tag(pg, 3.55, 3.58, 1.6, "upload", color=E.BLUE)
pg.line(5.00, 4.95, 3.70, 4.95, color=E.BLUE, sw=0.032, arrow=True, dash=True)
tag(pg, 2.90, 5.02, 1.9, "polling 2 dtk", color=E.BLUE, size=7.0)
pg.line(3.70, 7.40, 6.47, 7.40, color=E.BORDER, sw=0.032)
pg.line(6.47, 7.40, 6.47, 5.55, color=E.BORDER, sw=0.032, arrow=True)
# nginx -> next
pg.line(7.90, 4.40, 8.90, 4.40, color=E.BLUE, sw=0.042, arrow=True)
# next -> pipeline -> vllm
pg.line(13.50, 4.05, 14.30, 4.05, color=E.TEAL, sw=0.038)
pg.line(14.30, 4.05, 14.30, 2.52, color=E.TEAL, sw=0.038)
pg.line(14.30, 2.52, 14.90, 2.52, color=E.TEAL, sw=0.038, arrow=True)
tag(pg, 12.9, 3.45, 2.6, "gambar", color=E.TEAL, size=7.2)
pg.line(19.35, 2.52, 20.05, 2.52, color=E.TEAL, sw=0.038, arrow="both")
# next -> postgres
pg.line(13.50, 4.75, 14.30, 4.75, color=E.PURPLE, sw=0.038)
pg.line(14.30, 4.75, 14.30, 6.12, color=E.PURPLE, sw=0.038)
pg.line(14.30, 6.12, 14.90, 6.12, color=E.PURPLE, sw=0.038, arrow=True)
# next -> uploads
pg.line(11.22, 5.55, 11.22, 6.40, color=E.AMBER, sw=0.038, arrow=True)
# ══════════════════════════════════════════════ 2. NGINX ROUTING
pg = diagram("nginx", (0.35, 0.55, 24.7, 8.05))
box(pg, 0.7, 1.35, 4.3, 2.9, "Port 8000", "publik\nsemua path", stroke=E.INK,
fill=E.SURFACE, size=11)
box(pg, 0.7, 5.35, 4.3, 2.5, "Port 8001", "publik\nkhusus aplikasi HP", stroke=E.TEAL,
fill="#F4FBFA", size=11)
paths = [
(1.40, "/ /api /history /arena /gpu /_next", "Next.js :3000", E.BLUE, False),
(2.42, "/layout-parsing /health", "Pipeline OCR :8090", E.TEAL, False),
(3.44, "/v1", "vLLM :8118", E.TEAL, False),
(5.40, "/api/v1/*", "Next.js :3000", E.BLUE, False),
(6.42, "path lain apa pun", "404 ditolak", E.RED, True),
]
for y, p, target, col, deny in paths:
pg.rect(6.10, y, 9.4, 0.86, fill=E.WHITE, stroke=E.BORDER, sw=0.026, radius=0.12)
pg.text(6.30, y, 9.0, 0.86, p, size=9.2, mono=not deny, italic=deny,
color=E.INK if not deny else E.MUTED, valign="middle")
pg.line(15.60, y + 0.43, 16.55, y + 0.43, color=col, sw=0.036, arrow=True)
box(pg, 16.65, y, 7.6, 0.86,
target, None, stroke=col, size=9.4,
fill="#FEF4EE" if deny else E.WHITE, tc=E.RED if deny else E.INK)
pg.line(5.05, y + 0.43, 6.05, y + 0.43,
color=E.BORDER, sw=0.028, arrow=True)
# ══════════════════════════════════════════════ 3. SEQUENCE: LOGIN
def seq_header(pg, cols, y=0.75, h=1.0, bottom=8.6):
for cx, w, label, sub, col in cols:
box(pg, cx - w / 2, y, w, h, label, sub, stroke=col, size=9.6, subsize=7.4)
pg.line(cx, y + h, cx, bottom, color=E.BORDER, sw=0.022, dash=True)
def step(pg, n, x1, x2, y, txt, color=E.BLUE, dash=False, size=8.4):
pg.line(x1, y, x2, y, color=color, sw=0.034, arrow=True, dash=dash)
lo, hi = min(x1, x2), max(x1, x2)
pg.text(lo, y - 0.62, hi - lo, 0.55,
[([("%d " % n, {"bold": True, "color": color}), (txt, {"color": E.MUTED})],
{"align": "center"})], size=size, line=120)
pg = diagram("login", (0.35, 0.35, 19.3, 8.35))
seq_header(pg, [
(2.9, 4.2, "HP Petugas", "aplikasi Flutter", E.BLUE),
(9.9, 5.8, "Next.js :3000", "auth/login/route.ts", E.INK),
(17.0, 4.6, "PostgreSQL", "tabel accounts", E.PURPLE),
], bottom=8.0)
step(pg, 1, 2.9, 9.9, 2.60, "POST /api/v1/auth/login { username, password }")
step(pg, 2, 9.9, 17.0, 3.55, "SELECT accounts JOIN store_master", color=E.PURPLE)
step(pg, 3, 17.0, 9.9, 4.40, "hash password · is_active · kode_toko",
color=E.PURPLE, dash=True)
pg.rect(10.05, 4.95, 7.0, 1.95, fill=E.SURFACE, stroke=E.BORDER, sw=0.026, radius=0.12)
pg.text(10.30, 5.02, 6.6, 1.85,
[([("4 ", {"bold": True, "color": E.INK}),
("bcrypt.compareSync(password, hash)", {"mono": True, "color": E.INK})],
{"space_after": 0.08}),
("5 cek is_active — kalau false, tolak 401", {"color": E.MUTED}),
("6 buat JWT { accountId, username, kodeToko, role } — 30 hari",
{"color": E.MUTED})], size=8.2, line=126)
pg.line(9.9, 4.95, 9.9, 6.90, color=E.INK, sw=0.022, dash=True)
step(pg, 7, 9.9, 2.9, 7.25, "{ token, profile: kodeToko · namaToko }", dash=True)
pg.rect(0.6, 7.60, 18.5, 0.68, fill="#EFF5FF", stroke=E.BLUE, sw=0.022, radius=0.12)
pg.text(0.85, 7.60, 18.0, 0.68,
[([("Kuncinya: ", {"bold": True, "color": E.BLUE}),
("token membawa kodeToko — nama dan alamat toko diambil dari akun yang "
"mengunggah, tidak perlu dibaca OCR dari foto.",
{"color": E.MUTED})], {"align": "center"})], size=8.0, valign="middle")
# ══════════════════════════════════════════════ 4. SEQUENCE: UPLOAD
pg = diagram("upload", (0.35, 0.35, 20.6, 9.15))
seq_header(pg, [
(2.2, 3.3, "HP Petugas", "Flutter", E.BLUE),
(7.3, 4.6, "Next.js :3000", "documents/upload", E.INK),
(11.6, 3.1, "uploads/", "file foto", E.AMBER),
(15.4, 3.5, "Pipeline + vLLM", "GPU", E.TEAL),
(19.0, 3.1, "Postgres", "tabel", E.PURPLE),
], bottom=8.7)
step(pg, 1, 2.2, 7.3, 2.45, "POST upload + GPS + token", size=8.0)
pg.rect(7.45, 2.75, 4.0, 1.35, fill=E.SURFACE, stroke=E.BORDER, sw=0.026, radius=0.12)
pg.text(7.62, 2.82, 3.7, 1.25,
[("2 baca token → kodeToko", {"color": E.MUTED}),
("3 hitung SHA-256 file", {"color": E.MUTED}),
("4 cek duplikat file_hash", {"color": E.MUTED})], size=7.8, line=124)
pg.rect(0.6, 4.22, 6.5, 0.92, fill="#FEF6E7", stroke=E.AMBER, sw=0.022, radius=0.10)
pg.text(0.80, 4.22, 6.1, 0.92,
[("kalau hash sudah pernah masuk:", {}),
("kembalikan dokumen lama, OCR tidak diulang", {})],
size=7.6, color="#7C4A03", valign="middle", line=124)
step(pg, 5, 7.3, 11.6, 5.30, "simpan file .jpg", color=E.AMBER, size=8.0)
step(pg, 6, 7.3, 19.0, 5.95, "INSERT documents (parsed=false, confirmed=false)",
color=E.PURPLE, size=8.0)
step(pg, 7, 7.3, 15.4, 6.60, "POST /api/parse → OCR di GPU", color=E.TEAL, size=8.0)
step(pg, 8, 15.4, 7.3, 7.20, "teks + baris item", color=E.TEAL, dash=True, size=8.0)
step(pg, 9, 7.3, 19.0, 7.85, "INSERT ocr_items · UPDATE documents SET parsed=true",
color=E.PURPLE, size=8.0)
step(pg, 10, 7.3, 2.2, 8.50, "201 — HP lanjut polling", dash=True, size=8.0)
# ─────────────────────────────────────────────────────────── render diagrams
def build_diagrams():
os.makedirs(WORK, exist_ok=True)
os.makedirs(IMG, exist_ok=True)
fodp = os.path.join(WORK, "ring-diagrams.fodp")
with open(fodp, "w") as f:
f.write(deck.render())
pdf = convert(fodp, "pdf")
for name, (page, x, y, w, h) in DIAGRAMS.items():
subprocess.run(["pdftoppm", "-r", str(DPI), "-f", str(page), "-l", str(page),
"-png", "-x", str(int(x * K)), "-y", str(int(y * K)),
"-W", str(int(w * K)), "-H", str(int(h * K)),
pdf, os.path.join(IMG, "ring-" + name)], check=True)
for cand in ("ring-%s-%02d.png" % (name, page), "ring-%s-%d.png" % (name, page)):
src = os.path.join(IMG, cand)
if os.path.exists(src):
os.replace(src, os.path.join(IMG, "ring-%s.png" % name))
break
if E.OVERFLOW:
print(" text overflow:", E.OVERFLOW)
def dia(name):
return os.path.join(IMG, "ring-%s.png" % name)
# ══════════════════════════════════════════════════════════════ THE DOCUMENT
def build_doc():
d = Doc(footer_text="PFM Scanner — Ringkasan Ruang Lingkup")
B = {"bold": True, "color": INK}
M = {"mono": True, "color": TEAL}
# ------------------------------------------------------------- COVER
d.p("Ringkasan untuk penerus", size=9.5, bold=True, color=BLUE, caps=True,
spacing=0.055, align="start", after=0.55, before=1.2)
d.title("Ruang Lingkup Sistem PFM OCR", "Apa yang sudah jalan, apa yang belum")
d.rule(TEAL, 0.07)
d.p("Sistem OCR on-premise ber-GPU yang mengubah foto Surat Jalan dari HP petugas "
"toko menjadi data terstruktur di database. Dokumen ini menjelaskan alurnya, "
"letak tiap bagian di filesystem, port dan jaringannya, cara menjalankan tiap "
"bagian dan dari folder mana, lalu memisahkan dengan tegas mana yang sudah "
"terbangun dan mana yang belum.",
size=11.5, color=MUTED, after=0.9, line=140)
d.table([1, 2.4], None,
[[("Untuk siapa", B), "Orang yang melanjutkan pekerjaan ini, dibantu AI coding agent"],
[("Repositori", B), "app-pfm-ocr-v2 (git, branch main)"],
[("Disusun dari", B), "isi repo per 27 Agustus 2026 — bukan dari ingatan"],
[("Dokumen pendamping", B),
"PFM-Scanner-Handover-Document.pdf (42 hal, lebih dalam) dan "
"PFM-Scanner-Knowledge-Transfer.pdf (30 slide)"]],
size=10, zebra=None)
d.note("Baca dokumen ini lebih dulu, baru dua dokumen pendamping di atas. Semua "
"angka, port, nama tabel dan nama file di sini diambil langsung dari file "
"yang di-commit — docker-compose.yml, backend/nginx.conf, app_config.dart, "
"db/init.ts, dan plans/next-enhancements.md. Tidak ada yang dikarang.",
accent=BLUE, bg="#EFF5FF", label="Mulai dari sini")
# ------------------------------------------------------------- 1
d.h1("1. Sistem ini sebenarnya apa")
d.p("Petugas toko memotret Surat Jalan (DO) pakai HP. Foto dikirim ke server "
"on-premise ber-GPU, dibaca oleh OCR, hasilnya jadi data terstruktur di "
"database. Petugas mengoreksi hasilnya di HP, lalu mencetak struk. Ada juga "
"web admin untuk melihat riwayat dan mengelola master data.")
d.table([1.1, 2.6], ["Bagian", "Letak dan perannya"],
[[("Aplikasi HP (Flutter)", B),
"Akar repo, folder lib/. Dipakai petugas toko. Satu akun terikat ke satu toko."],
[("Web app + API (Next.js)", B),
"backend/pfm-web-app/. Otak sistem: melayani aplikasi HP sekaligus jadi "
"halaman admin."],
[("Mesin OCR (Python + GPU)", B),
"backend/config/ dan Dockerfile. PaddleOCR + model vision-language vLLM."]])
d.h2("Alur besarnya")
d.image(dia("alur"), caption="Semua kotak di dalam bingkai abu-abu adalah container "
"Docker di satu server yang sama.")
d.h2("Delapan langkah, dari foto sampai tersimpan")
for i, t in enumerate([
[("Login. ", B), ("Petugas login. Satu akun terikat ke satu toko. Token "
"diverifikasi sungguhan ke backend, bukan stub.", {})],
[("Foto DO. ", B), ("Aplikasi memotret, lalu mengecek ketajaman gambar di HP "
"(Laplacian variance). Kalau buram, ditandai.", {})],
[("Tempel GPS dan unggah. ", B), ("Koordinat ikut dikirim ke "
"POST /api/v1/documents/upload.", {})],
[("Antrean tahan mati. ", B), ("Upload yang belum selesai disimpan di Hive. "
"Aplikasi ter-kill di tengah jalan tidak "
"menghilangkan dokumen — antrean dilanjutkan "
"saat dibuka lagi.", {})],
[("Server memproses. ", B), ("Next.js meneruskan gambar ke pipeline OCR di GPU, "
"hasilnya disimpan ke tabel documents dan ocr_items.", {})],
[("Aplikasi menunggu. ", B), ("HP polling tiap 2 detik (maksimal 130 kali, "
"sekitar 4,3 menit) sampai backend menandai "
"parsed = true.", {})],
[("Petugas mengoreksi. ", B), ("Hasil OCR ditampilkan untuk dibetulkan, lalu "
"disimpan lewat PUT /api/v1/documents/:id.", {})],
[("Cetak struk. ", B), ("PDF dibuat langsung di HP dan bisa dicetak di tempat.", {})],
], 1):
d.numbered(i, t)
# ------------------------------------------------------------- 2
d.h1("2. Peta folder")
d.p("Satu repo, dua aplikasi. Akar repo adalah aplikasi Flutter; folder backend/ "
"berisi semua yang jalan di server.")
d.h2("Aplikasi Flutter — akar repo")
d.table([1.15, 2.4], ["Lokasi", "Isinya"],
[[("lib/config/", M), "app_config.dart — tema dan alamat server"],
[("lib/core/", M), "Dio (HTTP), Hive (simpan lokal), GPS, go_router"],
[("lib/features/", M), "satu folder per fitur: auth, camera, documents, "
"editor, splash"],
[("lib/data/", M), "master_sku.dart — daftar SKU yang ikut ter-compile ke APK"],
[("test/", M), "tes Flutter"],
[("android/ ios/ web/", M), "wadah per platform, jarang disentuh"]])
d.h2("Backend — semua yang jalan di server")
d.table([1.15, 2.4], ["Lokasi", "Isinya"],
[[("backend/pfm-web-app/", M), "Next.js: web admin + API gateway"],
[(" src/app/api/v1/", M), "API yang dipakai aplikasi HP"],
[(" src/app/api/", M), "API internal web admin"],
[(" src/app/admin/", M), "halaman master data"],
[(" src/db/init.ts", M), "skema + seeding akun (lihat bab 5)"],
[("backend/config/", M), "setting pipeline OCR dan vLLM"],
[("backend/db/migrations/", M), "skema Postgres, jalan otomatis saat DB masih kosong"],
[("backend/uploads/", M), "foto DO masuk + hasil JSON — sekitar 173 MB"],
[("backend/sources/", M), "data klien dan ground truth — sekitar 283 MB, RAHASIA"],
[("backend/nginx.conf", M), "aturan routing semua port"],
[("backend/Dockerfile", M), "tiga target: vllm-server / pipeline-api / pfm-web-app"]])
d.h2("Di akar repo")
d.table([1.15, 2.4], ["Lokasi", "Isinya"],
[[("docker-compose.yml", M), "stack resmi — jalankan SELALU dari sini"],
[("docker-compose.demo.yml", M), "override mode produksi untuk demo"],
[("start-dev-tunnel.ps1", M), "sinkronisasi IP LAN ke app_config.dart + ngrok"],
[("docs/ plans/", M), "dokumentasi dan backlog"],
[("CLAUDE.md AGENTS.md", M), "instruksi untuk AI coding agent"]])
d.note("Ada dua file docker-compose.yml — satu di akar repo, satu di dalam backend/ "
"(sisa lama dengan nama proyek Compose berbeda). Selalu jalankan dari akar "
"repo; menjalankan dari dalam backend/ membuat nama container bentrok dengan "
"yang sudah menyala.", label="Jebakan")
# ------------------------------------------------------------- 3
d.h1("3. Port dan jaringan")
d.p("Hanya dua port terbuka ke luar server. Sisanya hanya bisa diakses antar-container.")
d.table([0.55, 1.5, 2.5, 0.7],
["Port", "Container", "Isinya", "Akses"],
[[("8000", M), "paddleocr-nginx", "Pintu depan. Semua request masuk sini lalu diteruskan.", "Publik"],
[("8001", M), "paddleocr-nginx", "Pintu sempit: hanya /api/v1/* yang lolos, sisanya 404.", "Publik"],
[("3000", M), "paddleocr-pfm-web-app", "Next.js — web admin + API gateway.", "Internal"],
[("8090", M), "paddleocr-pipeline-api", "PaddleOCR pipeline, endpoint /layout-parsing.", "Internal"],
[("8118", M), "paddleocr-vllm-server", "Model vision-language di GPU (PaddleOCR-VL).", "Internal"],
[("8120", M), "paddleocr-pipeline-api", "Classifier /classify-ocr.", "Internal"],
[("5432", M), "paddleocr-db", "Postgres 15, database dopfm.", "Internal"]],
size=9.0)
d.h2("Pembagian lalu lintas di Nginx")
d.image(dia("nginx"), caption="Diatur di backend/nginx.conf. Batas ukuran upload "
"dimatikan (client_max_body_size 0) karena foto DO besar; "
"timeout 300 detik karena OCR lama.")
d.h2("Cara HP menemukan server")
d.p([("Alamat server tidak di-hardcode satu nilai. Setiap aplikasi dibuka, ", {}),
("AppConfig.initializeApiBaseUrl()", M),
(" mencoba dua alamat berurutan:", {})])
d.bullet([("LAN dulu", B), (" — http://192.168.x.x:8000/api/v1, cepat, dipakai saat "
"HP dan server satu WiFi.", {})])
d.bullet([("Ngrok kalau LAN gagal", B), (" — tunnel publik, dipakai saat petugas di "
"luar jaringan kantor.", {})])
d.bullet("Kalau dua-duanya mati, tetap dipakai alamat LAN supaya pesan error menunjuk "
"ke server yang benar.")
d.p("Pengecekannya bukan sekadar “bisa connect”: aplikasi memastikan jawabannya JSON "
"asli dari backend, bukan halaman error tunnel atau halaman login router.")
d.note("Kalau login atau upload gagal di HP, periksa ini duluan — biasanya IP LAN "
"server berubah atau tunnel ngrok mati. Jalankan ./start-dev-tunnel.ps1, lalu "
"build ulang APK karena alamatnya konstanta di dalam kode.", label="Paling sering")
# ------------------------------------------------------------- 4
d.h1("4. Cara menjalankan — langkah demi langkah")
d.note("Aturan folder yang paling sering salah: semua perintah docker compose dan "
"semua perintah flutter dijalankan dari AKAR REPO (app-pfm-ocr-v2/), bukan "
"dari dalam backend/. Perintah npm hanya dari backend/pfm-web-app/, dan itu "
"pun jarang — npm biasanya sudah dijalankan di dalam container.",
accent=BLUE, bg="#EFF5FF", label="Baca dulu")
d.h2("4.1 Prasyarat di mesin server")
d.table([1.0, 2.6], ["Butuh", "Keterangan"],
[[("Docker + Compose v2", B), "Perintahnya docker compose (pakai spasi), bukan docker-compose."],
[("GPU NVIDIA + Container Toolkit", B),
"Wajib. Compose meminta driver: nvidia untuk vllm-server dan pipeline-api. "
"Tanpa toolkit, container gagal start."],
[("Flutter SDK 3.2.0+", B), "Hanya di mesin developer, bukan di server. "
"Lihat pubspec.yaml."],
[("ngrok (opsional)", B), "Hanya kalau perlu akses dari luar jaringan kantor."]])
d.h2("4.2 Setup pertama kali (sekali saja)")
d.code([
"cd app-pfm-ocr-v2 # semua dari akar repo",
"",
"cp backend/.env.example backend/.env",
"",
"# edit backend/.env — isi tiga nilai ini:",
"# APP_PORT=8000 port yang dibuka ke luar",
"# CUDA_VISIBLE_DEVICES=0 nomor GPU yang dipakai (cek: nvidia-smi)",
"# JWT_SECRET=<rahasia-baru> JANGAN biarkan \"change-me\"",
"",
"docker compose up --build # pertama kali LAMA: unduh model + image",
])
d.p("Saat pertama kali, Postgres menjalankan sendiri semua file di "
"backend/db/migrations/. Ini hanya terjadi kalau volume database masih kosong. "
"Kalau nanti ada migration baru sedangkan database sudah berisi, file itu tidak "
"jalan otomatis — harus dijalankan manual (lihat 4.7).")
d.h2("4.3 Menjalankan backend sehari-hari (mode dev)")
d.code([
"cd app-pfm-ocr-v2 # WAJIB dari akar repo",
"",
"docker compose up # jalan di depan, log langsung kelihatan",
"docker compose up -d # jalan di belakang layar",
"docker compose up --build # kalau Dockerfile / dependency berubah",
])
d.p("Mode ini menjalankan npm run dev di dalam container, dengan folder "
"backend/pfm-web-app di-mount masuk. Artinya edit file TypeScript di komputer "
"langsung berubah, tanpa rebuild. Ini yang dipakai untuk ngoprek parser sehari-hari.")
d.h2("4.4 Menjalankan backend untuk demo ke klien (mode produksi)")
d.code([
"cd app-pfm-ocr-v2",
"",
"docker compose -f docker-compose.yml -f docker-compose.demo.yml up -d --build",
])
d.p([("--build di sini wajib, bukan opsional. ", B),
("Mode demo membuang mount source dan menjalankan npm start terhadap hasil "
"npm run build yang sudah tertanam di image. Konsekuensinya mode ini tidak "
"hot-reload: kalau ada perubahan kode dan Anda lupa --build, yang jalan adalah "
"kode lama dari image sebelumnya.", {})])
d.p("Kenapa harus mode ini saat demo: dev server hanya satu proses dan akan tercekik "
"kalau beberapa HP mengunggah bersamaan.")
d.h2("4.5 Memastikan backend sudah hidup")
d.code([
"docker compose ps # semua harus running / healthy",
"curl http://localhost:8000/api/v1/health # harus balas JSON status ok",
"curl http://localhost:8001/api/v1/health # port khusus aplikasi HP",
"",
"docker compose logs -f pfm-web-app # log Next.js (paling sering dibaca)",
"docker compose logs -f pipeline-api # log OCR",
"docker compose logs -f vllm-server # cek kalau OCR lambat / gagal",
])
d.p("Urutan hidupnya bertahap: db dan vllm-server dulu, lalu pipeline-api menunggu "
"sampai healthy, baru pfm-web-app jalan. Jadi wajar kalau saat start awal "
"pfm-web-app belum langsung menyala. vllm-server memuat model ke GPU dan ini bisa "
"makan beberapa menit saat pertama kali.")
d.h2("4.6 Menghentikan dan membersihkan")
d.code([
"docker compose stop # hentikan, data & container tetap ada",
"docker compose down # hapus container, DATA TETAP AMAN",
"docker compose restart pfm-web-app # restart satu service saja",
"",
"docker compose down -v # HAPUS VOLUME — DATABASE IKUT TERHAPUS",
])
d.note("Jangan asal ketik down -v. Flag -v menghapus volume paddleocr_pgdata — "
"seluruh isi database hilang dan tidak ada backup otomatis. Cache model juga "
"terhapus, jadi model harus diunduh ulang.", accent=RED, bg="#FEF4EE")
d.h2("4.7 Mengakses database")
d.code([
"docker compose exec db psql -U postgres -d dopfm # masuk psql",
"# di dalam psql: \\dt (lihat tabel) \\q (keluar)",
"",
"# menjalankan migration baru pada DB yang sudah berisi:",
"docker compose exec -T db psql -U postgres -d dopfm \\",
" < backend/db/migrations/00X_nama.sql",
])
d.h2("4.8 Menjalankan aplikasi Flutter")
d.code([
"cd app-pfm-ocr-v2 # akar repo, BUKAN folder android/",
"",
"flutter pub get # setelah clone / ubah pubspec",
"flutter devices # pastikan HP / emulator terdeteksi",
"flutter run # jalankan ke perangkat terhubung",
"",
"flutter test # semua tes",
"flutter test test/blur_detector_test.dart # satu file saja",
"flutter analyze lib # cek statis — jalankan sebelum commit",
])
d.table([1.0, 2.2], ["Dijalankan di", "Alamat LAN yang benar di app_config.dart"],
[[("HP fisik", B), "http://<IP LAN mesin server>:8000/api/v1"],
[("Emulator Android", B), "http://10.0.2.2:8000/api/v1"],
[("iOS Simulator", B), "http://localhost:8000/api/v1"]])
d.h2("4.9 Membuat APK untuk dibagikan ke toko")
d.code([
"cd app-pfm-ocr-v2",
"",
"# 1. pastikan alamat server di lib/config/app_config.dart sudah BENAR",
"# (_lanBaseUrl dan _ngrokBaseUrl) — nilainya ikut ter-compile ke APK",
"",
"flutter clean # kalau hasil build sebelumnya mencurigakan",
"flutter pub get",
"flutter build apk --release",
"",
"# hasilnya: build/app/outputs/flutter-apk/app-release.apk",
])
d.note("Ini konsekuensi terbesar yang sering terlupa: alamat server adalah konstanta "
"di dalam kode, jadi ikut tertanam di APK. Setiap kali IP LAN server berubah "
"atau domain ngrok berganti, APK harus dibangun ulang dan disebar ulang ke "
"semua HP toko. Ini juga sebabnya “alamat bisa diubah tanpa build ulang” masuk "
"daftar yang belum ada di bab 7c.")
d.p("APK release saat ini masih ditandatangani kunci debug — cukup untuk instalasi "
"internal, belum bisa masuk Play Store.")
d.h2("4.10 Membuka akses dari luar jaringan (tunnel)")
d.h3("Cara A — ngrok, lewat script (Windows)")
d.code(["cd app-pfm-ocr-v2", "./start-dev-tunnel.ps1"])
d.p("Script ini mengerjakan tiga hal berurutan: mendeteksi IP LAN aktif mesin ini "
"(melewati adapter virtual dan WSL), menuliskan IP itu ke _lanBaseUrl di "
"lib/config/app_config.dart, lalu menyalakan ngrok. Setelah itu ia menunggu "
"sampai health check LAN dan tunnel dua-duanya menjawab ok.")
d.note("Perhatikan: ngrok diarahkan ke port 8001 — pintu sempit yang hanya melayani "
"/api/v1 — bukan 8000. Jadi web admin tidak ikut terbuka ke internet. Setelah "
"script selesai, build ulang APK karena file config baru saja berubah.")
d.h3("Cara B — Cloudflare, tanpa akun")
d.code([
"cd app-pfm-ocr-v2",
"docker compose --profile tunnel up -d",
"docker logs paddleocr-tunnel # cari baris https://xxxx.trycloudflare.com",
])
d.p("Service tunnel ini mati secara default (pakai profiles), jadi harus dinyalakan "
"sengaja. URL-nya berganti setiap restart, dan mengarah ke port 8000 — semua path, "
"termasuk web admin.")
d.h2("4.11 Bekerja langsung di dalam container web app")
d.code([
"docker compose exec pfm-web-app sh # masuk shell container",
"# di dalamnya: npm run lint, npm run build, dsb.",
])
d.p("Umumnya tidak perlu npm install di komputer sendiri — node_modules hidup di dalam "
"container sebagai volume terpisah. Kalau menambah dependency baru, tambahkan ke "
"package.json lalu jalankan docker compose up --build supaya image dibangun ulang.")
def newpage():
"""Writer keeps a heading glued to the diagram under it, so a section that
opens with a full-width diagram jumps to the next page anyway. Make that
jump deliberate instead of leaving a ragged half-page."""
d.p("", size=1, after=0.0, brk=True)
# ------------------------------------------------------------- 5
d.h1("5. Alur data — dari mana ke mana")
d.p("Bab ini menelusuri data yang sebenarnya mengalir, lengkap dengan nama file dan "
"nama tabelnya, supaya bisa langsung dicari di kode.")
d.h2("5.1 Username dan password itu dari mana?")
d.p([("Bukan dari file, melainkan dari ", {}), ("tabel accounts di Postgres", B),
(". Akun-akun itu dibuat otomatis oleh sistem, tidak diketik manual. Prosesnya "
"ada di ", {}), ("backend/pfm-web-app/src/db/init.ts", M), (".", {})])
d.table([0.85, 2.6], ["Langkah", "Yang terjadi"],
[[("1. Daftar toko masuk", B),
"Tabel store_master diisi dari data master toko (kode_toko, nama_toko, alamat)."],
[("2. Akun toko dibuat", B),
"Untuk SETIAP baris di store_master (kecuali WH_JOFFICE) dibuatkan satu akun: "
"username = kode toko, password = \"password\", role = store."],
[("3. Akun admin dibuat", B),
"Satu akun admin / password dengan role admin, terikat ke WH_JOFFICE."],
[("4. Password lama dirapikan", B),
"Password yang masih polos (tidak diawali $2) di-hash ulang dengan bcrypt."]])
d.p([("Jadi kalau ada toko baru, caranya bukan membuat akun — cukup ", {}),
("tambahkan tokonya ke store_master", B), (", akunnya menyusul sendiri.", {})])
d.note("Dua hal yang wajib diketahui soal password ini. (1) Password awal semua akun "
"adalah kata \"password\", sama untuk semua toko. (2) Seeding di atas memakai "
"ON CONFLICT (username) DO UPDATE SET password, dan initDb() dipanggil setiap "
"kali container web app menyentuh database pertama kali setelah start — "
"artinya SETIAP RESTART BACKEND, password semua akun kembali ke \"password\", "
"termasuk yang sudah diganti. Belum ada fitur ganti password yang bertahan. "
"Ini perlu dibereskan sebelum dipakai produksi luas.",
accent=RED, bg="#FEF4EE", label="Penting")
newpage()
d.h2("5.2 Apa yang terjadi saat petugas menekan “Login”")
d.image(dia("login"))
d.p([("Token disimpan di HP lewat Hive. Setiap permintaan berikutnya membawanya di "
"header ", {}), ("Authorization: Bearer <token>", M),
(". Saat aplikasi dibuka lagi, checkLoginState() memanggil GET /api/v1/auth/me "
"untuk memastikan token masih sah; kalau dijawab 401, petugas dikeluarkan.", {})])
d.note("Perhatikan JWT_SECRET. Kalau tidak diisi di backend/.env, kode memakai nilai "
"cadangan \"dev-only-insecure-secret-change-me\" yang tertulis di source. Siapa "
"pun yang tahu nilai itu bisa membuat token palsu untuk toko mana pun. Wajib "
"diisi nilai acak sendiri.", accent=RED, bg="#FEF4EE")
d.h2("5.3 Apa yang terjadi saat foto diunggah")
d.image(dia("upload"))
d.p("Tiga hal yang perlu dipahami dari alur ini:")
d.bullet([("Anti-duplikat pakai hash isi file. ", B),
("Kalau HP mengirim ulang foto yang sama (karena upload sebelumnya dikira "
"gagal padahal berhasil), server mengenalinya dari SHA-256 dan "
"mengembalikan dokumen lama — tidak membuat baris kedua dan tidak "
"menjalankan OCR ulang. Ini yang membuat “ulangi upload” aman.", {})])
d.bullet([("Foto disimpan sebagai file biasa, bukan di database. ", B),
("Di dalam container path-nya /uploads, di komputer itu folder "
"backend/uploads/. Yang masuk database hanya nama file-nya. Jadi database "
"dan folder uploads harus di-backup berpasangan — kalau salah satu hilang, "
"yang tersisa tidak berguna.", {})])
d.bullet([("Dokumen baru lahir dengan confirmed = false. ", B),
("Selama masih false, dokumen itu tidak muncul di GET /api/v1/documents. Ia "
"baru muncul setelah petugas menyimpan hasil koreksi lewat "
"PUT /api/v1/documents/:id. Inilah gerbang konfirmasi.", {})])
d.h2("5.4 Kenapa HP perlu polling")
d.p("Balasan upload di langkah 10 dikirim dengan field masih kosong, karena OCR bisa "
"memakan waktu lama. Karena itu HP tidak menunggu di satu permintaan, melainkan "
"polling GET /api/v1/documents tiap 2 detik (maksimal 130 kali, sekitar 4,3 menit) "
"sampai dokumen bertanda parsed = true.")
d.p("Sisi server sudah dijaga berlapis: panggilan ke /api/parse dibatasi 210 detik; "
"kalau pipeline gagal, dokumen tetap ditandai selesai dengan metadata placeholder "
"“Not Found” supaya tidak menggantung selamanya; kalau panggilannya sendiri yang "
"gagal, alasannya dicatat di kolom parse_error supaya HP bisa diberi tahu gagal, "
"bukan disuruh menunggu sia-sia.")
d.h2("5.5 Ringkasan: data mendarat di mana")
d.table([1.0, 1.15, 2.0], ["Data", "Disimpan di", "Bentuknya"],
[[("Akun & password", B), ("tabel accounts", M),
"Password di-hash bcrypt. Dibuat otomatis dari store_master."],
[("Daftar toko", B), ("tabel store_master", M),
"Sumber kebenaran nama & alamat toko. Menentukan akun apa saja yang ada."],
[("Sesi login", B), ("Hive di HP", M),
"JWT berlaku 30 hari. Tidak ada sesi tersimpan di server."],
[("Foto DO asli", B), ("backend/uploads/", M),
"File .jpg, nama diawali timestamp. Sekitar 173 MB saat ini."],
[("Hasil OCR mentah", B), ("backend/uploads/", M),
"File .jpg.json berpasangan dengan fotonya."],
[("Header dokumen", B), ("tabel documents", M),
"Nama file, waktu, GPS, kode_toko, file_hash, scan_mode, parsed, confirmed, metadata."],
[("Baris item", B), ("tabel ocr_items", M),
"Satu baris per item: kode_barang, nama_barang, banyak, jumlah."],
[("Master data", B), ("sku_master, vendors,\ncustomers", M),
"Dikelola lewat halaman admin."],
[("Antrean belum terkirim", B), ("Hive di HP", M),
"Bertahan meski aplikasi ditutup paksa; dilanjutkan saat dibuka lagi."],
[("Struk PDF", B), ("dibuat di HP", M),
"Tidak disimpan di server sama sekali."]], size=9.0)
# ------------------------------------------------------------- 6
d.h1("6. Yang SUDAH ada dan berjalan")
d.p("Ini bukan rencana — semuanya sudah terpasang di kode dan dipakai.")
d.table([1.0, 2.7], ["Area", "Yang sudah berjalan"],
[[("Login & sesi", B),
"Login diverifikasi sungguhan ke POST /api/v1/auth/login (bukan stub). Sesi "
"JWT disimpan di Hive. Splash mengarahkan otomatis sesuai status sesi. "
"checkLoginState() memanggil GET /auth/me dan logout kalau 401."],
[("Kamera & geotag", B),
"Deteksi blur Laplacian dengan badge lolos/gagal sebelum upload. Kunci "
"shutter sampai HP diam (IMU). GPS ditempel baik dari kamera maupun galeri."],
[("Antrean upload", B),
"Antrean per-item dengan status uploading/processing/success/error, retry "
"(GPS asli dipertahankan), hapus, dan filter pencarian. Sudah tahan mati: "
"antrean dipersistensi ke Hive, jadi aplikasi ter-kill di tengah upload "
"tidak menghilangkan dokumen."],
[("Editor & struk", B),
"Validasi field header (format tanggal, nomor PO/SO), tambah/ubah/hapus item, "
"tanda tangan + konfirmasi penerimaan, dan cetak PDF struk langsung dari HP."],
[("Mode scan", B),
"Dua mode — DO dan Product Scan — dengan penanda warna berbeda dan state global."],
[("API gateway", B),
"Rute upload/parse/documents CRUD, endpoint status GPU, proxy vLLM, dan tool "
"pelabelan manual."],
[("Pipeline OCR", B),
"PaddleOCR + klasifikasi vLLM, cache layout di DB, koreksi pergeseran kolom "
"tabel, normalisasi tanggal, plus harness regresi akurasi. Akurasi saat ini "
"sekitar 89,4% (target 95%)."],
[("Database", B),
"Postgres 15. Tabel inti: documents, ocr_items, accounts, store_master, "
"sku_master, vendors, customers. Migration jalan otomatis saat volume masih kosong."],
[("DevOps", B),
"Compose dev (hot-reload) dan compose demo (mode produksi). Script "
"start-dev-tunnel.ps1 sinkronisasi IP LAN + ngrok. Tunnel Cloudflare opsional."]])
# ------------------------------------------------------------- 7
d.h1("7. Yang BELUM ada")
d.p("Backlog di plans/next-enhancements.md mencatat 51 task berstatus TODO berbanding "
"5 yang DONE. Berikut yang paling perlu diketahui, dikelompokkan menurut dampaknya.")
d.h2("7a. Risiko keamanan & data")
d.table([1.05, 2.6], ["Belum ada", "Kenapa penting"],
[[("Ganti password yang bertahan", B),
"Semua akun lahir dengan password sama, yaitu kata \"password\". Lebih parah, "
"seeding di db/init.ts memakai ON CONFLICT DO UPDATE SET password dan jalan "
"tiap container start — jadi setiap restart backend, semua password kembali "
"ke \"password\". Belum ada layar ganti password sama sekali."],
[("JWT_SECRET wajib diisi", B),
"Kalau backend/.env tidak mengisinya, kode jatuh ke nilai cadangan yang "
"tertulis di source. Siapa pun yang tahu nilai itu bisa membuat token palsu "
"untuk toko mana pun."],
[("Enkripsi jalur LAN", B),
"Alamat LAN masih http:// polos — token bearer dan seluruh isi DO lewat WiFi "
"toko tanpa enkripsi. Jalur ngrok sudah HTTPS, jalur LAN belum."],
[("Pembatasan log sensitif", B),
"Dio mencatat seluruh isi request/response — termasuk token dan data pribadi "
"(nama penerima, koordinat GPS, isi DO) — dan ini tidak dimatikan di build "
"release, jadi bocor ke adb logcat di lapangan."],
[("Penandatanganan APK", B),
"APK release masih pakai kunci debug. Cukup untuk instalasi internal, belum "
"layak Play Store."],
[("Rencana backup", B),
"Volume paddleocr_pgdata dan folder backend/uploads/ belum punya prosedur "
"backup/restore terdokumentasi. Ini risiko terbesar di daftar ini."]])
d.h2("7b. Kebenaran data")
d.table([1.05, 2.6], ["Belum ada", "Kenapa penting"],
[[("Penegakan cek blur", B),
"Cek blur sudah ada tapi masih kosmetik — tombol \"Unggah Dokumen\" tidak "
"dikunci saat foto ditandai blur, jadi foto buram tetap bisa dikirim."],
[("SKU dari server", B),
"Editor DO memvalidasi SKU terhadap daftar bawaan yang di-compile ke dalam "
"aplikasi (lib/data/master_sku.dart), bukan endpoint /api/v1/master/skus yang "
"hidup. SKU baru tidak lolos validasi sampai APK dibangun dan disebar ulang."],
[("Penguncian simpan", B),
"Dokumen dengan SKU tak terdaftar, atau dengan nol item, masih bisa disimpan "
"dan dicetak."],
[("Pembersihan field dummy", B),
"Alur Product Scan masih mengirim nilai hardcoded sebagai data asli — "
"noSo '1002003004' dan platTruk 'B 1234 PFM' — dan itu tersimpan di server "
"seolah nyata. Ada pula fallback nama toko ke satu nama toko asli yang bisa "
"salah-atribusi data."],
[("Guard perubahan belum tersimpan", B),
"Tekan tombol back di editor langsung membuang koreksi manual tanpa "
"peringatan. Hapus dokumen pending juga tanpa dialog konfirmasi."]])
d.h2("7c. Pengalaman pemakaian di lapangan")
d.table([1.05, 2.6], ["Belum ada", "Kenapa penting"],
[[("Failover di tengah sesi", B),
"Alamat server hanya di-resolve sekali saat aplikasi dibuka. Kalau petugas "
"berpindah dari WiFi ke seluler, aplikasi tidak pindah endpoint sendiri — "
"harus tutup-buka aplikasi."],
[("Alamat bisa diubah tanpa build ulang", B),
"Kedua URL masih konstanta di kode. Tiap IP server berubah berarti build dan "
"sebar APK ulang ke semua toko."],
[("Umpan balik GPS gagal", B),
"Semua jalur kegagalan lokasi hanya mencatat log lalu diam — petugas tidak "
"diberi tahu, dokumen tetap terkirim tanpa geotag."],
[("Penanda data basi", B),
"Kalau ambil data gagal, daftar dokumen tetap tampil seperti biasa tanpa "
"tanda bahwa yang ditampilkan data cache lama."],
[("Pesan error yang jujur", B),
"Kegagalan jaringan dilaporkan sebagai \"Gagal mengekstrak data (Timeout)\" — "
"masalah WiFi tersamar jadi masalah OCR, menyulitkan diagnosis."],
[("Pembersihan file foto", B),
"Foto yang sudah tersinkron tidak pernah dihapus dari penyimpanan HP — hanya "
"catatan Hive-nya yang hilang. Penyimpanan tumbuh tanpa batas."],
[("Badge antrean di layar kamera", B),
"Petugas yang meninggalkan layar dokumen tidak bisa melihat masih ada upload "
"berjalan atau macet."]])
d.h2("7d. Fitur yang direncanakan tapi belum dibangun sama sekali")
d.table([1.05, 2.6], ["Belum ada", "Keterangan"],
[[("Menu Stok", B),
"Seluruh alur DO ke stok (§9 backlog): model data stok, layar input stok, "
"menu daftar/detail stok, dan penulisan ulang Product Scan agar memakai batch "
"stok sungguhan. Belum ada satu pun."],
[("Jalur cepat kasir", B),
"Konfirmasi kedaluwarsa satu-ketuk di kasir, badge inferred, dan mode burst "
"capture untuk kamera terpasang (§10 backlog). Masih desain."],
[("Switch Demo/Live", B),
"Mode data palsu untuk demo belum ada. Catatan: docker-compose.demo.yml "
"memakai kata \"demo\" untuk hal berbeda — itu mode produksi, bukan mode data "
"palsu. Jangan tertukar."],
[("Switch Cloud/Local", B),
"Pilihan deployment cloud vs on-premise belum ada."]])
d.h2("7e. Belum ada di luar kode")
d.bullet([("Runbook “kalau rusak, lakukan ini”", B),
(" — gejala, penyebab tersering, perintah pengecekan. Misalnya HP tidak bisa "
"login, OCR menggantung, GPU kehabisan memori, container tidak mau start.", {})])
d.bullet([("Spesifikasi & akses server", B),
(" — GPU apa, RAM berapa, OS-nya, siapa pegang akses SSH, dan apa yang "
"terjadi kalau server mati lampu. Container sudah restart: unless-stopped, "
"tapi Docker daemon-nya sendiri belum dipastikan.", {})])
d.bullet([("Prosedur distribusi APK", B),
(" — siapa yang build, dibagikan lewat apa, dan bagaimana memberi tahu "
"petugas saat alamat server berubah.", {})])
d.bullet([("Screenshot tiap layar", B),
(" — folder screenshots/ sudah berisi tangkapan layar nyata dan dipakai oleh "
"dokumen handover besar; ringkasan ini sengaja tidak mengulanginya.", {})])
# ------------------------------------------------------------- 8
d.h1("8. Aturan kerja yang wajib diikuti penerus")
d.table([1.0, 2.7], ["Aturan", "Isinya"],
[[("Data rahasia", B),
"backend/sources/ dan backend/uploads/ berisi data klien asli — master SKU, "
"vendor, customer, foto DO nyata, label ground truth. Jangan di-export, "
"di-log, atau dikirim ke layanan luar."],
[("Dua set aturan terpisah", B),
"Aturan di akar repo (CLAUDE.md + AGENTS.md) hanya mengatur aplikasi Flutter. "
"Untuk apa pun di dalam backend/, ikuti backend/CLAUDE.md dan "
"backend/AGENTS.md. Jangan dicampur."],
[("Jangan ubah parser diam-diam", B),
"Pipeline OCR punya benchmark akurasi beku. Setiap perubahan parser harus "
"dijalankan lewat harness regresi akurasi (perintahnya di backend/CLAUDE.md)."],
[("Manfaatkan knowledge graph", B),
"Ada graph struktur kode di graphify-out/ yang dibangun otomatis lewat git "
"hook. Untuk pertanyaan “apa yang memanggil X” atau “apa yang rusak kalau Y "
"diganti nama”, tanya ke situ dulu."],
[("Batas 256 baris", B),
"File kode yang melewati 256 baris harus dipecah saat disentuh. Berlaku untuk "
"aplikasi Flutter; backend/ punya aturannya sendiri."]])
d.spacer(0.5)
d.rule(BORDER, 0.02)
d.p("Sumber: docker-compose.yml · docker-compose.demo.yml · backend/nginx.conf · "
"backend/.env.example · lib/config/app_config.dart · backend/db/init.ts · "
"backend/db/migrations/ · src/app/api/v1/auth/login/route.ts · "
"src/app/api/v1/documents/upload/route.ts · plans/next-enhancements.md · "
"docs/feature-list.md",
size=8.4, color=FAINT, align="start", line=134)
return d
def convert(src_path, fmt, tries=6, wait=20):
"""LibreOffice allows only one instance at a time: a conversion started while
another soffice is running fails silently (no output file, exit code 0). Retry
rather than assume it worked."""
import time
out = os.path.join(WORK, os.path.splitext(os.path.basename(src_path))[0] + "." + fmt)
for n in range(tries):
if os.path.exists(out):
os.remove(out)
subprocess.run([SOFFICE, "--headless", "--convert-to", fmt, "--outdir", WORK,
src_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if os.path.exists(out):
return out
if n < tries - 1:
print(" LibreOffice sedang dipakai proses lain, menunggu %ds…" % wait)
time.sleep(wait)
raise RuntimeError("konversi ke %s gagal setelah %d percobaan — pastikan tidak ada "
"soffice lain yang berjalan (pgrep -af soffice)" % (fmt, tries))
def main():
print("1/3 menggambar diagram…")
build_diagrams()
print("2/3 menyusun dokumen…")
d = build_doc()
os.makedirs(WORK, exist_ok=True)
fodt = os.path.join(WORK, "ringkasan.fodt")
with open(fodt, "w") as f:
f.write(d.render())
print("3/3 konversi ke PDF…")
out = os.path.join(os.path.dirname(HERE), "PFM-Scanner-Ringkasan-Scope.pdf")
os.replace(convert(fodt, "pdf"), out)
os.replace(convert(fodt, "odt"),
os.path.join(os.path.dirname(HERE), "PFM-Scanner-Ringkasan-Scope.odt"))
import shutil
shutil.rmtree(WORK, ignore_errors=True) # staging dir lives in $HOME; don't leave it
print("selesai ->", out)
if __name__ == "__main__":
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+283
View File
@@ -0,0 +1,283 @@
"""Minimal flat-ODF (.fodp) presentation writer.
Diagrams are native draw:frame / draw:rect / draw:line shapes so they stay fully
editable in LibreOffice Impress and Draw. Screenshots are embedded PNGs.
"""
import base64
from xml.sax.saxutils import escape
W, H = 25.4, 14.288 # 16:9 slide, cm
OVERFLOW = []
# palette
INK = "#16213A"
MUTED = "#5A6478"
FAINT = "#8B93A5"
BLUE = "#2563EB"
TEAL = "#0D9488"
AMBER = "#B45309"
RED = "#C2410C"
GREEN = "#15803D"
PURPLE = "#6D28D9"
SURFACE = "#F1F5F9"
SURFACE2 = "#E2E8F0"
BORDER = "#CBD5E1"
WHITE = "#FFFFFF"
SANS = "'Liberation Sans','DejaVu Sans',Arial,sans-serif"
MONO = "'Liberation Mono','DejaVu Sans Mono',monospace"
class Deck:
def __init__(self):
self.pages = []
self.styles = {} # key -> (name, xml)
self._n = 0
# ---------- style helpers ----------
def _style(self, key, xml_fn):
if key not in self.styles:
self._n += 1
name = "as%d" % self._n
self.styles[key] = (name, xml_fn(name))
return self.styles[key][0]
def gstyle(self, fill=None, stroke=None, sw=0.03, valign="top", pad=0.0,
radius=None, dash=False, marker=None, opacity=None):
key = ("g", fill, stroke, sw, valign, pad, dash, marker, opacity)
def build(name):
p = []
if fill:
p.append('draw:fill="solid" draw:fill-color="%s"' % fill)
if opacity is not None:
p.append('draw:opacity="%d%%"' % opacity)
else:
p.append('draw:fill="none"')
if stroke:
p.append('draw:stroke="%s" svg:stroke-width="%.3fcm" svg:stroke-color="%s"'
% ("dash" if dash else "solid", sw, stroke))
if dash:
p.append('draw:stroke-dash="Dash_20_2"')
else:
p.append('draw:stroke="none"')
if marker:
p.append('draw:marker-end="Arrow" draw:marker-end-width="0.28cm" '
'draw:marker-end-center="false"')
if marker == "both":
p.append('draw:marker-start="Arrow" draw:marker-start-width="0.28cm" '
'draw:marker-start-center="false"')
p.append('draw:textarea-vertical-align="%s"' % valign)
p.append('draw:auto-grow-width="false" draw:auto-grow-height="false"')
p.append('fo:padding-top="%.3fcm" fo:padding-bottom="%.3fcm" '
'fo:padding-left="%.3fcm" fo:padding-right="%.3fcm"' % (pad, pad, pad, pad))
p.append('draw:shadow="none"')
return ('<style:style style:name="%s" style:family="graphic">'
'<style:graphic-properties %s/></style:style>' % (name, " ".join(p)))
return self._style(key, build)
def pstyle(self, align="start", space_before=0.0, space_after=0.0, line=None, indent=0.0):
key = ("p", align, space_before, space_after, line, indent)
def build(name):
p = ['fo:text-align="%s"' % align,
'fo:margin-top="%.3fcm"' % space_before,
'fo:margin-bottom="%.3fcm"' % space_after,
'fo:margin-left="%.3fcm"' % indent,
'fo:text-indent="0cm"']
if line:
p.append('fo:line-height="%d%%"' % line)
return ('<style:style style:name="%s" style:family="paragraph">'
'<style:paragraph-properties %s/></style:style>' % (name, " ".join(p)))
return self._style(key, build)
def tstyle(self, size=12, color=INK, bold=False, italic=False, mono=False,
spacing=None, caps=False):
key = ("t", size, color, bold, italic, mono, spacing, caps)
def build(name):
fam = MONO if mono else SANS
p = ['fo:font-size="%gpt"' % size,
'fo:color="%s"' % color,
'fo:font-family="%s"' % fam,
'style:font-family-complex="%s"' % fam,
'fo:font-weight="%s"' % ("bold" if bold else "normal"),
'style:font-weight-complex="%s"' % ("bold" if bold else "normal"),
'fo:font-style="%s"' % ("italic" if italic else "normal")]
if spacing:
p.append('fo:letter-spacing="%.3fcm"' % spacing)
if caps:
p.append('fo:text-transform="uppercase"')
return ('<style:style style:name="%s" style:family="text">'
'<style:text-properties %s/></style:style>' % (name, " ".join(p)))
return self._style(key, build)
# ---------- element builders ----------
def page(self, name):
pg = Page(self, name)
self.pages.append(pg)
return pg
def render(self):
auto = "".join(x[1] for x in self.styles.values())
auto += ('<style:page-layout style:name="PM1">'
'<style:page-layout-properties fo:page-width="%.3fcm" fo:page-height="%.3fcm" '
'style:print-orientation="landscape" fo:margin-top="0cm" fo:margin-bottom="0cm" '
'fo:margin-left="0cm" fo:margin-right="0cm"/></style:page-layout>' % (W, H))
auto += ('<style:style style:name="dp1" style:family="drawing-page">'
'<style:drawing-page-properties draw:fill="solid" draw:fill-color="#FFFFFF" '
'presentation:background-visible="true" presentation:background-objects-visible="true" '
'presentation:display-footer="false" presentation:display-page-number="false" '
'presentation:display-date-time="false"/></style:style>')
body = "".join(p.render() for p in self.pages)
return TEMPLATE % (auto, body)
class Page:
def __init__(self, deck, name):
self.d = deck
self.name = name
self.items = []
# --- primitives ---
def rect(self, x, y, w, h, fill=None, stroke=None, sw=0.03, radius=None, dash=False, z=None):
st = self.d.gstyle(fill=fill, stroke=stroke, sw=sw, dash=dash)
r = ' draw:corner-radius="%.3fcm"' % radius if radius else ""
self.items.append('<draw:rect draw:style-name="%s" svg:x="%.3fcm" svg:y="%.3fcm" '
'svg:width="%.3fcm" svg:height="%.3fcm"%s/>' % (st, x, y, w, h, r))
return self
def ellipse(self, x, y, w, h, fill=None, stroke=None, sw=0.03):
st = self.d.gstyle(fill=fill, stroke=stroke, sw=sw)
self.items.append('<draw:ellipse draw:style-name="%s" svg:x="%.3fcm" svg:y="%.3fcm" '
'svg:width="%.3fcm" svg:height="%.3fcm"/>' % (st, x, y, w, h))
return self
def line(self, x1, y1, x2, y2, color=BORDER, sw=0.03, arrow=False, dash=False):
st = self.d.gstyle(stroke=color, sw=sw, dash=dash,
marker=("end" if arrow is True else arrow) if arrow else None)
self.items.append('<draw:line draw:style-name="%s" svg:x1="%.3fcm" svg:y1="%.3fcm" '
'svg:x2="%.3fcm" svg:y2="%.3fcm"/>' % (st, x1, y1, x2, y2))
return self
def text(self, x, y, w, h, runs, align="start", valign="top", size=12, color=INK,
bold=False, italic=False, mono=False, line=None, fill=None, stroke=None,
pad=0.0, radius=None, space_after=0.0, caps=False, spacing=None):
"""runs: str, or list of (text, kwargs) or list of paragraphs."""
gs = self.d.gstyle(fill=fill, stroke=stroke, valign=valign, pad=pad)
paras = runs if isinstance(runs, list) else [runs]
out = []
for p in paras:
if isinstance(p, tuple):
txt, over = p
else:
txt, over = p, {}
o = dict(size=size, color=color, bold=bold, italic=italic, mono=mono,
caps=caps, spacing=spacing)
al = over.pop("align", align)
sa = over.pop("space_after", space_after)
sb = over.pop("space_before", 0.0)
ind = over.pop("indent", 0.0)
ln = over.pop("line", line)
o.update(over)
ps = self.d.pstyle(align=al, space_after=sa, space_before=sb, line=ln, indent=ind)
if isinstance(txt, list):
# mixed-format paragraph: list of (string, style-overrides)
spans = []
for part in txt:
if (isinstance(part, (tuple, list)) and len(part) == 2
and isinstance(part[1], dict)):
s, po = part
else:
s, po = part, {}
po2 = dict(o)
po2.update(po)
spans.append('<text:span text:style-name="%s">%s</text:span>'
% (self.d.tstyle(**po2), escape(s)))
out.append('<text:p text:style-name="%s">%s</text:p>' % (ps, "".join(spans)))
elif txt == "":
out.append('<text:p text:style-name="%s"/>' % ps)
else:
out.append('<text:p text:style-name="%s"><text:span text:style-name="%s">%s'
'</text:span></text:p>' % (ps, self.d.tstyle(**o), escape(txt)))
# --- overflow estimate (layout QA only) ---
est = 0.0
for p_ in paras:
txt_, over_ = (p_ if isinstance(p_, tuple) else (p_, {}))
sz = over_.get("size", size)
ln = over_.get("line", line) or 100
if isinstance(txt_, list):
flat = "".join((q[0] if isinstance(q, (tuple, list)) else q) for q in txt_)
else:
flat = txt_
cpl = max(1.0, (w - 2 * pad - 0.1) / (0.0175 * sz))
nlines = max(1, int(len(flat) / cpl) + (1 if len(flat) % cpl else 0))
est += nlines * sz * 0.03528 * (ln / 100.0) + over_.get("space_after", space_after)
if est > h + 0.06:
OVERFLOW.append((self.name, round(est - h, 2), round(h, 2),
(flat[:52] if paras else "")))
r = ' draw:corner-radius="%.3fcm"' % radius if radius else ""
self.items.append('<draw:frame draw:style-name="%s" svg:x="%.3fcm" svg:y="%.3fcm" '
'svg:width="%.3fcm" svg:height="%.3fcm"%s>'
'<draw:text-box>%s</draw:text-box></draw:frame>'
% (gs, x, y, w, h, r, "".join(out)))
return self
def image(self, path, x, y, w=None, h=None, frame=BORDER, sw=0.025, radius=0.10):
"""Embed a PNG. Give either w or h; the other follows the native aspect."""
with open(path, "rb") as f:
raw = f.read()
px = int.from_bytes(raw[16:20], "big")
py = int.from_bytes(raw[20:24], "big")
if w is None and h is None:
raise ValueError("give w or h")
if w is None:
w = h * px / py
if h is None:
h = w * py / px
if frame:
self.rect(x - 0.045, y - 0.045, w + 0.09, h + 0.09,
fill=None, stroke=frame, sw=sw, radius=radius)
data = base64.b64encode(raw).decode("ascii")
st = self.d.gstyle()
self.items.append(
'<draw:frame draw:style-name="%s" svg:x="%.3fcm" svg:y="%.3fcm" '
'svg:width="%.3fcm" svg:height="%.3fcm" draw:z-index="1">'
'<draw:image><office:binary-data>%s</office:binary-data></draw:image>'
'</draw:frame>' % (st, x, y, w, h, data))
return w, h
def render(self):
return ('<draw:page draw:name="%s" draw:style-name="dp1" '
'draw:master-page-name="Default">%s</draw:page>'
% (escape(self.name), "".join(self.items)))
TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:presentation="urn:oasis:names:tc:opendocument:xmlns:presentation:1.0"
office:version="1.3"
office:mimetype="application/vnd.oasis.opendocument.presentation">
<office:styles>
<draw:marker draw:name="Arrow" svg:viewBox="0 0 20 30" svg:d="M10 0 L0 30 L20 30 Z"/>
<draw:stroke-dash draw:name="Dash_20_2" draw:style="rect" draw:dots1="1"
draw:dots1-length="0.12cm" draw:distance="0.12cm"/>
</office:styles>
<office:automatic-styles>%s</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Default" style:page-layout-name="PM1" draw:style-name="dp1"/>
</office:master-styles>
<office:body><office:presentation>%s</office:presentation></office:body>
</office:document>
"""
+440
View File
@@ -0,0 +1,440 @@
"""Minimal flat-ODF (.fodt) Writer document builder.
Produces a single XML file LibreOffice converts to .odt / .pdf. Supports
headings, prose with inline runs, bullets, code blocks, callouts, tables and
embedded PNG images.
"""
import base64
import os
from xml.sax.saxutils import escape
INK = "#16213A"
MUTED = "#4A5568"
FAINT = "#8B93A5"
BLUE = "#2563EB"
TEAL = "#0D9488"
AMBER = "#B45309"
RED = "#C2410C"
GREEN = "#15803D"
PURPLE = "#6D28D9"
SURFACE = "#F1F5F9"
SURFACE2 = "#E2E8F0"
BORDER = "#CBD5E1"
WHITE = "#FFFFFF"
SANS = "Liberation Sans"
MONO = "Liberation Mono"
PW, PH = 21.0, 29.7 # A4 portrait
ML, MR, MT, MB = 2.1, 2.1, 2.0, 1.9
CW = PW - ML - MR # usable content width, cm
class Doc:
def __init__(self, footer_text=""):
self.body = []
self.styles = {}
self._n = 0
self.footer_text = footer_text
# ---------------- style plumbing ----------------
def _st(self, key, fn):
if key not in self.styles:
self._n += 1
name = "s%d" % self._n
self.styles[key] = (name, fn(name))
return self.styles[key][0]
def pstyle(self, size=10.5, color=INK, bold=False, italic=False, mono=False,
align="justify", before=0.0, after=0.30, line=132, left=0.0,
indent=0.0, bg=None, border=None, pad=0.0, brk=False, keep=False,
caps=False, spacing=None, border_left=None, right=0.0):
key = ("p", size, color, bold, italic, mono, align, before, after, line, left,
indent, bg, border, pad, brk, keep, caps, spacing, border_left, right)
def fn(name):
pp = ['fo:text-align="%s"' % align,
'fo:margin-top="%.3fcm"' % before,
'fo:margin-bottom="%.3fcm"' % after,
'fo:margin-left="%.3fcm"' % left,
'fo:margin-right="%.3fcm"' % right,
'fo:text-indent="%.3fcm"' % indent,
'fo:line-height="%d%%"' % line]
if bg:
pp.append('fo:background-color="%s"' % bg)
if border:
pp.append('fo:border="0.02cm solid %s"' % border)
if border_left:
pp.append('fo:border-left="0.09cm solid %s"' % border_left)
pp.append('fo:border-top="none" fo:border-bottom="none" fo:border-right="none"')
if pad:
pp.append('fo:padding="%.3fcm"' % pad)
if brk:
pp.append('fo:break-before="page"')
if keep:
pp.append('fo:keep-with-next="always"')
tp = self._textprops(size, color, bold, italic, mono, caps, spacing)
return ('<style:style style:name="%s" style:family="paragraph" '
'style:parent-style-name="Standard">'
'<style:paragraph-properties %s/><style:text-properties %s/>'
'</style:style>' % (name, " ".join(pp), tp))
return self._st(key, fn)
@staticmethod
def _textprops(size, color, bold, italic, mono, caps=False, spacing=None):
fam = MONO if mono else SANS
p = ['fo:font-size="%gpt"' % size,
'style:font-size-complex="%gpt"' % size,
'fo:color="%s"' % color,
'style:font-name="%s"' % fam,
'fo:font-weight="%s"' % ("bold" if bold else "normal"),
'style:font-weight-complex="%s"' % ("bold" if bold else "normal"),
'fo:font-style="%s"' % ("italic" if italic else "normal")]
if caps:
p.append('fo:text-transform="uppercase"')
if spacing:
p.append('fo:letter-spacing="%.3fcm"' % spacing)
return " ".join(p)
def tstyle(self, size=10.5, color=INK, bold=False, italic=False, mono=False,
caps=False, spacing=None):
key = ("t", size, color, bold, italic, mono, caps, spacing)
def fn(name):
return ('<style:style style:name="%s" style:family="text">'
'<style:text-properties %s/></style:style>'
% (name, self._textprops(size, color, bold, italic, mono, caps, spacing)))
return self._st(key, fn)
# ---------------- content ----------------
def _runs(self, content, base):
"""content: str or list of (str, override-dict)."""
if isinstance(content, str):
return escape(content)
out = []
for part in content:
if isinstance(part, (tuple, list)) and len(part) == 2 and isinstance(part[1], dict):
s, over = part
else:
s, over = part, {}
o = dict(base)
o.update(over)
out.append('<text:span text:style-name="%s">%s</text:span>'
% (self.tstyle(**o), escape(s)))
return "".join(out)
def p(self, content, **kw):
base = dict(size=kw.get("size", 10.5), color=kw.get("color", INK),
bold=kw.get("bold", False), italic=kw.get("italic", False),
mono=kw.get("mono", False))
ps = self.pstyle(**kw)
self.body.append('<text:p text:style-name="%s">%s</text:p>'
% (ps, self._runs(content, base)))
return self
def title(self, txt, sub=None):
self.p(txt, size=27, bold=True, color=INK, align="start", after=0.25, line=112)
if sub:
self.p(sub, size=13, color=BLUE, align="start", after=0.9, bold=True)
def h1(self, txt, brk=True):
self.p(txt, size=17, bold=True, color=INK, align="start",
before=0.0, after=0.45, brk=brk, keep=True, line=118)
self.rule(BLUE, 0.05)
def h2(self, txt):
self.p(txt, size=12.5, bold=True, color=INK, align="start",
before=0.62, after=0.24, keep=True)
def h3(self, txt, color=BLUE):
self.p(txt, size=10.8, bold=True, color=color, align="start",
before=0.44, after=0.16, keep=True)
def rule(self, color=BORDER, w=0.02):
key = ("rule", color, w)
def fn(name):
return ('<style:style style:name="%s" style:family="paragraph" '
'style:parent-style-name="Standard">'
'<style:paragraph-properties fo:margin-top="0cm" '
'fo:margin-bottom="0.30cm" fo:border-bottom="%.3fcm solid %s" '
'fo:border-top="none" fo:border-left="none" fo:border-right="none" '
'fo:padding="0cm" text:number-lines="false"/>'
'<style:text-properties fo:font-size="1pt"/></style:style>'
% (name, w, color))
self.body.append('<text:p text:style-name="%s"/>' % self._st(key, fn))
return self
def bullet(self, content, dot="•", color=BLUE, size=10.5, after=0.16, left=0.55,
**kw):
base = dict(size=size, color=kw.pop("color_text", MUTED), bold=False,
italic=False, mono=False)
ps = self.pstyle(size=size, color=base["color"], align="justify",
after=after, left=left, indent=-0.55, line=130, **kw)
run = ('<text:span text:style-name="%s">%s\t</text:span>'
% (self.tstyle(size=size, color=color, bold=True), dot))
self.body.append('<text:p text:style-name="%s">%s%s</text:p>'
% (ps, run, self._runs(content, base)))
return self
def numbered(self, i, content, color=BLUE, size=10.5):
return self.bullet(content, dot="%d." % i, color=color, size=size)
def code(self, lines, size=9, color=INK):
n = len(lines)
for i, ln in enumerate(lines):
key_bg = SURFACE
ps = self.pstyle(size=size, color=color, mono=True, align="start",
before=(0.28 if i == 0 else 0.0),
after=(0.34 if i == n - 1 else 0.0),
line=124, bg=key_bg, left=0.0, pad=0.0)
self.body.append('<text:p text:style-name="%s">%s</text:p>'
% (ps, escape(ln) if ln else ""))
return self
def note(self, content, accent=AMBER, bg="#FEF6E7", label=None):
base = dict(size=9.8, color="#5B3A05", bold=False, italic=False, mono=False)
ps = self.pstyle(size=9.8, color="#5B3A05", align="justify", before=0.34,
after=0.40, line=132, bg=bg, border_left=accent, pad=0.26,
left=0.0)
pre = ""
if label:
pre = ('<text:span text:style-name="%s">%s </text:span>'
% (self.tstyle(size=9.8, color=accent, bold=True, caps=True,
spacing=0.02), escape(label)))
self.body.append('<text:p text:style-name="%s">%s%s</text:p>'
% (ps, pre, self._runs(content, base)))
return self
def table(self, widths, header, rows, size=9.3, header_bg=SURFACE2,
zebra="#FAFBFD", align=None, before=0.34, after=0.40):
self._n += 1
tname = "T%d" % self._n
total = sum(widths)
scale = CW / total
widths = [w * scale for w in widths]
tstyle = self._st(("tbl", round(CW, 2)), lambda n: (
'<style:style style:name="%s" style:family="table">'
'<style:table-properties style:width="%.3fcm" table:align="left" '
'fo:margin-top="%.2fcm" fo:margin-bottom="%.2fcm"/></style:style>'
% (n, CW, before, after)))
cols = []
for w in widths:
cs = self._st(("col", round(w, 3)), lambda n, w=w: (
'<style:style style:name="%s" style:family="table-column">'
'<style:table-column-properties style:column-width="%.3fcm"/>'
'</style:style>' % (n, w)))
cols.append('<table:table-column table:style-name="%s"/>' % cs)
def cellstyle(bg, top):
return self._st(("cell", bg, top), lambda n: (
'<style:style style:name="%s" style:family="table-cell">'
'<style:table-cell-properties fo:background-color="%s" '
'fo:padding-top="0.12cm" fo:padding-bottom="0.12cm" '
'fo:padding-left="0.18cm" fo:padding-right="0.18cm" '
'fo:border-top="%s" fo:border-bottom="0.01cm solid %s" '
'fo:border-left="none" fo:border-right="none"/></style:style>'
% (n, bg if bg else "transparent",
("0.03cm solid " + INK) if top else "none", BORDER)))
out = ['<table:table table:name="%s" table:style-name="%s">' % (tname, tstyle)]
out += cols
if header:
out.append('<table:table-header-rows><table:table-row>')
for i, htxt in enumerate(header):
al = (align[i] if align else "start")
ps = self.pstyle(size=size - 0.5, color=MUTED, bold=True, align=al,
after=0.0, line=120, caps=True, spacing=0.02)
out.append('<table:table-cell table:style-name="%s" office:value-type="string">'
'<text:p text:style-name="%s">%s</text:p></table:table-cell>'
% (cellstyle(header_bg, False), ps, escape(htxt)))
out.append('</table:table-row></table:table-header-rows>')
for r, row in enumerate(rows):
bg = zebra if (zebra and r % 2 == 0) else None
out.append('<table:table-row>')
for i, cell in enumerate(row):
al = (align[i] if align else "start")
over = {}
if isinstance(cell, tuple) and len(cell) == 2 and isinstance(cell[1], dict):
cell, over = cell
o = dict(size=size, color=MUTED, bold=False, italic=False, mono=False)
o.update(over)
ps = self.pstyle(align=al, after=0.0, line=126, **o)
out.append('<table:table-cell table:style-name="%s" office:value-type="string">'
'<text:p text:style-name="%s">%s</text:p></table:table-cell>'
% (cellstyle(bg, False), ps, escape(cell)))
out.append('</table:table-row>')
out.append('</table:table>')
self.body.append("".join(out))
return self
def image(self, path, width=None, caption=None):
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode("ascii")
# native pixel size from the PNG IHDR
with open(path, "rb") as f:
head = f.read(33)
px = int.from_bytes(head[16:20], "big")
py = int.from_bytes(head[20:24], "big")
w = width or CW
h = w * py / px
fr = self._st(("fr",), lambda n: (
'<style:style style:name="%s" style:family="graphic">'
'<style:graphic-properties style:vertical-pos="top" '
'style:vertical-rel="baseline" style:horizontal-pos="center" '
'style:horizontal-rel="paragraph" fo:padding="0cm" '
'style:wrap="none" draw:stroke="none" draw:fill="none"/></style:style>' % n))
ps = self.pstyle(align="center", before=0.30, after=0.14)
self.body.append(
'<text:p text:style-name="%s">'
'<draw:frame draw:style-name="%s" text:anchor-type="as-char" '
'svg:width="%.3fcm" svg:height="%.3fcm" draw:z-index="0">'
'<draw:image><office:binary-data>%s</office:binary-data></draw:image>'
'</draw:frame></text:p>' % (ps, fr, w, h, data))
if caption:
self.p(caption, size=8.6, color=FAINT, italic=True, align="center",
after=0.45, line=126)
return self
def _image_xml(self, path, w):
with open(path, "rb") as f:
raw = f.read()
px = int.from_bytes(raw[16:20], "big")
py = int.from_bytes(raw[20:24], "big")
h = w * py / px
fr = self._st(("fr",), lambda n: (
'<style:style style:name="%s" style:family="graphic">'
'<style:graphic-properties style:vertical-pos="top" '
'style:vertical-rel="baseline" style:horizontal-pos="center" '
'style:horizontal-rel="paragraph" fo:padding="0cm" '
'style:wrap="none" draw:stroke="none" draw:fill="none"/></style:style>' % n))
data = base64.b64encode(raw).decode("ascii")
return ('<draw:frame draw:style-name="%s" text:anchor-type="as-char" '
'svg:width="%.3fcm" svg:height="%.3fcm" draw:z-index="0">'
'<draw:image><office:binary-data>%s</office:binary-data></draw:image>'
'</draw:frame>' % (fr, w, h, data)), h
def shots(self, items, cols=4, start=1, gutter=0.34, before=0.30, after=0.45):
"""Grid of screenshots with numbered captions. items: (path, caption)."""
self._n += 1
tname = "G%d" % self._n
colw = CW / cols
imgw = colw - gutter
tstyle = self._st(("gtbl", round(CW, 2), before, after), lambda n: (
'<style:style style:name="%s" style:family="table">'
'<style:table-properties style:width="%.3fcm" table:align="left" '
'fo:margin-top="%.2fcm" fo:margin-bottom="%.2fcm"/></style:style>'
% (n, CW, before, after)))
colstyle = self._st(("gcol", round(colw, 3)), lambda n: (
'<style:style style:name="%s" style:family="table-column">'
'<style:table-column-properties style:column-width="%.3fcm"/>'
'</style:style>' % (n, colw)))
cellstyle = self._st(("gcell",), lambda n: (
'<style:style style:name="%s" style:family="table-cell">'
'<style:table-cell-properties fo:background-color="transparent" '
'fo:padding="0.10cm" fo:border="none"/></style:style>' % n))
rowstyle = self._st(("growk",), lambda n: (
'<style:style style:name="%s" style:family="table-row">'
'<style:table-row-properties fo:keep-together="always"/>'
'</style:style>' % n))
imgp = self.pstyle(align="center", before=0.0, after=0.10)
capp = self.pstyle(size=8.2, color=MUTED, align="start", after=0.0, line=124)
numt = self.tstyle(size=8.2, color=BLUE, bold=True)
capt = self.tstyle(size=8.2, color=MUTED)
out = ['<table:table table:name="%s" table:style-name="%s">' % (tname, tstyle)]
out += ['<table:table-column table:style-name="%s"/>' % colstyle] * cols
for r in range(0, len(items), cols):
row = items[r:r + cols]
out.append('<table:table-row table:style-name="%s">' % rowstyle)
for i, (path, cap) in enumerate(row):
img, _ = self._image_xml(path, imgw)
out.append('<table:table-cell table:style-name="%s" '
'office:value-type="string">' % cellstyle)
out.append('<text:p text:style-name="%s">%s</text:p>' % (imgp, img))
out.append('<text:p text:style-name="%s">'
'<text:span text:style-name="%s">%d </text:span>'
'<text:span text:style-name="%s">%s</text:span></text:p>'
% (capp, numt, start + r + i, capt, escape(cap)))
out.append('</table:table-cell>')
for _ in range(cols - len(row)):
out.append('<table:table-cell table:style-name="%s"/>' % cellstyle)
out.append('</table:table-row>')
out.append('</table:table>')
self.body.append("".join(out))
return self
def spacer(self, h=0.4):
self.body.append('<text:p text:style-name="%s"/>'
% self.pstyle(size=h * 20, after=0.0))
return self
# ---------------- render ----------------
def render(self):
auto = "".join(v[1] for v in self.styles.values())
auto += ('<style:page-layout style:name="pm1">'
'<style:page-layout-properties fo:page-width="%.2fcm" '
'fo:page-height="%.2fcm" style:print-orientation="portrait" '
'fo:margin-top="%.2fcm" fo:margin-bottom="%.2fcm" '
'fo:margin-left="%.2fcm" fo:margin-right="%.2fcm" '
'style:writing-mode="lr-tb">'
'<style:footer-style>'
'<style:header-footer-properties fo:min-height="0.7cm" '
'fo:margin-top="0.5cm"/></style:footer-style>'
'</style:page-layout-properties></style:page-layout>'
% (PW, PH, MT, MB, ML, MR))
return TEMPLATE % (auto, escape(self.footer_text or ""),
"".join(self.body))
TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
<office:document
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0"
xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"
xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"
xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0"
xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0"
xmlns:xlink="http://www.w3.org/1999/xlink"
office:version="1.3"
office:mimetype="application/vnd.oasis.opendocument.text">
<office:font-face-decls>
<style:font-face style:name="Liberation Sans" svg:font-family="'Liberation Sans'"
style:font-family-generic="swiss" style:font-pitch="variable"/>
<style:font-face style:name="Liberation Mono" svg:font-family="'Liberation Mono'"
style:font-family-generic="modern" style:font-pitch="fixed"/>
</office:font-face-decls>
<office:styles>
<style:default-style style:family="paragraph">
<style:paragraph-properties fo:hyphenate="false" style:writing-mode="lr-tb"/>
<style:text-properties style:font-name="Liberation Sans" fo:font-size="10.5pt"
fo:color="#16213A" fo:language="en" fo:country="US"/>
</style:default-style>
<style:style style:name="Standard" style:family="paragraph"/>
<style:style style:name="Foot" style:family="paragraph">
<style:paragraph-properties fo:text-align="start" fo:margin-top="0cm"
fo:border-top="0.01cm solid #CBD5E1" fo:padding-top="0.18cm"/>
<style:text-properties fo:font-size="8pt" fo:color="#8B93A5"
style:font-name="Liberation Sans"/>
</style:style>
</office:styles>
<office:automatic-styles>%s</office:automatic-styles>
<office:master-styles>
<style:master-page style:name="Standard" style:page-layout-name="pm1">
<style:footer>
<text:p text:style-name="Foot">%s<text:tab/><text:tab/>Page
<text:page-number text:select-page="current">1</text:page-number></text:p>
</style:footer>
</style:master-page>
</office:master-styles>
<office:body><office:text>%s</office:text></office:body>
</office:document>
"""
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Generates shots_small/ — display-resolution copies of the repo screenshots.
The originals in screenshots/ are 1080x2400 phone captures (and 1920x991 web
captures), far larger than the few centimetres they occupy in the deck and the
document. Embedding them at full size makes a 10MB+ .odp for no visible gain, so
both generators read from shots_small/ instead. Run this once before build_deck.py
or build_doc.py; re-run it if the screenshots change.
Requires Pillow. Nothing else in this directory does.
"""
import os
import glob
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.abspath(os.path.join(HERE, "..", "..", ".."))
SRC_DIRS = [os.path.join(REPO, "screenshots", "v2"), # phone captures
os.path.join(REPO, "screenshots")] # admin / web captures
OUT = os.path.join(HERE, "shots_small")
PHONE_WIDTH = 420 # portrait captures
WEB_WIDTH = 900 # landscape captures
def main():
os.makedirs(OUT, exist_ok=True)
n = bytes_in = bytes_out = 0
for d in SRC_DIRS:
for path in sorted(glob.glob(os.path.join(d, "*.png"))):
im = Image.open(path).convert("RGB")
w, h = im.size
target = PHONE_WIDTH if h > w else WEB_WIDTH
if w > target:
im = im.resize((target, round(h * target / w)), Image.LANCZOS)
out = os.path.join(OUT, os.path.basename(path))
im.save(out, "PNG", optimize=True)
n += 1
bytes_in += os.path.getsize(path)
bytes_out += os.path.getsize(out)
print("%d screenshots %.1f MB -> %.1f MB -> %s"
% (n, bytes_in / 1e6, bytes_out / 1e6, OUT))
if __name__ == "__main__":
main()