Edit mode: zoom + chuyển trang + dòng lẫn nhiều style (thường/đậm/mono)
Build Windows portable / build (push) Canceled after 0s

3 vấn đề user báo trong chế độ Sửa nội dung:

1. ZOOM: stage sửa trước đây cứng 820px — nút +/-, dropdown %, Vừa rộng/
   Vừa trang và Ctrl+lăn chuột đều vô tác dụng. Giờ editZoom riêng (kế thừa
   zoom viewer khi vào): co giãn ảnh + overlay NGAY (CSS, mượt), render nét
   PDFium sau 220ms ngừng thao tác; mọi nút zoom/fit + Ctrl+wheel hoạt động;
   fix flex-center overflow (margin auto) để zoom to không cắt mép trái.

2. CHUYỂN TRANG: nút ▲▼/ô nhập trang trước đây chỉ điều khiển viewer đang
   ẩn. Giờ goToPage/gotoPrev/Next nhận biết edit mode → switchEditPage
   (đóng ô sửa đang mở, giữ nguyên undo/thay đổi các trang khác vì dùng
   chung editBase).

3. DÒNG LẪN STYLE (thường + đậm/mono): ô sửa trước áp 1 style/dòng nên
   "windowsInitialPassword" đậm hiện thành chữ thường, commit còn ép cả
   dòng về 1 font. Giờ:
   - UI: mỗi nhóm run liền kề cùng style = 1 SPAN riêng (đúng font/cỡ/màu
     từng phần khi gõ); commit đọc theo đoạn style (text node trần mượn
     style span kề); >=2 style gửi kèm richLines.
   - Engine: ReflowText thêm rich (Vec<Vec<RichSeg>>) — resolve thang
     giữ-font RIÊNG cho từng style-run (probe token gốc/nhúng lại bytes/
     builtin/cùng họ), token hoá theo từ giữ style + glue giữa đoạn dính
     liền, bẻ dòng chung cả khối, vẽ mỗi cụm cùng-style thành 1 object
     đúng font/cỡ/màu, tự kiểm + fallback từng từ như đường 1-style.
   - fitEditLinesToPdf: đo text node sâu trong span; hiệu chỉnh cỡ scale
     cả span. Tầng-0 token gốc tắt khi KHỐI có run nested (trước chỉ xét
     anchor — token vô hiệu sau khi mở lại document hậu phẫu thuật).
   - Test: fixture 1 dòng Helvetica + Courier-Bold → sửa phần thường,
     phần mono giữ nguyên BaseFont Courier-Bold, cùng baseline đúng thứ tự.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 09:03:32 +07:00
co-authored by Claude Fable 5
parent 0bb1a12ac1
commit f0b4de9af1
6 changed files with 673 additions and 32 deletions
+118 -5
View File
@@ -635,7 +635,7 @@ của khối đoạn văn ban đầu, giữ nguyên phông chữ nhúng và kho
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: runs.iter().map(|r| r.index).collect(), text: long_text.into() }],
&[EditOp::ReflowText { indices: runs.iter().map(|r| r.index).collect(), text: long_text.into(), rich: None }],
&out,
None,
)
@@ -686,6 +686,7 @@ fn reflow_hard_break_creates_new_line() {
&[EditOp::ReflowText {
indices: runs.iter().map(|r| r.index).collect(),
text: "Đoạn một ngắn.\nĐoạn hai riêng.".into(),
rich: None,
}],
&out,
None,
@@ -718,7 +719,7 @@ the declared standard Helvetica base font untouched.";
&pdf,
&input,
0,
&[EditOp::ReflowText { indices: vec![idx], text: long_ascii.into() }],
&[EditOp::ReflowText { indices: vec![idx], text: long_ascii.into(), rich: None }],
&out,
None,
)
@@ -775,7 +776,7 @@ fn reflow_expands_indices_to_cover_block() {
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: vec![first, third], text: "Dòng đã thay hoàn toàn".into() }],
&[EditOp::ReflowText { indices: vec![first, third], text: "Dòng đã thay hoàn toàn".into(), rich: None }],
&out,
None,
)
@@ -853,6 +854,7 @@ fn reflow_preserves_centered_alignment() {
&[EditOp::ReflowText {
indices: idxs,
text: "TIÊU ĐỀ MỚI DÀI HƠN CHÚT\nNGẮN".into(),
rich: None,
}],
&out,
None,
@@ -910,7 +912,7 @@ fn reflow_keeps_per_line_font_sizes() {
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: idxs, text: "TIÊU ĐỀ MỚI\nphụ đề mới".into() }],
&[EditOp::ReflowText { indices: idxs, text: "TIÊU ĐỀ MỚI\nphụ đề mới".into(), rich: None }],
&out,
None,
)
@@ -964,7 +966,7 @@ fn reflow_hard_line_grows_without_rewrap() {
&pdf,
&fx,
0,
&[EditOp::ReflowText { indices: vec![idx], text: new_text.into() }],
&[EditOp::ReflowText { indices: vec![idx], text: new_text.into(), rich: None }],
&out,
None,
)
@@ -1165,6 +1167,7 @@ fn form_xobject_reflow_after_flatten_roundtrip() {
&[EditOp::ReflowText {
indices: vec![idx],
text: "Đoạn văn tiếng Việt thay thế trong form, đủ dài để chắc chắn phải bẻ xuống dòng mới".into(),
rich: None,
}],
&out,
None,
@@ -1275,6 +1278,7 @@ fn form_xobject_surgical_reflow() {
&[EditOp::ReflowText {
indices: vec![idx],
text: "Sửa trong form bằng phẫu thuật stream".into(),
rich: None,
}],
&out,
None,
@@ -1379,6 +1383,7 @@ fn flipped_form_surgical_reflow() {
&[EditOp::ReflowText {
indices: vec![idx],
text: "Đổi chữ trong form lật trục".into(),
rich: None,
}],
&out,
None,
@@ -1402,3 +1407,111 @@ fn flipped_form_surgical_reflow() {
new_run.rect
);
}
/// Fixture 1 dòng LẪN 2 STYLE trên cùng baseline: Helvetica thường +
/// Courier-Bold (mono đậm) — đúng kiểu "Trang quản trị hiển thị `code`".
fn build_mixed_style_pdf(path: &std::path::Path) {
use lopdf::{dictionary, Document, Object, Stream};
let mut doc = Document::with_version("1.5");
let pages_id = doc.new_object_id();
let f1 = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Helvetica",
});
let f2 = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => "Type1",
"BaseFont" => "Courier-Bold",
});
let content_id = doc.add_object(Stream::new(
dictionary! {},
b"BT /F1 12 Tf 72 700 Td (Trang quan tri hien thi ) Tj /F2 12 Tf (windowsInitialPassword) Tj ET".to_vec(),
));
let page_id = doc.add_object(dictionary! {
"Type" => "Page",
"Parent" => pages_id,
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
"Resources" => dictionary! { "Font" => dictionary! { "F1" => f1, "F2" => f2 } },
"Contents" => content_id,
});
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![page_id.into()],
"Count" => 1,
}),
);
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
doc.trailer.set("Root", catalog_id);
doc.save(path).expect("lưu fixture mixed style");
}
// Reflow NHIỀU STYLE: sửa phần chữ thường, phần mono-đậm GIỮ NGUYÊN FONT
// (BaseFont Courier-Bold còn nguyên), 2 đoạn nằm cùng dòng đúng thứ tự.
#[test]
fn rich_reflow_preserves_mixed_styles() {
use ff_engine::RichSeg;
let pdf = pdfium();
let input = tmp("ff_edit_rich_mixed.pdf");
build_mixed_style_pdf(&input);
let out = tmp("ff_edit_rich_mixed_out.pdf");
let idx_a = find_text_index(&pdf, &input, "Trang quan tri");
let idx_b = find_text_index(&pdf, &input, "windowsInitialPassword");
ff_engine::apply_edits(
&pdf,
&input,
0,
&[EditOp::ReflowText {
indices: vec![idx_a, idx_b],
text: "Trang quan tri hien thi MOI windowsInitialPassword".into(),
rich: Some(vec![vec![
RichSeg { text: "Trang quan tri hien thi MOI ".into(), style: idx_a },
RichSeg { text: "windowsInitialPassword".into(), style: idx_b },
]]),
}],
&out,
None,
)
.expect("apply_edits rich reflow");
let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract");
let norm = text.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(norm.contains("hien thi MOI"), "chữ sửa phải có mặt: {text:?}");
assert!(norm.contains("windowsInitialPassword"), "đoạn mono phải còn: {text:?}");
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
let mono = objs
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("windowsInitialPassword")).unwrap_or(false))
.unwrap_or_else(|| panic!("phải có run mono: {objs:?}"));
let plain = objs
.iter()
.find(|o| o.text.as_deref().map(|t| t.contains("hien thi MOI")).unwrap_or(false))
.unwrap_or_else(|| panic!("phải có run thường: {objs:?}"));
assert!(
mono.font_name.as_deref().map(|f| f.contains("Courier")).unwrap_or(false),
"đoạn mono phải GIỮ font Courier: {:?}",
mono.font_name
);
assert!(
plain.font_name.as_deref().map(|f| f.contains("Helvetica")).unwrap_or(false),
"đoạn thường phải giữ Helvetica: {:?}",
plain.font_name
);
// Cùng dòng, mono nằm bên PHẢI đoạn thường.
assert!(
(mono.rect.bottom - plain.rect.bottom).abs() < 4.0,
"2 đoạn phải cùng baseline: {:?} vs {:?}",
mono.rect,
plain.rect
);
assert!(
mono.rect.left > plain.rect.left + 10.0,
"mono phải nằm sau đoạn thường: {:?} vs {:?}",
mono.rect,
plain.rect
);
}