Files
FoFreeXit/docs/12-phase4-summary.md
T
adminandClaude Fable 5 68883b9e75
Build Windows portable / build (push) Canceled after 0s
Gốc rễ lỗi font ô sửa: field IPC lệch tên camelCase/snake_case + kéo-thả mở file
Debug trên app thật (WebView2 remote debugging + CDP) với file thật tìm ra
nguyên nhân gốc chuỗi lỗi font: DTO Tauri serialize camelCase (fontBold,
fontFamily, fontSize) nhưng code edit-mode đọc snake_case (font_bold...) →
mọi thuộc tính font trong UI luôn undefined (không đậm, sai family, cỡ mặc
định 12). Đổi hết sang camelCase nhất quán với app.

- Engine: fontmatch::style_from_font_bytes parse sfnt thủ công — OS/2
  usWeightClass/fsSelection, fallback head.macStyle (LibreOffice subset vứt
  OS/2 nhưng head luôn còn; kiểm bằng qpdf trích FontFile2 file thật). 2
  unit test sfnt tự dựng.
- Kéo-thả .pdf từ Explorer vào cửa sổ → mở file (tauri://drag-drop).
- Ẩn khung dòng overlay khi ô sửa mở (nền trong suốt lộ khung gây nhiễu).
- Ignore /*.pdf ở root (file test thật của user không được commit).

Kiểm chứng E2E không cần rebuild: inject hàm mới vào app build-12 đang chạy
qua CDP, mở ô sửa trên file thật + chụp màn hình — tiêu đề 2 dòng bold đúng
cỡ/vị trí nền trong suốt; khối bullet 8 dòng sạch PUA, bullet gốc nguyên
trên trang. Test Node trên dữ liệu run thật từ engine: 6/6 PASS.
Chi tiết: docs/12-phase4-summary.md (Vá vòng 5).

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

25 KiB
Raw Blame History

12 — Tổng kết Phase 4 (Chỉnh sửa nội dung — Edit) · Iteration 1 + 2 + 3

Trạng thái sau Iteration 3: sửa CẢ ĐOẠN với reflow "như Word" + GIỮ FONT. 52/52 test engine xanh ngoài qpdf (17 test edit + 6 unit fontmatch + 29 test cũ).

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).