Files
FoFreeXit/docs/12-phase4-summary.md

49 KiB
Raw Permalink Blame History

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 NGANG đạt.
  • ĐÍNH CHÍNH + bug thứ 2 (đè DỌC): ban đầu tưởng file test của user "nhiễm" mảnh Path=/ lạc chỗ — SAI. File gốc sạch: Path=/ là ĐẦU BULLET hợp lệ của dòng "Path=/ khiến refresh token…" nằm NGAY TRÊN khối sửa. Thủ phạm là vòng nở dọc pad_y = 8 (sinh ra để bắt dấu tiếng Việt vẽ rời): với giãn dòng sát (~13–14pt) tâm mực run của DÒNG KỀ TRÊN lọt vào bbox+8pt → cả câu bị nuốt (xoá), baseline của nó thành first_baseline → khối vẽ lại trồi lên 1 dòng đè chữ, còn sót mảnh mono Path=/ (tâm cao hơn ngưỡng chút). Fix: vùng nở 8pt chỉ nuốt op NHỎ (cao < 0.6 × median cao run trong khối — mảnh dấu); op cao cỡ chữ phải có tâm trong bbox LÕI. Test hồi quy: reflow_tight_leading_does_not_swallow_line_above (giãn dòng 13pt).

Vòng 7 (10/08/2026): ô sửa dùng FONT NHÚNG của PDF (hết "mất in đậm")

User báo: chữ mono vốn in đậm (newWindowsPassword), mở ô sửa thì "về chữ thường". Chẩn đoán qua CSS.getPlatformFontsForNode: span ĐÃ có font-weight:700 — nhưng máy không cài font tài liệu nên WebView thay NotoSansMono-Bold bằng Consolas-Bold nét mảnh (nhìn như không đậm), NotoSans thành Arial.

Giải pháp: ô sửa dùng chính font nhúng trong PDF.

  • Engine font_data(input, page, index): bytes font nhúng của run (index phẳng — vào được font trong Form XObject của file Canva).
  • fontfix.rs: OTS (OpenType Sanitizer của Chromium) từ chối subset PDF vì (1) thiếu bảng OS/2 (bắt buộc), (2) cmap chỉ có (1,0) format 0 Mac — không hỗ trợ. Vá: dựng OS/2 v4 từ head/hhea + cờ đậm/nghiêng (weight khớp → không bị synthesize-bold chồng); convert cmap → (3,1) format 4; ghép sfnt chuẩn checksum (tổng = B1B0AFBA), tag 'true' → 0x00010000. CFF trần giữ nguyên (UI tự fallback).
  • tounicode.rs: subset LibreOffice gán mã TUỲ BIẾN 1–31 cho ký tự ngoài WinAnsi (chữ Việt có dấu) — chỉ /ToUnicode giải nghĩa được. Parse bfchar/bfrange (lopdf, cả surrogate pair), match BaseFont theo suffix sau "ABCDEF+", tìm cả trong resources của form (sâu 3). Không có ToUnicode: ASCII identity + MacRoman; mã không giải nghĩa được thì bỏ (ký tự rơi xuống stack hệ thống).
  • UI: nạp lười theo tên font khi vào trang sửa (dedup), FontFace weight/style theo run, family nhúng đứng đầu stack span/div; đổi phiên sửa bỏ face cũ.

Kiểm chứng (build 35, CDP getPlatformFontsForNode): file dịch — 3 span đều isCustomFont ("Noto Sans", "Noto Sans Mono" thật, mono đậm y bản in); CV Canva gốc — span dùng "Lato" nhúng (run trong form). Quy trình debug đáng nhớ: bắt log "OTS parsing error" qua CDP Log.enable; nhân bản thuật toán sang Node để vá bytes THẬT và load thử trong WebView2 đang chạy TRƯỚC khi chờ CI.

Phát hiện ngoài lề: file CV_NGUYENVANTAI.pdf trong thư mục project bị GHI ĐÈ hỏng lúc 09:49 10/08 (xref trỏ 153271 nhưng file phình 259KB, stream lệch offset — kiểu hỏng do copy/tải mangle bytes; Producer (Canva), KHÔNG phải app ghi). Bản gốc lành còn ở Downloads\CV_NGUYENVANTAI.pdf (157KB). File hỏng: PDFium render được nhưng object list = 0 → không sửa được; qpdf cứu được hiển thị nhưng mất cấu trúc text.

Vòng 7b: ĐÓNG GÓI bộ font Noto — không thoả hiệp font hệ thống ở tầng nào

User yêu cầu: khách hàng không có font chuẩn sẵn — phần mềm phải tự chuẩn bị font, không thay bằng font "gần giống". Sau font-nhúng (7a) vẫn còn 3 khe hở rơi về font hệ thống: font không nhúng, CFF không vá được, và KÝ TỰ MỚI GÕ không có trong subset (subset chỉ chứa glyph đã dùng).

  • scripts/fetch-fonts.ps1: 10 file Noto hinted static (Sans/Serif/Mono × Regular/Bold/Italic/BoldItalic, phủ tiếng Việt, ~6.7MB) + OFL license → fonts/ (gitignore); CI cache + đóng vào zip cạnh exe.
  • Tauri main() đặt FOFREEXIT_FONTS_PATH (fonts/ cạnh exe; dev = gốc workspace); command app_font_data(kind) (whitelist 10 kind) trả base64.
  • UI: FontFace ff-app-{sans|serif|mono} đăng ký weight/style thật (không synthesize chồng), đứng GIỮA stack: font nhúng PDF → Noto đóng gói → stack hệ thống (lưới lý thuyết cuối). Phân loại theo cssFontStack; nạp trước cùng font nhúng khi vào trang sửa.
  • Engine find_font_bytes (đường ghi file cần font thay thế/coverage): ưu tiên tuyệt đối fonts/ đóng gói qua env — kết quả LƯU giống nhau mọi máy.

Kiểm chứng build 36 (instance riêng port 9223, không đụng phiên user):

  • Ô sửa, gõ Thử ẶỄỠỮ ngoài subset: 41 glyph đều isCustomFont (Noto Sans nhúng/đóng gói + Noto Sans Mono), 0 glyph font hệ thống.
  • Commit chuỗi đó: run mới trong PDF dùng NotoSans-Regular NHÚNG (embedded=true) từ bộ đóng gói — không còn Arial Windows.

Vòng 8 (10/08/2026): edit chuẩn mọi PDF — CID/CFF/OCR, format từng chữ, UX khởi động

Yêu cầu user: "đáp ứng edit đẹp và chuẩn mọi file pdf, trải nghiệm như Word, mượt như Foxit, kể cả file OCR; format riêng từng chữ trong dòng" + fix đơ khởi động + Open with.

Đã làm (build 40, commit 74f161a/57b8b84/090e387)

  1. Font CID/Identity-H (PDF từ Word/InDesign) — fontfix v2: tổng hợp bảng name/post thiếu; font KHÔNG có cmap → dựng (3,1) fmt4 từ /ToUnicode × /CIDToGIDMap (Identity: GID=CID; stream 2B/CID); chặn GID ≥ maxp.
  2. CFF/Type1C trần — cffwrap.rs: parser CFF tối thiểu tự viết (INDEX/DICT/Encoding/charset/FontMatrix, kể cả real nibble), bọc thành OTF: bảng CFF giữ nguyên, hmtx từ /Widths + /W (tounicode thu thêm widths/DW/Ascent/Descent), maxp v0.5, head magic chuẩn.
  3. Format TỪNG CHỮ (chuẩn Word): bôi đen → B/I (nút không cướp focus + Ctrl+B/I); UI tách span, override theo đoạn; engine RichKey (run nguồn + override) → font BIẾN THỂ THẬT cùng họ. Bug tìm ra khi E2E: đổi format thuần (chữ giữ nguyên) bị commit coi "không thay đổi" → nuốt op — fix changed tính cả override.
  4. Edit file OCR: khối toàn run render-mode INVISIBLE → vẽ rect TRẮNG che vùng chữ cũ trên ảnh + text mới vẽ HIỆN màu đen (4 đường vẽ).
  5. Bù metrics kiểu Adobe Sans MM: span mang data-pdfw; pass letter-spacing trong fitEditLinesToPdf cho khớp quãng gốc khi font thay thế (>2%, kẹp 18%).
  6. UX khởi động: window visible:false + JS show() khi sẵn sàng + splash; lưới an toàn 4s. Open with: registry Applications tự đăng ký lại mỗi lần chạy (HKCU) — hết vòng lặp "chọn app khác" do entry trỏ exe cũ.
  7. CJK pack tuỳ chọn (fetch-fonts-cjk.ps1, OTC 2 file ~40MB): UI nạp lười khi text có CJK; chưa nối đường GHI (Windows luôn có font CJK hệ thống).
  8. CI: fetch-qpdf dùng GITHUB_TOKEN + pin v12.2.0 (hết fail oan rate-limit).

Kiểm chứng build 40 (E2E CDP, 10/10 PASS + 2 bài hồi quy)

  • Bôi đen "không" giữa câu → B → commit: 3 mảnh "Quản trị viên | không[B] (NotoSans-Bold) | phải là…", worst overlap 0pt.
  • File CID kiểu Word (in từ Edge headless, CIDFontType2/Identity-H, 4 font subset đều nạp FontFace OK): sửa đoạn tiếng Việt, chữ đậm giữa câu GIỮ, overlap 0.43pt.
  • File OCR (ảnh scan + text ẩn 3 Tr): sửa lớp ẩn → rect trắng che + text mới màu đen HIỆN.
  • Hồi quy: nhiều trang + min-height ✓; đè ngang 0pt + dòng kề sống sót ✓.
  • Registry Open-with trỏ đúng exe; splash gỡ sau khởi động.

Test PDF chuẩn tạo bằng: Edge headless --print-to-pdf (file kiểu Word thật) + raw PDF tự dựng (ảnh DeviceGray + text 3 Tr) — xem scratchpad/make-test-pdfs.js của phiên làm việc.