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.
No files matched your search
@@ -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.
|
||||||
@@ -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/
|
||||||
@@ -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()
|
||||||
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 132 KiB |
@@ -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>
|
||||||
|
"""
|
||||||
@@ -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>
|
||||||
|
"""
|
||||||
@@ -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()
|
||||||