From 390fb82dd6af5b3f665fc132bc597ea01c418216 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Nam?= Date: Sat, 8 Aug 2026 18:15:23 +0700 Subject: [PATCH] =?UTF-8?q?Fix=20c=E1=BB=A1=20ch=E1=BB=AF=20=C3=82M=20v?= =?UTF-8?q?=E1=BB=9Bi=20file=20to=E1=BA=A1=20=C4=91=E1=BB=99=20l=E1=BA=ADt?= =?UTF-8?q?=20(Canva):=20pdfium-render=20l=E1=BA=A5y=20vertical=20scale=20?= =?UTF-8?q?=3D=20matrix.d=20C=C3=93=20D=E1=BA=A4U=20=E2=80=94=20chu?= =?UTF-8?q?=E1=BA=A9n=20ho=C3=A1=20.abs()=20t=E1=BA=A1i=20m=E1=BB=8Di=20?= =?UTF-8?q?=C4=91i=E1=BB=83m=20=C4=91=E1=BB=8Dc=20c=E1=BB=A1=20trong=20eng?= =?UTF-8?q?ine=20(list/geo/anchor/inplace/capture).=20C=E1=BB=A1=20=C3=A2m?= =?UTF-8?q?=20l=C3=A0m=20UI=20k=E1=BA=B9p=20=C3=B4=20s=E1=BB=ADa=20v?= =?UTF-8?q?=E1=BB=81=206px=20(sai=20v=E1=BB=8B=20tr=C3=AD/style)=20v=C3=A0?= =?UTF-8?q?=20reflow=20=C4=91o=20b=E1=BB=81=20r=E1=BB=99ng=20=C3=A2m=20?= =?UTF-8?q?=E2=86=92=20kh=C3=B4ng=20b=E1=BA=BB=20d=C3=B2ng,=20ch=E1=BB=AF?= =?UTF-8?q?=20m=E1=BB=9Bi=20v=C4=83ng=20kh=E1=BB=8Fi=20trang=20(c=E1=BB=95?= =?UTF-8?q?ng=20pass=20v=C3=AC=20=C3=ADt=20pixel)=20=E2=86=92=20'text=20bi?= =?UTF-8?q?=E1=BA=BFn=20m=E1=BA=A5t'.=20K=C3=A8m=202=20test=20fixture=20fo?= =?UTF-8?q?rm=20MA=20TR=E1=BA=ACN=20L=E1=BA=ACT=20(c=E1=BB=A1=20d=C6=B0?= =?UTF-8?q?=C6=A1ng=20~24,=20reflow=20=C4=91=E1=BA=B7t=20ch=E1=BB=AF=20?= =?UTF-8?q?=C4=91=C3=BAng=20v=C3=B9ng=20c=C5=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- crates/ff-engine/src/edit.rs | 21 +++-- crates/ff-engine/tests/edit_roundtrip.rs | 99 ++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/crates/ff-engine/src/edit.rs b/crates/ff-engine/src/edit.rs index d985d0e..f740477 100644 --- a/crates/ff-engine/src/edit.rs +++ b/crates/ff-engine/src/edit.rs @@ -550,7 +550,9 @@ pub fn list_objects( info.font_italic = Some(italic); info.font_embedded = t.font().is_embedded().ok(); // Cỡ hiển thị = cỡ đã scale của run × scale dọc của form chứa nó. - info.font_size = Some(t.scaled_font_size().value * mat_vscale(entry.acc)); + // .abs(): pdfium-render lấy scale = matrix.d CÓ DẤU — file Canva + // dùng hệ toạ độ lật (d<0) làm cỡ chữ ÂM, vỡ mọi công thức phía sau. + info.font_size = Some((t.scaled_font_size().value * mat_vscale(entry.acc)).abs()); info.color = t .fill_color() .ok() @@ -874,8 +876,11 @@ pub fn apply_edits( return Ok(None); }; let vs = mat_vscale(entry.acc); - let (tf, scaled) = - (t.unscaled_font_size().value, t.scaled_font_size().value * vs); + // .abs(): matrix lật (Canva) cho cỡ ÂM — chuẩn hoá dương. + let (tf, scaled) = ( + t.unscaled_font_size().value.abs(), + (t.scaled_font_size().value * vs).abs(), + ); let m = obj.matrix().map_err(err)?; let (_, fy) = mat_apply(entry.acc, m.e(), m.f()); let b_raw = obj.bounds().map(|q| quad_to_rect(&q)).unwrap_or(Rect { @@ -968,9 +973,9 @@ pub fn apply_edits( let m = anchor.matrix().map_err(err)?; let mc = mat_mul(mat_of(&m), entry.acc); let tok = t.font().token(); - let unscaled = t.unscaled_font_size().value; + let unscaled = t.unscaled_font_size().value.abs(); let scaled = - (t.scaled_font_size().value * mat_vscale(entry.acc)).max(1.0); + (t.scaled_font_size().value * mat_vscale(entry.acc)).abs().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(); @@ -1234,7 +1239,7 @@ pub fn apply_edits( // Cỡ hiển thị so theo KHÔNG GIAN TRANG (nhân scale form) — target // từ UI cũng theo trang; hệ số k không đổi khi áp trong form. let (old_text, scaled) = match obj.as_text_object() { - Some(t) => (t.text(), t.scaled_font_size().value * mat_vscale(entry.acc)), + Some(t) => (t.text(), (t.scaled_font_size().value * mat_vscale(entry.acc)).abs()), None => continue, }; if *text != old_text { @@ -1295,8 +1300,8 @@ pub fn apply_edits( let vs = mat_vscale(entry.acc); let (unscaled, scaled, color) = if let Some(t) = obj.as_text_object() { ( - t.unscaled_font_size().value, - t.scaled_font_size().value * vs, + t.unscaled_font_size().value.abs(), + (t.scaled_font_size().value * vs).abs(), t.fill_color().unwrap_or(PdfColor::new(0, 0, 0, 255)), ) } else { diff --git a/crates/ff-engine/tests/edit_roundtrip.rs b/crates/ff-engine/tests/edit_roundtrip.rs index 9cdfa25..67bb8c0 100644 --- a/crates/ff-engine/tests/edit_roundtrip.rs +++ b/crates/ff-engine/tests/edit_roundtrip.rs @@ -1303,3 +1303,102 @@ fn flatten_is_visually_lossless_on_simple_form() { let mm = ff_engine::page_render_mismatch(&pdf, &input, &out, 0, 500).expect("mismatch"); assert!(mm < 0.005, "mở gói phải giữ nguyên hiển thị, lệch {:.3}%", mm * 100.0); } + +/// Fixture kiểu CANVA: form /Matrix lật trục Y, text bên trong cũng lật +/// (net = xuôi). pdfium-render trả vertical scale CÓ DẤU → cỡ chữ từng bị ÂM. +fn build_flipped_form_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 font_id = doc.add_object(dictionary! { + "Type" => "Font", + "Subtype" => "Type1", + "BaseFont" => "Helvetica", + }); + let form_id = doc.add_object(Stream::new( + dictionary! { + "Type" => "XObject", + "Subtype" => "Form", + "BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()], + "Matrix" => vec![1.into(), 0.into(), 0.into(), Object::Real(-1.0), 0.into(), 792.into()], + "Resources" => dictionary! { "Font" => dictionary! { "F1" => font_id } }, + }, + b"BT /F1 24 Tf 1 0 0 -1 72 92 Tm (Hello flipped form) Tj ET BT /F1 18 Tf 1 0 0 -1 72 142 Tm (Second flipped stays) Tj ET".to_vec(), + )); + let content_id = doc.add_object(Stream::new(dictionary! {}, b"q /Fx1 Do Q".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! { "XObject" => dictionary! { "Fx1" => form_id } }, + "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 flipped form"); +} + +// Ma trận lật (Canva): cỡ chữ phải DƯƠNG và đúng ~24, rect đúng vùng y=700. +#[test] +fn flipped_form_reports_positive_font_size() { + let pdf = pdfium(); + let input = tmp("ff_edit_flip_list.pdf"); + build_flipped_form_pdf(&input); + let objs = ff_engine::list_objects(&pdf, &input, 0, None).expect("list"); + let t = objs + .iter() + .find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|s| s.contains("Hello flipped")).unwrap_or(false)) + .unwrap_or_else(|| panic!("phải thấy text: {objs:?}")); + let fs = t.font_size.unwrap_or(0.0); + assert!(fs > 20.0 && fs < 28.0, "cỡ chữ phải DƯƠNG ~24, được {fs}"); + assert!(t.rect.bottom > 660.0 && t.rect.top < 740.0, "rect quanh y=700: {:?}", t.rect); +} + +// Reflow trong form LẬT: chữ mới hiện đúng (không văng khỏi trang do bề +// rộng đo bằng cỡ âm), chữ cũ mất, dòng kia giữ nguyên. +#[test] +fn flipped_form_surgical_reflow() { + let pdf = pdfium(); + let input = tmp("ff_edit_flip_reflow.pdf"); + build_flipped_form_pdf(&input); + let out = tmp("ff_edit_flip_reflow_out.pdf"); + let idx = find_text_index(&pdf, &input, "Hello flipped"); + + ff_engine::apply_edits( + &pdf, + &input, + 0, + &[EditOp::ReflowText { + indices: vec![idx], + text: "Đổi chữ trong form lật trục".into(), + }], + &out, + None, + ) + .expect("reflow trong form lật"); + + let text = ff_engine::extract_text(&pdf, &out, 0, None).expect("extract"); + let norm = text.split_whitespace().collect::>().join(" "); + assert!(norm.contains("Đổi chữ trong form"), "chữ mới phải có: {text:?}"); + assert!(!norm.contains("Hello flipped"), "chữ cũ phải mất: {text:?}"); + assert!(norm.contains("Second flipped stays"), "dòng kia phải nguyên: {text:?}"); + // Chữ mới phải nằm QUANH vị trí cũ (y~700) — không văng khỏi trang. + let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out"); + let new_run = objs + .iter() + .find(|o| o.kind == ObjectKind::Text && o.text.as_deref().map(|s| s.contains("Đổi chữ")).unwrap_or(false)) + .unwrap_or_else(|| panic!("phải thấy run mới: {objs:?}")); + assert!( + new_run.rect.left > 50.0 && new_run.rect.left < 200.0 && new_run.rect.bottom > 650.0 && new_run.rect.top < 740.0, + "run mới phải quanh chỗ cũ: {:?}", + new_run.rect + ); +}