Sửa 3 lỗi edit inline vòng 2 (file thật Word-export): hết □ thay dấu cách, dòng không vỡ khi thêm ký tự, ô sửa WYSIWYG đúng cỡ từng dòng
Chẩn đoán □: PDFium FPDFText_LoadFont nạp LẠI font subset rồi SetText map U+0020 ra .notdef (hộp vuông) dù cmap font CÓ glyph space (đã probe cả 2 subtable) — lỗi PDFium với font nạp động, không phải do font. Engine (edit.rs): - Tầng 0 mới của thang font reflow: DÙNG LẠI chính font object gốc trong PDF (PdfFontToken của run neo, không nạp lại) — glyph + charcode map gốc xử lý dấu cách chuẩn, file không phình; chỉ chọn sau khi PROBE ghi/đọc-lại toàn bộ text mới thành công (tạo object nháp → so text → gỡ + mem::forget) - Ghi từng dòng xong ĐỌC LẠI NGAY tự kiểm; PDFium vẫn vứt ký tự → gỡ và ghi lại TỪNG TỪ tự đặt vị trí bằng advance hmtx (không bao giờ ghi glyph dấu cách) — lưới an toàn cuối cho font lạ - Dòng cứng (\n từ ô sửa) NỞ tới +35% bề rộng khối (chặn mép trang) trước khi bẻ lại — hộp text tự nở như Foxit; khối căn giữa nở đều quanh tâm - line_styles theo baseline gốc: dòng cứng thứ i giữ ĐÚNG CỠ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép cả khối về 1 cỡ) UI (main.js + styles.css): - Ô sửa cả đoạn đổi textarea → contenteditable plaintext-only: MỖI DÒNG GỐC = 1 div đúng font/cỡ/màu/đậm-nghiêng CỦA DÒNG ĐÓ, khối căn giữa hiện căn giữa (nở đều 2 phía quanh tâm), khung bù lệch dọc line-box → chữ trong ô TRÙNG vị trí chữ gốc khi mở; con trỏ đặt đúng điểm đúp (caretRangeFromPoint); Enter xuống dòng, Ctrl+Enter/blur áp dụng, Esc huỷ, dán luôn chữ thuần Kiểm chứng file thật: "HỢP"→"HỢPP" giữ đúng 2 dòng căn giữa cỡ 22.5/20, font BAAAAA+TimesNewRomanPS-BoldMT gốc, dấu cách nguyên vẹn, render khớp bản gốc. Test: +2 hồi quy (giữ cỡ từng dòng; dòng nở không bẻ) → edit 21/21, các suite annot/organize/redact/form xanh. Docs 12 (mục vá vòng 2), 13 (E39–E42). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HgnbPwSoRGNH7LHsDUmRpk
This commit is contained in:
+225
-56
@@ -265,12 +265,32 @@ fn builtin_for(family_key: &str, bold: bool, italic: bool) -> Option<PdfFontBuil
|
||||
})
|
||||
}
|
||||
|
||||
/// Font để VẼ các dòng reflow mới.
|
||||
enum ReflowFont {
|
||||
/// Dùng lại CHÍNH font object gốc của PDF (không nạp lại) — giữ nguyên
|
||||
/// glyph + mapping charcode gốc. Đường ưu tiên với font nhúng: PDFium
|
||||
/// `FPDFText_LoadFont` nạp lại font subset (Word xuất) hay map dấu cách
|
||||
/// ra .notdef (□), còn font object gốc thì không. Chỉ chọn sau khi probe
|
||||
/// round-trip toàn bộ text thành công.
|
||||
Original(PdfFontToken),
|
||||
/// Font nạp mới (nhúng lại bytes / base-14 / hệ thống) theo key trong
|
||||
/// bảng `tokens`.
|
||||
Loaded(FontKey),
|
||||
}
|
||||
|
||||
/// Cỡ chữ 1 dòng gốc (theo baseline, trên→dưới): Tf unscaled + cỡ hiển thị.
|
||||
#[derive(Clone, Copy)]
|
||||
struct LineStyle {
|
||||
tf: f32,
|
||||
scaled: f32,
|
||||
}
|
||||
|
||||
/// Kế hoạch reflow 1 đoạn, dựng ở pha (A) từ hình học các run gốc.
|
||||
struct ReflowPlan {
|
||||
/// Run text hợp lệ sẽ bị thay (đã sort, dedup).
|
||||
indices: Vec<u16>,
|
||||
/// Key font đã nạp ở pha (B) để tạo các dòng mới.
|
||||
font_key: FontKey,
|
||||
/// Font vẽ các dòng mới.
|
||||
draw_font: ReflowFont,
|
||||
/// Bytes để ĐO bề rộng (hmtx) — có thể khác font vẽ (vd base-14 đo bằng
|
||||
/// font metric-compatible). None = xấp xỉ 0.5em/ký tự.
|
||||
measure_bytes: Option<Vec<u8>>,
|
||||
@@ -284,12 +304,40 @@ struct ReflowPlan {
|
||||
line_advance: f32,
|
||||
/// Phần tuyến tính matrix của run neo (giữ scale/nghiêng khi tạo dòng mới).
|
||||
linear: (f32, f32, f32, f32),
|
||||
/// Cỡ Tf cho object mới (unscaled) + cỡ hiển thị (để đo).
|
||||
/// Cỡ Tf cho object mới (unscaled) + cỡ hiển thị (để đo) của run neo —
|
||||
/// fallback khi dòng không có style riêng.
|
||||
tf: f32,
|
||||
scaled: f32,
|
||||
/// Cỡ chữ TỪNG DÒNG GỐC (trên→dưới) — dòng cứng thứ i của text mới giữ
|
||||
/// đúng cỡ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép về 1 cỡ).
|
||||
line_styles: Vec<LineStyle>,
|
||||
color: PdfColor,
|
||||
}
|
||||
|
||||
/// Probe: font `token` có ghi được `text` tròn trịa qua PDFium không?
|
||||
/// Tạo text object nháp → đọc lại → xoá ngay (mem::forget vì object đã
|
||||
/// thuộc trang). Bắt được các case PDFium âm thầm vứt ký tự (dấu cách với
|
||||
/// font subset nạp lại, chữ ngoài ToUnicode...).
|
||||
fn probe_font_roundtrip(page: &mut PdfPage<'_>, token: PdfFontToken, text: &str) -> bool {
|
||||
let probe: String = text.chars().map(|c| if c == '\n' { ' ' } else { c }).collect();
|
||||
let ok = match page.objects_mut().create_text_object(
|
||||
PdfPoints::ZERO,
|
||||
PdfPoints::ZERO,
|
||||
&probe,
|
||||
token,
|
||||
PdfPoints::new(10.0),
|
||||
) {
|
||||
Ok(obj) => obj.as_text_object().map(|t| t.text() == probe).unwrap_or(false),
|
||||
Err(_) => return false,
|
||||
};
|
||||
let idx = page.objects().len().saturating_sub(1);
|
||||
match page.objects_mut().remove_object_at_index(idx as usize) {
|
||||
Ok(removed) => std::mem::forget(removed),
|
||||
Err(_) => return false,
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -357,10 +405,12 @@ pub fn apply_edits(
|
||||
let mut reflow_plans: HashMap<usize, ReflowPlan> = HashMap::new();
|
||||
let mut font_needed: HashMap<FontKey, FontLoad> = HashMap::new();
|
||||
{
|
||||
let page = document
|
||||
let mut page = document
|
||||
.pages()
|
||||
.get(page_index)
|
||||
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
|
||||
// Pha A chỉ đọc + probe font (tạo/xoá object nháp) — không đụng content.
|
||||
page.set_content_regeneration_strategy(PdfPageContentRegenerationStrategy::Manual);
|
||||
let obj_count = page.objects().len();
|
||||
let valid = |i: u16| (i as usize) < obj_count;
|
||||
|
||||
@@ -448,12 +498,16 @@ pub fn apply_edits(
|
||||
f: f32,
|
||||
left: f32,
|
||||
right: f32,
|
||||
tf: f32,
|
||||
scaled: f32,
|
||||
}
|
||||
let geo_of = |i: u16| -> Result<Option<RunGeo>, EngineError> {
|
||||
let geo_of = |page: &PdfPage<'_>, i: u16| -> Result<Option<RunGeo>, EngineError> {
|
||||
let obj = page.objects().get(i as usize).map_err(err)?;
|
||||
if obj.as_text_object().is_none() {
|
||||
let Some(t) = obj.as_text_object() else {
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
let (tf, scaled) =
|
||||
(t.unscaled_font_size().value, t.scaled_font_size().value);
|
||||
let m = obj.matrix().map_err(err)?;
|
||||
let b = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect {
|
||||
left: m.e(),
|
||||
@@ -461,11 +515,11 @@ pub fn apply_edits(
|
||||
right: m.e(),
|
||||
top: m.f(),
|
||||
});
|
||||
Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right }))
|
||||
Ok(Some(RunGeo { idx: i, f: m.f(), left: b.left, right: b.right, tf, scaled }))
|
||||
};
|
||||
let mut geos: Vec<RunGeo> = Vec::new();
|
||||
for &i in &idxs {
|
||||
if let Some(g) = geo_of(i)? {
|
||||
if let Some(g) = geo_of(&page, i)? {
|
||||
geos.push(g);
|
||||
}
|
||||
}
|
||||
@@ -520,7 +574,7 @@ pub fn apply_edits(
|
||||
&& cy <= bb.top + pad
|
||||
{
|
||||
idxs.push(i);
|
||||
if let Some(g) = geo_of(i)? {
|
||||
if let Some(g) = geo_of(&page, i)? {
|
||||
geos.push(g);
|
||||
}
|
||||
}
|
||||
@@ -537,13 +591,21 @@ pub fn apply_edits(
|
||||
})
|
||||
.map(|g| g.idx)
|
||||
.unwrap_or(idxs[0]);
|
||||
let anchor = page.objects().get(anchor_idx as usize).map_err(err)?;
|
||||
let t = anchor.as_text_object().expect("anchor là text");
|
||||
let (family, bold, italic) = text_object_style(t);
|
||||
let m = anchor.matrix().map_err(err)?;
|
||||
let unscaled = t.unscaled_font_size().value;
|
||||
let scaled = t.scaled_font_size().value.max(1.0);
|
||||
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
|
||||
// Chụp mọi thuộc tính của run neo trong 1 block để nhả mượn
|
||||
// trang (probe font phía dưới cần &mut page).
|
||||
let (family, bold, italic, m, unscaled, scaled, color, font_embedded, orig_token, font_data) = {
|
||||
let anchor = page.objects().get(anchor_idx as usize).map_err(err)?;
|
||||
let t = anchor.as_text_object().expect("anchor là text");
|
||||
let (family, bold, italic) = text_object_style(t);
|
||||
let m = anchor.matrix().map_err(err)?;
|
||||
let tok = t.font().token();
|
||||
let unscaled = t.unscaled_font_size().value;
|
||||
let scaled = t.scaled_font_size().value.max(1.0);
|
||||
let color = t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255));
|
||||
let embedded = t.font().is_embedded().unwrap_or(false);
|
||||
let data = t.font().data().ok();
|
||||
(family, bold, italic, m, unscaled, scaled, color, embedded, tok, data)
|
||||
};
|
||||
|
||||
// Baseline: gom cụm giá trị f (dung sai 1pt) → dòng; advance =
|
||||
// median hiệu 2 cụm kề nhau; 1 dòng → 1.25 × cỡ hiển thị.
|
||||
@@ -595,41 +657,70 @@ pub fn apply_edits(
|
||||
all_centered && any_indented
|
||||
};
|
||||
|
||||
// Cỡ chữ TỪNG DÒNG gốc (theo baseline, trên→dưới): dòng cứng
|
||||
// thứ i của text mới giữ đúng cỡ dòng gốc thứ i (tiêu đề
|
||||
// 22.5/20pt không bị ép hết về cỡ run neo).
|
||||
let line_styles: Vec<LineStyle> = baselines
|
||||
.iter()
|
||||
.map(|&bl| {
|
||||
geos.iter()
|
||||
.filter(|g| (g.f - bl).abs() <= 1.0 && g.scaled > 0.5)
|
||||
.max_by(|a, b| {
|
||||
a.scaled
|
||||
.partial_cmp(&b.scaled)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
})
|
||||
.map(|g| LineStyle { tf: g.tf.max(1.0), scaled: g.scaled.max(1.0) })
|
||||
.unwrap_or(LineStyle { tf: unscaled.max(1.0), scaled })
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Thang font cho các dòng mới (giữ font như SetText):
|
||||
// (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại chính bytes đó
|
||||
// (same glyphs — trông y hệt);
|
||||
// (2) family thuộc nhóm base-14 + text ASCII → font chuẩn PDF,
|
||||
// BaseFont giữ tên chuẩn, không phình file;
|
||||
// (0) font NHÚNG + probe ghi/đọc lại tròn trịa → dùng LẠI chính
|
||||
// font object gốc (glyph + charcode y hệt, file không phình;
|
||||
// tránh lỗi PDFium nạp lại subset làm dấu cách thành □);
|
||||
// (1) font NHÚNG parse được + phủ đủ glyph → nhúng lại bytes đó;
|
||||
// (2) family thuộc nhóm base-14 + text ASCII → font chuẩn PDF;
|
||||
// (3) font hệ thống CÙNG HỌ; (4) fallback mặc định.
|
||||
let fam_key = fontmatch::normalize_key(&family);
|
||||
let ascii_only =
|
||||
text.chars().all(|c| c.is_control() || (' '..='~').contains(&c));
|
||||
let embedded_bytes = if t.font().is_embedded().unwrap_or(false) {
|
||||
t.font().data().ok().filter(|b| fontmatch::coverage_ok(b, text))
|
||||
let original_ok =
|
||||
font_embedded && probe_font_roundtrip(&mut page, orig_token, text);
|
||||
let embedded_bytes = if font_embedded {
|
||||
font_data.clone().filter(|b| fontmatch::coverage_ok(b, text))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (font_key, load, measure_bytes) = if let Some(bytes) = embedded_bytes {
|
||||
let (draw_font, measure_bytes) = if original_ok {
|
||||
// Đo vẫn bằng bytes font nhúng (hmtx) nếu parse được.
|
||||
let measure =
|
||||
font_data.filter(|b| ttf_parser::Face::parse(b, 0).is_ok());
|
||||
(ReflowFont::Original(orig_token), measure)
|
||||
} else if let Some(bytes) = embedded_bytes {
|
||||
let key = (format!("emb:{fam_key}:{anchor_idx}"), bold, italic);
|
||||
(key, FontLoad::Bytes(bytes.clone()), Some(bytes))
|
||||
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes.clone()));
|
||||
(ReflowFont::Loaded(key), Some(bytes))
|
||||
} else if let (Some(builtin), true) =
|
||||
(builtin_for(&fam_key, bold, italic), ascii_only)
|
||||
{
|
||||
// Đo bằng font hệ thống metric-compatible (Liberation/Arial…).
|
||||
let measure = fontmatch::find_family_font_bytes(&family, bold, italic)
|
||||
.or_else(|| find_font_bytes(bold, italic));
|
||||
((format!("b14:{fam_key}"), bold, italic), FontLoad::Builtin(builtin), measure)
|
||||
let key = (format!("b14:{fam_key}"), bold, italic);
|
||||
font_needed.entry(key.clone()).or_insert(FontLoad::Builtin(builtin));
|
||||
(ReflowFont::Loaded(key), measure)
|
||||
} else {
|
||||
let (key, bytes) = resolve_substitute_font(&family, bold, italic, text)?;
|
||||
(key, FontLoad::Bytes(bytes.clone()), Some(bytes))
|
||||
font_needed.entry(key.clone()).or_insert(FontLoad::Bytes(bytes.clone()));
|
||||
(ReflowFont::Loaded(key), Some(bytes))
|
||||
};
|
||||
font_needed.entry(font_key.clone()).or_insert(load);
|
||||
|
||||
reflow_plans.insert(
|
||||
opi,
|
||||
ReflowPlan {
|
||||
indices: idxs,
|
||||
font_key,
|
||||
draw_font,
|
||||
measure_bytes,
|
||||
left,
|
||||
width,
|
||||
@@ -639,6 +730,7 @@ pub fn apply_edits(
|
||||
linear: (m.a(), m.b(), m.c(), m.d()),
|
||||
tf: unscaled.max(1.0),
|
||||
scaled,
|
||||
line_styles,
|
||||
color,
|
||||
},
|
||||
);
|
||||
@@ -908,48 +1000,125 @@ pub fn apply_edits(
|
||||
}
|
||||
EditOp::ReflowText { text, .. } => {
|
||||
let Some(plan) = reflow_plans.get(&opi) else { continue };
|
||||
let token = tokens.get(&plan.font_key).copied().ok_or_else(|| {
|
||||
EngineError::Pdfium("thiếu token font cho ReflowText".into())
|
||||
})?;
|
||||
// Đo bề rộng ký tự theo hmtx của font (tại cỡ hiển thị);
|
||||
// không parse được → xấp xỉ 0.5em.
|
||||
let token = match &plan.draw_font {
|
||||
ReflowFont::Original(tok) => *tok,
|
||||
ReflowFont::Loaded(key) => tokens.get(key).copied().ok_or_else(|| {
|
||||
EngineError::Pdfium("thiếu token font cho ReflowText".into())
|
||||
})?,
|
||||
};
|
||||
// Đo bề rộng ký tự theo hmtx của font (tại cỡ hiển thị của
|
||||
// TỪNG dòng); không parse được → xấp xỉ 0.5em.
|
||||
let face = plan
|
||||
.measure_bytes
|
||||
.as_deref()
|
||||
.and_then(|b| ttf_parser::Face::parse(b, 0).ok());
|
||||
let scaled = plan.scaled;
|
||||
let measure = move |c: char| match &face {
|
||||
Some(f) => fontmatch::char_advance(f, c, scaled),
|
||||
None => scaled * 0.5,
|
||||
let measure_at = |c: char, size: f32| match &face {
|
||||
Some(f) => fontmatch::char_advance(f, c, size),
|
||||
None => size * 0.5,
|
||||
};
|
||||
let lines = fontmatch::wrap_lines(text, plan.width, &measure);
|
||||
|
||||
// Bề rộng tối đa 1 dòng có thể NỞ thêm (không bẻ lại) khi
|
||||
// người dùng chỉ gõ thêm vài ký tự: +35% bề rộng khối, chặn
|
||||
// bởi mép trang — hành vi hộp text tự nở của Foxit. Vượt quá
|
||||
// mới bẻ dòng theo đúng bề rộng khối.
|
||||
let page_w = page.width().value;
|
||||
let avail = if plan.centered {
|
||||
let c = plan.left + plan.width / 2.0;
|
||||
2.0 * (c.min(page_w - c) - 8.0).max(0.0)
|
||||
} else {
|
||||
(page_w - plan.left - 8.0).max(0.0)
|
||||
};
|
||||
let grow_limit = (plan.width * 1.35).min(avail.max(plan.width * 1.02));
|
||||
|
||||
// Dòng cứng thứ i (theo \n từ ô sửa) giữ cỡ chữ dòng gốc thứ i.
|
||||
let mut out_lines: Vec<(String, LineStyle)> = Vec::new();
|
||||
let anchor_style = LineStyle { tf: plan.tf, scaled: plan.scaled };
|
||||
for (li, hard) in text.split('\n').enumerate() {
|
||||
let style = plan
|
||||
.line_styles
|
||||
.get(li)
|
||||
.or(plan.line_styles.last())
|
||||
.copied()
|
||||
.unwrap_or(anchor_style);
|
||||
let w: f32 = hard.chars().map(|c| measure_at(c, style.scaled)).sum();
|
||||
if hard.trim().is_empty() || w <= grow_limit {
|
||||
out_lines.push((hard.trim_end().to_string(), style));
|
||||
} else {
|
||||
let m = |c: char| measure_at(c, style.scaled);
|
||||
for piece in fontmatch::wrap_lines(hard, plan.width, &m) {
|
||||
out_lines.push((piece, style));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (a, b, c2, d) = plan.linear;
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
for (i, (line, style)) in out_lines.iter().enumerate() {
|
||||
if line.is_empty() {
|
||||
continue; // đoạn trống vẫn chiếm 1 nhịp baseline
|
||||
}
|
||||
let y = plan.first_baseline - (i as f32) * plan.line_advance;
|
||||
// Khối căn giữa → từng dòng mới cũng căn giữa theo tâm khối.
|
||||
let line_w: f32 =
|
||||
line.chars().map(|c| measure_at(c, style.scaled)).sum();
|
||||
let x = if plan.centered {
|
||||
let w: f32 = line.chars().map(&measure).sum();
|
||||
(plan.left + plan.width / 2.0 - w / 2.0).max(plan.left)
|
||||
let x = plan.left + plan.width / 2.0 - line_w / 2.0;
|
||||
x.max(8.0) // căn giữa nở đều 2 phía, không tràn mép trang
|
||||
} else {
|
||||
plan.left
|
||||
};
|
||||
let mut obj = page
|
||||
.objects_mut()
|
||||
.create_text_object(
|
||||
PdfPoints::ZERO,
|
||||
PdfPoints::ZERO,
|
||||
line.clone(),
|
||||
token,
|
||||
PdfPoints::new(plan.tf),
|
||||
)
|
||||
.map_err(err)?;
|
||||
// Giữ scale/nghiêng của run neo; đặt gốc dòng tại (x, y).
|
||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
|
||||
.map_err(err)?;
|
||||
obj.set_fill_color(plan.color).map_err(err)?;
|
||||
// Ghi cả dòng; đọc lại NGAY để chắc PDFium không âm thầm
|
||||
// vứt ký tự (dấu cách với font subset...). Hỏng → gỡ và
|
||||
// ghi lại TỪNG TỪ, tự đặt vị trí bằng advance đo được —
|
||||
// không bao giờ ghi glyph dấu cách nữa.
|
||||
let wrote_ok = {
|
||||
let mut obj = page
|
||||
.objects_mut()
|
||||
.create_text_object(
|
||||
PdfPoints::ZERO,
|
||||
PdfPoints::ZERO,
|
||||
line.clone(),
|
||||
token,
|
||||
PdfPoints::new(style.tf),
|
||||
)
|
||||
.map_err(err)?;
|
||||
let ok = obj
|
||||
.as_text_object()
|
||||
.map(|t| t.text() == *line)
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
|
||||
.map_err(err)?;
|
||||
obj.set_fill_color(plan.color).map_err(err)?;
|
||||
}
|
||||
ok
|
||||
};
|
||||
if !wrote_ok {
|
||||
let idx = page.objects().len().saturating_sub(1);
|
||||
let removed = page
|
||||
.objects_mut()
|
||||
.remove_object_at_index(idx as usize)
|
||||
.map_err(err)?;
|
||||
std::mem::forget(removed);
|
||||
let space_w = measure_at(' ', style.scaled);
|
||||
let mut cx = x;
|
||||
for word in line.split_whitespace() {
|
||||
let mut obj = page
|
||||
.objects_mut()
|
||||
.create_text_object(
|
||||
PdfPoints::ZERO,
|
||||
PdfPoints::ZERO,
|
||||
word,
|
||||
token,
|
||||
PdfPoints::new(style.tf),
|
||||
)
|
||||
.map_err(err)?;
|
||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
|
||||
.map_err(err)?;
|
||||
obj.set_fill_color(plan.color).map_err(err)?;
|
||||
cx += word.chars().map(|c| measure_at(c, style.scaled)).sum::<f32>()
|
||||
+ space_w;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
|
||||
@@ -872,6 +872,113 @@ fn reflow_preserves_centered_alignment() {
|
||||
assert_eq!(checked, 2, "phải kiểm được 2 dòng mới");
|
||||
}
|
||||
|
||||
/// Mỗi DÒNG CỨNG (theo `\n` từ ô sửa) giữ đúng CỠ CHỮ của dòng gốc cùng thứ
|
||||
/// tự — tiêu đề 22/16pt không bị ép cả khối về 1 cỡ.
|
||||
#[test]
|
||||
fn reflow_keeps_per_line_font_sizes() {
|
||||
let pdf = pdfium();
|
||||
let fx = tmp("ff_reflow_sz_fx.pdf");
|
||||
let out = tmp("ff_reflow_sz_out.pdf");
|
||||
let mk = |y: f32, s: &str, size: f32| EditOp::AddText {
|
||||
x: 80.0,
|
||||
y,
|
||||
text: s.into(),
|
||||
font_size: size,
|
||||
color: [0, 0, 0, 255],
|
||||
font_family: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
};
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&sample(),
|
||||
0,
|
||||
&[mk(500.0, "TIÊU ĐỀ CHÍNH CỠ LỚN", 22.0), mk(474.0, "phụ đề bên dưới cỡ nhỏ", 16.0)],
|
||||
&fx,
|
||||
None,
|
||||
)
|
||||
.expect("fixture 2 cỡ chữ");
|
||||
let idxs: Vec<u16> = ff_engine::list_objects(&pdf, &fx, 0, None)
|
||||
.expect("list fx")
|
||||
.into_iter()
|
||||
.filter(|o| o.text.as_deref().map(|t| t.contains("CỠ LỚN") || t.contains("cỡ nhỏ")).unwrap_or(false))
|
||||
.map(|o| o.index)
|
||||
.collect();
|
||||
assert_eq!(idxs.len(), 2);
|
||||
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&fx,
|
||||
0,
|
||||
&[EditOp::ReflowText { indices: idxs, text: "TIÊU ĐỀ MỚI\nphụ đề mới".into() }],
|
||||
&out,
|
||||
None,
|
||||
)
|
||||
.expect("reflow 2 cỡ");
|
||||
|
||||
let after = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||
let size_of = |needle: &str| {
|
||||
after
|
||||
.iter()
|
||||
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
|
||||
.unwrap_or_else(|| panic!("thiếu dòng {needle:?}"))
|
||||
.font_size
|
||||
.expect("font_size")
|
||||
};
|
||||
let s1 = size_of("TIÊU ĐỀ MỚI");
|
||||
let s2 = size_of("phụ đề mới");
|
||||
assert!((s1 - 22.0).abs() < 0.6, "dòng 1 phải giữ ~22pt, được {s1}");
|
||||
assert!((s2 - 16.0).abs() < 0.6, "dòng 2 phải giữ ~16pt, được {s2}");
|
||||
}
|
||||
|
||||
/// Dòng cứng chỉ DÀI THÊM CHÚT (≤35% bề rộng khối) thì NỞ ra chứ không bị bẻ
|
||||
/// lại thành 2 dòng (hộp text tự nở như Foxit) — sửa "HỢP"→"HỢPP" phải giữ
|
||||
/// nguyên số dòng. Dấu cách cũng phải sống sót qua đường ghi.
|
||||
#[test]
|
||||
fn reflow_hard_line_grows_without_rewrap() {
|
||||
let pdf = pdfium();
|
||||
let fx = tmp("ff_reflow_grow_fx.pdf");
|
||||
let out = tmp("ff_reflow_grow_out.pdf");
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&sample(),
|
||||
0,
|
||||
&[EditOp::AddText {
|
||||
x: 90.0,
|
||||
y: 520.0,
|
||||
text: "Dòng tiêu đề dài vừa khít khung gốc".into(),
|
||||
font_size: 18.0,
|
||||
color: [0, 0, 0, 255],
|
||||
font_family: None,
|
||||
bold: false,
|
||||
italic: false,
|
||||
}],
|
||||
&fx,
|
||||
None,
|
||||
)
|
||||
.expect("fixture 1 dòng");
|
||||
let idx = find_text_index(&pdf, &fx, "vừa khít");
|
||||
|
||||
let new_text = "Dòng tiêu đề dài vừa khít khung gốc nở";
|
||||
ff_engine::apply_edits(
|
||||
&pdf,
|
||||
&fx,
|
||||
0,
|
||||
&[EditOp::ReflowText { indices: vec![idx], text: new_text.into() }],
|
||||
&out,
|
||||
None,
|
||||
)
|
||||
.expect("reflow nở dòng");
|
||||
|
||||
let new_runs: Vec<_> = ff_engine::list_objects(&pdf, &out, 0, None)
|
||||
.expect("list out")
|
||||
.into_iter()
|
||||
.filter(|o| o.text.as_deref().map(|t| t.contains("tiêu đề") || t.contains("nở thêm")).unwrap_or(false))
|
||||
.collect();
|
||||
assert_eq!(new_runs.len(), 1, "dòng chỉ dài thêm ~20% phải GIỮ 1 dòng: {new_runs:?}");
|
||||
assert_eq!(new_runs[0].text.as_deref(), Some(new_text), "nội dung (gồm dấu cách) phải tròn trịa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_image_adds_image_object() {
|
||||
let pdf = pdfium();
|
||||
|
||||
Reference in New Issue
Block a user