Kết luận sau 3 vòng CI + đọc nguyên văn source PDFium: PDFium hiện KHÔNG ghi lại được stream của Form XObject — CPDF_PageContentManager chỉ biết key /Contents (trang), form là stream tự thân nên nội dung regenerate rơi vào key /Contents vô nghĩa trên dict form, stream thật giữ nguyên. Mọi cách sửa "trong form" đều không thể lưu. - Engine mới: flatten_form_xobjects — rút từng con khỏi form (FPDFFormObj_RemoveObject), 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, mọi object thành cấp trang, sửa bằng đường page-level đã test kỹ. - Tauri: command edit_flatten_to_temp; UI loadEditPage thấy object nested → tự mở gói một lần rồi nạp lại (bản mở gói không tính là "có thay đổi"). - apply_edits: gỡ toàn bộ đường sửa-trong-form (NestedRecreate, rút con, đánh thức trang — đã chứng minh không thể lưu); op nhắm vào object trong form khi chưa flatten → lỗi rõ "trang chưa được mở gói" (có test). - Test viết lại theo flow flatten: 6 test (liệt kê nested, flatten giữ vị trí/nội dung, settext/reflow/delete sau flatten, từ chối op nested). Docs 12-phase4-summary.md cập nhật kết luận + giới hạn (mất clip/ExtGState mức form khi flatten; cấu trúc file đổi khi lưu). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
31 KiB
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 quaFPDFFormObj_CountObjects/GetObject(pdfium-render 0.8.37:PdfPageXObjectFormObject::len()/get()public; match thẳng variantPdfPageObject::XObjectFormđể giữ lifetime trang — accessoras_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.indexcủ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ânmat_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ứ:
FPDFText_SetTexttrê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);- rút con bằng
FPDFFormObj_RemoveObject+ làm trang dirty →ProcessFormCÓ chạy generator cho form, NHƯNGCPDF_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-renderpdfium_latest→pdfium_7350vì 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:loadEditPagethấy objectnested→ gọiedit_flatten_to_tempmộ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/formnested; 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.
Giới hạn ghi nhận (iteration 4 v1)
- Flatten làm MẤT clip path/ExtGState/transparency group đặt Ở MỨC FORM (file Canva wrapper toàn trang thường không có — hiển thị y hệt; file có clip ở form sẽ khác biệt, sẽ xử lý khi gặp thật).
- Cấu trúc file sau khi SỬA thay đổi (form được mở ra trang) — nội dung và hiển thị giữ nguyên; chỉ xảy ra khi người dùng thật sự lưu thay đổi.
- 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_advancequa 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):
- Font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó (glyph y hệt);
- 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); - 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 (
paragraphLinesgom 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—\nra đú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 (
\ngiữ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_LoadFontnạp LẠI font subset (Word xuất) rồiSetTextsẽ 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ấyPdfFontTokentừ 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 (
\ntừ ô 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_stylestheo 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-BoldMTgố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:
perLineAdvancestí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:
cssFontStacktá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_sizeengine, 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 đó (canvasmeasureTextactualBoundingBox, đ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-indenttừ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):
paragraphLinesgiờ 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_tempvớ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_stylethêm fallback đọc OS/2 usWeightClass ≥600 + cờ italic từ bytes font nhúng (ttf-parser);list_objectscache 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êmfontmatch::style_from_font_bytesparse sfnt thủ công: OS/2 usWeightClass/ fsSelection nếu còn, fallbackhead.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ọiedit_list_objectslấ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)
SetText3 tầng, ưu tiên GIỮ FONT GỐC (sửa tại chỗ bằngFPDFText_SetText, không xoá/tạo lại):- 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. - 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-matchtrên Linux; alias Helvetica→Arial, Times→Times New Roman…), có kiểm coverage trước khi nhận. - 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).
- 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
- Đổ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. ObjectInfothêmfont_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/AddTextnhậnfont_family/bold/italicdạng Option — None = giữ nguyên; Some = chủ động đổi (đổi font qua tầng 2).- Bẫy mới ghi nhận:
set_matrixcủa pdfium-render 0.8.37 là alias deprecated củaapply_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
nullcho 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_*.pdfcủa phiên sửa được xoá khi thoát chế độ/lưu (commandedit_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_textre-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ụngset_textan 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
PdfPageTextObjectlà 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ớiEditOp: 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 quaapply_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_indexbị đánh dấu unowned →DropgọiFPDFPageObj_Destroygây SEGFAULT với PDFium build hiện tại. Phảistd::mem::forgetobject đã 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 theoscale = 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_applyghi file mới rồiloadDocument.
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).