Files
FoFreeXit/docs/12-phase4-summary.md
T
adminandClaude Fable 5 d812f72dc7
Build Windows portable / build (push) Canceled after 0s
Fix cuon lech khi vua vao che do sua nhieu trang: slot active giu min-height
Slot trang active cao theo anh that (CSS :has -> height auto), nhung luc vua
vao che do sua anh CHUA load nen slot cao ~0 -> scrollIntoView ban dau lech
ca cot va listener cuon (debounce 200ms) tu chuyen nham trang active (vao
trang 2 nhay sang trang 3). sizeEditSlot dat them min-height = chieu cao
uoc tinh nen slot khong bao gio sap ve 0.

Kem ket qua kiem chung build 31 tren app that (docs/12): dong lan style sau
commit tang dan tuyet doi (worst overlap 0pt) - fix mep muc dat; mảnh
"Path=/" con de la artifact NHIEM SAN trong file test cua user tu lan luu
bang build loi cu (reflow bao ton nhu marker) - khong phai bug moi.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 10:17:48 +07:00

535 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 + 3 + 4
> Trạng thái sau Iteration 4: sửa được cả text **NẰM TRONG Form XObject**
> (file Canva/Illustrator/InDesign gói cả trang vào form).
## Iteration 4 — Sửa text trong Form XObject (file Canva...)
User mở CV xuất từ Canva: xem được nhưng **không sửa được gì** — trang chỉ có
10 path + 1 Form XObject phủ toàn trang, TOÀN BỘ chữ nằm trong form (16 font
Type0/CID nhúng), trong khi `list_objects` chỉ duyệt object cấp trang.
### Engine (`edit.rs`)
- **Danh sách PHẲNG đệ quy** (`collect_flat`): duyệt object trang theo thứ tự
vẽ, đi sâu vào Form XObject qua `FPDFFormObj_CountObjects/GetObject`
(pdfium-render 0.8.37: `PdfPageXObjectFormObject::len()/get()` public; match
thẳng variant `PdfPageObject::XObjectForm` để giữ lifetime trang — accessor
`as_x_object_form_object()` bị rút ngắn lifetime theo `&self`). Mỗi mục có
`path` ([i] cấp trang, [i,j,…] con trong form) + `acc` = tích ma trận form
tổ tiên. Chặn depth 3 / 4000 mục. **`index` của ObjectInfo/EditOp = vị trí
trong danh sách phẳng này** — trang không có form thì trùng index trang như
cũ (tương thích ngược 100%, UI không đổi cách gửi op).
- **Toạ độ/cỡ quy về trang**: rect con = AABB của 4 góc qua `acc`; cỡ hiển
thị nhân `mat_vscale(acc)` (độ dài ảnh vector đơn vị Y).
- **ObjectInfo thêm `nested`** (con trong form — sửa text OK, kéo/resize chưa)
**+ `expanded`** (form đã liệt kê con — UI bỏ khung form để khỏi che con).
- **KẾT LUẬN QUAN TRỌNG NHẤT (3 vòng CI + đọc source PDFium): PDFium hiện
KHÔNG ghi lại được stream của Form XObject.** Chuỗi chứng cứ:
1. `FPDFText_SetText` trên con không làm form dirty (`dirty_streams_` chỉ
được đánh dấu bởi Remove/Insert trên chính form — cpdf_pageobjectholder.cpp);
2. rút con bằng `FPDFFormObj_RemoveObject` + làm trang dirty → `ProcessForm`
CÓ chạy generator cho form, NHƯNG `CPDF_PageContentManager` (nơi ghi kết
quả) chỉ biết key `/Contents` — form là stream TỰ THÂN, không có
/Contents → content mới rơi vào key /Contents vô nghĩa trên dict form,
stream thật giữ nguyên (cpdf_pagecontentmanager.cpp, constructor +
AddStream). Test round-trip bắt đúng triệu chứng: extract sau lưu vẫn
thấy text cũ.
- **Giải pháp cuối: MỞ GÓI (flatten) form ra cấp trang** —
`flatten_form_xobjects`: rút từng con khỏi form (`FPDFFormObj_RemoveObject`
— cần đổi feature pdfium-render `pdfium_latest`→`pdfium_7350` vì wrapper
0.8.37 sót gate; đã quét 0 hàm 7543-only bị mất), nhân ma trận form vào
con, chèn lại vào TRANG, xoá form rỗng; lặp ≤4 vòng cho form lồng form.
Hiển thị y hệt (đã kiểm vị trí/nội dung), mọi object thành cấp trang → sửa
bằng đường page-level đã được test kỹ từ iteration 1-3. UI: `loadEditPage`
thấy object `nested` → gọi `edit_flatten_to_temp` một lần rồi nạp lại
(bản mở gói không tính là "có thay đổi" cho tới khi sửa thật).
- Op nhắm vào object trong form khi CHƯA flatten → engine trả lỗi rõ ràng
("trang chưa được mở gói") thay vì âm thầm ghi bản không đổi — có test.
- CI test chạy `--test-threads=1`: PDFium serialize qua 1 mutex toàn cục —
1 test panic (kể cả panic Ở ASSERT khi instance Pdfium còn sống — unwind
drop guard giữa panic) sẽ poison mutex, mọi test SAU đó trong cùng process
chết oan tại thread_safe.rs:76. Khi đọc log CI: chỉ failure ĐẦU TIÊN là
lỗi thật.
- Vá kèm: đọc source crate pdfium-render tải từ crates.io để chốt API vì máy
dev không compile được Rust (xem mục CI bên dưới).
### UI (`main.js`)
- Bỏ khung overlay cho form `expanded` (khung phủ kín trang chặn hết click)
và cho ảnh/form `nested`; run text trong form tự chảy qua clusterTextLines
như run thường (rect đã quy về trang).
- Chặn kéo di chuyển dòng chứa run `nested` (engine chưa hỗ trợ Transform
trong form) — vẫn chọn/sửa text/xoá bình thường.
### Test (6 — `edit_roundtrip.rs`, fixture tự dựng bằng lopdf)
- Fixture: PDF 1 trang, chữ "Hello inside form" Helvetica 24pt nằm TRONG
Form XObject (trang chỉ có 1 object form) — đúng cấu trúc Canva.
- `form_xobject_children_are_listed` — thấy text nested, form expanded, rect
quy về trang đúng chỗ (72, ~700), cỡ ~24.
- `flatten_form_xobjects_unwraps_page` — mở gói: hết form, text thành cấp
trang, vị trí giữ nguyên, nội dung không đổi.
- `form_xobject_settext_after_flatten_roundtrip` / `..._reflow_...` /
`..._delete_...` — sửa/reflow tiếng Việt/xoá SAU flatten round-trip chuẩn.
- `nested_target_without_flatten_is_rejected` — op vào object trong form khi
chưa flatten bị từ chối với thông điệp rõ.
### CI chạy test engine
Máy dev không có Rust nên **CI là nơi duy nhất chạy test**: workflow thêm
bước `cargo test -p ff-engine` TRƯỚC khi build release (fail test = fail
build, không publish bản hỏng); cache thêm `target/` gốc workspace.
### Vòng 5 — zoom/chuyển trang trong chế độ sửa + dòng lẫn nhiều style
Ba vấn đề user báo (kiểm chứng E2E build 29 trên file dịch 3 trang):
- **Zoom**: stage sửa hết cứng 820px — `editZoom` riêng (kế thừa zoom viewer),
Ctrl+wheel/nút ±/dropdown %/Vừa rộng/Vừa trang đều chạy; co giãn CSS ngay,
render nét sau 220ms; `margin:auto` fix flex-center overflow. Kiểm: 794→1191px.
- **Chuyển trang**: ▲▼/ô nhập trang nhận biết edit mode (`switchEditPage` —
giữ undo/thay đổi trang khác vì chung editBase); guard `editLoadSeq` cho
loadEditPage/sharp-render: lượt nạp chậm không đè lượt mới (race khi
chuyển trang/zoom nhanh). Kiểm: Trang 2 · 114 đối tượng.
- **Dòng lẫn style (thường + mono-đậm…)**: ô sửa dựng SPAN theo nhóm run
cùng style (styleKeyOf/styleGroupsOfLine) — WYSIWYG từng phần; commit đọc
đoạn-style (`readRich`, text node trần mượn span kề), ≥2 style gửi
`richLines`. Engine: `ReflowText.rich` — resolve thang giữ-font RIÊNG cho
từng style-run, token hoá theo từ (giữ glue giữa đoạn dính liền), bẻ dòng
chung, vẽ mỗi cụm cùng-style 1 object đúng font/cỡ/màu + tự kiểm/fallback
từng từ. Tầng-0 token gốc tắt khi KHỐI có run nested (token vô hiệu sau
mở lại document hậu phẫu thuật — trước chỉ xét anchor). Kiểm trên dòng
"Hiển thị `vmDetails.vm.windowsInitialPassword`.": ô sửa 3 span đúng
style; sau commit mono giữ NotoSansMono-Bold, "." giữ NotoSans-Regular
(token gốc), phần sửa sang Arial hợp lệ (subset gốc thiếu glyph Ể hoa).
Test engine: fixture Helvetica + Courier-Bold cùng dòng.
### CỔNG AN TOÀN + giới hạn ghi nhận (iteration 4 v1)
Kiểm chứng E2E trên file Canva THẬT (CV, 1149 object sau mở gói) phát hiện:
**generator của PDFium ghi lại cả trang là LOSSY với file phức tạp**:
- Map font theo (type, BaseFont) — Canva nhúng 2+ subset TRÙNG TÊN
("AAAAAA+Now-Bold" ×2): run của subset 2 bị ép qua subset 1 → mất glyph
rải rác (PROFILE → "O ILE").
- `ProcessImage` BỎ ảnh inline (`IsInline() → return`) — mất ảnh chân dung.
- Mở form theo vòng append cuối trang → z-order xáo trộn (panel đè lên chữ).
→ **`edit_flatten_to_temp` có CỔNG AN TOÀN `page_render_mismatch`**: render
trang trước/sau mở gói (500px, dung sai 12/255/kênh), lệch >0.5% điểm ảnh →
HUỶ file tạm + trả lỗi rõ; UI hiển thị thông điệp và CHẶN mở ô sửa phần nằm
trong form (xem/chú thích/tổ chức trang vẫn bình thường). File gói form đơn
giản (đa số file xuất máy in ảo, fixture test) qua cổng → sửa thoải mái.
**Iteration 4b — PHẪU THUẬT stream (ĐÃ LÀM, thay thế flatten ở UI)**:
`formsurgery.rs` (lopdf): xoá đúng op vẽ chữ (Tj/TJ/'/") của run bị sửa
trong stream form — map text-child thứ k ↔ show-op thứ k, form con thứ n ↔
Do-trỏ-form thứ n (thứ tự parse PDFium tuần tự); MỖI MỨC có bất biến kiểm
đếm (số op == số object PDFium thấy — lệch là từ chối); '/" thay bằng
T*/Tw+Tc+T* giữ hiệu ứng vị trí. Chữ mới vẽ ở CẤP TRANG (regenerate trang
không đụng stream form → không dính lỗi trùng tên font). `apply_edits`:
run nested trong ReflowText/Delete → phẫu thuật ở pha (A2) rồi MỞ LẠI
document (tầng-0 token gốc tắt cho anchor nested — token vô hiệu sau mở
lại); cổng an toàn `page_render_mismatch_masked`: pixel ngoài vùng khối sửa
lệch >0.5% → huỷ output. UI bỏ auto-flatten, sửa nested trong suốt qua đúp
chuột; SetText thuộc tính/kéo khối trong form chặn-với-thông-điệp.
Bẫy lopdf: `decompressed_content()` BÁO LỖI với stream không nén (thiếu
/Filter) → fallback bytes thô.
**Kiểm chứng E2E trên CV Canva thật (build 26)**: sửa 1 dòng trong form
(1155 object nested) → commit thành công, chữ mới đúng chỗ/đúng màu, ảnh
chân dung + toàn bộ chữ khác nguyên vẹn (PROFILE/CONTACT đủ glyph), cổng
masked pass, undo hoạt động. 2 test round-trip surgical (delete/reflow giữ
nguyên phần khác của form) + test SetText nested bị từ chối.
**Vá theo phản hồi user trên CV Canva (build 27-28)**:
- **Cỡ chữ ÂM**: pdfium-render lấy vertical scale = `matrix.d` CÓ DẤU; file
Canva hệ toạ độ lật (d<0) → mọi cỡ chữ âm → UI kẹp ô sửa về 6px (sai vị
trí/style) và reflow đo bề rộng âm → không bẻ dòng, chữ văng khỏi trang
("text biến mất" — cổng masked không bắt được vì chữ văng ra ít pixel).
Fix: chuẩn hoá `.abs()` tại 5 điểm đọc cỡ trong engine + 2 test fixture
form MA TRẬN LẬT.
- **"Text biến mất" phần còn lại là ẢO GIÁC hiển thị**: sau commit, app còn
hiện ảnh render-ẩn (chữ cũ đã xoá) suốt vài giây khi engine áp thay đổi
(file nặng). Fix: đóng ô sửa → trả ảnh GỐC ngay + hint "Đang áp dụng thay
đổi…" → ảnh mới thay khi xong.
- **Tàn dư dấu "~"** (ngã của Ễ): Canva vẽ dấu bằng op RIÊNG có tâm nằm
ngoài bbox khối +2pt → vòng nở không gom để xoá. Fix: pad nở DỌC 8pt
(vẫn < nhịp dòng 12pt+ nên không nuốt dòng cạnh).
- Kiểm chứng cuối (build 28, CV thật): ô sửa đè khít đúng cỡ 37px bold đúng
màu; commit ra "NGUYỄN VĂN TÀI X" đủ dấu, không tàn dư, trang nguyên vẹn.
Giới hạn khác:
- Flatten làm mất clip/ExtGState/transparency group đặt Ở MỨC FORM — các
trường hợp này thường cũng làm lệch render → cổng an toàn tự chặn.
- Cấu trúc file sau khi SỬA thay đổi (form mở ra trang) — hiển thị giữ
nguyên (đã có cổng kiểm); chỉ xảy ra khi người dùng thật sự lưu.
- Form lồng sâu >4 vòng mở hoặc >4000 object: phần vượt giữ nguyên trong form.
> Các iteration trước: 52/52 test engine xanh ngoài qpdf (17 test edit + 6
> unit fontmatch + 29 test cũ) — nay thêm 4 test form + 2 unit sfnt style.
## Iteration 3 — Reflow đoạn văn "như Word" (mới nhất)
Đóng gap trải nghiệm cuối cùng của tính năng moat (mục 3.1 của
`docs/14-foxit-gap-analysis.md`): double-click vào đoạn nhiều dòng → sửa cả
đoạn trong 1 ô, chữ tự bẻ dòng lại theo bề rộng khối khi commit.
### Engine — `EditOp::ReflowText { indices, text }`
- Engine TỰ suy hình học từ các run: lề trái/bề rộng khối từ bounds, baseline
từ `matrix.f` (gom cụm ±1pt), khoảng cách dòng = median hiệu baseline
(1 dòng → 1.25× cỡ hiển thị) — UI chỉ cần gửi indices + text mới.
- **Bẻ dòng đo bằng hmtx thật** (`fontmatch::wrap_lines` + `char_advance` qua
ttf-parser): greedy theo từ, `\n` = ngắt cứng, từ quá dài cắt theo ký tự,
dòng rỗng giữ nhịp baseline. Không kerning (chấp nhận v1, nới 2%).
- **Giữ font theo thang 4 mức** (nhất quán triết lý iteration 2):
1. Font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó (glyph y hệt);
2. Family nhóm base-14 (Helvetica/Times/Courier + Arial alias) + text ASCII →
**font chuẩn PDF qua `FPDFText_LoadStandardFont`** — BaseFont giữ tên chuẩn,
KHÔNG nhúng, file không phình; đo width bằng font metric-compatible
(Liberation/Arial);
3. Font hệ thống cùng họ (coverage-checked); 4. fallback mặc định.
- Dòng mới giữ phần tuyến tính matrix của run neo (scale/nghiêng), gốc tại
(lề trái, baseline thứ i); Tf = unscaled của run neo → cỡ hiển thị không đổi.
- Đoạn dài ra thì các dòng mới nối xuống dưới theo đúng nhịp baseline (hành vi
tương tự Foxit khi khối text nở ra).
### UI
- Double-click: **đoạn ≥2 dòng → textarea sửa cả đoạn** (`paragraphLines` gom
dòng baseline cách đều ±25%, cỡ chữ tương đồng, giao ngang ≥30%); 1 dòng →
ô sửa dòng như iteration 2.
- Textarea WYSIWYG: đúng font/cỡ/màu/kiểu + line-height đúng nhịp baseline;
nội dung khởi tạo = các dòng nối bằng khoảng trắng (đoạn chảy tự nhiên);
**Enter = ngắt cứng, Ctrl+Enter hoặc bấm ra ngoài = áp dụng, Esc = huỷ**.
- 1 commit = 1 op `reflowText` = 1 nấc undo.
### Test mới (3 integration + 3 unit)
- `reflow_wraps_and_keeps_embedded_font` — đoạn 3 dòng font nhúng + text Việt
dài gấp mấy lần → bẻ >3 dòng, mọi dòng trong bề rộng khối, **font nhúng giữ
nguyên**, baseline đều 15pt±2, text cũ biến mất.
- `reflow_hard_break_creates_new_line` — `\n` ra đúng 2 dòng cách 1 nhịp.
- `reflow_base14_ascii_keeps_standard_font` — Helvetica không nhúng + ASCII
dài → nhiều dòng, **BaseFont vẫn "Helvetica" chuẩn** (builtin, không nhúng).
- Unit `wrap_lines`: greedy theo từ / ngắt cứng + dòng rỗng / cắt từ quá dài.
### Vá theo phản hồi file thật (Word-export, run cắt theo từ/ký tự)
Kiểm với PDF thật xuất từ Word (tiêu đề 2 dòng bị cắt thành 41 run per-word/
per-char xen run rỗng, cỡ 22.5pt + 20pt, bbox chữ có dấu tụt thấp):
- **UI cluster DÒNG theo GIAO DỌC** (≥50% chiều cao nhỏ) thay vì so baseline —
miễn nhiễm dấu tiếng Việt; overlay 1 khung/dòng (file thật: 231 ô → 18 khung);
chọn/kéo/xoá/đổi thuộc tính tác động CẢ DÒNG (batch ops).
- **Đúp mở CẢ ĐOẠN giữ xuống dòng** (`\n` giữa các dòng gốc — sửa được cả vị
trí ngắt dòng); đúp trúng KHE giữa các từ vẫn mở (hit-test theo điểm);
click trong ô sửa di chuyển con trỏ, không thoát edit; ô che kín chữ cũ.
- **Engine ReflowText tự NỞ danh sách run theo bbox khối** (tâm nằm trong
union + 2pt) — không bao giờ sót run rỗng/lệch bbox → hết cảnh chữ mới đè
chữ cũ; **phát hiện khối căn giữa** (tâm các dòng trùng tâm khối) → dòng mới
giữ căn giữa. 2 test hồi quy mới (nở indices, giữ căn giữa) — 19 test edit.
### Vá vòng 2 theo phản hồi file thật (□ thay dấu cách, vỡ dòng, ô sửa lệch)
Ba lỗi user báo sau khi thử bản build 6 trên chính file Word-export:
- **Dấu cách thành ô vuông □ sau khi lưu**: PDFium `FPDFText_LoadFont` nạp
LẠI font subset (Word xuất) rồi `SetText` sẽ map U+0020 ra `.notdef` (glyph
hộp) dù cmap của font CÓ space — lỗi nằm ở PDFium, không phải font. Fix:
**tầng 0 mới của thang font reflow — dùng LẠI chính font object gốc trong
PDF** (lấy `PdfFontToken` từ run neo, không nạp lại): glyph + charcode map
gốc xử lý dấu cách chuẩn, file không phình thêm bản font nữa. Chỉ chọn sau
khi **probe ghi/đọc-lại toàn bộ text mới** thành công (tạo object nháp →
so text → gỡ). Khi ghi từng dòng cũng **đọc lại ngay để tự kiểm** — nếu
PDFium vẫn âm thầm vứt ký tự thì gỡ và **ghi lại theo TỪNG TỪ tự đặt vị trí
bằng advance hmtx** (không bao giờ ghi glyph dấu cách) — lưới an toàn cuối.
- **Thêm 1 ký tự làm vỡ dòng** ("HỢP"→"HỢPP" đẩy "LOG" rơi xuống dòng mới):
dòng cứng (`\n` từ ô sửa) giờ được **NỞ tới +35% bề rộng khối** (chặn mép
trang) trước khi phải bẻ lại — hành vi hộp text tự nở của Foxit; khối căn
giữa nở đều 2 phía quanh tâm.
- **Mỗi dòng giữ đúng CỠ CHỮ GỐC của dòng đó** (tiêu đề 22.5/20pt không còn
bị ép cả khối về cỡ run neo): kế hoạch reflow ghi `line_styles` theo từng
baseline gốc, dòng cứng thứ i ăn cỡ dòng gốc thứ i.
- **Ô sửa WYSIWYG**: thay textarea bằng `contenteditable` — mỗi dòng gốc là
1 div với đúng **font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ**, khối căn giữa hiện
căn giữa, khung đặt trùng bbox khối (bù lệch dọc line-box) → chữ trong ô
nằm NGUYÊN vị trí chữ gốc khi bắt đầu sửa; con trỏ đặt đúng chỗ vừa đúp
(`caretRangeFromPoint`).
- Kiểm chứng trên file thật: sửa "HỢP"→"HỢPP" giữ đúng 2 dòng căn giữa cỡ
22.5/20pt, font `BAAAAA+TimesNewRomanPS-BoldMT` gốc, dấu cách nguyên vẹn,
render so khớp bản gốc. 2 test hồi quy mới (`reflow_keeps_per_line_font_sizes`,
`reflow_hard_line_grows_without_rewrap`) — 21 test edit.
### Vá vòng 3 — ô sửa trùng khít chữ gốc bằng ĐO–HIỆU CHỈNH (fix hồi quy vòng 2)
User báo (kèm ảnh): ô sửa hiện chữ SAI font + SAI cỡ + SAI vị trí. Ba nguyên nhân:
- **Hồi quy vòng 2**: `perLineAdvances` tính line-height mỗi dòng bằng KHOẢNG HỞ
giữa 2 bbox (`bottom trên − top dưới` ≈ 0/âm → kẹp 8px) thay vì khoảng cách
baseline → các dòng dồn cục. Fix: advance = hiệu mép DƯỚI 2 bbox liền kề
(bbox PDFium ôm sát glyph nên bottom bám baseline).
- **Sai font**: engine trả family PostScript CamelCase ("TimesNewRoman",
"SegoeUI") không khớp tên font cài trên Windows → CSS rơi về serif/sans mặc
định. Fix: `cssFontStack` tách CamelCase thành tên có dấu cách + bảng alias
(Helvetica→Arial, TimesNewRomanPS→Times New Roman…).
- **Sai cỡ với file lạ**: cỡ px suy mở từ `font_size` engine, không tự kiểm.
Fix: **`fitEditLinesToPdf` — vòng đo–hiệu chỉnh sau khi mount ô sửa**:
(1) CỠ: chiều cao MỰC của đúng chuỗi đó (canvas `measureText` actualBoundingBox,
đo ở cỡ tham chiếu 100px để né sai số hinting ~5% ở cỡ nhỏ) phải bằng chiều
cao bbox dòng PDF (cùng là ink-bbox, PDFium bounds = union glyph box) — engine
báo cỡ lệch bao nhiêu cũng tự sửa về đúng, lệch <12% coi là khác metric font
→ giữ cỡ engine (ổn định file chuẩn); (2) VỊ TRÍ: quy 2 phía về MÉP MỰC TRÊN
(DOM: Range rect + (fontAscent − inkAscent) từ canvas; PDF: rect.top) rồi bù
`margin-top`/`text-indent` từng dòng (dòng đầu bù vào khung để nền trắng che
kín); khối căn giữa cũng bù ngang qua text-indent. Thêm: giữ thụt lề từng
dòng; đúp từ viewer thường nay đặt con trỏ đúng điểm đúp (toạ độ giả lập từ
điểm PDF, chờ `img.decode()` trước khi đo).
- **Kiểm chứng**: harness HTML mô phỏng trang bằng canvas (chữ gốc đỏ, bbox mực
thật làm ground truth) + Edge headless chụp 3 kịch bản: (A) engine báo cỡ sai
MỘT NỬA + family CamelCase → tự sửa về đúng, lệch ≤0.4px dọc / 0px ngang;
(B) engine báo đúng → dead-band giữ nguyên, trùng khít 100%; (C) khối căn
giữa Times → lệch ngang ≤1.3px. (Không unit-test tự động được vì cần layout
engine thật; harness lưu ở scratchpad phiên làm việc.)
### Vá vòng 4 — khối sửa chuẩn Foxit: tách đoạn theo FORMAT, nền trong suốt, bullet, in đậm
User test build 11 trên file thật, báo 4 lỗi (kèm ảnh). Đối chiếu hành vi
Foxit (help.foxit.com — Edit Text): mỗi ĐOẠN là 1 text block riêng, click vào
đâu chỉ sửa block đó, reflow trong block; muốn sửa nhiều block phải Link thủ
công. Sửa tương ứng:
- **Gom đoạn quá tham** (tiêu đề + dòng code + thân bài + nhãn đỏ dính 1 khối):
`paragraphLines` giờ chỉ gom dòng kề nhau **CÙNG FORMAT** — cùng font family
+ đậm/nghiêng + màu (của run chữ đầu dòng), cỡ chênh ≤1.25× (tiêu đề 22.5/20
vẫn 1 đoạn, tiêu đề ≠ thân bài) — cộng điều kiện hình học cũ (hở ≤1.3× cao
dòng, giao ngang ≥30%, nhịp đều ±35%).
- **Bullet thành ô vuông □ trong ô sửa**: bullet Word/LibreOffice là run font
Symbol/Wingdings mã PUA (U+F0B7…) DOM không render được. Thêm `isMarkerRun`
(run chữ trái nhất của dòng, ≤2 ký tự, PUA/ký tự bullet/font symbol; "-","*",
"o" chỉ nhận khi có khoảng hở rõ với chữ sau) + `stripLineMarkers`: marker bị
LOẠI khỏi ô sửa/commit/rect khối (thụt lề tính từ CHỮ) — bullet giữ nguyên
trên trang như Foxit. Đúp trúng chính bullet vẫn mở khối chữ của dòng đó.
- **Ô sửa nền trắng che nền gốc**: giờ mở ô xong render NGẦM bản trang đã ẨN
các run đang sửa (`edit_apply_to_temp` với Delete — chỉ lấy ảnh, commit vẫn
tính trên editBase gốc) → đổi ảnh stage + nền ô chuyển TRONG SUỐT: thấy dải
màu nền/bullet/kẻ bảng dưới chữ đang gõ, đúng cảm giác Foxit. Chờ render thì
tạm nền trắng; huỷ/không đổi → trả ảnh gốc; file tạm vào editTemps để dọn.
- **Mất in đậm dù gốc đậm**: file Word/Chrome xuất subset không khai
/FontWeight và tên không chứa "Bold" → engine `text_object_style` thêm
fallback đọc **OS/2 usWeightClass ≥600 + cờ italic từ bytes font nhúng**
(ttf-parser); `list_objects` cache theo tên font (file 200+ run chung vài
font, không đọc lại bytes).
- **Kiểm chứng**: test Node trích HÀM THẬT từ main.js (11 case: tách khối theo
format/màu, tiêu đề 2 cỡ vẫn 1 đoạn, bullet loại khỏi khối + rect từ chữ,
đúp trúng marker, phân biệt "-" gạch đầu dòng vs "-5°C") — tất cả PASS.
Phần render nền ẩn cần app thật → kiểm trên build CI.
### Vá vòng 5 — TÌM RA GỐC RỄ: field IPC lệch tên; kiểm chứng E2E trên app thật
User test build 12 với file thật (LibreOffice export, NotoSans): vẫn mất in
đậm. Lần này debug TRÊN CHÍNH APP đang chạy (WebView2 remote debugging qua
`WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port` + CDP) và
phát hiện **nguyên nhân gốc của cả chuỗi lỗi font**: DTO Tauri serialize
`rename_all = "camelCase"` (`fontBold`, `fontFamily`, `fontSize`…) nhưng toàn
bộ code edit-mode trong main.js đọc snake_case (`font_bold`…) → **mọi thuộc
tính font trong UI luôn undefined từ đầu**: không đậm, sai family, cỡ mặc
định 12 (giải thích luôn ảnh lỗi "chữ nhỏ đều nhau" ban đầu — vòng 3 đã chữa
TRIỆU CHỨNG cỡ chữ bằng đo mực, vòng này chữa đúng BỆNH). Fix: đổi hết sang
camelCase (nhất quán với phần còn lại của app: `widthPt`, `pageIndex`…).
- **Engine đọc kiểu chữ từ bytes font — vá tiếp cho LibreOffice**: subset của
LibreOffice VỨT bảng OS/2 (kiểm bằng qpdf trích FontFile2: chỉ còn cmap/
glyf/head/hhea/…) nên ttf-parser `weight()` trả 400 mặc định. Thêm
`fontmatch::style_from_font_bytes` parse sfnt thủ công: OS/2 usWeightClass/
fsSelection nếu còn, fallback **`head.macStyle`** (bảng bắt buộc, bit0=bold
bit1=italic — file thật: 0x1 cho NotoSans-Bold, 0x0 cho Regular). 2 unit
test sfnt tự dựng. (File này BaseFont có "-Bold" nên tên đã bắt được; vá để
chống file tên không nói gì.)
- **Kéo-thả mở file**: nghe `tauri://drag-drop` (Tauri v2, dragDropEnabled mặc
định) → thả .pdf từ Explorer vào cửa sổ là mở (thoát edit/organize mode
trước); thả file khác báo "Chỉ mở được file .pdf".
- **Ẩn khung dòng overlay khi ô sửa đang mở** (nền trong suốt làm khung chấm
lộ qua gây nhiễu) — khôi phục khi đóng.
- **Kiểm chứng E2E trên app thật + file thật** (không cần rebuild): inject
hàm đã sửa vào app build-12 đang chạy qua CDP `Runtime.evaluate` (main.js
là classic script nên function declaration đè được binding global), gọi
`edit_list_objects` lấy run thật, điều khiển mở ô sửa và chụp màn hình:
tiêu đề 2 dòng bold 25.48px (=18.5pt đúng engine) 1 khối nền trong suốt;
khối bullet 8 dòng không PUA, bullet gốc còn nguyên trên trang. Test Node
(hàm thật + dữ liệu run thật từ engine): tiêu đề gom [27,28], khối bullet
loại sạch OpenSymbol, nhãn đậm màu tách riêng — tất cả PASS.
### Giới hạn ghi nhận (v1, sẽ nâng ở vòng sau)
- Đoạn justify (giãn đều 2 lề) reflow về căn trái; chưa kerning; khối text
XOAY chưa reflow theo hướng xoay (dòng mới đặt theo trục ngang).
- Khối lẫn NHIỀU font/cỡ trong 1 ĐOẠN: cỡ giữ theo TỪNG DÒNG; font/màu theo
run neo (dòng lẫn nhiều font sẽ thống nhất font).
> Iteration 1: 43/43 test, đã chạy app thật + chụp ảnh xác minh.
## Iteration 2 — Giữ font gốc + trải nghiệm Foxit (mới)
Đóng khoảng cách lớn nhất so với Foxit được chỉ ra khi review: **mọi lần sửa
text đều bị đổi sang font hệ thống** (Helvetica → Arial/DejaVu), kể cả khi chỉ
đổi cỡ/màu; bold/italic gốc bị mất; sửa theo mảnh run thay vì cả dòng.
### Engine (`edit.rs` viết lại + `fontmatch.rs` mới)
- **`SetText` 3 tầng, ưu tiên GIỮ FONT GỐC** (sửa tại chỗ bằng `FPDFText_SetText`,
không xoá/tạo lại):
1. *In-place an toàn chắc chắn*: text mới chỉ dùng ký tự đã có trong run; hoặc
font **không nhúng** (base-14 Helvetica/Times…) + text mới toàn ASCII
(BaseFont khai báo giữ nguyên trong file — đúng hành vi Foxit); hoặc cmap
của font (đọc qua `FPDFFont_GetFontData` + `ttf-parser`) phủ đủ ký tự mới
→ **font giữ nguyên 100%**, gồm cả font NHÚNG với tiếng Việt.
2. *Thiếu glyph thật sự* → thay bằng font hệ thống **CÙNG HỌ, đúng đậm/nghiêng**
(`fontmatch::find_family_font_bytes`: bảng family Windows/macOS, Liberation
metric-compatible + `fc-match` trên Linux; alias Helvetica→Arial,
Times→Times New Roman…), có kiểm coverage trước khi nhận.
3. Bất đắc dĩ mới rơi về font mặc định (`find_font_bytes` — nay có đủ biến thể
đậm/nghiêng trên cả Linux).
- **Đổi cỡ chữ tại chỗ không đụng font**: nhân matrix `[k,0,0,k, e(1−k), f(1−k)]`
(neo baseline như Foxit). **Fix bug phóng đại kép**: cỡ chữ nghĩa "hiển thị"
(đã nhân matrix scale) — trước đây đặt cỡ 20 lên text có matrix scale ×2 ra 40pt.
- `ObjectInfo` thêm `font_family` (đã làm sạch tên PostScript/subset),
`font_bold`, `font_italic`, `font_embedded` — nguồn từ `name()` (BaseFont) vì
`family()` của font không nhúng trả tên stub nội bộ PDFium ("Chrom Sans OTF").
- `SetText`/`AddText` nhận `font_family`/`bold`/`italic` dạng Option — **None =
giữ nguyên**; Some = chủ động đổi (đổi font qua tầng 2).
- Bẫy mới ghi nhận: `set_matrix` của pdfium-render 0.8.37 là alias deprecated
của `apply_matrix` (NHÂN DỒN, không thay thế) — phải tự dựng matrix delta.
### UI (`main.js` + toolbar)
- **Sửa CẢ DÒNG như Foxit**: double-click gom các run cùng baseline liền kề
(PDF hay cắt 1 dòng thành nhiều run) → 1 ô sửa cho cả dòng; commit = SetText
run đầu + Delete các run còn lại (1 batch, 1 nấc undo).
- **WYSIWYG khi gõ**: ô sửa dùng đúng family (CSS xấp xỉ theo `font_family`),
cỡ, màu, đậm/nghiêng của run gốc; nền trắng che text cũ.
- **Giữ nguyên mặc định**: mọi commit sửa text gửi `null` cho font/cỡ/màu/kiểu
— chỉ field người dùng chủ động đổi mới được gửi.
- **Toolbar Format kiểu Foxit**: dropdown Font (mặc định "(giữ nguyên: X)"),
nút **B**/**I** toggle theo kiểu thật của run (đổi biến thể cùng họ), Cỡ chữ,
Màu — đổi thuộc tính KHÔNG đổi font.
- **Kéo-thả live**: khung đi theo con trỏ ngay khi kéo (move + resize), thả
chuột mới commit; resize neo góc trên-trái đúng như preview.
- **Dọn file tạm**: mọi file `ff_edit_*.pdf` của phiên sửa được xoá khi thoát
chế độ/lưu (command `edit_cleanup` — chỉ xoá đúng pattern trong %TEMP%).
- Hint hiển thị font đang chọn: `Tên font · cỡ pt · font nhúng/hệ thống`.
### Test mới (7): `edit_roundtrip.rs` 14 test + `fontmatch` 3 unit
- `set_text_keeps_original_font` — sửa ASCII trên Helvetica base-14 GIỮ NGUYÊN
"Helvetica" (ép ký tự ngoài charset cũ để không ăn may).
- `set_text_preserves_embedded_font_vietnamese` — **font NHÚNG + tiếng Việt
giữ nguyên font** (case quan trọng nhất với tài liệu Việt).
- `vietnamese_on_base14_uses_matched_family` — tiếng Việt trên base-14 (không
có glyph Việt để giữ) match đúng họ metric-compatible (Liberation/Arial),
không rơi bừa về generic.
- `font_size_change_keeps_font_and_anchors`; `font_size_change_respects_matrix_scale`
(hồi quy bug phóng đại kép); `bold_override_substitutes_font_and_keeps_text`;
`line_merge_batch_set_text_plus_delete` (luồng UI gộp dòng).
### Đối chiếu FINAL TARGET & RULE
- Vướng thư viện (PDFium `set_text` re-encode theo subset) → **không cắt giảm**:
kiểm tra coverage bằng chính font bytes + luật charset/ASCII để tận dụng
`set_text` an toàn, chỉ thay font khi về mặt vật lý không còn glyph để giữ —
và khi thay thì match cùng họ như Foxit. Đúng thứ tự a→c của luật.
## Iteration 1 (giữ nguyên bên dưới để tham chiếu)
Phase 4 là tính năng lõi/moat theo `docs/03-roadmap.md`: sửa text & object trực tiếp trên trang — lý do quan trọng nhất để bỏ Foxit.
## 1. Khảo sát trước khi code
- **Foxit UX**: *Edit Text* (bấm đoạn → sửa như Word, reflow, đổi font/cỡ/màu); *Edit Object* (chọn text/ảnh/path → di chuyển/resize/xoay/xoá/thay ảnh, tab Format). Nguồn ở `docs/13-phase4-user-tests.md`.
- **pdfium-render 0.8.37** (đọc source vendored): PDFium đã expose page object cấp cao — KHÔNG cần tự parse content stream. Mỗi `PdfPageTextObject` là 1 **text run** sẵn để sửa (`text()`/`set_text()`/`font()`/`unscaled_font_size()`/fill color/transform). Có `create_text_object`/`create_image_object`/`remove_object_at_index`, `PdfFont::name/family/weight`, `regenerate_content()`. → khả thi cao, vượt kỳ vọng worst-case của roadmap.
## 2. Đã làm
### Engine (`crates/ff-engine/src/edit.rs`)
- `list_objects(input, page, password) -> Vec<ObjectInfo>`: liệt kê object (kind, AABB từ `bounds()`, và với text: nội dung/font/cỡ/màu) — cấp dữ liệu cho overlay UI.
- `apply_edits(input, page, ops, output, password)` với `EditOp`: **Transform** (di chuyển + scale quanh góc dưới-trái), **SetText** (sửa text run — tạo lại bằng FULL font nhúng để tiếng Việt đúng dấu, giữ matrix/cỡ/màu gốc qua `apply_matrix`), **Delete**, **ReplaceImage**, **AddText**, **AddImage**. Thứ tự xử lý giữ index gốc hợp lệ: Transform in-place → chụp dữ liệu object sắp thay → xoá theo index GIẢM DẦN → thêm bản thay thế → thêm object mới → `regenerate_content()` → lưu.
- Tái dùng `annot.rs::find_font_bytes` + `fonts_mut().load_true_type_from_bytes(bytes, true)` (như watermark.rs) cho sửa/thêm text Unicode.
- **Bài học quan trọng (đã ghi memory):** object trả về từ `remove_object_at_index` bị đánh dấu *unowned* → `Drop` gọi `FPDFPageObj_Destroy` gây **SEGFAULT** với PDFium build hiện tại. Phải `std::mem::forget` object đã xoá (đã tách khỏi trang, không cần destroy; rò rỉ nhỏ giải phóng khi đóng document).
### Tauri commands (`app/src-tauri/src/main.rs`)
`edit_list_objects`, `edit_apply` (ghi ra output), `edit_apply_to_temp` (áp ops → file tạm mới, trả path — cho mô hình materialize tức thì ở UI), `edit_preview` (render WYSIWYG), `pick_image`. DTO `ObjectInfoDto` (reuse `RectDto`), `EditOpDto` (tagged theo field `op`).
### UI (`app/src`) — chế độ "✏️ Sửa nội dung"
- Nút toolbar bật chế độ riêng: thay viewport bằng `#editStage` (ảnh trang lớn) + `#editOverlay` (1 box/đối tượng, map pdf→css theo `scale = STAGE_W/pageWidthPt`).
- **Mô hình "materialize tức thì"**: mỗi thao tác áp NGAY vào 1 file tạm mới (`edit_apply_to_temp`) rồi đọc lại object + render ảnh từ đó → index luôn khớp ảnh đang hiện (WYSIWYG thật), không cần tự suy đoán vị trí sau biến đổi.
- Thao tác: click chọn (viền xanh + handle); **double-click text → sửa tại chỗ** (ô input, commit Enter → SetText); đổi **cỡ chữ/màu** ở thanh công cụ cho text đang chọn; **Thêm chữ** (bấm nút → click lên trang → gõ); **Thêm ảnh**/**Thay ảnh** (chọn file → đặt); **Xoá** (nút/phím Delete); **kéo di chuyển / kéo handle resize** (chuột thật).
- **Undo/Redo riêng cho edit** (stack file tạm trước mỗi op), dùng chung 2 nút Hoàn tác/Làm lại + Ctrl+Z/Ctrl+Y khi đang ở chế độ sửa.
- **Lưu**: `edit_apply` ghi file mới rồi `loadDocument`.
## 3. Test tự động (7 mới, `tests/edit_roundtrip.rs`)
list_objects thấy text trang 1; SetText đổi nội dung (text cũ biến mất); SetText **tiếng Việt** round-trip đúng dấu; Delete giảm đúng 1 object; Transform translate dịch bounds ~+50; AddText xuất hiện trong extract_text; AddImage tăng 1 object kind Image. Tổng `ff-engine`: **43/43 xanh**.
## 4. Đã kiểm bằng ảnh (app build release thật)
Bật chế độ Sửa nội dung (overlay 2 đối tượng); double-click dòng 2 → sửa thành "Sửa: nội dung Tiếng Việt" → commit → trang render lại ĐÚNG (dấu chuẩn); Thêm chữ "Dòng chữ MỚI thêm" tại vị trí click; Hoàn tác bỏ chữ vừa thêm (giữ phần sửa); Xoá tiêu đề → còn 1 đối tượng. Ảnh: `tmp-out/phase4-*.png`.
## 5. Còn lại / follow-up (ghi nhận, không phải thiếu sót — Iteration sau)
- **Reflow đoạn nhiều dòng "như Word"** — khoảng cách chính còn lại so với Foxit
(Iteration 2 đã sửa được CẢ DÒNG; bước tiếp: gom block nhiều dòng theo khoảng
cách baseline + đo width bằng hmtx của font (ttf-parser đã có) + tự bẻ dòng).
Xem kế hoạch ở `docs/14-foxit-gap-analysis.md`.
- Xoay/lật/shear/clip object; tab Format nâng cao (viền, opacity, căn lề); z-order arrange; convert text→path. (Đổi font-family/B/I cho object cũ: ĐÃ XONG ở Iteration 2.)
- Đặt ảnh theo đúng tỉ lệ gốc (hiện mặc định 150×112pt, resize bằng kéo handle); preview ảnh trước khi đặt.
- Sửa nhiều trang trong 1 phiên lưu (hiện lưu theo trang đang sửa); spell-check; sửa bảng; link text blocks.
- Mã hoá UI (nợ từ Phase 3).
---
# Vòng 6 (10/08/2026): sửa NHIỀU TRANG + nút Huỷ + fix đè chữ khi lưu dòng lẫn style
## 1. Fix đè chữ khi lưu dòng lẫn style (engine, `edit.rs`)
**Triệu chứng:** sửa dòng có nhiều style (thường + mono-bold, ví dụ "Các thuộc
tính `HttpOnly`, `Secure` và `SameSite`...") → sau commit các mảnh chữ đè lên
nhau ("auth-storagetừ", dấu "," nằm đè trong chữ trước).
**Nguyên nhân:** con trỏ x đặt mảnh KẾ TIẾP tiến theo bề rộng ĐO bằng hmtx
(ttf-parser). Với subset font mà ttf-parser không parse được (`measure=None`),
fallback 0.5em/ký tự ƯỚC LƯỢNG THIẾU (đo "HttpOnly" = 36pt trong khi mực thật
42.6pt) → mảnh sau bắt đầu bên trong mực mảnh trước.
**Fix:** sau khi vẽ từng mảnh rich, đọc `obj.bounds()` từ PDFium (mép mực
thật) và tiến con trỏ bằng `max(x + measured, ink_right)` — cả đường vẽ cả
mảnh lẫn đường fallback bẻ theo từng từ. Ước lượng thiếu bao nhiêu cũng không
đè được nữa; ước lượng thừa giữ nguyên hành vi cũ.
## 2. Chế độ sửa NHIỀU TRANG (UI)
**Yêu cầu user:** "khi edit text ở trang nào thì các trang còn lại biến mất,
chỉ xem được trang đó" → giờ chế độ sửa hiển thị CỘT MỌI TRANG như viewer.
Kiến trúc (`#editPagesCol` trong `#editStage`):
- Mỗi trang 1 `.edit-slot` kích thước theo zoom. Trang ACTIVE chứa
`#editPageWrap` (img + overlay sửa được — giữ nguyên toàn bộ code 1-trang
cũ, chỉ DI CHUYỂN wrap giữa các slot). Trang khác render ảnh read-only
LƯỜI bằng IntersectionObserver (rootMargin 300px).
- Stamp `editBase + "|" + zoom` trên mỗi slot: op/undo/redo đổi editBase,
zoom đổi mức — stamp lệch là render lại khi trang còn trong khung nhìn.
`refreshRoSlots()` gọi ở cuối `loadEditPage` + sau render nét debounce.
- **Đúp vào trang read-only** → trang đó thành active + mở ô sửa NGAY tại
điểm đúp (tái dùng cơ chế `editPendingPoint` của luồng đúp-từ-viewer).
- **Cuộn** (debounce 200ms): ô số trang bám trang giữa khung nhìn; nếu không
có ô sửa đang mở, trang đó tự thành active (toolbar/phím tắt luôn tác động
đúng trang đang xem). Không auto-switch khi ô sửa đang mở — không mất chữ.
- Zoom: mọi slot co giãn CSS ngay, render nét sau 220ms (trang active) +
refresh trang read-only đang thấy. Nav ▲▼/ô số trang → `switchEditPage`
cuộn `scrollIntoView` (start khi nav, nearest khi đúp — không giật).
## 3. Nút "↩ Huỷ thay đổi" (UI)
Cạnh nút Lưu; bật khi `editBase !== path` (đồng bộ cùng chỗ với edSave trong
`loadEditPage`). `discardEdits()`: đóng ô sửa đang mở → xoá file tạm
(`cleanupEditTemps` — editBase cũ nằm trong `editTemps`) → `editBase = path`,
xoá sạch undo/redo → `loadEditPage()` (stamp đổi nên mọi trang read-only tự
render lại về nội dung gốc).
## 4. Kiểm chứng E2E (CDP trên app thật, file dịch 3 trang)
Tiêm bản vá vào build 29 đang chạy (không cần rebuild) rồi đo:
- Cột 3 slot, wrap nằm đúng slot active, trang 1+2 read-only render khi cuộn.
- Cuộn tới trang 2 → `pageInput=2`, active tự đổi; slot cũ trả về read-only.
- Đúp lên trang 3 (read-only) → active=2, ô sửa mở đúng dòng "ĐÁNH GIÁ AN
TOÀN THÔNG TIN" tại điểm đúp.
- Huỷ thay đổi: `editBase === path`, edSave/edDiscard disabled, undo/redo
rỗng, text sửa thử ("KIỂM") biến mất khỏi object list.
- Zoom 130%: cả 3 slot 1032px, label cập nhật; ▲ về trang 2 cuộn đúng.
Ảnh: `mp-1-top` → `mp-6-ro-dblclick` (scratchpad phiên làm việc).
Fix đè chữ (mục 1) là code Rust — kiểm chứng lại trên build CI mới bằng đúng
kịch bản đã tái hiện bug (sửa span thường của dòng lẫn style rồi so bounds
các mảnh sau commit).
**Bổ sung sau kiểm chứng build 31 (E2E trên build thật):**
- Fix thêm: slot trang active phải giữ `min-height` ước tính — lúc vừa vào
chế độ sửa ảnh chưa load, CSS `:has` cho height auto làm slot cao ≈ 0 →
cuộn ban đầu lệch cả cột, listener cuộn tự chuyển nhầm trang active.
- Kiểm đè chữ trên build thật: dòng lẫn style sau commit tăng dần tuyệt đối
(worst overlap 0pt) — fix mép mực OK. Lưu ý: file test của user
(Ban_dich_tieng_Viet_...) đã NHIỄM sẵn mảnh "Path=/" lạc chỗ từ lần lưu
bằng build lỗi cũ — mảnh đó được reflow bảo tồn như marker, KHÔNG phải bug
mới; đo kiểm phải loại các mảnh giữ-nguyên-vị-trí.