Initial commit: FoFreeXit Phase 1 (Viewer) + Phase 2 (Annotate)

PDF viewer (render/search/text-select/thumbnails/outline) and annotation
engine (Highlight/Underline/Strikeout/Square/FreeText/Note) over PDFium +
lopdf, with Unicode font embedding for Vietnamese text and undo/redo.
This commit is contained in:
2026-06-17 22:34:06 +07:00
commit 274012f854
52 changed files with 28479 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ff-engine"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FoFreeXit engine: render/edit/io over PDFium + QPDF"
[dependencies]
ff-cos = { path = "../ff-cos" }
ff-pdmodel = { path = "../ff-pdmodel" }
anyhow.workspace = true
thiserror.workspace = true
pdfium-render = "0.8"
image = "0.25"
lopdf = "0.36"
ttf-parser = "0.25"
+808
View File
@@ -0,0 +1,808 @@
//! Annotation (chú thích): tạo/ghi & đọc lại. Phase 2.
//!
//! Dùng PDFium (qua pdfium-render) để tạo annotation rồi lưu ra file mới.
//! Đường GHI file được test round-trip (ghi → mở lại → còn nguyên) vì lưu sai
//! có thể hỏng file người dùng.
use std::collections::BTreeMap;
use std::path::Path;
use pdfium_render::prelude::*;
use crate::text::Rect;
use crate::EngineError;
/// Loại annotation hỗ trợ ở Phase 2 (mở rộng dần).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnnotKind {
Highlight,
Underline,
Strikeout,
Square,
/// Hộp văn bản (free text) — `contents` là nội dung hiển thị.
FreeText,
/// Ghi chú dán (sticky note) — `contents` là nội dung popup.
Note,
}
/// Mô tả một annotation cần tạo.
#[derive(Clone, Debug)]
pub struct AnnotSpec {
pub kind: AnnotKind,
pub page_index: u16,
/// Bao toàn bộ annotation (Square/FreeText/Note dùng trực tiếp; với
/// Highlight/Underline/Strikeout đây là hộp bao toàn bộ — bounds thực tế
/// vẽ ra theo `quads`).
pub rect: Rect,
/// Highlight/Underline/Strikeout theo TEXT thật thường trải nhiều dòng —
/// mỗi dòng là 1 quad riêng (giống Foxit: chọn văn bản nhiều dòng tạo
/// nhiều quad, không phải 1 khối chữ nhật phủ luôn cả khoảng trắng giữa
/// dòng). Rỗng = suy ra 1 quad duy nhất từ `rect` (tương thích cũ).
pub quads: Vec<Rect>,
/// Màu RGBA (0–255). Với text = màu chữ; markup = màu tô/viền.
pub color: [u8; 4],
pub contents: Option<String>,
/// Định dạng cho FreeText (text box).
pub font_size: f32,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl AnnotSpec {
/// Giá trị mặc định tiện cho test/markup (không phải text box). `quads`
/// rỗng → 1 quad duy nhất suy ra từ `rect`.
pub fn markup(kind: AnnotKind, page_index: u16, rect: Rect, color: [u8; 4]) -> Self {
AnnotSpec {
kind,
page_index,
rect,
quads: Vec::new(),
color,
contents: None,
font_size: 14.0,
bold: false,
italic: false,
underline: false,
}
}
}
/// Thông tin một annotation đọc được từ file.
#[derive(Clone, Debug, PartialEq)]
pub struct AnnotInfo {
pub page_index: u16,
pub kind: String,
pub rect: Rect,
pub contents: Option<String>,
/// Số attachment point (quad) — markup nhiều dòng sẽ >1.
pub quad_count: usize,
}
fn to_pdf_rect(r: &Rect) -> PdfRect {
PdfRect::new(
PdfPoints::new(r.bottom),
PdfPoints::new(r.left),
PdfPoints::new(r.top),
PdfPoints::new(r.right),
)
}
/// QuadPoints cho text-markup theo thứ tự PDFium/Acrobat mong đợi:
/// (top-left, top-right, bottom-left, bottom-right). Lưu ý `PdfQuadPoints::from_rect`
/// dùng thứ tự khác (LL,LR,UR,UL) khiến highlight bị xoắn thành sọc mỏng.
fn quad_from_rect(r: &PdfRect) -> PdfQuadPoints {
PdfQuadPoints::new(
r.left(), r.top(), // x1,y1 = top-left
r.right(), r.top(), // x2,y2 = top-right
r.left(), r.bottom(), // x3,y3 = bottom-left
r.right(), r.bottom(), // x4,y4 = bottom-right
)
}
fn from_pdf_rect(r: &PdfRect) -> Rect {
Rect {
left: r.left().value,
bottom: r.bottom().value,
right: r.right().value,
top: r.top().value,
}
}
fn is_text_kind(k: AnnotKind) -> bool {
matches!(k, AnnotKind::FreeText | AnnotKind::Note)
}
/// Quad thật sự cần vẽ cho markup: dùng `quads` (1 quad/dòng) nếu có,
/// nếu không suy ra 1 quad duy nhất từ `rect` (tương thích spec cũ/đơn giản).
fn effective_quads(spec: &AnnotSpec) -> Vec<PdfRect> {
if spec.quads.is_empty() {
vec![to_pdf_rect(&spec.rect)]
} else {
spec.quads.iter().map(to_pdf_rect).collect()
}
}
/// Hộp bao nhỏ nhất chứa toàn bộ quad — dùng làm `/Rect` của annotation.
fn union_pdf_rect(quads: &[PdfRect]) -> PdfRect {
let mut left = quads[0].left().value;
let mut bottom = quads[0].bottom().value;
let mut right = quads[0].right().value;
let mut top = quads[0].top().value;
for q in &quads[1..] {
left = left.min(q.left().value);
bottom = bottom.min(q.bottom().value);
right = right.max(q.right().value);
top = top.max(q.top().value);
}
PdfRect::new(
PdfPoints::new(bottom),
PdfPoints::new(left),
PdfPoints::new(top),
PdfPoints::new(right),
)
}
/// Tạo các annotation theo `specs` rồi lưu sang `output`. Không sửa `input`.
///
/// - Markup/shape (Highlight/Underline/Strikeout/Square): tạo bằng PDFium.
/// - Text (FreeText/Note): ghi bằng lopdf với /DA + /DR đầy đủ (font, cỡ, màu,
/// đậm/nghiêng) để GIỮ ĐỊNH DẠNG khi lưu — đúng như Foxit.
pub fn apply_annotations(
pdfium: &Pdfium,
input: &Path,
output: &Path,
specs: &[AnnotSpec],
) -> Result<(), EngineError> {
// ----- Pass 1: PDFium tạo markup/shape, luôn ghi ra `output` (kể cả copy) -----
{
let document = pdfium
.load_pdf_from_file(input, None)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let mut by_page: BTreeMap<u16, Vec<&AnnotSpec>> = BTreeMap::new();
for s in specs.iter().filter(|s| !is_text_kind(s.kind)) {
by_page.entry(s.page_index).or_default().push(s);
}
for (page_index, page_specs) in by_page {
let mut page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("trang {page_index}: {e}")))?;
for spec in page_specs {
let color =
PdfColor::new(spec.color[0], spec.color[1], spec.color[2], spec.color[3]);
let annots = page.annotations_mut();
let err = |e: PdfiumError| EngineError::Pdfium(format!("tạo annotation: {e}"));
// Mỗi quad = 1 dòng văn bản đã chọn (như Foxit: chọn văn bản nhiều
// dòng tạo nhiều quad trong CÙNG 1 annotation, không phải 1 khối
// chữ nhật phủ cả khoảng trắng giữa dòng). `create_*_annotation()`
// trả về kiểu cụ thể khác nhau nên không gộp match-arm được —
// tách riêng từng loại, logic giống nhau.
match spec.kind {
AnnotKind::Highlight => {
let quads = effective_quads(spec);
let bounds = union_pdf_rect(&quads);
let mut a = annots.create_highlight_annotation().map_err(err)?;
a.set_bounds(bounds).map_err(err)?;
for q in &quads {
a.attachment_points_mut()
.create_attachment_point_at_end(quad_from_rect(q))
.map_err(err)?;
}
a.set_fill_color(color).map_err(err)?;
}
AnnotKind::Underline => {
let quads = effective_quads(spec);
let bounds = union_pdf_rect(&quads);
let mut a = annots.create_underline_annotation().map_err(err)?;
a.set_bounds(bounds).map_err(err)?;
for q in &quads {
a.attachment_points_mut()
.create_attachment_point_at_end(quad_from_rect(q))
.map_err(err)?;
}
a.set_stroke_color(color).map_err(err)?;
}
AnnotKind::Strikeout => {
let quads = effective_quads(spec);
let bounds = union_pdf_rect(&quads);
let mut a = annots.create_strikeout_annotation().map_err(err)?;
a.set_bounds(bounds).map_err(err)?;
for q in &quads {
a.attachment_points_mut()
.create_attachment_point_at_end(quad_from_rect(q))
.map_err(err)?;
}
a.set_stroke_color(color).map_err(err)?;
}
AnnotKind::Square => {
let rect = to_pdf_rect(&spec.rect);
let mut a = annots.create_square_annotation().map_err(err)?;
a.set_bounds(rect).map_err(err)?;
a.set_stroke_color(color).map_err(err)?;
}
AnnotKind::FreeText | AnnotKind::Note => unreachable!(),
}
}
}
document
.save_to_file(output)
.map_err(|e| EngineError::Pdfium(format!("lưu file: {e}")))?;
}
// ----- Pass 2: lopdf ghi FreeText/Note với định dạng đầy đủ -----
let text: Vec<&AnnotSpec> = specs.iter().filter(|s| is_text_kind(s.kind)).collect();
if !text.is_empty() {
add_text_annotations(output, &text)?;
}
Ok(())
}
// ---- Ghi FreeText/Note bằng lopdf (giữ định dạng qua /DA + /DR) ----
use lopdf::{Dictionary, Document as LoDoc, Object as LoObj, ObjectId, StringFormat};
// ---- Type1 fonts (dự phòng cho ASCII) ----
struct DrFonts {
helv: ObjectId,
bold: ObjectId,
obl: ObjectId,
boldobl: ObjectId,
}
fn ensure_type1_fonts(doc: &mut LoDoc) -> DrFonts {
let mut mk = |bf: &str| {
let mut d = Dictionary::new();
d.set("Type", LoObj::Name(b"Font".to_vec()));
d.set("Subtype", LoObj::Name(b"Type1".to_vec()));
d.set("BaseFont", LoObj::Name(bf.as_bytes().to_vec()));
d.set("Encoding", LoObj::Name(b"WinAnsiEncoding".to_vec()));
doc.add_object(LoObj::Dictionary(d))
};
DrFonts {
helv: mk("Helvetica"),
bold: mk("Helvetica-Bold"),
obl: mk("Helvetica-Oblique"),
boldobl: mk("Helvetica-BoldOblique"),
}
}
fn pdf_text_string(s: &str) -> LoObj {
if s.is_ascii() {
LoObj::String(s.as_bytes().to_vec(), StringFormat::Literal)
} else {
// UTF-16BE với BOM 0xFEFF
let mut bytes = vec![0xFE, 0xFF];
for u in s.encode_utf16() {
bytes.push((u >> 8) as u8);
bytes.push((u & 0xFF) as u8);
}
LoObj::String(bytes, StringFormat::Hexadecimal)
}
}
// ---- Unicode font (Type0/CIDFontType2) cho tiếng Việt ----
/// Tìm file TTF có Unicode trên hệ thống theo đậm/nghiêng (Windows/Linux/Mac).
/// Thử đúng file biến thể (vd `arialbd.ttf`) trước, rồi mới hạ cấp dần về regular
/// của family khác — để FreeText giữ đúng đậm/nghiêng như Foxit.
fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
#[cfg(windows)]
{
// [regular, bold, italic, bold-italic] cho từng family phổ biến trên Windows.
const FAMILIES: &[[&str; 4]] = &[
["arial.ttf", "arialbd.ttf", "ariali.ttf", "arialbi.ttf"],
["segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf", "segoeuiz.ttf"],
["calibri.ttf", "calibrib.ttf", "calibrii.ttf", "calibriz.ttf"],
["verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf"],
["tahoma.ttf", "tahomabd.ttf", "tahoma.ttf", "tahomabd.ttf"],
];
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(format!(r"C:\Windows\Fonts\{}", fam[idx])) {
return Some(bytes);
}
}
// Không có biến thể đúng → hạ cấp về regular của family đầu tiên có sẵn.
for fam in FAMILIES {
if let Ok(bytes) = std::fs::read(format!(r"C:\Windows\Fonts\{}", fam[0])) {
return Some(bytes);
}
}
None
}
#[cfg(target_os = "macos")]
{
let _ = (bold, italic);
let candidates: &[&str] = &[
"/System/Library/Fonts/Supplemental/Arial.ttf",
"/Library/Fonts/Arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
];
candidates.iter().find_map(|p| std::fs::read(p).ok())
}
#[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",
];
candidates.iter().find_map(|p| std::fs::read(p).ok())
}
}
/// Nhúng TTF vào doc như Type0/CIDFontType2 (Identity-H). `used_text` gồm toàn bộ
/// nội dung sẽ hiển thị bằng font này — dùng để dựng `/W` (độ rộng glyph) đúng;
/// PDF spec quy định widths của Type0/CIDFont LUÔN lấy từ /W + /DW, KHÔNG lấy từ
/// bảng hmtx trong font nhúng — thiếu /W khiến mọi glyph bị coi là rộng /DW (1 em),
/// chữ bị dãn cách quá rộng. Trả về object ID Type0 font.
fn embed_type0_font(doc: &mut LoDoc, font_bytes: &[u8], used_text: &str) -> Result<ObjectId, EngineError> {
let face = ttf_parser::Face::parse(font_bytes, 0)
.map_err(|e| EngineError::Pdfium(format!("ttf-parser: {:?}", e)))?;
let upem = face.units_per_em() as f32;
let scale = |v: i16| (v as f32 * 1000.0 / upem) as i64;
let bb = face.global_bounding_box();
// Độ rộng (1000 units/em) cho mọi glyph thực sự dùng tới, kể cả dấu cách và
// ký tự '?' (dự phòng khi thiếu glyph) — đủ để mọi Tj trong AP stream khớp.
let mut widths: BTreeMap<u16, i64> = BTreeMap::new();
for ch in used_text.chars().chain([' ', '?']) {
let gid = face
.glyph_index(ch)
.unwrap_or_else(|| face.glyph_index('?').unwrap_or(ttf_parser::GlyphId(0)));
let w = face
.glyph_hor_advance(gid)
.map_or(1000, |a| (a as f32 * 1000.0 / upem) as i64);
widths.insert(gid.0, w);
}
let w_array: Vec<LoObj> = widths
.into_iter()
.flat_map(|(gid, w)| vec![LoObj::Integer(gid as i64), LoObj::Array(vec![LoObj::Integer(w)])])
.collect();
// 1. Font file stream (CIDFontType2 dùng /FontFile2)
let mut fs_dict = Dictionary::new();
fs_dict.set("Length1", LoObj::Integer(font_bytes.len() as i64));
let font_file_id = doc.add_object(LoObj::Stream(lopdf::Stream::new(
fs_dict,
font_bytes.to_vec(),
)));
// 2. FontDescriptor
let mut fd = Dictionary::new();
fd.set("Type", LoObj::Name(b"FontDescriptor".to_vec()));
fd.set("FontName", LoObj::Name(b"ArialMT".to_vec()));
fd.set("Flags", LoObj::Integer(32)); // Nonsymbolic
fd.set("Ascent", LoObj::Integer(scale(face.ascender())));
fd.set("Descent", LoObj::Integer(scale(face.descender())));
fd.set("CapHeight", LoObj::Integer(scale(face.ascender()) * 7 / 10));
fd.set("StemV", LoObj::Integer(80));
fd.set("ItalicAngle", LoObj::Integer(0));
fd.set(
"FontBBox",
LoObj::Array(vec![
LoObj::Integer(scale(bb.x_min)),
LoObj::Integer(scale(bb.y_min)),
LoObj::Integer(scale(bb.x_max)),
LoObj::Integer(scale(bb.y_max)),
]),
);
fd.set("FontFile2", LoObj::Reference(font_file_id));
let fd_id = doc.add_object(LoObj::Dictionary(fd));
// 3. CIDFont (CIDFontType2 = TrueType)
let mut cidfont = Dictionary::new();
cidfont.set("Type", LoObj::Name(b"Font".to_vec()));
cidfont.set("Subtype", LoObj::Name(b"CIDFontType2".to_vec()));
cidfont.set("BaseFont", LoObj::Name(b"ArialMT".to_vec()));
let mut cidsys = Dictionary::new();
cidsys.set("Registry", LoObj::String(b"Adobe".to_vec(), StringFormat::Literal));
cidsys.set("Ordering", LoObj::String(b"Identity".to_vec(), StringFormat::Literal));
cidsys.set("Supplement", LoObj::Integer(0));
cidfont.set("CIDSystemInfo", LoObj::Dictionary(cidsys));
cidfont.set("FontDescriptor", LoObj::Reference(fd_id));
cidfont.set("DW", LoObj::Integer(1000));
cidfont.set("W", LoObj::Array(w_array));
cidfont.set("CIDToGIDMap", LoObj::Name(b"Identity".to_vec()));
let cidfont_id = doc.add_object(LoObj::Dictionary(cidfont));
// 4. Type0 wrapper
let mut font0 = Dictionary::new();
font0.set("Type", LoObj::Name(b"Font".to_vec()));
font0.set("Subtype", LoObj::Name(b"Type0".to_vec()));
font0.set("BaseFont", LoObj::Name(b"ArialMT-Identity-H".to_vec()));
font0.set("Encoding", LoObj::Name(b"Identity-H".to_vec()));
font0.set("DescendantFonts", LoObj::Array(vec![LoObj::Reference(cidfont_id)]));
Ok(doc.add_object(LoObj::Dictionary(font0)))
}
/// Mã hoá chuỗi thành CID bytes (Identity-H: glyph ID 2 bytes big-endian mỗi char).
fn encode_cid(text: &str, face: &ttf_parser::Face) -> Vec<u8> {
let mut out = Vec::with_capacity(text.chars().count() * 2);
for ch in text.chars() {
let gid = face
.glyph_index(ch)
.unwrap_or_else(|| face.glyph_index('?').unwrap_or(ttf_parser::GlyphId(0)));
out.push((gid.0 >> 8) as u8);
out.push((gid.0 & 0xFF) as u8);
}
out
}
/// Advance width (point) cho một ký tự theo font_size.
fn char_adv(ch: char, face: &ttf_parser::Face, fs: f32) -> f32 {
let upem = face.units_per_em() as f32;
face.glyph_index(ch)
.and_then(|gid| face.glyph_hor_advance(gid))
.map_or(fs * 0.5, |adv| adv as f32 / upem * fs)
}
/// Ngắt văn bản thành dòng không vượt quá max_w (pt).
fn wrap_lines(text: &str, face: &ttf_parser::Face, fs: f32, max_w: f32) -> Vec<String> {
let mut lines = Vec::new();
for para in text.split('\n') {
let words: Vec<&str> = para.split_whitespace().collect();
if words.is_empty() {
lines.push(String::new());
continue;
}
let sp = char_adv(' ', face, fs);
let mut cur = String::new();
let mut cur_w = 0.0f32;
for word in words {
let ww: f32 = word.chars().map(|c| char_adv(c, face, fs)).sum();
if cur.is_empty() {
cur.push_str(word);
cur_w = ww;
} else if cur_w + sp + ww <= max_w {
cur.push(' ');
cur.push_str(word);
cur_w += sp + ww;
} else {
lines.push(cur);
cur = word.to_string();
cur_w = ww;
}
}
lines.push(cur);
}
if lines.is_empty() {
lines.push(String::new());
}
lines
}
/// Dựng Form XObject (AP /N) cho FreeText với font Unicode.
/// Vẽ nền trắng + viền xám + text nhiều dòng, có word-wrap.
fn build_unicode_ap(
doc: &mut LoDoc,
spec: &AnnotSpec,
font0_id: ObjectId,
font_bytes: &[u8],
) -> Result<ObjectId, EngineError> {
let face = ttf_parser::Face::parse(font_bytes, 0)
.map_err(|e| EngineError::Pdfium(format!("ttf-parser AP: {:?}", e)))?;
let box_w = spec.rect.right - spec.rect.left;
let box_h = spec.rect.top - spec.rect.bottom;
let fs = spec.font_size;
let upem = face.units_per_em() as f32;
let ascender = face.ascender() as f32 / upem * fs;
let (cr, cg, cb) = (
spec.color[0] as f32 / 255.0,
spec.color[1] as f32 / 255.0,
spec.color[2] as f32 / 255.0,
);
let margin = 2.0f32;
let usable_w = (box_w - margin * 2.0).max(1.0);
let text = spec.contents.as_deref().unwrap_or("");
let lines = wrap_lines(text, &face, fs, usable_w);
let line_h = fs * 1.2;
let y_start = box_h - margin - ascender;
// Dựng content stream
let mut cs = String::new();
// Nền trắng + viền xám
cs.push_str("q\n1 1 1 rg\n");
cs.push_str(&format!("0 0 {:.2} {:.2} re f\n", box_w, box_h));
cs.push_str("0.5 0.5 0.5 RG\n0.5 w\n");
cs.push_str(&format!("0 0 {:.2} {:.2} re S\n", box_w, box_h));
// Text
cs.push_str("BT\n");
cs.push_str(&format!("/F0 {:.2} Tf\n", fs));
cs.push_str(&format!("{:.4} {:.4} {:.4} rg\n", cr, cg, cb));
cs.push_str(&format!("{:.2} TL\n", line_h));
cs.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n", margin, y_start));
for (i, line) in lines.iter().enumerate() {
if i > 0 {
cs.push_str("T*\n");
}
let cid_bytes = encode_cid(line, &face);
let hex: String = cid_bytes.iter().map(|b| format!("{:02X}", b)).collect();
cs.push_str(&format!("<{}> Tj\n", hex));
// Gạch chân nếu cần (tính từ baseline xuống ~0.1*fs)
if spec.underline && !line.is_empty() {
let line_w: f32 = line.chars().map(|c| char_adv(c, &face, fs)).sum();
let ul_y = y_start - i as f32 * line_h - fs * 0.1;
cs.push_str("ET\n");
cs.push_str(&format!("{:.4} {:.4} {:.4} RG\n", cr, cg, cb));
cs.push_str(&format!("0.5 w\n{:.2} {:.2} m {:.2} {:.2} l S\n",
margin, ul_y, margin + line_w, ul_y));
cs.push_str("BT\n");
cs.push_str(&format!("/F0 {:.2} Tf\n", fs));
cs.push_str(&format!("{:.4} {:.4} {:.4} rg\n", cr, cg, cb));
cs.push_str(&format!("{:.2} TL\n", line_h));
cs.push_str(&format!("1 0 0 1 {:.2} {:.2} Tm\n",
margin, y_start - i as f32 * line_h));
}
}
cs.push_str("ET\nQ\n");
let content = cs.into_bytes();
// Resources
let mut fr = Dictionary::new();
fr.set("F0", LoObj::Reference(font0_id));
let mut res = Dictionary::new();
res.set("Font", LoObj::Dictionary(fr));
// Form XObject
let mut xd = Dictionary::new();
xd.set("Type", LoObj::Name(b"XObject".to_vec()));
xd.set("Subtype", LoObj::Name(b"Form".to_vec()));
xd.set("FormType", LoObj::Integer(1));
xd.set(
"BBox",
LoObj::Array(vec![
LoObj::Real(0.0), LoObj::Real(0.0),
LoObj::Real(box_w), LoObj::Real(box_h),
]),
);
xd.set("Resources", LoObj::Dictionary(res));
xd.set("Length", LoObj::Integer(content.len() as i64));
Ok(doc.add_object(LoObj::Stream(lopdf::Stream::new(xd, content))))
}
// ---- Orchestration ----
fn add_text_annotations(path: &Path, specs: &[&AnnotSpec]) -> Result<(), EngineError> {
let mut doc =
LoDoc::load(path).map_err(|e| EngineError::Pdfium(format!("lopdf load: {e}")))?;
let type1 = ensure_type1_fonts(&mut doc);
// Nhúng font hệ thống (Type0/CIDFont) cho MỌI FreeText (ASCII lẫn Unicode) — để
// AP stream tự dựng (đo độ rộng glyph từ font thật, hỗ trợ word-wrap & underline
// nhất quán) áp dụng cho tất cả, không chỉ tiếng Việt. Nhúng riêng theo từng tổ
// hợp đậm/nghiêng thực sự dùng tới (đúng biến thể font, vd arialbd.ttf).
let mut combos: BTreeMap<(bool, bool), String> = BTreeMap::new();
for s in specs.iter().filter(|s| matches!(s.kind, AnnotKind::FreeText)) {
let entry = combos.entry((s.bold, s.italic)).or_default();
if let Some(c) = &s.contents {
entry.push_str(c);
entry.push('\n');
}
}
let mut text_fonts: BTreeMap<(bool, bool), (ObjectId, Vec<u8>)> = BTreeMap::new();
for (key, combined) in combos {
if let Some(bytes) = find_font_bytes(key.0, key.1) {
if let Ok(id) = embed_type0_font(&mut doc, &bytes, &combined) {
text_fonts.insert(key, (id, bytes));
}
}
}
let pages = doc.get_pages();
for spec in specs {
let page_id = match pages.get(&(spec.page_index as u32 + 1)) {
Some(id) => *id,
None => continue,
};
let annot_id = build_text_annot(
&mut doc,
spec,
&type1,
text_fonts
.get(&(spec.bold, spec.italic))
.map(|(id, b)| (*id, b.as_slice())),
)?;
attach_annot(&mut doc, page_id, annot_id)?;
}
doc.save(path)
.map_err(|e| EngineError::Pdfium(format!("lopdf save: {e}")))?;
Ok(())
}
fn build_text_annot(
doc: &mut LoDoc,
spec: &AnnotSpec,
type1: &DrFonts,
unicode_font: Option<(ObjectId, &[u8])>,
) -> Result<ObjectId, EngineError> {
let r = &spec.rect;
let (cr, cg, cb) = (
spec.color[0] as f32 / 255.0,
spec.color[1] as f32 / 255.0,
spec.color[2] as f32 / 255.0,
);
let contents = pdf_text_string(spec.contents.as_deref().unwrap_or(""));
let mut d = Dictionary::new();
d.set("Type", LoObj::Name(b"Annot".to_vec()));
match spec.kind {
AnnotKind::FreeText => {
d.set("Subtype", LoObj::Name(b"FreeText".to_vec()));
d.set(
"Rect",
LoObj::Array(vec![
LoObj::Real(r.left),
LoObj::Real(r.bottom),
LoObj::Real(r.right),
LoObj::Real(r.top),
]),
);
d.set("Contents", contents);
d.set("F", LoObj::Integer(4));
// Mọi FreeText (ASCII lẫn Unicode) dùng AP stream tự dựng từ font hệ
// thống thật — đo độ rộng glyph chính xác nên word-wrap & underline
// nhất quán. Chỉ fallback Type1/DA-only khi không tìm được font nào.
if let Some((font0_id, font_bytes)) = unicode_font {
let da = format!("/F0 {:.2} Tf {:.4} {:.4} {:.4} rg", spec.font_size, cr, cg, cb);
let mut dr_font = Dictionary::new();
dr_font.set("F0", LoObj::Reference(font0_id));
let mut dr = Dictionary::new();
dr.set("Font", LoObj::Dictionary(dr_font));
d.set("DA", LoObj::String(da.into_bytes(), StringFormat::Literal));
d.set("DR", LoObj::Dictionary(dr));
let ap_id = build_unicode_ap(doc, spec, font0_id, font_bytes)?;
let mut ap_dict = Dictionary::new();
ap_dict.set("N", LoObj::Reference(ap_id));
d.set("AP", LoObj::Dictionary(ap_dict));
} else {
set_type1_da(&mut d, spec, type1, cr, cg, cb);
}
}
AnnotKind::Note => {
d.set("Subtype", LoObj::Name(b"Text".to_vec()));
d.set(
"Rect",
LoObj::Array(vec![
LoObj::Real(r.left),
LoObj::Real(r.top - 18.0),
LoObj::Real(r.left + 18.0),
LoObj::Real(r.top),
]),
);
d.set("Contents", contents);
d.set(
"C",
LoObj::Array(vec![LoObj::Real(cr), LoObj::Real(cg), LoObj::Real(cb)]),
);
d.set("Name", LoObj::Name(b"Note".to_vec()));
d.set("Open", LoObj::Boolean(false));
}
_ => unreachable!(),
}
Ok(doc.add_object(LoObj::Dictionary(d)))
}
fn set_type1_da(d: &mut Dictionary, spec: &AnnotSpec, fonts: &DrFonts, cr: f32, cg: f32, cb: f32) {
let (fname, fid) = match (spec.bold, spec.italic) {
(true, true) => ("HeBO", fonts.boldobl),
(true, false) => ("HeBo", fonts.bold),
(false, true) => ("HeOb", fonts.obl),
(false, false) => ("Helv", fonts.helv),
};
let da = format!("/{} {:.2} Tf {:.4} {:.4} {:.4} rg", fname, spec.font_size, cr, cg, cb);
let mut dr_font = Dictionary::new();
dr_font.set(fname, LoObj::Reference(fid));
let mut dr = Dictionary::new();
dr.set("Font", LoObj::Dictionary(dr_font));
d.set("DA", LoObj::String(da.into_bytes(), StringFormat::Literal));
d.set("DR", LoObj::Dictionary(dr));
}
enum AnnotsLoc {
Ref(ObjectId),
Inline,
None,
}
fn attach_annot(doc: &mut LoDoc, page_id: ObjectId, annot_id: ObjectId) -> Result<(), EngineError> {
let loc = {
let page = doc
.get_object(page_id)
.and_then(|o| o.as_dict())
.map_err(|e| EngineError::Pdfium(format!("đọc page dict: {e}")))?;
match page.get(b"Annots") {
Ok(LoObj::Reference(r)) => AnnotsLoc::Ref(*r),
Ok(LoObj::Array(_)) => AnnotsLoc::Inline,
_ => AnnotsLoc::None,
}
};
let me = |e: lopdf::Error| EngineError::Pdfium(format!("sửa Annots: {e}"));
match loc {
AnnotsLoc::Ref(rid) => {
let arr = doc.get_object_mut(rid).and_then(|o| o.as_array_mut()).map_err(me)?;
arr.push(LoObj::Reference(annot_id));
}
AnnotsLoc::Inline => {
let page = doc.get_object_mut(page_id).and_then(|o| o.as_dict_mut()).map_err(me)?;
let arr = page.get_mut(b"Annots").and_then(|o| o.as_array_mut()).map_err(me)?;
arr.push(LoObj::Reference(annot_id));
}
AnnotsLoc::None => {
let page = doc.get_object_mut(page_id).and_then(|o| o.as_dict_mut()).map_err(me)?;
page.set("Annots", LoObj::Array(vec![LoObj::Reference(annot_id)]));
}
}
Ok(())
}
/// Đọc danh sách annotation trong toàn tài liệu.
pub fn list_annotations(
pdfium: &Pdfium,
input: &Path,
) -> Result<Vec<AnnotInfo>, EngineError> {
let document = pdfium
.load_pdf_from_file(input, None)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let mut out = Vec::new();
for (pi, page) in document.pages().iter().enumerate() {
for annot in page.annotations().iter() {
let rect = annot.bounds().map(|r| from_pdf_rect(&r)).unwrap_or(Rect {
left: 0.0,
bottom: 0.0,
right: 0.0,
top: 0.0,
});
out.push(AnnotInfo {
page_index: pi as u16,
kind: format!("{:?}", annot.annotation_type()),
rect,
contents: annot.contents(),
quad_count: annot.attachment_points().len(),
});
}
}
Ok(out)
}
/// Đếm số annotation trên một trang.
pub fn count_annotations(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
) -> Result<usize, EngineError> {
let document = pdfium
.load_pdf_from_file(input, None)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("trang {page_index}: {e}")))?;
Ok(page.annotations().len() as usize)
}
+30
View File
@@ -0,0 +1,30 @@
//! ff-engine — engine xử lý PDF của FoFreeXit.
//!
//! Phase 1: bọc PDFium (qua crate `pdfium-render`, tải động `pdfium.dll`)
//! để mở tài liệu, đếm trang và render trang ra ảnh. Các module edit/io/font/
//! ocr... sẽ thêm dần ở các phase sau (xem docs/03-roadmap.md, 04-architecture.md).
pub mod annot;
pub mod meta;
pub mod render;
pub mod text;
pub use annot::{
apply_annotations, count_annotations, list_annotations, AnnotInfo, AnnotKind, AnnotSpec,
};
pub use meta::{outline, page_dims, OutlineItem, PageDim};
pub use render::{bind_pdfium, page_count, render_page_png, PageImage};
pub use text::{extract_text, page_char_boxes, search, CharBox, Rect, SearchHit};
/// Lỗi cấp engine.
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("không tìm thấy thư viện PDFium. Đặt biến môi trường FOFREEXIT_PDFIUM_PATH trỏ tới thư mục chứa pdfium.dll, hoặc chạy `scripts/fetch-pdfium`. Chi tiết: {0}")]
PdfiumNotFound(String),
#[error("lỗi PDFium: {0}")]
Pdfium(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
+72
View File
@@ -0,0 +1,72 @@
//! Metadata tài liệu: kích thước trang & outline (bookmarks).
use std::path::Path;
use pdfium_render::prelude::*;
use crate::EngineError;
/// Kích thước một trang theo điểm PDF (points, 1/72 inch).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageDim {
pub index: u16,
pub width_pt: f32,
pub height_pt: f32,
}
/// Một mục trong outline (bookmark).
#[derive(Debug, Clone, PartialEq)]
pub struct OutlineItem {
pub title: String,
/// Trang đích (0-based) nếu xác định được.
pub page_index: Option<u16>,
/// Độ sâu trong cây outline (0 = cấp cao nhất).
pub level: u32,
}
/// Lấy kích thước mọi trang.
pub fn page_dims(
pdfium: &Pdfium,
input: &Path,
password: Option<&str>,
) -> Result<Vec<PageDim>, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let mut dims = Vec::new();
for (i, page) in document.pages().iter().enumerate() {
dims.push(PageDim {
index: i as u16,
width_pt: page.width().value,
height_pt: page.height().value,
});
}
Ok(dims)
}
/// Lấy outline (bookmarks) dạng phẳng theo thứ tự duyệt sâu (DFS).
pub fn outline(
pdfium: &Pdfium,
input: &Path,
password: Option<&str>,
) -> Result<Vec<OutlineItem>, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let mut items = Vec::new();
for bookmark in document.bookmarks().iter() {
let title = bookmark.title().unwrap_or_default();
let page_index = bookmark
.destination()
.and_then(|dest| dest.page_index().ok())
.map(|idx| idx as u16);
items.push(OutlineItem {
title,
page_index,
level: 0,
});
}
Ok(items)
}
+111
View File
@@ -0,0 +1,111 @@
//! Render PDF qua PDFium (tải động pdfium.dll qua pdfium-render).
use std::path::{Path, PathBuf};
use pdfium_render::prelude::*;
use crate::EngineError;
/// Ảnh một trang đã render (RGBA8).
pub struct PageImage {
pub width: u32,
pub height: u32,
pub image: image::DynamicImage,
}
/// Khởi tạo PDFium: tìm và nạp thư viện động.
///
/// Thứ tự tìm: biến môi trường `FOFREEXIT_PDFIUM_PATH` (thư mục chứa lib) →
/// thư mục làm việc hiện tại → thư mục chứa file thực thi → thư viện hệ thống.
pub fn bind_pdfium() -> Result<Pdfium, EngineError> {
let mut tried: Vec<String> = Vec::new();
let mut candidates: Vec<PathBuf> = Vec::new();
if let Ok(dir) = std::env::var("FOFREEXIT_PDFIUM_PATH") {
candidates.push(PathBuf::from(dir));
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd.clone());
candidates.push(cwd.join("pdfium"));
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push(dir.to_path_buf());
}
}
for dir in candidates {
let lib = Pdfium::pdfium_platform_library_name_at_path(&dir);
match Pdfium::bind_to_library(&lib) {
Ok(bindings) => return Ok(Pdfium::new(bindings)),
Err(e) => tried.push(format!("{} ({e})", lib.display())),
}
}
// Cuối cùng thử thư viện hệ thống.
match Pdfium::bind_to_system_library() {
Ok(bindings) => Ok(Pdfium::new(bindings)),
Err(e) => {
tried.push(format!("system library ({e})"));
Err(EngineError::PdfiumNotFound(tried.join("; ")))
}
}
}
/// Đếm số trang của tài liệu.
pub fn page_count(pdfium: &Pdfium, input: &Path, password: Option<&str>) -> Result<u16, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
Ok(document.pages().len())
}
/// Render một trang (0-based) ra ảnh với chiều rộng mục tiêu `target_width` px.
pub fn render_page(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
target_width: u32,
password: Option<&str>,
) -> Result<PageImage, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("không lấy được trang {page_index}: {e}")))?;
let config = PdfRenderConfig::new()
.set_target_width(target_width as i32)
.set_maximum_height((target_width as i32) * 4);
let bitmap = page
.render_with_config(&config)
.map_err(|e| EngineError::Pdfium(format!("render trang {page_index} thất bại: {e}")))?;
let image = bitmap.as_image();
Ok(PageImage {
width: image.width(),
height: image.height(),
image,
})
}
/// Render một trang và ghi ra file PNG.
pub fn render_page_png(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
output: &Path,
target_width: u32,
password: Option<&str>,
) -> Result<PageImage, EngineError> {
let rendered = render_page(pdfium, input, page_index, target_width, password)?;
rendered
.image
.save(output)
.map_err(|e| EngineError::Pdfium(format!("ghi PNG thất bại: {e}")))?;
Ok(rendered)
}
+204
View File
@@ -0,0 +1,204 @@
//! Trích xuất & tìm kiếm text trên trang PDF.
use std::path::Path;
use pdfium_render::prelude::*;
use crate::EngineError;
/// Hình chữ nhật theo điểm PDF (gốc toạ độ ở góc dưới-trái trang).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Rect {
pub left: f32,
pub bottom: f32,
pub right: f32,
pub top: f32,
}
/// Một kết quả tìm kiếm.
#[derive(Debug, Clone, PartialEq)]
pub struct SearchHit {
pub page_index: u16,
/// Vị trí ký tự bắt đầu trong text của trang.
pub char_start: usize,
/// Số ký tự khớp.
pub char_len: usize,
/// Khung bao (hợp của các ký tự khớp). Có thể None nếu không lấy được bounds.
pub rect: Option<Rect>,
}
/// Hộp bao một ký tự trên trang (cho text-layer / selection).
#[derive(Debug, Clone, PartialEq)]
pub struct CharBox {
/// Ký tự (chuỗi để xử lý cả ký tự ngoài BMP / không có glyph).
pub ch: String,
pub left: f32,
pub bottom: f32,
pub right: f32,
pub top: f32,
}
/// Lấy hộp bao từng ký tự của một trang, theo thứ tự đọc (giống `extract_text`).
/// Dùng để dựng "text layer" cho phép chọn & copy text trên ảnh render.
pub fn page_char_boxes(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
password: Option<&str>,
) -> Result<Vec<CharBox>, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("trang {page_index}: {e}")))?;
let text = page
.text()
.map_err(|e| EngineError::Pdfium(format!("đọc text trang {page_index}: {e}")))?;
let mut boxes: Vec<CharBox> = Vec::new();
for ch in text.chars().iter() {
let s = ch
.unicode_char()
.map(|c| c.to_string())
.unwrap_or_default();
// Ưu tiên loose_bounds (gồm advance/khoảng cách) cho ký tự như dấu cách.
let bounds = ch.loose_bounds().or_else(|_| ch.tight_bounds()).ok();
let bx = match bounds {
Some(b) => CharBox {
ch: s,
left: b.left().value,
bottom: b.bottom().value,
right: b.right().value,
top: b.top().value,
},
None => {
// Không có hộp (vd. dấu cách ở vài file): suy ra hộp mảnh ngay sau
// ký tự trước để giữ thứ tự & vị trí hợp lý cho selection.
let prev = boxes.last();
let (l, b, r, t) = match prev {
Some(p) => {
let h = (p.top - p.bottom).max(6.0);
(p.right, p.bottom, p.right + h * 0.3, p.top)
}
None => (0.0, 0.0, 0.0, 0.0),
};
CharBox { ch: s, left: l, bottom: b, right: r, top: t }
}
};
boxes.push(bx);
}
Ok(boxes)
}
/// Trích toàn bộ text của một trang.
pub fn extract_text(
pdfium: &Pdfium,
input: &Path,
page_index: u16,
password: Option<&str>,
) -> Result<String, EngineError> {
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let page = document
.pages()
.get(page_index)
.map_err(|e| EngineError::Pdfium(format!("trang {page_index}: {e}")))?;
let text = page
.text()
.map_err(|e| EngineError::Pdfium(format!("đọc text trang {page_index}: {e}")))?;
Ok(text.all())
}
/// Tìm `query` trong toàn tài liệu. `case_sensitive=false` -> không phân biệt hoa thường.
pub fn search(
pdfium: &Pdfium,
input: &Path,
query: &str,
case_sensitive: bool,
password: Option<&str>,
) -> Result<Vec<SearchHit>, EngineError> {
if query.is_empty() {
return Ok(Vec::new());
}
let document = pdfium
.load_pdf_from_file(input, password)
.map_err(|e| EngineError::Pdfium(e.to_string()))?;
let needle: Vec<char> = query
.chars()
.map(|c| fold(c, case_sensitive))
.collect();
let mut hits = Vec::new();
for (pi, page) in document.pages().iter().enumerate() {
let text = match page.text() {
Ok(t) => t,
Err(_) => continue,
};
let full = text.all();
let hay: Vec<char> = full.chars().map(|c| fold(c, case_sensitive)).collect();
// Tìm tất cả vị trí khớp (sliding window đơn giản).
if hay.len() < needle.len() {
continue;
}
let mut start = 0usize;
while start + needle.len() <= hay.len() {
if hay[start..start + needle.len()] == needle[..] {
let rect = char_range_rect(&text, start, needle.len());
hits.push(SearchHit {
page_index: pi as u16,
char_start: start,
char_len: needle.len(),
rect,
});
start += needle.len();
} else {
start += 1;
}
}
}
Ok(hits)
}
fn fold(c: char, case_sensitive: bool) -> char {
if case_sensitive {
c
} else {
// Hạ thường đơn giản; với ASCII là đủ, Unicode dùng to_lowercase ký tự đầu.
c.to_lowercase().next().unwrap_or(c)
}
}
/// Khung bao hợp của các ký tự [start, start+len) trên trang.
fn char_range_rect(text: &PdfPageText, start: usize, len: usize) -> Option<Rect> {
let mut acc: Option<Rect> = None;
for ch in text.chars().iter() {
let idx = ch.index();
if idx < start || idx >= start + len {
continue;
}
if let Ok(b) = ch.tight_bounds() {
let r = Rect {
left: b.left().value,
bottom: b.bottom().value,
right: b.right().value,
top: b.top().value,
};
acc = Some(match acc {
None => r,
Some(a) => Rect {
left: a.left.min(r.left),
bottom: a.bottom.min(r.bottom),
right: a.right.max(r.right),
top: a.top.max(r.top),
},
});
}
}
acc
}
+362
View File
@@ -0,0 +1,362 @@
//! Round-trip test cho annotation (đường GHI file — phần rủi ro nhất Phase 2):
//! tạo annotation → lưu file mới → mở lại → còn đúng số lượng/loại → render ra
//! đúng màu. Dùng sample-multipage.pdf.
use std::path::PathBuf;
use ff_engine::{AnnotKind, AnnotSpec, Rect};
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
#[test]
fn highlight_and_square_roundtrip() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = std::env::temp_dir().join("ff_annot_roundtrip.pdf");
let _ = std::fs::remove_file(&output);
// Highlight vàng phủ từ "content" ở trang 0 (toạ độ đã biết từ test search),
// và một khung vuông đỏ ở trang 1.
let specs = vec![
AnnotSpec::markup(
AnnotKind::Highlight,
0,
Rect { left: 155.0, bottom: 690.0, right: 213.0, top: 706.0 },
[255, 255, 0, 255],
),
AnnotSpec::markup(
AnnotKind::Square,
1,
Rect { left: 100.0, bottom: 600.0, right: 300.0, top: 700.0 },
[255, 0, 0, 255],
),
];
ff_engine::apply_annotations(&pdf, &input, &output, &specs).expect("apply_annotations");
assert!(output.exists(), "file output chưa được tạo");
// Mở lại: đúng số lượng.
assert_eq!(
ff_engine::count_annotations(&pdf, &output, 0).expect("count p0"),
1,
"trang 0 phải có 1 annotation"
);
assert_eq!(
ff_engine::count_annotations(&pdf, &output, 1).expect("count p1"),
1,
"trang 1 phải có 1 annotation"
);
// Đúng loại + contents giữ lại.
let list = ff_engine::list_annotations(&pdf, &output).expect("list");
assert_eq!(list.len(), 2, "tổng phải 2 annotation: {list:?}");
let kinds: Vec<&str> = list.iter().map(|a| a.kind.as_str()).collect();
assert!(kinds.iter().any(|k| k.contains("Highlight")), "thiếu Highlight: {kinds:?}");
assert!(kinds.iter().any(|k| k.contains("Square")), "thiếu Square: {kinds:?}");
// Render trang 0 (có annotation) -> phải có pixel vàng (highlight).
let img = ff_engine::render::render_page(&pdf, &output, 0, 800, None).expect("render");
let rgba = img.image.to_rgba8();
let yellow = rgba
.pixels()
.filter(|p| p.0[0] > 200 && p.0[1] > 200 && p.0[2] < 120)
.count();
assert!(yellow > 50, "không thấy highlight vàng được render ({yellow} px)");
}
/// Highlight phải bám theo TỪNG DÒNG văn bản (như chọn text rồi tô sáng trong
/// Foxit), không phải 1 khối chữ nhật phủ luôn khoảng trắng giữa 2 dòng.
/// Dùng 2 quad cho 2 dòng "FoFreeXit Test Document" (y≈716-736) và "Page one
/// content alpha" (y≈690-706) — khoảng trống giữa 2 dòng (y≈707-715) phải
/// KHÔNG bị tô vàng.
#[test]
fn multiline_highlight_uses_per_line_quads() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = std::env::temp_dir().join("ff_annot_multiline_hl.pdf");
let _ = std::fs::remove_file(&output);
let line1 = Rect { left: 72.0, bottom: 716.0, right: 280.0, top: 736.0 };
let line2 = Rect { left: 72.0, bottom: 690.0, right: 280.0, top: 706.0 };
let bounds = Rect { left: 72.0, bottom: 690.0, right: 280.0, top: 736.0 };
let spec = AnnotSpec {
kind: AnnotKind::Highlight,
page_index: 0,
rect: bounds,
quads: vec![line1, line2],
color: [255, 255, 0, 255],
contents: None,
font_size: 14.0,
bold: false,
italic: false,
underline: false,
};
ff_engine::apply_annotations(&pdf, &input, &output, &[spec]).expect("apply");
let list = ff_engine::list_annotations(&pdf, &output).expect("list");
assert_eq!(list.len(), 1, "phải đúng 1 annotation: {list:?}");
assert_eq!(
list[0].quad_count, 2,
"highlight 2 dòng phải có 2 quad (attachment points): {:?}", list[0]
);
// Render & kiểm 2 dải có vàng, dải trống giữa 2 dòng thì không.
let img = ff_engine::render::render_page(&pdf, &output, 0, 800, None).expect("render");
let rgba = img.image.to_rgba8();
let (iw, ih) = rgba.dimensions();
let scale = 800.0 / 612.0f32;
let is_yellow = |x: u32, y: u32| {
let p = rgba.get_pixel(x.min(iw - 1), y.min(ih - 1));
p.0[0] > 200 && p.0[1] > 200 && p.0[2] < 150
};
let count_band = |top_pt: f32, bot_pt: f32| -> u32 {
let y0 = ((792.0 - top_pt) * scale) as u32;
let y1 = ((792.0 - bot_pt) * scale) as u32;
let x0 = (72.0 * scale) as u32;
let x1 = (280.0 * scale) as u32;
(y0..=y1).flat_map(|y| (x0..=x1).map(move |x| (x, y))).filter(|&(x, y)| is_yellow(x, y)).count() as u32
};
let band_line1 = count_band(736.0, 716.0);
let band_line2 = count_band(706.0, 690.0);
let band_gap = count_band(715.0, 707.0); // khoảng trống giữa 2 dòng
assert!(band_line1 > 50, "dòng 1 không có pixel vàng ({band_line1}px)");
assert!(band_line2 > 50, "dòng 2 không có pixel vàng ({band_line2}px)");
assert!(
band_gap < band_line1.min(band_line2) / 4,
"khoảng trống giữa 2 dòng bị tô vàng như 1 khối liền ({band_gap}px so với dòng1={band_line1}px, dòng2={band_line2}px) — highlight đang phủ cả khoảng trắng, không bám theo dòng"
);
}
#[test]
fn underline_and_strikeout_roundtrip() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = std::env::temp_dir().join("ff_annot_markup.pdf");
let _ = std::fs::remove_file(&output);
let specs = vec![
AnnotSpec::markup(
AnnotKind::Underline,
0,
Rect { left: 155.0, bottom: 690.0, right: 213.0, top: 706.0 },
[0, 0, 255, 255],
),
AnnotSpec::markup(
AnnotKind::Strikeout,
0,
Rect { left: 72.0, bottom: 716.0, right: 151.0, top: 736.0 },
[255, 0, 0, 255],
),
];
ff_engine::apply_annotations(&pdf, &input, &output, &specs).expect("apply");
let list = ff_engine::list_annotations(&pdf, &output).expect("list");
let kinds: Vec<&str> = list.iter().map(|a| a.kind.as_str()).collect();
assert!(kinds.iter().any(|k| k.contains("Underline")), "thiếu Underline: {kinds:?}");
assert!(kinds.iter().any(|k| k.contains("Strikeout")), "thiếu Strikeout: {kinds:?}");
// Render được, không trắng trơn (có nét màu của underline/strikeout).
let img = ff_engine::render::render_page(&pdf, &output, 0, 800, None).expect("render");
let colored = img
.image
.to_rgba8()
.pixels()
.filter(|p| (p.0[2] > 180 && p.0[0] < 100) || (p.0[0] > 180 && p.0[1] < 100 && p.0[2] < 100))
.count();
assert!(colored > 20, "không thấy nét màu underline/strikeout ({colored} px)");
}
#[test]
fn freetext_and_note_roundtrip() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = std::env::temp_dir().join("ff_annot_text.pdf");
let _ = std::fs::remove_file(&output);
// FreeText màu xanh, cỡ 22, đậm (ASCII để kiểm định dạng render được);
// Note nội dung tiếng Việt (kiểm round-trip UTF-16).
let specs = vec![
AnnotSpec {
kind: AnnotKind::FreeText,
page_index: 0,
rect: Rect { left: 90.0, bottom: 560.0, right: 360.0, top: 620.0 },
quads: vec![],
color: [0, 0, 255, 255],
contents: Some("Hello 22 blue".into()),
font_size: 22.0,
bold: true,
italic: false,
underline: false,
},
AnnotSpec {
kind: AnnotKind::Note,
page_index: 0,
rect: Rect { left: 400.0, bottom: 600.0, right: 418.0, top: 618.0 },
quads: vec![],
color: [255, 200, 0, 255],
contents: Some("ghi chú dán tiếng Việt".into()),
font_size: 14.0,
bold: false,
italic: false,
underline: false,
},
];
ff_engine::apply_annotations(&pdf, &input, &output, &specs).expect("apply");
let list = ff_engine::list_annotations(&pdf, &output).expect("list");
let kinds: Vec<&str> = list.iter().map(|a| a.kind.as_str()).collect();
assert!(kinds.iter().any(|k| k.contains("FreeText")), "thiếu FreeText: {kinds:?}");
assert!(kinds.iter().any(|k| k.contains("Text")), "thiếu Text(note): {kinds:?}");
// Nội dung được giữ lại (gồm tiếng Việt qua UTF-16).
assert!(
list.iter().any(|a| a.contents.as_deref() == Some("Hello 22 blue")),
"mất contents FreeText: {list:?}"
);
assert!(
list.iter().any(|a| a.contents.as_deref() == Some("ghi chú dán tiếng Việt")),
"mất contents Note (tiếng Việt): {list:?}"
);
// Render trang 0: chữ FreeText màu xanh phải xuất hiện (DA hoạt động).
let img = ff_engine::render::render_page(&pdf, &output, 0, 800, None).expect("render");
let blue = img
.image
.to_rgba8()
.pixels()
.filter(|p| p.0[2] > 180 && p.0[0] < 90 && p.0[1] < 90)
.count();
assert!(blue > 40, "không thấy chữ FreeText màu xanh được render ({blue} px)");
}
/// Kiểm tra FreeText tiếng Việt render được (font Unicode nhúng + AP stream).
/// Annotation [72, 400, 360, 470] trên trang 612×792pt, render 800px.
#[test]
fn vietnamese_freetext_renders() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
let output = std::env::temp_dir().join("ff_viet_freetext.pdf");
let _ = std::fs::remove_file(&output);
let specs = vec![AnnotSpec {
kind: AnnotKind::FreeText,
page_index: 0,
rect: Rect { left: 72.0, bottom: 400.0, right: 360.0, top: 470.0 },
quads: vec![],
color: [0, 0, 0, 255], // chữ đen để dễ kiểm
contents: Some("Tiếng Việt: thử nghiệm font Unicode".into()),
font_size: 14.0,
bold: false,
italic: false,
underline: false,
}];
ff_engine::apply_annotations(&pdf, &input, &output, &specs).expect("apply");
assert!(output.exists(), "không tạo được file");
// Kiểm contents vẫn đúng
let list = ff_engine::list_annotations(&pdf, &output).expect("list");
assert!(
list.iter().any(|a| a.contents.as_deref() == Some("Tiếng Việt: thử nghiệm font Unicode")),
"mất contents tiếng Việt: {list:?}"
);
// Render và kiểm có pixels đen trong vùng annotation:
// trang 612×792pt, render 800px → scale ≈ 1.307
// pixel_y_top = (792 - 470) * scale ≈ 421, pixel_y_bot = (792 - 400) * scale ≈ 513
// pixel_x_left = 72 * scale ≈ 94, pixel_x_right = 360 * scale ≈ 471
let img = ff_engine::render::render_page(&pdf, &output, 0, 800, None).expect("render");
let rgba = img.image.to_rgba8();
let (iw, ih) = rgba.dimensions();
let scale = 800.0 / 612.0f32;
let x0 = (72.0 * scale) as u32;
let x1 = (360.0 * scale) as u32;
let y0 = ((792.0 - 470.0) * scale) as u32;
let y1 = ((792.0 - 400.0) * scale) as u32;
let x1 = x1.min(iw - 1);
let y1 = y1.min(ih - 1);
// Đếm pixels không phải trắng trong vùng annotation
let dark: u32 = (y0..=y1)
.flat_map(|y| (x0..=x1).map(move |x| (x, y)))
.filter(|&(x, y)| {
let p = rgba.get_pixel(x, y);
// pixel tối (nền trắng mà có glyph → lọc non-white)
(p.0[0] as u32 + p.0[1] as u32 + p.0[2] as u32) < 700
})
.count() as u32;
assert!(
dark > 50,
"tiếng Việt không render trong vùng annotation ({dark} dark px trong [{x0},{y0}]-[{x1},{y1}] của {iw}×{ih})"
);
}
/// Kiểm underline trong FreeText AP stream tự dựng: so sánh số pixel tối trong
/// vùng annotation giữa bản có underline=true và underline=false — bản có gạch
/// chân phải nhiều pixel tối hơn rõ rệt (thêm 1 đường kẻ ngang dưới chữ).
#[test]
fn freetext_underline_renders() {
let pdf = pdfium();
let input = workspace_root().join("corpus").join("sample-multipage.pdf");
fn count_dark(pdf: &pdfium_render::prelude::Pdfium, path: &std::path::Path) -> u32 {
let img = ff_engine::render::render_page(pdf, path, 0, 800, None).expect("render");
let rgba = img.image.to_rgba8();
let (iw, ih) = rgba.dimensions();
let scale = 800.0 / 612.0f32;
let x0 = (72.0 * scale) as u32;
let x1 = ((300.0 * scale) as u32).min(iw - 1);
let y0 = ((792.0 - 650.0) * scale) as u32;
let y1 = (((792.0 - 600.0) * scale) as u32).min(ih - 1);
(y0..=y1)
.flat_map(|y| (x0..=x1).map(move |x| (x, y)))
.filter(|&(x, y)| {
let p = rgba.get_pixel(x, y);
(p.0[0] as u32 + p.0[1] as u32 + p.0[2] as u32) < 700
})
.count() as u32
}
let make_spec = |underline: bool| AnnotSpec {
kind: AnnotKind::FreeText,
page_index: 0,
rect: Rect { left: 72.0, bottom: 600.0, right: 300.0, top: 650.0 },
quads: vec![],
color: [0, 0, 0, 255],
contents: Some("Underline".into()),
font_size: 20.0,
bold: false,
italic: false,
underline,
};
let no_ul = std::env::temp_dir().join("ff_freetext_no_underline.pdf");
let with_ul = std::env::temp_dir().join("ff_freetext_with_underline.pdf");
let _ = std::fs::remove_file(&no_ul);
let _ = std::fs::remove_file(&with_ul);
ff_engine::apply_annotations(&pdf, &input, &no_ul, &[make_spec(false)]).expect("apply no-ul");
ff_engine::apply_annotations(&pdf, &input, &with_ul, &[make_spec(true)]).expect("apply with-ul");
let dark_no_ul = count_dark(&pdf, &no_ul);
let dark_with_ul = count_dark(&pdf, &with_ul);
assert!(
dark_with_ul > dark_no_ul + 50,
"gạch chân không render rõ rệt: no_underline={dark_no_ul}px, with_underline={dark_with_ul}px"
);
}
+61
View File
@@ -0,0 +1,61 @@
//! Stress test với tài liệu lớn (corpus/big-1000.pdf — 1000 trang).
//! Mỗi trang có text "Trang N"; riêng trang index 500 có thêm "ZZMARKER".
use std::path::PathBuf;
use std::time::Instant;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn fixture() -> PathBuf {
workspace_root().join("corpus").join("big-1000.pdf")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
#[test]
fn big_doc_page_count() {
let dims = ff_engine::page_dims(&pdfium(), &fixture(), None).expect("page_dims");
assert_eq!(dims.len(), 1000, "phải có 1000 trang");
}
#[test]
fn big_doc_render_first_and_last() {
let pdf = pdfium();
for idx in [0u16, 999u16] {
let t = Instant::now();
let r = ff_engine::render::render_page(&pdf, &fixture(), idx, 600, None)
.unwrap_or_else(|e| panic!("render trang {idx}: {e}"));
let ms = t.elapsed().as_millis();
// Không trắng trơn.
let dark = r
.image
.to_rgba8()
.pixels()
.filter(|p| p.0[3] > 10 && p.0[0] < 128 && p.0[1] < 128 && p.0[2] < 128)
.count();
assert!(dark > 100, "trang {idx} trắng trơn ({dark} px tối)");
// Ngưỡng rộng rãi để bắt hồi quy hiệu năng nghiêm trọng (không phải benchmark).
assert!(ms < 5000, "render trang {idx} quá chậm: {ms} ms");
}
}
#[test]
fn big_doc_search_single_marker() {
let t = Instant::now();
let hits = ff_engine::search(&pdfium(), &fixture(), "ZZMARKER", false, None).expect("search");
let ms = t.elapsed().as_millis();
assert_eq!(hits.len(), 1, "ZZMARKER phải có đúng 1 kết quả");
assert_eq!(hits[0].page_index, 500, "ZZMARKER phải ở trang index 500");
assert!(ms < 10000, "search toàn tài liệu quá chậm: {ms} ms");
}
+62
View File
@@ -0,0 +1,62 @@
//! Smoke test cho pipeline render Phase 1.
//!
//! Dùng golden test "ổn định" (không hash byte vì kết quả render có thể đổi
//! theo phiên bản PDFium): kiểm tra mở được file, đúng số trang, đúng tỉ lệ
//! kích thước, và trang KHÔNG trắng trơn (có pixel tối = đã vẽ nội dung).
use std::path::PathBuf;
/// Thư mục gốc workspace (từ crate dir đi lên 2 cấp: crates/ff-engine -> root).
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("canonicalize workspace root")
}
fn ensure_pdfium_path() {
// Trỏ engine tới pdfium.dll ở gốc workspace (tải bằng scripts/fetch-pdfium.ps1).
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
}
#[test]
fn renders_hello_sample() {
ensure_pdfium_path();
let pdfium = ff_engine::bind_pdfium().expect("nạp PDFium (chạy scripts/fetch-pdfium.ps1?)");
let pdf = workspace_root().join("corpus").join("hello.pdf");
assert!(pdf.exists(), "thiếu file mẫu: {}", pdf.display());
// Đúng số trang.
let pages = ff_engine::page_count(&pdfium, &pdf, None).expect("đếm trang");
assert_eq!(pages, 1, "hello.pdf phải có 1 trang");
// Render trang 0.
let target_width = 800;
let rendered = ff_engine::render::render_page(&pdfium, &pdf, 0, target_width, None)
.expect("render trang 0");
// Đúng chiều rộng mục tiêu.
assert_eq!(rendered.width, target_width, "chiều rộng render sai");
// Trang Letter (612x792) -> tỉ lệ cao/rộng ~ 792/612 = 1.294.
let ratio = rendered.height as f32 / rendered.width as f32;
assert!(
(ratio - 792.0 / 612.0).abs() < 0.02,
"tỉ lệ trang sai: {ratio}"
);
// Trang phải có nội dung: đếm pixel "tối" (chữ đen trên nền trắng).
let rgba = rendered.image.to_rgba8();
let dark = rgba
.pixels()
.filter(|p| p.0[3] > 10 && p.0[0] < 128 && p.0[1] < 128 && p.0[2] < 128)
.count();
assert!(
dark > 200,
"trang gần như trắng trơn (chỉ {dark} pixel tối) -> render hỏng?"
);
}
+113
View File
@@ -0,0 +1,113 @@
//! Test các năng lực viewer ở tầng engine: kích thước trang, trích text,
//! tìm kiếm, outline. Dựa trên fixture corpus/sample-multipage.pdf có nội dung
//! biết trước:
//! trang 0: "FoFreeXit Test Document" / "Page one content alpha"
//! trang 1: "Page two content beta searchterm"
//! trang 2: "Page three content gamma"
//! outline: Chapter 1/2/3 -> trang 0/1/2
use std::path::PathBuf;
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.expect("workspace root")
}
fn fixture() -> PathBuf {
workspace_root().join("corpus").join("sample-multipage.pdf")
}
fn pdfium() -> pdfium_render::prelude::Pdfium {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
ff_engine::bind_pdfium().expect("nạp PDFium")
}
#[test]
fn page_dims_letter_3pages() {
let dims = ff_engine::page_dims(&pdfium(), &fixture(), None).expect("page_dims");
assert_eq!(dims.len(), 3, "phải có 3 trang");
for d in &dims {
assert!((d.width_pt - 612.0).abs() < 0.5, "rộng sai: {}", d.width_pt);
assert!((d.height_pt - 792.0).abs() < 0.5, "cao sai: {}", d.height_pt);
}
}
#[test]
fn extract_text_per_page() {
let pdf = pdfium();
let p0 = ff_engine::extract_text(&pdf, &fixture(), 0, None).expect("text trang 0");
assert!(p0.contains("FoFreeXit Test Document"), "thiếu tiêu đề: {p0:?}");
assert!(p0.contains("alpha"), "thiếu 'alpha': {p0:?}");
let p1 = ff_engine::extract_text(&pdf, &fixture(), 1, None).expect("text trang 1");
assert!(p1.contains("searchterm"), "thiếu 'searchterm': {p1:?}");
}
#[test]
fn search_counts_and_locations() {
let pdf = pdfium();
// "content" xuất hiện 1 lần/trang -> 3 kết quả, ở trang 0,1,2.
let hits = ff_engine::search(&pdf, &fixture(), "content", false, None).expect("search");
assert_eq!(hits.len(), 3, "'content' phải có 3 kết quả: {hits:?}");
let pages: Vec<u16> = hits.iter().map(|h| h.page_index).collect();
assert_eq!(pages, vec![0, 1, 2], "vị trí trang sai: {pages:?}");
// Ít nhất một kết quả có khung bao (rect) hợp lệ.
assert!(
hits.iter().any(|h| h.rect.is_some()),
"không kết quả nào có rect"
);
// "searchterm" chỉ ở trang 1, đúng 1 kết quả.
let st = ff_engine::search(&pdf, &fixture(), "searchterm", false, None).expect("search");
assert_eq!(st.len(), 1);
assert_eq!(st[0].page_index, 1);
}
#[test]
fn search_case_sensitivity() {
let pdf = pdfium();
// Không phân biệt hoa thường: khớp.
let ci = ff_engine::search(&pdf, &fixture(), "SEARCHTERM", false, None).expect("search ci");
assert_eq!(ci.len(), 1, "case-insensitive phải khớp");
// Phân biệt hoa thường: không khớp (text gốc là chữ thường).
let cs = ff_engine::search(&pdf, &fixture(), "SEARCHTERM", true, None).expect("search cs");
assert_eq!(cs.len(), 0, "case-sensitive không được khớp");
}
#[test]
fn char_boxes_match_text_and_bounds() {
let boxes = ff_engine::page_char_boxes(&pdfium(), &fixture(), 0, None).expect("char boxes");
assert!(!boxes.is_empty(), "phải có ký tự");
// Ghép ký tự theo thứ tự đọc -> chứa đúng nội dung trang.
let concat: String = boxes.iter().map(|b| b.ch.as_str()).collect();
assert!(concat.contains("FoFreeXit"), "thiếu 'FoFreeXit': {concat:?}");
assert!(concat.contains("Document"), "thiếu 'Document'");
assert!(concat.contains("content"), "thiếu 'content'");
assert!(concat.contains("alpha"), "thiếu 'alpha'");
// Mọi hộp nằm trong khổ trang Letter (612x792), cho dung sai nhỏ.
for b in &boxes {
assert!(b.left >= -1.0 && b.right <= 613.0, "x ngoài trang: {b:?}");
assert!(b.bottom >= -1.0 && b.top <= 793.0, "y ngoài trang: {b:?}");
}
}
#[test]
fn outline_three_chapters() {
let items = ff_engine::outline(&pdfium(), &fixture(), None).expect("outline");
assert_eq!(items.len(), 3, "phải có 3 mục outline: {items:?}");
assert_eq!(items[0].title, "Chapter 1");
assert_eq!(items[1].title, "Chapter 2");
assert_eq!(items[2].title, "Chapter 3");
assert_eq!(items[0].page_index, Some(0));
assert_eq!(items[1].page_index, Some(1));
assert_eq!(items[2].page_index, Some(2));
}