Phase 4 Iteration 2: sửa text GIỮ NGUYÊN FONT (chuẩn Foxit) + UX edit mượt

Engine:
- fontmatch.rs mới: làm sạch tên PostScript/subset, kiểm coverage cmap bằng
  ttf-parser, match font hệ thống CÙNG HỌ (bảng Windows/macOS, Liberation
  metric-compatible + fc-match trên Linux, alias base-14 Helvetica→Arial...)
- edit.rs: SetText quyết định 3 tầng — (1) sửa TẠI CHỖ giữ nguyên font gốc
  (charset-subset / base-14+ASCII / cmap phủ đủ, gồm font nhúng + tiếng Việt);
  (2) thiếu glyph → thay font cùng họ đúng đậm/nghiêng; (3) mới đến fallback.
  Đổi cỡ chữ = scale matrix neo baseline, không đụng font; fix bug phóng đại
  kép khi text có matrix scale. ObjectInfo thêm font_family/bold/italic/embedded.
  SetText/AddText nhận font_family + bold/italic dạng Option (None = giữ nguyên)
- annot.rs: fallback Linux có đủ biến thể đậm/nghiêng (DejaVu/Liberation)

UI (chuẩn trải nghiệm Foxit):
- Double-click sửa CẢ DÒNG (gộp run cùng baseline), commit 1 batch = 1 undo
- Ô sửa WYSIWYG: đúng font/cỡ/màu/đậm-nghiêng của run gốc khi gõ
- Toolbar Format: dropdown font "(giữ nguyên: X)", nút B/I theo kiểu thật,
  đổi cỡ/màu/kiểu gửi đúng field đó (null = giữ nguyên → không đổi font)
- Kéo di chuyển/resize có phản hồi trực tiếp (live), resize neo góc trên-trái
- Dọn file tạm ff_edit_* khi thoát/lưu (command edit_cleanup)

Test: 14 test edit (7 mới: giữ font ASCII/base-14, giữ font NHÚNG tiếng Việt,
match cùng họ, cỡ chữ neo + hồi quy matrix, bold override, gộp dòng) + 3 unit
fontmatch. Toàn bộ suite engine ngoài qpdf: 50/50 xanh (chạy trên Linux).

Docs: 12 (iteration 2), 13 (E17–E25), 14 MỚI — phân tích định hướng/công nghệ/
tiến độ so với tham vọng thay Foxit, gap còn lại + giải pháp; README cập nhật.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
2026-07-14 16:58:22 +00:00
co-authored by Claude Fable 5
parent e442a9f678
commit 1285a8d6ae
13 changed files with 1553 additions and 153 deletions
+44 -7
View File
@@ -334,14 +334,51 @@ pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
}
#[cfg(not(any(windows, target_os = "macos")))]
{
let _ = (bold, italic);
let candidates: &[&str] = &[
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
// [regular, bold, italic, bold-italic] cho các family Linux phổ biến.
const FAMILIES: &[[&str; 4]] = &[
[
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Oblique.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-BoldOblique.ttf",
],
[
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Italic.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-BoldItalic.ttf",
],
[
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Oblique.ttf",
"/usr/share/fonts/TTF/DejaVuSans-BoldOblique.ttf",
],
[
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-Oblique.ttf",
"/usr/share/fonts/dejavu/DejaVuSans-BoldOblique.ttf",
],
];
candidates.iter().find_map(|p| std::fs::read(p).ok())
let idx = match (bold, italic) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
for fam in FAMILIES {
if let Ok(bytes) = std::fs::read(fam[idx]) {
return Some(bytes);
}
}
// Không có biến thể đúng → hạ cấp về regular đầu tiên có sẵn.
for fam in FAMILIES {
if let Ok(bytes) = std::fs::read(fam[0]) {
return Some(bytes);
}
}
None
}
}
+359 -95
View File
@@ -3,22 +3,34 @@
//!
//! PDFium đã expose page object cấp cao nên KHÔNG cần tự parse content stream:
//! mỗi `PdfPageTextObject` là 1 text run sẵn để sửa (có text/font/size/matrix/
//! màu riêng). Xem khảo sát trong kế hoạch Phase 4.
//! màu riêng).
//!
//! Bài học tái dùng:
//! - Sửa/Thêm text dùng FULL font nhúng (`find_font_bytes` +
//! `load_true_type_from_bytes(..., true)`) để tiếng Việt hiển thị đúng dấu —
//! `set_text()` của PDFium re-encode theo font HIỆN TẠI (thường là subset)
//! nên glyph ngoài subset sẽ mất; vì vậy SetText = tạo lại object bằng full
//! font, giữ nguyên matrix/cỡ/màu gốc.
//! - PDFium cần `regenerate_content()` trước khi lưu, nếu không mất thay đổi.
//! ## Giữ font gốc khi sửa (chuẩn Foxit — iteration 2)
//! Sửa text KHÔNG được đổi font. Quyết định theo 3 tầng cho mỗi `SetText`:
//! 1. **Giữ nguyên font gốc, sửa tại chỗ** (`FPDFText_SetText` re-encode theo
//! charmap của font hiện tại) khi chắc chắn an toàn: text mới chỉ dùng ký tự
//! đã có trong text cũ, HOẶC cmap của font (đọc qua `FPDFFont_GetFontData`)
//! phủ đủ mọi ký tự mới. Đổi cỡ chữ tại chỗ = scale matrix (không đụng font).
//! 2. Font gốc thiếu glyph (subset) → thay bằng font hệ thống **cùng họ, đúng
//! đậm/nghiêng** (`fontmatch::find_family_font_bytes`) — gần như không nhìn
//! ra khác biệt.
//! 3. Bất đắc dĩ mới rơi về font mặc định (`annot::find_font_bytes`).
//!
//! Cỡ chữ trong `SetText`/`ObjectInfo` theo nghĩa **hiển thị** (đã nhân scale
//! của matrix — đúng như UI thấy). Tạo lại object phải quy đổi ngược về cỡ
//! "unscaled" trước khi áp matrix gốc, nếu không chữ sẽ phóng đại kép.
//!
//! Bài học PDFium giữ nguyên: `regenerate_content()` trước khi lưu; object gỡ
//! bằng `remove_object_at_index` phải `std::mem::forget` (Drop gọi
//! FPDFPageObj_Destroy → SEGFAULT).
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use pdfium_render::prelude::*;
use crate::annot::find_font_bytes;
use crate::fontmatch;
use crate::text::Rect;
use crate::EngineError;
@@ -67,9 +79,16 @@ pub struct ObjectInfo {
pub rect: Rect,
/// Chỉ với text: nội dung hiện tại.
pub text: Option<String>,
/// Chỉ với text: tên font.
/// Chỉ với text: tên font gốc trong PDF (có thể kèm prefix subset).
pub font_name: Option<String>,
/// Chỉ với text: cỡ chữ hiệu dụng (đã tính scale của matrix).
/// Chỉ với text: family đã làm sạch (để UI hiển thị + CSS xấp xỉ).
pub font_family: Option<String>,
/// Chỉ với text: font đậm / nghiêng (từ weight/italic-angle + tên font).
pub font_bold: Option<bool>,
pub font_italic: Option<bool>,
/// Chỉ với text: font có nhúng trong file không.
pub font_embedded: Option<bool>,
/// Chỉ với text: cỡ chữ hiển thị (đã tính scale của matrix).
pub font_size: Option<f32>,
/// Chỉ với text: màu chữ RGBA.
pub color: Option<[u8; 4]>,
@@ -81,14 +100,34 @@ pub enum EditOp {
/// Dịch (dx,dy) và/hoặc scale (sx,sy) object `index` — scale quanh góc
/// dưới-trái của object để giữ neo góc khi resize.
Transform { index: u16, dx: f32, dy: f32, sx: f32, sy: f32 },
/// Sửa nội dung text object `index` (tạo lại bằng full font, giữ matrix gốc).
SetText { index: u16, text: String, font_size: Option<f32>, color: Option<[u8; 4]>, bold: bool, italic: bool },
/// Sửa text object `index`. Mặc định GIỮ NGUYÊN font/cỡ/màu/kiểu gốc:
/// mọi field `None` nghĩa là "không đổi". `font_size` theo cỡ hiển thị.
/// `font_family`/`bold`/`italic` = Some(...) chủ động đổi font (match font
/// hệ thống cùng họ).
SetText {
index: u16,
text: String,
font_size: Option<f32>,
color: Option<[u8; 4]>,
font_family: Option<String>,
bold: Option<bool>,
italic: Option<bool>,
},
/// Xoá object `index`.
Delete { index: u16 },
/// Thay ảnh của image object `index` bằng ảnh từ `image_path`, giữ khung cũ.
ReplaceImage { index: u16, image_path: String },
/// Thêm text box mới tại (x,y) (gốc dưới-trái, điểm PDF).
AddText { x: f32, y: f32, text: String, font_size: f32, color: [u8; 4], bold: bool, italic: bool },
AddText {
x: f32,
y: f32,
text: String,
font_size: f32,
color: [u8; 4],
font_family: Option<String>,
bold: bool,
italic: bool,
},
/// Thêm ảnh mới từ file, khung width_pt × height_pt tại (x,y).
AddImage { x: f32, y: f32, width_pt: f32, height_pt: f32, image_path: String },
}
@@ -102,6 +141,29 @@ fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
}
}
/// Kiểu chữ (đậm/nghiêng) của 1 text object, tổng hợp từ weight, italic-angle
/// và tên font. Family lấy từ `name()` (BaseFont khai báo trong PDF) — với
/// font KHÔNG nhúng, `family()` trả tên font stub nội bộ của PDFium ("Chrom
/// Sans OTF") chứ không phải font thật, nên chỉ dùng làm fallback.
fn text_object_style(t: &PdfPageTextObject) -> (String, bool, bool) {
let raw_name = t.font().name();
let family_src = if raw_name.trim().is_empty() {
t.font().family()
} else {
raw_name
};
let (family, name_bold, name_italic) = fontmatch::clean_font_name(&family_src);
let weight_bold = matches!(
t.font().weight(),
Ok(PdfFontWeight::Weight600)
| Ok(PdfFontWeight::Weight700Bold)
| Ok(PdfFontWeight::Weight800)
| Ok(PdfFontWeight::Weight900)
) || matches!(t.font().weight(), Ok(PdfFontWeight::Custom(v)) if v >= 600);
let angle_italic = t.font().italic_angle().map(|a| a != 0).unwrap_or(false);
(family, weight_bold || name_bold, angle_italic || name_italic)
}
/// Liệt kê các page object của 1 trang để UI vẽ overlay chỉnh sửa.
pub fn list_objects(
pdfium: &Pdfium,
@@ -125,43 +187,89 @@ pub fn list_objects(
.map(|q| quad_to_rect(&q))
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (text, font_name, font_size, color) = if let Some(t) = object.as_text_object() {
let c = t.fill_color().ok();
(
Some(t.text()),
Some(t.font().name()),
Some(t.scaled_font_size().value),
c.map(|c| [c.red(), c.green(), c.blue(), c.alpha()]),
)
} else {
(None, None, None, None)
};
out.push(ObjectInfo {
let mut info = ObjectInfo {
index: i as u16,
kind,
rect,
text,
font_name,
font_size,
color,
});
text: None,
font_name: None,
font_family: None,
font_bold: None,
font_italic: None,
font_embedded: None,
font_size: None,
color: None,
};
if let Some(t) = object.as_text_object() {
let (family, bold, italic) = text_object_style(t);
info.text = Some(t.text());
info.font_name = Some(t.font().name());
info.font_family = Some(family);
info.font_bold = Some(bold);
info.font_italic = Some(italic);
info.font_embedded = t.font().is_embedded().ok();
info.font_size = Some(t.scaled_font_size().value);
info.color = t
.fill_color()
.ok()
.map(|c| [c.red(), c.green(), c.blue(), c.alpha()]);
}
out.push(info);
}
Ok(out)
}
/// Key nhận diện 1 font thay thế cần nạp: (family chuẩn hoá — rỗng = font mặc
/// định, đậm, nghiêng).
type FontKey = (String, bool, bool);
/// Cách xử lý 1 op SetText, quyết định ở pha lập kế hoạch.
enum SetTextMode {
/// Giữ nguyên font gốc — sửa text/cỡ/màu ngay trên object.
InPlace,
/// Phải thay font (glyph thiếu hoặc người dùng đổi family/kiểu) — xoá và
/// tạo lại object với font `key`.
Substitute(FontKey),
}
/// Dữ liệu chụp lại từ 1 object trước khi xoá để tạo lại bản thay thế.
struct Captured {
matrix: PdfMatrix,
unscaled_font_size: f32,
scaled_font_size: f32,
color: PdfColor,
rect: Rect,
}
/// Chọn font thay thế cho (family mong muốn, kiểu chữ, text cần hiển thị):
/// font hệ thống cùng họ nếu có + đủ glyph, ngược lại font mặc định.
fn resolve_substitute_font(
family: &str,
bold: bool,
italic: bool,
text: &str,
) -> Result<(FontKey, Vec<u8>), EngineError> {
if !family.trim().is_empty() {
if let Some(bytes) = fontmatch::find_family_font_bytes(family, bold, italic) {
if fontmatch::coverage_ok(&bytes, text) {
return Ok(((fontmatch::normalize_key(family), bold, italic), bytes));
}
}
}
let bytes = find_font_bytes(bold, italic).ok_or_else(|| {
EngineError::Pdfium("không tìm được font hệ thống để sửa/thêm chữ".into())
})?;
Ok(((String::new(), bold, italic), bytes))
}
/// Áp danh sách `ops` lên trang `page_index` của `input`, ghi ra `output`.
/// Không sửa `input`. Thứ tự xử lý giữ index gốc hợp lệ: Transform in-place →
/// chụp dữ liệu object sắp thay/xoá → xoá theo index GIẢM DẦN → thêm bản thay
/// thế → thêm object mới → regenerate → lưu.
/// Không sửa `input`.
///
/// Trình tự: (A) lập kế hoạch font cho SetText/AddText (đọc trang lần 1) →
/// (B) nạp các font thay thế cần thiết vào document → (C) áp thay đổi (đọc
/// trang lần 2): Transform in-place → SetText in-place (giữ font gốc) → 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 → lưu.
pub fn apply_edits(
pdfium: &Pdfium,
input: &Path,
@@ -174,32 +282,109 @@ pub fn apply_edits(
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
// Nạp trước token cho mọi tổ hợp (bold,italic) cần dùng (SetText/AddText) —
// phải làm TRƯỚC khi mượn page vì cần `document.fonts_mut()`.
let mut needed_fonts: Vec<(bool, bool)> = Vec::new();
for op in ops {
match op {
EditOp::SetText { bold, italic, .. } | EditOp::AddText { bold, italic, .. } => {
if !needed_fonts.contains(&(*bold, *italic)) {
needed_fonts.push((*bold, *italic));
}
}
_ => {}
}
}
let mut tokens: HashMap<(bool, bool), PdfFontToken> = HashMap::new();
for (bold, italic) in needed_fonts {
let bytes = find_font_bytes(bold, italic)
.ok_or_else(|| EngineError::Pdfium("không tìm được font hệ thống để sửa/thêm chữ".into()))?;
let token = document
.fonts_mut()
.load_true_type_from_bytes(&bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?;
tokens.insert((bold, italic), token);
}
let err = |e: PdfiumError| EngineError::Pdfium(format!("sửa nội dung: {e}"));
// ---- (A) Lập kế hoạch font (mượn trang lần 1, chỉ đọc) ----
let mut set_text_modes: HashMap<usize, SetTextMode> = HashMap::new();
let mut add_text_keys: HashMap<usize, FontKey> = HashMap::new();
let mut font_bytes_needed: HashMap<FontKey, Vec<u8>> = HashMap::new();
{
let page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
let obj_count = page.objects().len();
let valid = |i: u16| (i as usize) < obj_count;
for (opi, op) in ops.iter().enumerate() {
match op {
EditOp::SetText { index, text, font_family, bold, italic, .. } => {
if !valid(*index) {
continue;
}
let obj = page.objects().get(*index as usize).map_err(err)?;
let t = match obj.as_text_object() {
Some(t) => t,
None => continue, // không phải text → bỏ qua op
};
let (cur_family, cur_bold, cur_italic) = text_object_style(t);
let target_bold = bold.unwrap_or(cur_bold);
let target_italic = italic.unwrap_or(cur_italic);
let style_change = target_bold != cur_bold || target_italic != cur_italic;
let family_change = font_family.as_deref().map_or(false, |f| {
!f.trim().is_empty()
&& fontmatch::normalize_key(f) != fontmatch::normalize_key(&cur_family)
});
let mode = if !style_change && !family_change {
let old_text = t.text();
let old_chars: HashSet<char> = old_text.chars().collect();
let new_covered_by_old = text
.chars()
.all(|c| c.is_control() || old_chars.contains(&c));
// Font KHÔNG nhúng (base-14 Helvetica/Times...): viewer nào
// cũng tự thay bằng font hệ thống đủ Latin → re-encode text
// ASCII tại chỗ chắc chắn an toàn, BaseFont khai báo giữ
// nguyên trong file (đúng hành vi Foxit với base-14).
let non_embedded = !t.font().is_embedded().unwrap_or(true);
let ascii_only = text
.chars()
.all(|c| c.is_control() || (' '..='~').contains(&c));
if *text == old_text || new_covered_by_old {
SetTextMode::InPlace
} else if non_embedded && ascii_only {
SetTextMode::InPlace
} else if t
.font()
.data()
.ok()
.map_or(false, |bytes| fontmatch::coverage_ok(&bytes, text))
{
SetTextMode::InPlace
} else {
let (key, bytes) = resolve_substitute_font(
&cur_family,
target_bold,
target_italic,
text,
)?;
font_bytes_needed.entry(key.clone()).or_insert(bytes);
SetTextMode::Substitute(key)
}
} else {
let family = font_family
.clone()
.filter(|f| !f.trim().is_empty())
.unwrap_or(cur_family);
let (key, bytes) =
resolve_substitute_font(&family, target_bold, target_italic, text)?;
font_bytes_needed.entry(key.clone()).or_insert(bytes);
SetTextMode::Substitute(key)
};
set_text_modes.insert(opi, mode);
}
EditOp::AddText { text, font_family, bold, italic, .. } => {
let family = font_family.clone().unwrap_or_default();
let (key, bytes) = resolve_substitute_font(&family, *bold, *italic, text)?;
font_bytes_needed.entry(key.clone()).or_insert(bytes);
add_text_keys.insert(opi, key);
}
_ => {}
}
}
}
// ---- (B) Nạp các font thay thế cần dùng (cần fonts_mut → ngoài mượn trang) ----
let mut tokens: HashMap<FontKey, PdfFontToken> = HashMap::new();
for (key, bytes) in &font_bytes_needed {
let token = document
.fonts_mut()
.load_true_type_from_bytes(bytes, true)
.map_err(|e| EngineError::Pdfium(format!("nạp font: {e}")))?;
tokens.insert(key.clone(), token);
}
// ---- (C) Áp thay đổi (mượn trang lần 2) ----
{
let mut page = document
.pages()
@@ -231,73 +416,150 @@ pub fn apply_edits(
}
}
// (2) Chụp dữ liệu cho object sắp THAY (SetText/ReplaceImage) — sau khi
// đã Transform để bản thay thế kế thừa cả phép biến đổi đó.
// (2) SetText IN-PLACE — giữ nguyên font gốc (chuẩn Foxit). Đổi cỡ chữ
// = scale matrix quanh gốc text (giữ điểm neo baseline e,f).
for (opi, op) in ops.iter().enumerate() {
let EditOp::SetText { index, text, font_size, color, .. } = op else {
continue;
};
if !matches!(set_text_modes.get(&opi), Some(SetTextMode::InPlace)) {
continue;
}
let mut obj = page.objects().get(*index as usize).map_err(err)?;
let (old_text, scaled) = match obj.as_text_object() {
Some(t) => (t.text(), t.scaled_font_size().value),
None => continue,
};
if *text != old_text {
obj.as_text_object_mut()
.ok_or_else(|| EngineError::Pdfium("object không phải text".into()))?
.set_text(text)
.map_err(err)?;
}
if let Some(target) = font_size {
if scaled > 0.01 && (*target - scaled).abs() > 0.01 {
let k = *target / scaled;
let m = obj.matrix().map_err(err)?;
// PDFium post-multiply: M' = M · [k,0,0,k, e(1−k), f(1−k)]
// → phần tuyến tính nhân k (đổi cỡ), translation (e,f) giữ
// nguyên (neo tại gốc baseline như Foxit).
obj.apply_matrix(PdfMatrix::new(
k,
0.0,
0.0,
k,
m.e() * (1.0 - k),
m.f() * (1.0 - k),
))
.map_err(err)?;
}
}
if let Some(c) = color {
obj.set_fill_color(PdfColor::new(c[0], c[1], c[2], c[3])).map_err(err)?;
}
}
// (3) Chụp dữ liệu cho object sắp THAY (SetText-substitute/ReplaceImage)
// — sau Transform để bản thay thế kế thừa cả phép biến đổi đó.
let mut captured: HashMap<u16, Captured> = HashMap::new();
for op in ops {
for (opi, op) in ops.iter().enumerate() {
let idx = match op {
EditOp::SetText { index, .. } | EditOp::ReplaceImage { index, .. } => *index,
EditOp::SetText { index, .. }
if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) =>
{
*index
}
EditOp::ReplaceImage { index, .. } => *index,
_ => continue,
};
if !valid(idx) || captured.contains_key(&idx) {
continue;
}
let obj = page.objects().get(idx as usize).map_err(err)?;
let rect = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (matrix, unscaled, color) = if let Some(t) = obj.as_text_object() {
let rect = obj
.bounds()
.map(|q| quad_to_rect(&q))
.unwrap_or(Rect { left: 0.0, bottom: 0.0, right: 0.0, top: 0.0 });
let (matrix, unscaled, scaled, color) = if let Some(t) = obj.as_text_object() {
(
obj.matrix().map_err(err)?,
t.unscaled_font_size().value,
t.scaled_font_size().value,
t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)),
)
} else {
(obj.matrix().map_err(err)?, 0.0, PdfColor::new(0, 0, 0, 255))
(obj.matrix().map_err(err)?, 0.0, 0.0, PdfColor::new(0, 0, 0, 255))
};
captured.insert(idx, Captured { matrix, unscaled_font_size: unscaled, color, rect });
captured.insert(
idx,
Captured { matrix, unscaled_font_size: unscaled, scaled_font_size: scaled, color, rect },
);
}
// (3) Xoá mọi index thuộc SetText/ReplaceImage/Delete theo GIẢM DẦN.
let mut to_remove: Vec<u16> = ops
.iter()
.filter_map(|op| match op {
// (4) Xoá mọi index thuộc SetText-substitute/ReplaceImage/Delete GIẢM DẦN.
let mut to_remove: Vec<u16> = Vec::new();
for (opi, op) in ops.iter().enumerate() {
match op {
EditOp::SetText { index, .. }
| EditOp::ReplaceImage { index, .. }
| EditOp::Delete { index } => Some(*index),
_ => None,
})
.filter(|i| valid(*i))
.collect();
if matches!(set_text_modes.get(&opi), Some(SetTextMode::Substitute(_))) =>
{
to_remove.push(*index)
}
EditOp::ReplaceImage { index, .. } | EditOp::Delete { index } => {
to_remove.push(*index)
}
_ => {}
}
}
to_remove.retain(|i| valid(*i));
to_remove.sort_unstable();
to_remove.dedup();
for idx in to_remove.into_iter().rev() {
let removed = page.objects_mut().remove_object_at_index(idx as usize).map_err(err)?;
// BẪY PDFium: object vừa tách khỏi trang bị đánh dấu "unowned" → Drop
// gọi FPDFPageObj_Destroy, mà destroy object vốn thuộc document gây
// SEGFAULT (chính pdfium-render cũng cảnh báo điều này trong Drop của
// PdfPageObject). Object đã được FPDFPage_RemoveObject tách ra nên sẽ
// không còn render/lưu; ta `forget` để KHỎI gọi destroy. Rò rỉ không
// đáng kể (giải phóng khi document đóng ngay sau khi lưu).
// SEGFAULT. Object đã tách nên không còn render/lưu; `forget` để khỏi
// destroy (rò rỉ nhỏ, giải phóng khi đóng document).
std::mem::forget(removed);
}
// (4) Thêm bản thay thế cho SetText / ReplaceImage.
for op in ops {
// (5) Thêm bản thay thế cho SetText-substitute / ReplaceImage.
for (opi, op) in ops.iter().enumerate() {
match op {
EditOp::SetText { index, text, font_size, color, bold, italic } => {
EditOp::SetText { index, text, font_size, color, .. } => {
let key = match set_text_modes.get(&opi) {
Some(SetTextMode::Substitute(key)) => key,
_ => continue,
};
let cap = match captured.get(index) {
Some(c) => c,
None => continue,
};
let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| {
let token = tokens.get(key).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho SetText".into())
})?;
let size = font_size.unwrap_or(cap.unscaled_font_size).max(1.0);
// Quy đổi cỡ hiển thị mong muốn → cỡ unscaled trước khi áp
// matrix gốc (matrix có thể chứa scale — tránh phóng đại kép).
let tf = match font_size {
Some(target) if cap.scaled_font_size > 0.01 => {
cap.unscaled_font_size * (*target / cap.scaled_font_size)
}
Some(target) => *target,
None => cap.unscaled_font_size,
}
.max(1.0);
let mut obj = page
.objects_mut()
.create_text_object(PdfPoints::ZERO, PdfPoints::ZERO, text.clone(), token, PdfPoints::new(size))
.create_text_object(
PdfPoints::ZERO,
PdfPoints::ZERO,
text.clone(),
token,
PdfPoints::new(tf),
)
.map_err(err)?;
// Object mới tạo tại gốc (0,0) cỡ unscaled → matrix ~identity,
// nên apply_matrix(matrix gốc) tương đương đặt lại đúng vị trí/scale.
// Object mới tạo tại gốc (0,0) → matrix ~identity, nên
// apply_matrix(matrix gốc) đặt lại đúng vị trí/scale.
obj.apply_matrix(cap.matrix).map_err(err)?;
let c = color
.map(|c| PdfColor::new(c[0], c[1], c[2], c[3]))
@@ -327,13 +589,15 @@ pub fn apply_edits(
}
}
// (5) Thêm object MỚI (AddText / AddImage).
for op in ops {
// (6) Thêm object MỚI (AddText / AddImage).
for (opi, op) in ops.iter().enumerate() {
match op {
EditOp::AddText { x, y, text, font_size, color, bold, italic } => {
let token = tokens.get(&(*bold, *italic)).copied().ok_or_else(|| {
EngineError::Pdfium("thiếu token font cho AddText".into())
})?;
EditOp::AddText { x, y, text, font_size, color, .. } => {
let token = add_text_keys
.get(&opi)
.and_then(|k| tokens.get(k))
.copied()
.ok_or_else(|| EngineError::Pdfium("thiếu token font cho AddText".into()))?;
let mut obj = page
.objects_mut()
.create_text_object(
+233
View File
@@ -0,0 +1,233 @@
//! Khớp & kiểm tra font cho chỉnh sửa text (Phase 4 iteration 2).
//!
//! Mục tiêu chuẩn Foxit: sửa text KHÔNG được đổi font. Chiến lược 3 tầng
//! (dùng ở `edit.rs`):
//! 1. Giữ nguyên font gốc của object nếu font mã hoá được text mới
//! (`FPDFText_SetText` dùng charmap của font hiện tại) — kiểm bằng
//! charset-subset hoặc cmap của font bytes (`coverage_ok`).
//! 2. Nếu font gốc thiếu glyph (subset) → tìm font HỆ THỐNG CÙNG HỌ
//! (`find_family_font_bytes`) đúng đậm/nghiêng — trông gần như y hệt.
//! 3. Bất đắc dĩ mới rơi về font mặc định (`annot::find_font_bytes`).
/// Chuẩn hoá tên family để so khớp: chữ thường, chỉ giữ chữ+số.
pub(crate) fn normalize_key(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_lowercase())
.collect()
}
/// Tách tên font PDF (PostScript name) thành (family sạch, đậm?, nghiêng?).
/// Ví dụ: "ABCDEF+TimesNewRomanPS-BoldItalicMT" → ("TimesNewRoman", true, true).
pub(crate) fn clean_font_name(raw: &str) -> (String, bool, bool) {
let mut name = raw.trim();
// Bỏ prefix subset "ABCDEF+".
let bytes = name.as_bytes();
if bytes.len() > 7 && bytes[6] == b'+' && bytes[..6].iter().all(|b| b.is_ascii_uppercase()) {
name = &name[7..];
}
let lower = name.to_ascii_lowercase();
let bold = ["bold", "black", "heavy", "semibold", "demibold"]
.iter()
.any(|k| lower.contains(k));
let italic = lower.contains("italic") || lower.contains("oblique");
// Family = phần trước dấu '-' hoặc ',' đầu tiên (quy ước PostScript).
let mut family = name.split(['-', ',']).next().unwrap_or(name).to_string();
// Bỏ từ khoá kiểu dính liền cuối family ("ArialBold", "CalibriItalic"...).
let lower_fam = family.to_ascii_lowercase();
for suffix in ["bolditalic", "boldoblique", "bold", "italic", "oblique", "regular"] {
if lower_fam.ends_with(suffix) && lower_fam.len() > suffix.len() {
family.truncate(family.len() - suffix.len());
break;
}
}
// Bỏ hậu tố PostScript vô nghĩa với người dùng.
for suffix in ["PSMT", "PS", "MT"] {
if family.ends_with(suffix) && family.len() > suffix.len() {
family.truncate(family.len() - suffix.len());
}
}
(family.trim().to_string(), bold, italic)
}
/// Font bytes (TTF/OTF/TTC) có glyph cho MỌI ký tự của `text` không?
/// Parse hỏng (Type1 thuần...) → false (caller sẽ thử đường khác).
pub(crate) fn coverage_ok(font_bytes: &[u8], text: &str) -> bool {
let n = ttf_parser::fonts_in_collection(font_bytes).unwrap_or(1).max(1);
'face: for i in 0..n {
let face = match ttf_parser::Face::parse(font_bytes, i) {
Ok(f) => f,
Err(_) => continue,
};
for ch in text.chars() {
if ch.is_control() {
continue;
}
if face.glyph_index(ch).is_none() {
continue 'face;
}
}
return true;
}
false
}
/// Tìm font HỆ THỐNG cùng family (đúng đậm/nghiêng) — trả về bytes TTF.
/// Family so khớp mềm theo `normalize_key` (chứa nhau, tối thiểu 4 ký tự).
pub(crate) fn find_family_font_bytes(family: &str, bold: bool, italic: bool) -> Option<Vec<u8>> {
let mut key = normalize_key(family);
if key.is_empty() {
return None;
}
// Alias các tên chuẩn PDF (base-14) → family hệ thống tương đương.
if key == "helvetica" || key == "helveticaneue" {
key = "arial".into();
} else if key == "times" || key == "timesroman" {
key = "timesnewroman".into();
} else if key == "courier" {
key = "couriernew".into();
}
let style_idx = match (bold, italic) {
(false, false) => 0,
(true, false) => 1,
(false, true) => 2,
(true, true) => 3,
};
#[cfg(windows)]
{
// (key, [regular, bold, italic, bold-italic]) trong C:\Windows\Fonts.
const TABLE: &[(&str, [&str; 4])] = &[
("timesnewroman", ["times.ttf", "timesbd.ttf", "timesi.ttf", "timesbi.ttf"]),
("arial", ["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"]),
("calibri", ["calibri.ttf", "calibrib.ttf", "calibrii.ttf", "calibriz.ttf"]),
("cambria", ["cambria.ttc", "cambriab.ttf", "cambriai.ttf", "cambriaz.ttf"]),
("candara", ["candara.ttf", "candarab.ttf", "candarai.ttf", "candaraz.ttf"]),
("georgia", ["georgia.ttf", "georgiab.ttf", "georgiai.ttf", "georgiaz.ttf"]),
("verdana", ["verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf"]),
("tahoma", ["tahoma.ttf", "tahomabd.ttf", "tahoma.ttf", "tahomabd.ttf"]),
("trebuchetms", ["trebuc.ttf", "trebucbd.ttf", "trebucit.ttf", "trebucbi.ttf"]),
("couriernew", ["cour.ttf", "courbd.ttf", "couri.ttf", "courbi.ttf"]),
("segoeui", ["segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf", "segoeuiz.ttf"]),
("consolas", ["consola.ttf", "consolab.ttf", "consolai.ttf", "consolaz.ttf"]),
("comicsansms", ["comic.ttf", "comicbd.ttf", "comici.ttf", "comicz.ttf"]),
("impact", ["impact.ttf", "impact.ttf", "impact.ttf", "impact.ttf"]),
("garamond", ["gara.ttf", "garabd.ttf", "garait.ttf", "garabd.ttf"]),
("palatinolinotype", ["pala.ttf", "palab.ttf", "palai.ttf", "palabi.ttf"]),
];
for (k, files) in TABLE {
if keys_match(&key, k) {
if let Ok(bytes) = std::fs::read(format!(r"C:\Windows\Fonts\{}", files[style_idx])) {
return Some(bytes);
}
}
}
None
}
#[cfg(target_os = "macos")]
{
const TABLE: &[(&str, &str)] = &[
("timesnewroman", "Times New Roman"),
("arial", "Arial"),
("georgia", "Georgia"),
("verdana", "Verdana"),
("tahoma", "Tahoma"),
("trebuchetms", "Trebuchet MS"),
("couriernew", "Courier New"),
("comicsansms", "Comic Sans MS"),
("impact", "Impact"),
];
let style = ["", " Bold", " Italic", " Bold Italic"][style_idx];
for (k, display) in TABLE {
if keys_match(&key, k) {
for dir in ["/System/Library/Fonts/Supplemental", "/Library/Fonts"] {
if let Ok(bytes) = std::fs::read(format!("{dir}/{display}{style}.ttf")) {
return Some(bytes);
}
}
}
}
None
}
#[cfg(not(any(windows, target_os = "macos")))]
{
// 1) Liberation: tương thích metric với Arial/Times/Courier — ưu tiên.
let lib = match key.as_str() {
"arial" => Some("LiberationSans"),
"timesnewroman" => Some("LiberationSerif"),
"couriernew" => Some("LiberationMono"),
_ => None,
};
if let Some(base) = lib {
let style = ["Regular", "Bold", "Italic", "BoldItalic"][style_idx];
for dir in [
"/usr/share/fonts/truetype/liberation",
"/usr/share/fonts/liberation",
"/usr/share/fonts/TTF",
] {
if let Ok(bytes) = std::fs::read(format!("{dir}/{base}-{style}.ttf")) {
return Some(bytes);
}
}
}
// 2) fontconfig: fc-match trả family+file; chỉ nhận khi family khớp thật
// (fc-match luôn trả "gần nhất" kể cả khi không có font đó).
let style = ["Regular", "Bold", "Italic", "Bold Italic"][style_idx];
let pattern = format!("{family}:style={style}");
if let Ok(out) = std::process::Command::new("fc-match")
.args(["-f", "%{family}\t%{file}", &pattern])
.output()
{
if out.status.success() {
let s = String::from_utf8_lossy(&out.stdout);
if let Some((fam, file)) = s.trim().split_once('\t') {
if keys_match(&key, &normalize_key(fam)) {
if let Ok(bytes) = std::fs::read(file) {
return Some(bytes);
}
}
}
}
}
None
}
}
/// So khớp mềm 2 key đã chuẩn hoá: bằng nhau, hoặc chứa nhau (đủ dài để tránh nhiễu).
fn keys_match(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let (short, long) = if a.len() <= b.len() { (a, b) } else { (b, a) };
short.len() >= 4 && long.contains(short)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_subset_postscript_name() {
let (fam, bold, italic) = clean_font_name("ABCDEF+TimesNewRomanPS-BoldItalicMT");
assert_eq!(fam, "TimesNewRoman");
assert!(bold);
assert!(italic);
}
#[test]
fn clean_simple_names() {
assert_eq!(clean_font_name("Helvetica"), ("Helvetica".into(), false, false));
assert_eq!(clean_font_name("Arial-Bold"), ("Arial".into(), true, false));
assert_eq!(clean_font_name("CalibriItalic"), ("Calibri".into(), false, true));
}
#[test]
fn keys_match_soft() {
assert!(keys_match("timesnewroman", "timesnewromanps"));
assert!(!keys_match("arial", "georgia"));
assert!(keys_match("arial", "arial"));
}
}
+1
View File
@@ -6,6 +6,7 @@
pub mod annot;
pub mod edit;
pub(crate) mod fontmatch;
pub mod meta;
pub mod organize;
pub mod qpdf;
+399 -4
View File
@@ -70,8 +70,9 @@ fn set_text_replaces_run_content() {
text: "Edited line ABC".into(),
font_size: None,
color: None,
bold: false,
italic: false,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
@@ -100,8 +101,9 @@ fn set_text_vietnamese_round_trips() {
text: vi.into(),
font_size: None,
color: None,
bold: false,
italic: false,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
@@ -168,6 +170,7 @@ fn add_text_appears_in_extract() {
text: "ADDEDXYZ".into(),
font_size: 14.0,
color: [10, 20, 30, 255],
font_family: None,
bold: false,
italic: false,
}],
@@ -180,6 +183,398 @@ fn add_text_appears_in_extract() {
assert!(text.contains("ADDEDXYZ"), "text thêm mới phải xuất hiện: {text:?}");
}
/// CHUẨN FOXIT (iteration 2): sửa text ASCII trên font base-14 (Helvetica,
/// không nhúng) phải GIỮ NGUYÊN BaseFont — không đổi font. Text mới cố ý dùng
/// ký tự ngoài text cũ để không "ăn may" qua luật charset-subset.
#[test]
fn set_text_keeps_original_font() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_keepfont.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let font_before = before
.iter()
.find(|o| o.index == idx)
.and_then(|o| o.font_name.clone())
.expect("font gốc");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Fixed by editor 2026 JQXZ!".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits SetText");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("JQXZ")).unwrap_or(false))
.expect("run đã sửa");
assert_eq!(
edited.font_name.as_deref(),
Some(font_before.as_str()),
"sửa text ASCII phải GIỮ NGUYÊN font (chuẩn Foxit)"
);
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Fixed by editor"), "text mới phải có mặt: {text:?}");
}
/// Tiếng Việt trên font base-14 (KHÔNG có glyph Việt ở bất kỳ đâu để giữ):
/// phải thay bằng font hệ thống CÙNG HỌ metric-compatible (Helvetica→Arial/
/// LiberationSans), tuyệt đối không rơi bừa về font generic.
#[test]
fn vietnamese_on_base14_uses_matched_family() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_vnmatch.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Thay thế hoàn chỉnh".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits VI");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("hoàn chỉnh")).unwrap_or(false))
.expect("run đã sửa");
let font = edited.font_name.clone().unwrap_or_default();
#[cfg(not(any(windows, target_os = "macos")))]
assert!(
font.contains("Liberation"),
"Helvetica+VI phải match LiberationSans (metric-compatible), được {font:?}"
);
#[cfg(windows)]
assert!(font.to_lowercase().contains("arial"), "Helvetica+VI phải match Arial, được {font:?}");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("hoàn chỉnh"), "tiếng Việt đúng dấu: {text:?}");
}
/// Case quan trọng nhất với tài liệu Việt thực tế: font NHÚNG đầy đủ glyph →
/// sửa sang nội dung Việt hoàn toàn khác phải GIỮ NGUYÊN font nhúng (sửa tại
/// chỗ, không tạo lại bằng font khác).
#[test]
fn set_text_preserves_embedded_font_vietnamese() {
let pdf = pdfium();
let input = sample();
let step1 = tmp("ff_edit_emb1.pdf");
let step2 = tmp("ff_edit_emb2.pdf");
// B1: tạo run với font nhúng FULL (AddText nhúng nguyên font hệ thống).
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddText {
x: 60.0,
y: 300.0,
text: "Chào FoFreeXit".into(),
font_size: 18.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
}],
&step1,
None,
)
.expect("add embedded run");
let idx = find_text_index(&pdf, &step1, "Chào");
let mid = ff_engine::list_objects(&pdf, &step1, 0, None).expect("list mid");
let run = mid.iter().find(|o| o.index == idx).expect("run");
assert_eq!(run.font_embedded, Some(true), "fixture phải là font nhúng");
let font_before = run.font_name.clone().expect("tên font nhúng");
// B2: sửa sang câu Việt khác hẳn → phải giữ nguyên font nhúng.
ff_engine::apply_edits(
&pdf,
&step1,
0,
&[EditOp::SetText {
index: idx,
text: "Đã kiểm định — sửa giữ font nhúng".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
}],
&step2,
None,
)
.expect("edit embedded run");
let after = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("kiểm định")).unwrap_or(false))
.expect("run đã sửa");
assert_eq!(
edited.font_name.as_deref(),
Some(font_before.as_str()),
"font NHÚNG phải được giữ nguyên khi sửa tiếng Việt"
);
let text = ff_engine::extract_text(&pdf, &step2, 0, None).expect("extract");
assert!(text.contains("sửa giữ font nhúng"), "text round-trip: {text:?}");
}
/// Đổi CỠ CHỮ (không đổi nội dung) phải giữ nguyên font + text, cỡ mới đúng
/// theo nghĩa hiển thị và vị trí neo (left) không trôi.
#[test]
fn font_size_change_keeps_font_and_anchors() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_size.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let orig = before.iter().find(|o| o.index == idx).expect("obj").clone();
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: orig.text.clone().unwrap_or_default(),
font_size: Some(30.0),
color: None,
font_family: None,
bold: None,
italic: None,
}],
&out,
None,
)
.expect("apply_edits size");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let got = after.iter().find(|o| o.index == idx).expect("obj out");
assert_eq!(got.font_name, orig.font_name, "đổi cỡ không được đổi font");
assert_eq!(got.text, orig.text, "đổi cỡ không được đổi nội dung");
let sz = got.font_size.expect("size");
assert!((sz - 30.0).abs() < 0.5, "cỡ hiển thị phải ≈30, được {sz}");
assert!(
(got.rect.left - orig.rect.left).abs() < 2.0,
"điểm neo trái không được trôi: {} -> {}",
orig.rect.left,
got.rect.left
);
}
/// Hồi quy bug phóng đại kép: text có matrix scale (Tf nhỏ × matrix lớn) —
/// đặt cỡ hiển thị 20pt phải ra đúng ~20pt, không nhân đôi theo matrix.
#[test]
fn font_size_change_respects_matrix_scale() {
let pdf = pdfium();
let input = sample();
let step1 = tmp("ff_edit_mtx1.pdf");
let step2 = tmp("ff_edit_mtx2.pdf");
let step3 = tmp("ff_edit_mtx3.pdf");
// Tạo run 16pt rồi scale ×2 qua Transform → cỡ hiển thị 32pt, matrix scale 2.
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::AddText {
x: 80.0,
y: 200.0,
text: "MATRIXCASE".into(),
font_size: 16.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
}],
&step1,
None,
)
.expect("add");
let idx = find_text_index(&pdf, &step1, "MATRIXCASE");
ff_engine::apply_edits(
&pdf,
&step1,
0,
&[EditOp::Transform { index: idx, dx: 0.0, dy: 0.0, sx: 2.0, sy: 2.0 }],
&step2,
None,
)
.expect("scale");
let mid = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list mid");
let scaled = mid.iter().find(|o| o.index == idx).and_then(|o| o.font_size).expect("size mid");
assert!((scaled - 32.0).abs() < 1.0, "sau scale ×2 phải ~32pt, được {scaled}");
// Đặt cỡ hiển thị 20 — phải ra ~20 (bug cũ: tạo Tf=20 rồi nhân matrix 2 → 40).
ff_engine::apply_edits(
&pdf,
&step2,
0,
&[EditOp::SetText {
index: idx,
text: "MATRIXCASE".into(),
font_size: Some(20.0),
color: None,
font_family: None,
bold: None,
italic: None,
}],
&step3,
None,
)
.expect("resize");
let fin = ff_engine::list_objects(&pdf, &step3, 0, None).expect("list fin");
let got = fin.iter().find(|o| o.index == idx).and_then(|o| o.font_size).expect("size fin");
assert!((got - 20.0).abs() < 0.5, "cỡ hiển thị phải ≈20, được {got}");
}
/// Ép ĐẬM qua override: font phải đổi sang biến thể đậm (khác font gốc) nhưng
/// nội dung (kể cả tiếng Việt) round-trip nguyên vẹn.
#[test]
fn bold_override_substitutes_font_and_keeps_text() {
let pdf = pdfium();
let input = sample();
let out = tmp("ff_edit_bold.pdf");
let idx = find_text_index(&pdf, &input, "Page one");
let before = ff_engine::list_objects(&pdf, &input, 0, None).expect("list");
let font_before = before
.iter()
.find(|o| o.index == idx)
.and_then(|o| o.font_name.clone())
.expect("font gốc");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::SetText {
index: idx,
text: "Chữ đậm kiểm thử".into(),
font_size: None,
color: None,
font_family: None,
bold: Some(true),
italic: None,
}],
&out,
None,
)
.expect("apply_edits bold");
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let edited = after
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("đậm")).unwrap_or(false))
.expect("run đã sửa");
assert_ne!(
edited.font_name.as_deref(),
Some(font_before.as_str()),
"ép đậm phải chuyển sang biến thể font khác"
);
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
assert!(text.contains("Chữ đậm kiểm thử"), "text round-trip: {text:?}");
}
/// Luồng UI "sửa cả dòng": 1 batch = SetText(run đầu, text gộp) + Delete(các
/// run còn lại). Chốt hành vi: text mới có mặt, các run kia biến mất, tổng
/// object giảm đúng số run bị gộp.
#[test]
fn line_merge_batch_set_text_plus_delete() {
let pdf = pdfium();
let input = sample();
let step1 = tmp("ff_edit_line1.pdf");
let step2 = tmp("ff_edit_line2.pdf");
// Dựng "1 dòng bị cắt làm 2 run" bằng 2 AddText cạnh nhau.
ff_engine::apply_edits(
&pdf,
&input,
0,
&[
EditOp::AddText {
x: 60.0,
y: 260.0,
text: "Nửa đầu".into(),
font_size: 14.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
},
EditOp::AddText {
x: 130.0,
y: 260.0,
text: "nửa sau".into(),
font_size: 14.0,
color: [0, 0, 0, 255],
font_family: None,
bold: false,
italic: false,
},
],
&step1,
None,
)
.expect("dựng 2 run");
let objs = ff_engine::list_objects(&pdf, &step1, 0, None).expect("list");
let first = find_text_index(&pdf, &step1, "Nửa đầu");
let second = find_text_index(&pdf, &step1, "nửa sau");
let count_before = objs.len();
ff_engine::apply_edits(
&pdf,
&step1,
0,
&[
EditOp::SetText {
index: first,
text: "Cả dòng đã gộp và sửa".into(),
font_size: None,
color: None,
font_family: None,
bold: None,
italic: None,
},
EditOp::Delete { index: second },
],
&step2,
None,
)
.expect("batch gộp dòng");
let after = ff_engine::list_objects(&pdf, &step2, 0, None).expect("list out");
assert_eq!(after.len(), count_before - 1, "gộp 2 run còn 1 → tổng giảm 1");
let text = ff_engine::extract_text(&pdf, &step2, 0, None).expect("extract");
assert!(text.contains("Cả dòng đã gộp và sửa"), "text gộp phải có: {text:?}");
assert!(!text.contains("nửa sau"), "run bị gộp phải biến mất: {text:?}");
}
#[test]
fn add_image_adds_image_object() {
let pdf = pdfium();