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:
@@ -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
@@ -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(
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
pub mod annot;
|
||||
pub mod edit;
|
||||
pub(crate) mod fontmatch;
|
||||
pub mod meta;
|
||||
pub mod organize;
|
||||
pub mod qpdf;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user