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
+21
View File
@@ -0,0 +1,21 @@
/target
**/*.rs.bk
Cargo.lock.bak
# PDFium prebuilt binary (tải bằng scripts/fetch-pdfium.ps1)
/pdfium/
/pdfium.dll
/pdfium.dll.lib
# Tauri / node
/app/node_modules
/app/dist
/app/src-tauri/target
# Ảnh tạm khi test render
/tmp-out/
*.tmp.png
# File PDF thật của user (hồ sơ/kinh doanh cá nhân) — chỉ dùng test local,
# không đẩy lên repo công khai.
/pdf-file-to-test/
Generated
+1832
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[workspace]
resolver = "2"
members = [
"crates/ff-cos",
"crates/ff-pdmodel",
"crates/ff-engine",
"crates/ff-cli",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
repository = ""
description = "FoFreeXit - open source PDF editor (Foxit alternative)"
[workspace.dependencies]
anyhow = "1"
thiserror = "2"
+40
View File
@@ -0,0 +1,40 @@
# FoFreeXit
**Mục tiêu dự án:** Xây dựng một phần mềm chỉnh sửa PDF trên desktop có thể thay thế Foxit PDF Editor (và một phần Adobe Acrobat) cho các nhu cầu phổ biến — miễn phí / mã nguồn mở.
> Dự án được thực hiện qua nhiều session Claude Code. Token và thời gian không phải là ràng buộc. Ưu tiên số 1: **tạo ra được một sản phẩm dùng được, thay thế Foxit cho các tác vụ chính**.
## Tài liệu
| File | Nội dung |
|------|----------|
| [docs/01-research.md](docs/01-research.md) | Khảo sát tính năng Foxit, tính năng "đáng tiền", khó khăn kỹ thuật, thuật toán Foxit/Adobe, khảo sát thư viện PDF |
| [docs/02-tech-stack.md](docs/02-tech-stack.md) | Phân tích & lựa chọn ngôn ngữ / framework / engine |
| [docs/03-roadmap.md](docs/03-roadmap.md) | Lộ trình theo phase, mục tiêu, phương pháp, định nghĩa hoàn thành (DoD) |
| [docs/04-architecture.md](docs/04-architecture.md) | Kiến trúc kỹ thuật chi tiết (sẽ chi tiết hóa dần) |
## Trạng thái hiện tại
- [x] Phase 0 — Nghiên cứu & lập kế hoạch
- [x] **Đã chốt stack:** **Rust + Tauri** (App/UI) trên engine **PDFium + QPDF** (xem [docs/02-tech-stack.md](docs/02-tech-stack.md))
- [x] **Phase 1 — Engine core + Viewer** ✅ HOÀN TẤT (10/10 test xanh)
- [x] Cargo workspace + app Tauri; engine render qua PDFium
- [x] CLI `ff`: `info`/`render`/`pages`/`text`/`search`/`outline`
- [x] Engine: render, `page_dims`, `extract_text`, `search`(+toạ độ), `outline`, `page_char_boxes`
- [x] Viewer: mở file (hộp thoại), cuộn liên tục + lazy render (file 1000+ trang), zoom/Fit, thumbnails, outline, tìm kiếm + highlight, **chọn & copy text** (text-layer)
- Tổng kết: [docs/06-phase1-summary.md](docs/06-phase1-summary.md) · **Checklist test cho bạn**: [docs/07-phase1-user-tests.md](docs/07-phase1-user-tests.md)
- [x] **Phase 2 — Annotate** ✅ lõi hoàn tất (13/13 test xanh)
- [x] Engine: 6 loại annotation (Highlight/Underline/Strikeout/Square/FreeText/Note) + lưu file + đọc lại — round-trip test
- [x] UI: thanh công cụ + vẽ bằng chuột + preview + chọn màu + tab Chú thích + lưu qua hộp thoại
- [ ] Follow-up: Ink, sửa/di chuyển annotation đã lưu, XFDF — xem [docs/08-phase2-summary.md](docs/08-phase2-summary.md)
- **Checklist test cho bạn**: [docs/09-phase2-user-tests.md](docs/09-phase2-user-tests.md)
- [ ] **Phase 3 — Organize trang + lưu file vững** (kế tiếp) — xem [docs/03-roadmap.md](docs/03-roadmap.md)
Build & chạy: xem [docs/05-dev-setup.md](docs/05-dev-setup.md).
## Nguyên tắc làm việc qua nhiều session
1. **Tài liệu là nguồn chân lý.** Mọi quyết định kiến trúc phải được ghi vào `docs/`.
2. Mỗi phase có **Definition of Done** rõ ràng và **test** kèm theo.
3. Không viết lại engine PDF từ con số 0 ở giai đoạn đầu — đứng trên vai người khổng lồ (PDFium/QPDF), tự xây phần "editor" và "UX".
4. Ưu tiên giấy phép **permissive** (BSD/Apache/MIT) để sản phẩm có thể tự do phân phối.
+5726
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "fofreexit-app"
version = "0.1.0"
edition = "2021"
description = "FoFreeXit desktop app (Tauri shell)"
license = "Apache-2.0"
# Detach from the main crates/* workspace (Tauri uses its own build profiles).
[workspace]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
image = "0.25"
pdfium-render = "0.8"
ff-engine = { path = "../../crates/ff-engine" }
[profile.release]
opt-level = "s"
lto = true
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Quyền mặc định cho cửa sổ chính.",
"windows": ["main"],
"permissions": ["core:default", "dialog:default"]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Quyền mặc định cho cửa sổ chính.","local":true,"windows":["main"],"permissions":["core:default","dialog:default"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

+342
View File
@@ -0,0 +1,342 @@
// Ẩn cửa sổ console trên Windows ở bản release.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::io::Cursor;
use std::path::PathBuf;
use base64::Engine as _;
use serde::Serialize;
use tauri_plugin_dialog::DialogExt;
/// Thư mục gốc workspace (app/src-tauri -> ../../).
fn workspace_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
}
/// Đảm bảo engine tìm thấy pdfium.dll (dev: ở gốc workspace).
fn ensure_pdfium_env() {
if std::env::var("FOFREEXIT_PDFIUM_PATH").is_err() {
std::env::set_var("FOFREEXIT_PDFIUM_PATH", workspace_root());
}
}
fn pdfium() -> Result<pdfium_render::prelude::Pdfium, String> {
ensure_pdfium_env();
ff_engine::bind_pdfium().map_err(|e| e.to_string())
}
// ---- Kiểu dữ liệu trả về frontend ----
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PageMeta {
index: u16,
width_pt: f32,
height_pt: f32,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct OutlineMeta {
title: String,
page_index: Option<u16>,
level: u32,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DocMeta {
page_count: u16,
pages: Vec<PageMeta>,
outline: Vec<OutlineMeta>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RectMeta {
left: f32,
bottom: f32,
right: f32,
top: f32,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SearchHitMeta {
page_index: u16,
char_start: usize,
char_len: usize,
rect: Option<RectMeta>,
}
// ---- Commands ----
/// Mở tài liệu: trả số trang, kích thước từng trang, và outline.
#[tauri::command]
fn open_document(path: String) -> Result<DocMeta, String> {
let pdfium = pdfium()?;
let p = PathBuf::from(&path);
let dims = ff_engine::page_dims(&pdfium, &p, None).map_err(|e| e.to_string())?;
let outline = ff_engine::outline(&pdfium, &p, None).map_err(|e| e.to_string())?;
Ok(DocMeta {
page_count: dims.len() as u16,
pages: dims
.into_iter()
.map(|d| PageMeta {
index: d.index,
width_pt: d.width_pt,
height_pt: d.height_pt,
})
.collect(),
outline: outline
.into_iter()
.map(|o| OutlineMeta {
title: o.title,
page_index: o.page_index,
level: o.level,
})
.collect(),
})
}
/// Render một trang ra data URL PNG với chiều rộng `width` px.
#[tauri::command]
fn render_page(path: String, page: u16, width: u32) -> Result<String, String> {
let pdfium = pdfium()?;
let p = PathBuf::from(&path);
let rendered =
ff_engine::render::render_page(&pdfium, &p, page, width, None).map_err(|e| e.to_string())?;
let mut buf = Cursor::new(Vec::new());
rendered
.image
.write_to(&mut buf, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let b64 = base64::engine::general_purpose::STANDARD.encode(buf.get_ref());
Ok(format!("data:image/png;base64,{b64}"))
}
/// Tìm chuỗi trong tài liệu.
#[tauri::command]
fn search_document(
path: String,
query: String,
case_sensitive: bool,
) -> Result<Vec<SearchHitMeta>, String> {
let pdfium = pdfium()?;
let p = PathBuf::from(&path);
let hits =
ff_engine::search(&pdfium, &p, &query, case_sensitive, None).map_err(|e| e.to_string())?;
Ok(hits
.into_iter()
.map(|h| SearchHitMeta {
page_index: h.page_index,
char_start: h.char_start,
char_len: h.char_len,
rect: h.rect.map(|r| RectMeta {
left: r.left,
bottom: r.bottom,
right: r.right,
top: r.top,
}),
})
.collect())
}
/// Lấy text của một trang (cho chức năng copy).
#[tauri::command]
fn page_text(path: String, page: u16) -> Result<String, String> {
let pdfium = pdfium()?;
let p = PathBuf::from(&path);
ff_engine::extract_text(&pdfium, &p, page, None).map_err(|e| e.to_string())
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CharBoxMeta {
ch: String,
left: f32,
bottom: f32,
right: f32,
top: f32,
}
/// Hộp bao từng ký tự của một trang (dựng text-layer để chọn & copy).
#[tauri::command]
fn page_text_layer(path: String, page: u16) -> Result<Vec<CharBoxMeta>, String> {
let pdfium = pdfium()?;
let p = PathBuf::from(&path);
let boxes = ff_engine::page_char_boxes(&pdfium, &p, page, None).map_err(|e| e.to_string())?;
Ok(boxes
.into_iter()
.map(|b| CharBoxMeta {
ch: b.ch,
left: b.left,
bottom: b.bottom,
right: b.right,
top: b.top,
})
.collect())
}
/// Mở hộp thoại chọn file PDF; trả về đường dẫn đã chọn (hoặc None nếu huỷ).
#[tauri::command]
fn pick_pdf(app: tauri::AppHandle) -> Option<String> {
app.dialog()
.file()
.add_filter("PDF", &["pdf"])
.blocking_pick_file()
.map(|fp| fp.to_string())
}
/// Hộp thoại lưu file PDF; trả về đường dẫn (hoặc None nếu huỷ).
#[tauri::command]
fn pick_save_pdf(app: tauri::AppHandle) -> Option<String> {
app.dialog()
.file()
.add_filter("PDF", &["pdf"])
.set_file_name("annotated.pdf")
.blocking_save_file()
.map(|fp| fp.to_string())
}
/// Hộp chữ nhật đơn giản (1 quad) dùng cho `AnnotSpecDto.quads`.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RectDto {
left: f32,
bottom: f32,
right: f32,
top: f32,
}
/// DTO nhận từ frontend cho một annotation.
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct AnnotSpecDto {
kind: String,
page_index: u16,
left: f32,
bottom: f32,
right: f32,
top: f32,
/// Highlight/Underline/Strikeout theo text nhiều dòng: 1 quad/dòng.
/// Rỗng/thiếu → engine suy ra 1 quad duy nhất từ left/bottom/right/top.
#[serde(default)]
quads: Vec<RectDto>,
color: [u8; 4],
contents: Option<String>,
#[serde(default = "default_font_size")]
font_size: f32,
#[serde(default)]
bold: bool,
#[serde(default)]
italic: bool,
#[serde(default)]
underline: bool,
}
fn default_font_size() -> f32 {
14.0
}
/// Tạo annotation theo specs rồi lưu sang `output`.
#[tauri::command]
fn apply_annotations(
input: String,
output: String,
specs: Vec<AnnotSpecDto>,
) -> Result<(), String> {
use ff_engine::{AnnotKind, AnnotSpec, Rect};
let pdfium = pdfium()?;
let mut mapped = Vec::with_capacity(specs.len());
for s in specs {
let kind = match s.kind.as_str() {
"highlight" => AnnotKind::Highlight,
"underline" => AnnotKind::Underline,
"strikeout" => AnnotKind::Strikeout,
"square" => AnnotKind::Square,
"freetext" => AnnotKind::FreeText,
"note" => AnnotKind::Note,
other => return Err(format!("loại annotation không hỗ trợ: {other}")),
};
mapped.push(AnnotSpec {
kind,
page_index: s.page_index,
rect: Rect {
left: s.left,
bottom: s.bottom,
right: s.right,
top: s.top,
},
quads: s
.quads
.into_iter()
.map(|q| Rect { left: q.left, bottom: q.bottom, right: q.right, top: q.top })
.collect(),
color: s.color,
contents: s.contents,
font_size: s.font_size,
bold: s.bold,
italic: s.italic,
underline: s.underline,
});
}
ff_engine::apply_annotations(
&pdfium,
std::path::Path::new(&input),
std::path::Path::new(&output),
&mapped,
)
.map_err(|e| e.to_string())
}
/// Đường dẫn file PDF mẫu để mở khi khởi động.
#[tauri::command]
fn default_pdf() -> String {
workspace_root()
.join("corpus")
.join("sample-multipage.pdf")
.to_string_lossy()
.into_owned()
}
/// File PDF được truyền qua command-line (Explorer gọi `fofreexit-app.exe "C:\...\a.pdf"`
/// khi double-click hoặc "Open with FoFreeXit"). `None` nếu mở app trực tiếp (không kèm file).
#[tauri::command]
fn initial_file() -> Option<String> {
let arg = std::env::args().nth(1)?;
let path = std::path::PathBuf::from(&arg);
let is_pdf = path
.extension()
.map(|e| e.eq_ignore_ascii_case("pdf"))
.unwrap_or(false);
if is_pdf && path.exists() {
Some(arg)
} else {
None
}
}
fn main() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![
open_document,
render_page,
search_document,
page_text,
page_text_layer,
pick_pdf,
pick_save_pdf,
apply_annotations,
default_pdf,
initial_file
])
.run(tauri::generate_context!())
.expect("lỗi khi chạy ứng dụng Tauri");
}
+33
View File
@@ -0,0 +1,33 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "FoFreeXit",
"version": "0.1.0",
"identifier": "com.fofreexit.app",
"build": {
"frontendDist": "../src"
},
"app": {
"withGlobalTauri": true,
"windows": [
{
"label": "main",
"title": "FoFreeXit — PDF Viewer (Phase 1)",
"width": 1100,
"height": 850
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/icon.ico",
"icons/icon.png"
]
}
}
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FoFreeXit</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="toolbar">
<strong class="brand">FoFreeXit</strong>
<span class="phase">Phase 1 · Viewer</span>
<button id="openBtn" title="Mở file PDF">📂 Mở</button>
<span class="sep"></span>
<button id="pagePrev" title="Trang trước (Page Up)">▲</button>
<input id="pageInput" class="pagenum" type="text" inputmode="numeric" title="Đến trang…" />
<span class="pageof">/ <span id="pageTotal">—</span></span>
<button id="pageNext" title="Trang sau (Page Down)">▼</button>
<span class="sep"></span>
<button id="zoomOut" title="Thu nhỏ (Ctrl+-)">−</button>
<select id="zoomSelect" class="zoom" title="Mức zoom"></select>
<button id="zoomIn" title="Phóng to (Ctrl++)">+</button>
<button id="zoomFit" title="Vừa bề rộng">Vừa rộng</button>
<button id="zoomFitPage" title="Vừa trang">Vừa trang</button>
<span class="sep"></span>
<input id="searchBox" type="search" placeholder="Tìm trong tài liệu… (Ctrl+F)" autofocus />
<label class="searchopt" title="Phân biệt hoa/thường"><input id="searchCase" type="checkbox" />Aa</label>
<button id="searchPrev" title="Kết quả trước (Shift+Enter)">◀</button>
<span id="searchCount" class="searchcount">—</span>
<button id="searchNext" title="Kết quả sau (Enter)">▶</button>
<span class="sep"></span>
<button id="copyPage" title="Copy text trang hiện tại">⧉ Copy</button>
<div class="spacer"></div>
<span id="status" class="status"></span>
</header>
<div class="annobar">
<span class="annolabel">Chú thích:</span>
<button class="atool" data-tool="highlight" title="Tô sáng (kéo vùng)">🖍️ Tô sáng</button>
<button class="atool" data-tool="underline" title="Gạch chân">U̲ Gạch chân</button>
<button class="atool" data-tool="strikeout" title="Gạch ngang">S̶ Gạch ngang</button>
<button class="atool" data-tool="square" title="Khung chữ nhật">▭ Khung</button>
<button class="atool" data-tool="freetext" title="Hộp văn bản">🅣 Text box</button>
<button class="atool" data-tool="note" title="Ghi chú dán">📌 Ghi chú</button>
<span class="sep"></span>
<button id="annotColorBtn" class="atool" title="Màu chú thích"><span class="sw" id="annotSw"></span> Màu</button>
<span class="sep"></span>
<button id="undoBtn" title="Hoàn tác (Ctrl+Z)" disabled>↶ Hoàn tác</button>
<button id="redoBtn" title="Làm lại (Ctrl+Y)" disabled>↷ Làm lại</button>
<span class="spacer"></span>
<span id="annotHint" class="status"></span>
<button id="saveAnnots" disabled>💾 Lưu chú thích (<span id="annotCount">0</span>)</button>
</div>
<div class="body">
<aside class="sidebar">
<div class="tabs">
<button id="tabThumbs" class="tab active">Thumbnails</button>
<button id="tabOutline" class="tab">Outline</button>
<button id="tabComments" class="tab">Chú thích</button>
</div>
<div id="thumbs" class="panel thumbs"></div>
<div id="outline" class="panel outline hidden"></div>
<div id="comments" class="panel comments hidden"></div>
</aside>
<main id="viewport" class="viewport">
<div id="pages" class="pages"></div>
</main>
</div>
<script src="main.js"></script>
</body>
</html>
+1412
View File
File diff suppressed because it is too large Load Diff
+268
View File
@@ -0,0 +1,268 @@
:root { color-scheme: dark; --accent: #4d8bff; }
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
font-family: "Segoe UI", system-ui, sans-serif;
background: #525659;
color: #eee;
display: flex;
flex-direction: column;
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #323639;
border-bottom: 1px solid #1c1f21;
flex: 0 0 auto;
}
.brand { color: var(--accent); font-size: 16px; }
.phase { color: #9aa0a6; font-size: 12px; }
.sep { width: 1px; height: 22px; background: #4a4f52; margin: 0 4px; }
.spacer { flex: 1; }
.status, .pageind, .searchcount, .zoom { color: #9aa0a6; font-size: 12px; }
.zoom { min-width: 44px; text-align: center; }
.searchcount { min-width: 60px; text-align: center; }
.pageof { color: #9aa0a6; font-size: 12px; }
.pagenum {
width: 42px; text-align: center; background: #1f2224; color: #eee;
border: 1px solid #4a4f52; border-radius: 6px; padding: 4px 4px; font-size: 13px;
}
.searchopt {
display: flex; align-items: center; gap: 3px; color: #9aa0a6; font-size: 12px;
cursor: pointer; user-select: none;
}
button {
background: #4a4f52;
color: #fff;
border: none;
border-radius: 6px;
padding: 4px 9px;
cursor: pointer;
font-size: 13px;
}
button:hover { background: #595f63; }
button:disabled { opacity: .4; cursor: default; }
#copyPage { background: var(--accent); }
select.zoom {
background: #1f2224; color: #eee; border: 1px solid #4a4f52;
border-radius: 6px; padding: 4px 4px; font-size: 13px; min-width: 64px;
}
input[type="search"] {
background: #1f2224;
color: #eee;
border: 1px solid #4a4f52;
border-radius: 6px;
padding: 4px 8px;
width: 220px;
}
/* Annotation toolbar */
.annobar {
display: flex;
align-items: center;
gap: 8px;
padding: 5px 12px;
background: #2b2f31;
border-bottom: 1px solid #1c1f21;
flex: 0 0 auto;
}
.annolabel { color: #9aa0a6; font-size: 12px; display: flex; align-items: center; gap: 4px; }
.atool { background: #3a3f42; }
.atool.active { background: var(--accent); box-shadow: 0 0 0 2px rgba(77,139,255,.4); }
#saveAnnots { background: #2e7d32; }
#saveAnnots:disabled { background: #555; }
#annotColor { width: 28px; height: 22px; padding: 0; border: none; background: none; cursor: pointer; }
/* Body layout */
.body { flex: 1; display: flex; min-height: 0; }
/* Khi đang chọn công cụ chú thích: tắt chọn text, đổi con trỏ */
body.tool-active .textlayer { pointer-events: none; }
body.tool-active .page-slot { cursor: crosshair; }
/* Highlight/Underline/Strikeout: NGƯỢC LẠI — bám theo text đã CHỌN (như Foxit),
nên phải giữ textlayer bắt được chuột để chọn text bình thường. */
body.tool-mark .textlayer { pointer-events: auto; }
body.tool-mark .page-slot { cursor: text; }
/* Lớp annotation (preview trước khi lưu) */
.annotlayer { position: absolute; inset: 0; pointer-events: none; }
.annotlayer .a-hl { position: absolute; background: rgba(255,230,0,.4); }
.annotlayer .a-box { position: absolute; border: 1.5px solid; }
.annotlayer .a-line { position: absolute; height: 0; border-top: 2px solid; }
.annotlayer .a-text {
position: absolute; border: 1px dashed; padding: 2px 4px;
font-size: 12px; color: #111; background: rgba(255,255,255,.6); overflow: hidden;
}
.annotlayer .a-note {
position: absolute; width: 18px; height: 18px; pointer-events: auto;
background: #ffd400; border: 1px solid #b59000; border-radius: 3px;
font-size: 12px; text-align: center; line-height: 18px; cursor: help;
}
/* Annotation: chọn & xoá */
.a-sel { pointer-events: auto; cursor: pointer; }
body.tool-active .annotlayer * { pointer-events: none; }
.annotlayer .selected { outline: 2px solid #4d8bff; outline-offset: 1px; }
.annotlayer .a-del {
position: absolute; right: -10px; top: -10px; width: 18px; height: 18px;
background: #e53935; color: #fff; border-radius: 50%; font-size: 11px;
line-height: 18px; text-align: center; cursor: pointer; pointer-events: auto;
box-shadow: 0 1px 4px rgba(0,0,0,.4);
}
.annotlayer .a-line { min-height: 4px; }
/* Text box editor tại chỗ */
.a-editor {
position: absolute; pointer-events: auto; background: rgba(255,255,255,.9);
border: 1px solid #4d8bff; outline: none; padding: 2px 4px; line-height: 1.25;
white-space: pre-wrap; word-break: break-word; box-shadow: 0 2px 10px rgba(0,0,0,.3);
}
/* Thanh định dạng nổi */
.fmtbar {
position: fixed; z-index: 50; display: flex; gap: 3px; align-items: center;
background: #2b2f31; border: 1px solid #1c1f21; border-radius: 7px;
padding: 4px 6px; box-shadow: 0 4px 16px rgba(0,0,0,.5);
}
.fmtbar select { background: #1f2224; color: #eee; border: 1px solid #4a4f52; border-radius: 4px; padding: 2px; }
.fmtbtn {
width: 26px; height: 24px; background: #3a3f42; color: #fff; border: none;
border-radius: 4px; cursor: pointer; font-weight: bold; padding: 0;
display: inline-flex; align-items: center; justify-content: center; gap: 2px;
}
.fmtbtn.on { background: var(--accent); }
.fmtbtn .sw, .citem .csw, #annotSw, .np-color .sw, .fmt-color .sw {
display: inline-block; width: 12px; height: 12px; border-radius: 2px;
border: 1px solid rgba(255,255,255,.5); vertical-align: middle;
}
#annotSw { width: 14px; height: 14px; }
/* Note popup */
.notepop {
position: fixed; z-index: 50; width: 230px; background: #fffbea; color: #222;
border: 1px solid #c9b400; border-radius: 6px; box-shadow: 0 6px 20px rgba(0,0,0,.4);
}
.np-head { display: flex; justify-content: space-between; align-items: center;
background: #ffe600; padding: 4px 8px; font-size: 12px; font-weight: bold; border-radius: 6px 6px 0 0; }
.np-close { cursor: pointer; }
.np-text { width: 100%; min-height: 90px; border: none; outline: none; resize: vertical;
padding: 8px; background: transparent; color: #222; font-family: inherit; font-size: 13px; box-sizing: border-box; }
.np-foot { display: flex; justify-content: space-between; padding: 6px 8px; border-top: 1px solid #e0d480; }
.np-foot button { background: #eee; color: #333; font-size: 12px; }
.np-del { color: #c62828 !important; }
/* Color popover */
.colorpop {
position: fixed; z-index: 60; background: #2b2f31; border: 1px solid #1c1f21;
border-radius: 7px; padding: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.5);
}
.cgrid { display: grid; grid-template-columns: repeat(6, 20px); gap: 5px; }
.cs { width: 20px; height: 20px; border-radius: 4px; cursor: pointer; border: 2px solid transparent; }
.cs:hover { border-color: #fff; }
.cs.cur { border-color: #4d8bff; }
.clbl { color: #9aa0a6; font-size: 11px; margin: 8px 0 4px; }
.ccustom { display: block; margin-top: 8px; color: #ccc; font-size: 12px; cursor: pointer; }
.ccustom input { vertical-align: middle; margin-left: 4px; }
/* Comments panel */
.comments .citem {
padding: 6px; border-radius: 5px; cursor: pointer; font-size: 12px;
border: 1px solid #3a3f42; margin-bottom: 6px;
}
.comments .citem:hover { background: #3a3f42; }
.comments .citem .ckind { color: var(--accent); font-weight: bold; }
.comments .citem .cdel { float: right; color: #e57373; cursor: pointer; }
.comments .empty { color: #777; font-size: 12px; }
/* Sidebar */
.sidebar {
width: 220px;
flex: 0 0 220px;
background: #2b2f31;
border-right: 1px solid #1c1f21;
display: flex;
flex-direction: column;
min-height: 0;
}
.tabs { display: flex; flex: 0 0 auto; }
.tab {
flex: 1;
border-radius: 0;
background: #2b2f31;
color: #9aa0a6;
padding: 8px;
}
.tab.active { color: #fff; box-shadow: inset 0 -2px 0 var(--accent); }
.panel { flex: 1; overflow: auto; padding: 8px; }
.panel.hidden { display: none; }
/* Thumbnails */
.thumbs { display: flex; flex-direction: column; gap: 10px; align-items: center; }
.thumb { cursor: pointer; text-align: center; width: 100%; }
.thumb img {
width: 130px;
background: #fff;
border: 2px solid transparent;
box-shadow: 0 1px 6px rgba(0,0,0,.5);
}
.thumb.active img { border-color: var(--accent); }
.thumb .n { font-size: 11px; color: #9aa0a6; margin-top: 2px; }
/* Outline */
.outline .oitem {
padding: 5px 6px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
color: #d6d9dc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.outline .oitem:hover { background: #3a3f42; }
/* Viewer */
.viewport { flex: 1; overflow: auto; min-height: 0; }
.pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
}
.page-slot {
position: relative;
background: #fff;
box-shadow: 0 4px 18px rgba(0,0,0,.5);
flex: 0 0 auto;
}
.page-slot img { display: block; width: 100%; height: 100%; }
.page-slot .ph {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #888; font-size: 13px;
}
.overlay { position: absolute; inset: 0; pointer-events: none; }
/* Text layer: span trong suốt phủ lên ảnh để chọn & copy text */
.textlayer { position: absolute; inset: 0; overflow: hidden; }
.textlayer span {
position: absolute;
color: transparent;
white-space: pre;
overflow: hidden;
cursor: text;
transform-origin: 0 0;
}
.textlayer span::selection { background: rgba(77, 139, 255, .45); }
.page-slot img { user-select: none; -webkit-user-drag: none; }
.hl {
position: absolute;
background: rgba(255, 214, 0, .38);
outline: 1px solid rgba(255, 184, 0, .9);
}
.hl.current { background: rgba(255, 138, 0, .55); }
+19
View File
@@ -0,0 +1,19 @@
# Corpus PDF mẫu
Bộ file PDF dùng để test & hồi quy. Sẽ mở rộng dần theo roadmap.
| File | Mô tả | Dùng cho |
|------|-------|----------|
| `hello.pdf` | PDF tối giản 1 trang, text Helvetica, xref hợp lệ | Smoke test render (Phase 1) |
| `sample-multipage.pdf` | 3 trang Letter, nội dung biết trước + outline (Chapter 1/2/3 → trang 0/1/2) | Test page_dims, extract_text, search, outline; demo viewer |
| `big-1000.pdf` | 1000 trang ("Trang N"); trang index 500 có "ZZMARKER" | Stress-test hiệu năng/lazy-load (mở 55ms, render 110ms, search 79ms) |
## Cần bổ sung (các phase sau)
- File nhiều trang (100+, 1000+) — hiệu năng & lazy load.
- File scan (ảnh) — OCR (Phase 7).
- File mã hoá (mật khẩu) — bảo mật (Phase 5).
- File hỏng/xref sai — kiểm thử độ bền parser.
- File tiếng Việt (font nhúng, tổ hợp dấu), CJK, RTL — edit text (Phase 4).
- File có AcroForm — form (Phase 6).
> Lưu ý bản quyền: chỉ thêm file tự tạo hoặc có giấy phép cho phép phân phối.
+11020
View File
File diff suppressed because one or more lines are too long
+31
View File
@@ -0,0 +1,31 @@
%PDF-1.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R] /Count 1 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>
endobj
4 0 obj
<< /Length 57 >>
stream
BT /F1 24 Tf 72 700 Td (Hello FoFreeXit - Phase 1) Tj ET
endstream
endobj
5 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
xref 6
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000241 00000 n
0000000347 00000 n
trailer
<< /Size 6 /Root 1 0 R >>
startxref
417
%%EOF
+69
View File
@@ -0,0 +1,69 @@
%PDF-1.7
1 0 obj
<< /Type /Catalog /Pages 2 0 R /Outlines 7 0 R >>
endobj
2 0 obj
<< /Type /Pages /Kids [3 0 R 4 0 R 5 0 R] /Count 3 >>
endobj
3 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents 11 0 R >>
endobj
4 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents 12 0 R >>
endobj
5 0 obj
<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 6 0 R >> >> /Contents 13 0 R >>
endobj
6 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
endobj
7 0 obj
<< /Type /Outlines /First 8 0 R /Last 10 0 R /Count 3 >>
endobj
8 0 obj
<< /Title (Chapter 1) /Parent 7 0 R /Next 9 0 R /Dest [3 0 R /Fit] >>
endobj
9 0 obj
<< /Title (Chapter 2) /Parent 7 0 R /Prev 8 0 R /Next 10 0 R /Dest [4 0 R /Fit] >>
endobj
10 0 obj
<< /Title (Chapter 3) /Parent 7 0 R /Prev 9 0 R /Dest [5 0 R /Fit] >>
endobj
11 0 obj
<< /Length 92 >>
stream
BT /F1 18 Tf 72 720 Td (FoFreeXit Test Document) Tj 0 -28 Td (Page one content alpha) Tj ET
endstream
endobj
12 0 obj
<< /Length 64 >>
stream
BT /F1 18 Tf 72 720 Td (Page two content beta searchterm) Tj ET
endstream
endobj
13 0 obj
<< /Length 56 >>
stream
BT /F1 18 Tf 72 720 Td (Page three content gamma) Tj ET
endstream
endobj
xref
0 14 65535 f
0000000009 00000 n
0000000074 00000 n
0000000143 00000 n
0000000270 00000 n
0000000397 00000 n
0000000524 00000 n
0000000594 00000 n
0000000666 00000 n
0000000751 00000 n
0000000849 00000 n
0000000935 00000 n
0000001077 00000 n
0000001191 00000 n
trailer
<< /Size 14 /Root 1 0 R >>
startxref
1297
%%EOF
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "ff-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FoFreeXit command-line interface (runs engine features headless, for testing)"
[[bin]]
name = "ff"
path = "src/main.rs"
[dependencies]
ff-engine = { path = "../ff-engine" }
anyhow.workspace = true
clap = { version = "4", features = ["derive"] }
+203
View File
@@ -0,0 +1,203 @@
//! ff — CLI của FoFreeXit. Chạy các tính năng engine không cần UI (để test).
use std::path::PathBuf;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "ff", version, about = "FoFreeXit CLI")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// In số trang của một file PDF.
Info {
/// Đường dẫn file PDF.
input: PathBuf,
/// Mật khẩu (nếu file mã hoá).
#[arg(long)]
password: Option<String>,
},
/// Render một trang ra ảnh PNG.
Render {
/// Đường dẫn file PDF đầu vào.
input: PathBuf,
/// Đường dẫn PNG đầu ra.
output: PathBuf,
/// Số trang (0-based).
#[arg(long, default_value_t = 0)]
page: u16,
/// Chiều rộng ảnh mục tiêu (px).
#[arg(long, default_value_t = 1240)]
width: u32,
/// Mật khẩu (nếu file mã hoá).
#[arg(long)]
password: Option<String>,
},
/// In kích thước (points) của mọi trang.
Pages {
input: PathBuf,
#[arg(long)]
password: Option<String>,
},
/// In text trích từ một trang.
Text {
input: PathBuf,
#[arg(long, default_value_t = 0)]
page: u16,
#[arg(long)]
password: Option<String>,
},
/// Tìm chuỗi trong tài liệu; in số kết quả và vị trí.
Search {
input: PathBuf,
/// Chuỗi cần tìm.
query: String,
/// Phân biệt hoa/thường.
#[arg(long)]
case_sensitive: bool,
#[arg(long)]
password: Option<String>,
},
/// In outline (bookmarks) của tài liệu.
Outline {
input: PathBuf,
#[arg(long)]
password: Option<String>,
},
/// (debug) In hộp bao từng ký tự của một trang.
Chars {
input: PathBuf,
#[arg(long, default_value_t = 0)]
page: u16,
#[arg(long)]
password: Option<String>,
},
/// Liệt kê annotation trong tài liệu.
Annots { input: PathBuf },
}
fn main() -> Result<()> {
let cli = Cli::parse();
let pdfium = ff_engine::bind_pdfium().context("khởi tạo PDFium")?;
match cli.command {
Command::Info { input, password } => {
let n = ff_engine::page_count(&pdfium, &input, password.as_deref())
.with_context(|| format!("đọc {}", input.display()))?;
println!("{} trang: {}", input.display(), n);
}
Command::Render {
input,
output,
page,
width,
password,
} => {
let img = ff_engine::render_page_png(
&pdfium,
&input,
page,
&output,
width,
password.as_deref(),
)
.with_context(|| format!("render {} trang {}", input.display(), page))?;
println!(
"Đã render trang {} ({}x{}px) -> {}",
page,
img.width,
img.height,
output.display()
);
}
Command::Pages { input, password } => {
let dims = ff_engine::page_dims(&pdfium, &input, password.as_deref())
.with_context(|| format!("đọc {}", input.display()))?;
for d in &dims {
println!("trang {}: {:.1} x {:.1} pt", d.index, d.width_pt, d.height_pt);
}
println!("tổng: {} trang", dims.len());
}
Command::Text {
input,
page,
password,
} => {
let t = ff_engine::extract_text(&pdfium, &input, page, password.as_deref())
.with_context(|| format!("trích text {} trang {}", input.display(), page))?;
println!("{t}");
}
Command::Search {
input,
query,
case_sensitive,
password,
} => {
let hits = ff_engine::search(&pdfium, &input, &query, case_sensitive, password.as_deref())
.with_context(|| format!("tìm \"{query}\" trong {}", input.display()))?;
for h in &hits {
match h.rect {
Some(r) => println!(
"trang {} @char {} (len {}) rect [{:.0},{:.0},{:.0},{:.0}]",
h.page_index, h.char_start, h.char_len, r.left, r.bottom, r.right, r.top
),
None => println!(
"trang {} @char {} (len {})",
h.page_index, h.char_start, h.char_len
),
}
}
println!("tổng: {} kết quả", hits.len());
}
Command::Outline { input, password } => {
let items = ff_engine::outline(&pdfium, &input, password.as_deref())
.with_context(|| format!("đọc outline {}", input.display()))?;
for it in &items {
let p = it
.page_index
.map(|p| format!("-> trang {p}"))
.unwrap_or_else(|| "(không rõ trang)".into());
println!("{}{} {}", " ".repeat(it.level as usize), it.title, p);
}
println!("tổng: {} mục", items.len());
}
Command::Chars {
input,
page,
password,
} => {
let boxes = ff_engine::page_char_boxes(&pdfium, &input, page, password.as_deref())
.with_context(|| format!("char boxes {} trang {}", input.display(), page))?;
for (i, b) in boxes.iter().enumerate() {
println!(
"{i:3} {:?} L{:.1} B{:.1} R{:.1} T{:.1}",
b.ch, b.left, b.bottom, b.right, b.top
);
}
println!("tổng: {} ký tự", boxes.len());
}
Command::Annots { input } => {
let items = ff_engine::list_annotations(&pdfium, &input)
.with_context(|| format!("đọc annotation {}", input.display()))?;
for a in &items {
println!(
"trang {} [{}] rect [{:.0},{:.0},{:.0},{:.0}] {}",
a.page_index,
a.kind,
a.rect.left,
a.rect.bottom,
a.rect.right,
a.rect.top,
a.contents.as_deref().unwrap_or("")
);
}
println!("tổng: {} annotation", items.len());
}
}
Ok(())
}
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "ff-cos"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FoFreeXit COS layer: low-level PDF object model (dict/array/stream/xref)"
[dependencies]
thiserror.workspace = true
+10
View File
@@ -0,0 +1,10 @@
//! ff-cos — COS (Carousel Object System) layer.
//!
//! Tầng object thấp của PDF: Object/Dictionary/Array/Stream/Name/Ref/XRef.
//! Tương ứng "Cos layer" của Adobe và QPDF. Hiện là khung trống; sẽ được
//! xây dựng dần từ Phase 3 (ghi file vững) trở đi. Phase 1 chưa cần.
//!
//! Xem docs/04-architecture.md.
/// Phiên bản đặc tả PDF tối thiểu mà engine nhắm tới.
pub const TARGET_PDF_VERSION: &str = "1.7";
+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));
}
+10
View File
@@ -0,0 +1,10 @@
[package]
name = "ff-pdmodel"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "FoFreeXit PD layer: high-level document model (page/text/image/annotation)"
[dependencies]
ff-cos = { path = "../ff-cos" }
thiserror.workspace = true
+14
View File
@@ -0,0 +1,14 @@
//! ff-pdmodel — PD (Page Description) layer.
//!
//! Tầng tài liệu cao: Document/Page/Resources/ContentStream/TextRun/
//! ImageObject/Annotation/AcroForm... Tương ứng "PD layer" của Adobe.
//! Khung trống ở Phase 1; phát triển từ Phase 2 (annotation) trở đi.
//!
//! Xem docs/04-architecture.md.
/// Kích thước trang theo điểm (points, 1/72 inch).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageSize {
pub width: f32,
pub height: f32,
}
+157
View File
@@ -0,0 +1,157 @@
# 01 — Nghiên cứu: Tính năng, Khó khăn, Thuật toán, Thư viện
## A. Tính năng của Foxit PDF Editor
Foxit có 3 dòng: **PDF Editor**, **PDF Editor Pro**, **PDF Editor Pro+** (subscription, kèm AI/cloud).
Giá tham khảo (2025–2026): subscription từ ~$11.99/tháng; **lifetime/perpetual ~$159–$210**; Pro+ chỉ bán subscription. Enterprise báo giá riêng.
### Nhóm tính năng (theo mức độ quan trọng với người dùng)
#### 1. Xem & điều hướng (View) — *table stakes, miễn phí ở mọi reader*
- Render PDF chính xác, mượt; zoom, fit, thumbnails, bookmarks/outline, search full-text.
- Continuous scroll, two-page, reading mode, dark mode.
- Mở file lớn nhanh (lazy load từng trang).
#### 2. Chú thích (Annotate / Comment) — *quan trọng, dùng hằng ngày*
- Highlight, underline, strikethrough, squiggly.
- Sticky notes, text box, callout, stamp, drawing (pencil/shapes), measure.
- Reply/threads cho comment, import/export annotation (FDF/XFDF).
#### 3. **Chỉnh sửa nội dung (Edit) — TÍNH NĂNG "ĐÁNG TIỀN" SỐ 1** ⭐
- **Sửa text trực tiếp** trên trang (reflow trong đoạn, đổi font/size/màu).
- Thêm/xoá/di chuyển/đổi kích thước **ảnh** và **đối tượng** (object).
- Edit cả paragraph, link/unlink text box, OCR rồi sửa text trên ảnh scan.
- Đây là rào cản kỹ thuật lớn nhất và là lý do chính người dùng trả tiền — các reader miễn phí (đa số) **không** làm được.
#### 4. Tổ chức trang (Organize) — *quan trọng, đáng tiền*
- Chèn/xoá/xoay/trích/thay/đảo trang, **merge & split**, crop, đánh số, header/footer, watermark, background.
- Sắp xếp kéo-thả thumbnail.
#### 5. Chuyển đổi (Convert) — *rất đáng tiền* ⭐
- PDF ↔ Word/Excel/PowerPoint, PDF → ảnh, ảnh/Office → PDF.
- Chất lượng chuyển đổi (giữ layout) là điểm bán hàng then chốt.
#### 6. **OCR** — *rất đáng tiền* ⭐
- Nhận dạng text trên scan (đa ngôn ngữ), tạo "searchable PDF".
- Foxit 2025.2 quảng cáo OCR vượt Adobe ở chữ số/checkbox/handwriting.
#### 7. Form (AcroForm / XFA) — *đáng tiền với doanh nghiệp*
- Điền form, **tạo form** (text field, checkbox, radio, dropdown, button), nhận dạng field tự động, import/export dữ liệu form.
#### 8. Bảo mật & chữ ký — *đáng tiền với doanh nghiệp* ⭐
- Mật khẩu (mở/permission), **mã hoá AES**, redaction (bôi đen vĩnh viễn), xoá metadata ẩn.
- **Chữ ký số** (digital signature/PKI), chứng thực, timestamp; **eSign** (cloud).
#### 9. So sánh & cộng tác
- Compare 2 phiên bản PDF, shared review, cloud collaboration (Pro+).
#### 10. AI (Pro+) — *xu hướng mới*
- Tóm tắt, hỏi-đáp tài liệu, dịch, chat với PDF.
### ➜ Kết luận: Tính năng khiến người dùng phải mua license
Theo thứ tự "moat" (khó làm + người dùng sẵn sàng trả tiền):
1. **Edit text/object trực tiếp** (rào cản kỹ thuật cao nhất).
2. **Convert PDF→Office giữ layout** (Word/Excel).
3. **OCR** chất lượng cao, đa ngôn ngữ.
4. **Bảo mật**: redaction thật, mã hoá, chữ ký số.
5. **Tạo & xử lý form** (AcroForm).
6. **Organize**: merge/split/crop hàng loạt.
> Chiến lược FoFreeXit: chiếm các tính năng (1), (4), (6), (organize) trước — vốn có thể tự làm tốt trên nền engine mở. (2) Convert và (3) OCR sẽ tận dụng dự án mở khác (LibreOffice headless, Tesseract). AI để sau cùng.
---
## B. Khó khăn kỹ thuật (xếp theo độ khó)
### B1. Bản chất định dạng PDF
- PDF **không phải định dạng để chỉnh sửa**: nó mô tả "vẽ gì ở toạ độ nào", không lưu cấu trúc đoạn/dòng/bảng như Word. Text là chuỗi lệnh `Tj/TJ` đặt glyph theo toạ độ → **không có khái niệm "đoạn văn"**.
- Đặc tả PDF (ISO 32000) cực lớn; vô số file "hợp lệ một phần", file hỏng, file sinh bởi công cụ lạ → cần **độ bền (robustness)** rất cao.
### B2. Sửa text trực tiếp (khó nhất) ⭐
- Phải **tái dựng dòng/đoạn** từ các glyph rời rạc (gom theo toạ độ, baseline, khoảng cách) → suy ra "text run".
- Khi sửa: cần **đo lại chiều rộng glyph** theo font thật, **reflow** phần còn lại của dòng/đoạn, xử lý ligature, kerning, justification.
- **Font**: file có thể nhúng font subset (chỉ chứa glyph đã dùng) → muốn gõ ký tự mới phải có glyph đó; nếu không, phải **substitute font** giống nhất rồi nhúng/subset lại. Encoding phức tạp (CID, CMap, Type0, Type3).
- Văn bản phức tạp: RTL (Ả Rập/Do Thái), chữ Đông Á (CJK), **tiếng Việt dấu** (tổ hợp dấu), shaping (HarfBuzz).
### B3. Render chính xác
- Render giống Acrobat: blend modes, transparency groups, soft mask, shading/gradient, pattern, overprint, ICC color management, CMYK.
- Hiệu năng: file hàng nghìn trang, ảnh độ phân giải cao → cần cache, tile, đa luồng, GPU (tuỳ chọn).
### B4. OCR
- Tích hợp engine (Tesseract), tiền xử lý ảnh (deskew, denoise, binarize), bố cục (layout analysis), khớp toạ độ text vào trang để tạo lớp text vô hình → searchable PDF.
### B5. Convert PDF → Office
- "Bài toán ngược": từ glyph dựng lại đoạn/bảng/cột/heading của Word. **Rất khó giữ layout**. Đây là nơi cả Adobe lẫn Foxit đều chưa hoàn hảo.
### B6. Bảo mật làm đúng
- **Redaction**: phải **xoá thật** nội dung khỏi content stream + metadata + ảnh, không chỉ vẽ hộp đen lên trên (lỗi này từng làm lộ tài liệu chính phủ).
- Mã hoá RC4/AES, permission, public-key security handler; **chữ ký số** đúng chuẩn PAdES (byte range, /ByteRange, incremental update không phá chữ ký cũ).
### B7. Ghi file an toàn
- **Incremental update** (ghi thêm cuối file, giữ chữ ký) vs **full rewrite** (gọn, tối ưu). Cần xử lý xref/xref-stream, object stream, linearization ("fast web view").
- Không làm hỏng file gốc; undo/redo cho thao tác sửa.
### B8. UX desktop chất lượng cao
- Tương tác mượt (chọn text qua nhiều trang, kéo object), đa nền tảng, in ấn đúng, accessibility, đa ngôn ngữ UI.
---
## C. Thuật toán & kiến trúc của Foxit / Adobe (học hỏi được gì)
> Foxit & Adobe là mã đóng; dưới đây là suy luận từ tài liệu SDK công khai, cấu trúc đặc tả PDF, và kiến trúc các engine mở tương đương. Mục tiêu: **học mô hình kiến trúc**, không sao chép.
### C1. Foxit
- **Core engine viết bằng C++**; cung cấp SDK với binding C++, C#, C, Java, Python. Kiến trúc tách lớp rõ: **Core (parser + render + edit) → API → UI**.
- Render engine riêng (không dùng PDFium công khai), tối ưu tốc độ & bộ nhớ — Foxit nổi tiếng "nhẹ và nhanh".
- Mô hình edit dựa trên **page object model**: trang = tập object (text/path/image/form-xobject), edit = thao tác trên object rồi serialize lại content stream.
### C2. Adobe Acrobat
- Lõi C/C++ lâu đời (PDF do Adobe phát minh). **PDFL (PDF Library)** + Acrobat core; tách "Cos layer" (Carousel Object System — model object thấp tầng: dict/array/stream) và "PD layer" (Page Description — trang, annotation, form).
- Bài học quan trọng: **mô hình 2 tầng**:
- *Tầng object thấp (COS)*: đọc/ghi cấu trúc PDF (dictionary, stream, xref) — tương ứng QPDF/pikepdf.
- *Tầng tài liệu cao (PD)*: trang, annotation, form, content stream parsed thành object — tương ứng PDFium/MuPDF.
- Liquid Mode (mobile reflow) của Adobe dùng ML để suy ra cấu trúc đọc → cho thấy hướng "tái dựng cấu trúc" là bài toán AI/heuristic.
### ➜ Bài học kiến trúc áp dụng cho FoFreeXit
1. **Tách 2 tầng**: `cos` (cấu trúc) và `pdmodel` (trang/object/annotation) — đúng như Adobe/PDFBox làm.
2. **Page Object Model** cho editing: parse content stream → cây object → sửa → serialize.
3. **Undo/redo** ở tầng model bằng command pattern.
4. Tách hẳn **engine** (không phụ thuộc UI) khỏi **UI**, để test tự động và tái dùng (CLI, server).
---
## D. Khảo sát thư viện PDF (mọi ngôn ngữ) & giấy phép
> Giấy phép là yếu tố sống còn: muốn phân phối tự do, **tránh AGPL** (MuPDF, iText) trừ khi chấp nhận copyleft hoặc mua license.
| Thư viện | Ngôn ngữ | Render | Edit text/obj | Cấu trúc/repair | Giấy phép | Ghi chú |
|----------|----------|:------:|:------:|:------:|----------|------|
| **PDFium** | C/C++ | ⭐ rất tốt | ✔ có API (`FPDFPageObj_*`, `FPDFText_*`) | một phần | **BSD-3 (permissive)** | Engine của Chrome; binary prebuilt sẵn (bblanchon/pdfium-binaries). Nền tảng lý tưởng. |
| **QPDF** | C++ | ✘ | ✘ (cấu trúc) | ⭐ xuất sắc | Apache-2.0 | Repair, linearize, encrypt, object stream. Bổ trợ cho PDFium. |
| pikepdf | Python | ✘ | ✘ | ⭐ (wrap QPDF) | MPL-2.0 | Bản Python của QPDF, tốt cho prototype. |
| MuPDF | C | ⭐ | ✔ | ✔ | **AGPL/commercial** | Mạnh nhưng copyleft → tránh cho bản đóng. |
| PyMuPDF | Python | ⭐ | ✔ | ✔ | **AGPL/commercial** | Tốt để prototype/nghiên cứu, không ship. |
| iText | Java/.NET | một phần | ✔ tạo | ✔ | **AGPL/commercial $$$** | Tránh trừ khi mua. |
| Apache PDFBox | Java | ✔ ok | ✔ | ✔ | **Apache-2.0** | Permissive, full-feature, tham khảo thuật toán rất tốt (mã đọc được). |
| PDF.js | JS | ✔ (web) | ✘ (gần như render-only) | một phần | Apache-2.0 | Của Mozilla; tốt cho web viewer, không edit sâu. |
| pdf-lib | JS/TS | ✘ | ✔ (tạo/sửa cơ bản) | ✔ | MIT | Thuần JS, tạo & sửa nhẹ. |
| pdfcpu | Go | ✘ | thao tác trang | ✔ | Apache-2.0 | CLI mạnh cho organize/encrypt. |
| lopdf / pdf-rs / printpdf | Rust | một phần | ✔ thấp tầng / tạo | ✔ | MIT/Apache | Ecosystem Rust đang lớn, chưa bằng PDFium. |
| Tesseract | C++ | — | — (OCR) | — | Apache-2.0 | Engine OCR mở tốt nhất. |
| HarfBuzz | C++ | — | text shaping | — | MIT | Bắt buộc cho shaping (Việt/CJK/RTL). |
| FreeType | C | — | font rasterize | — | FTL/GPL | Đọc & render font. |
| LibreOffice (headless) | C++ | — | convert | — | MPL-2.0 | Dùng `soffice --convert-to` cho PDF↔Office. |
### ➜ Tận dụng được gì
- **Nền engine**: PDFium (render + edit object) + QPDF (cấu trúc/encrypt/repair) — cả hai permissive → **không phải viết parser/renderer từ đầu**.
- **OCR**: Tesseract. **Shaping**: HarfBuzz. **Font**: FreeType. **Convert Office**: LibreOffice headless.
- **Tham khảo thuật toán** (đọc mã được): Apache PDFBox (Java, permissive) cho cách dựng text run, content stream, form; pdf.js cho rendering model.
- **Tránh**: MuPDF/PyMuPDF/iText cho bản phân phối (AGPL) — chỉ dùng để học/prototype.
## Nguồn tham khảo
- [Foxit vs Adobe 2025](https://pdfpro.com/blog/general/foxit-vs-adobe) · [Foxit pricing](https://www.trustradius.com/products/foxit-pdf-editor/pricing) · [Foxit Editor blog](https://www.foxit.com/blog/the-new-foxit-pdf-editor-and-pdf-editor/)
- [Foxit PDF SDK for C++](https://developers.foxit.com/developer-hub/document/developer-guide-pdf-sdk/)
- [PDFium fpdf_edit.h](https://pdfium.googlesource.com/pdfium/+/refs/heads/main/public/fpdf_edit.h) · [PDFium LICENSE](https://pdfium.googlesource.com/pdfium/+/main/LICENSE)
- [pikepdf](https://github.com/pikepdf/pikepdf) · [Survey of Open-Source PDF Solutions (PDFA)](https://pdfa.org/wp-content/uploads/2021/06/Survey-of-OpenSource-Solutions.pdf)
- [Comparing open source PDF libraries (2025) — Joyfill](https://joyfill.io/blog/comparing-open-source-pdf-libraries-2025-edition) · [awesome-pdf](https://github.com/py-pdf/awesome-pdf)
- [MuPDF license](https://mupdf.readthedocs.io/en/1.27.0/license.html)
+93
View File
@@ -0,0 +1,93 @@
# 02 — Lựa chọn ngôn ngữ, framework & engine
## Nguyên tắc quyết định
1. **Permissive license** cho toàn bộ thành phần ship (BSD/Apache/MIT) → tránh AGPL.
2. **Không viết lại engine PDF từ đầu** ở giai đoạn đầu → dùng PDFium + QPDF.
3. Tách **Engine (không UI)** khỏi **App (UI)** để test & tái dùng.
4. Tối ưu cho **Windows trước** (thị trường Foxit), nhưng giữ đường ra **đa nền tảng**.
5. Dự án do AI hỗ trợ qua nhiều session → ưu tiên **ngôn ngữ có ecosystem PDF mạnh + tooling tốt + dễ test**, hơn là "ngôn ngữ dev người quen tay".
---
## Tầng ENGINE (lõi xử lý PDF) — quyết định gần như bắt buộc
**Chọn: PDFium (BSD-3) làm engine render + edit object, + QPDF (Apache-2.0) cho cấu trúc/mã hoá/repair.**
Lý do: cả hai permissive, trưởng thành, được hậu thuẫn lớn (Google/Chromium). PDFium chính là thứ Chrome/Edge dùng để hiển thị PDF → render chất lượng production. Có binary prebuilt (bblanchon/pdfium-binaries) → khỏi build từ nguồn (vốn rất khổ). PDFium là **C/C++** → FFI tự nhiên nhất từ C++; binding sẵn cho .NET (PDFiumCore/PdfiumViewer), Rust (pdfium-render), Python (pypdfium2).
Bổ trợ chuyên biệt: **Tesseract** (OCR), **HarfBuzz** (shaping), **FreeType** (font), **LibreOffice headless** (convert Office). Tham khảo thuật toán: **Apache PDFBox** (đọc mã được, permissive).
---
## ✅ QUYẾT ĐỊNH ĐÃ CHỐT (2026-06-16): **Rust + Tauri**
Tầng App/UI dùng **Rust + Tauri**; engine **PDFium (pdfium-render) + QPDF**; viewer có thể nhúng **PDF.js** trong webview cho tốc độ, dùng PDFium cho edit/save. Lý do chốt: an toàn bộ nhớ khi xử lý file không tin cậy, hiệu năng cao, license sạch, UI hiện đại phát triển nhanh, phù hợp dự án dài hơi nhiều session. Các lựa chọn A/C/D bên dưới giữ lại để tham khảo/đối chiếu.
## Tầng APP (ngôn ngữ + UI framework) — các lựa chọn đã cân nhắc
So sánh 4 lựa chọn khả thi:
### Lựa chọn A — C++ + Qt (giống Foxit/Adobe nhất)
- **+** FFI với PDFium = 0 ma sát (cùng C++); hiệu năng tối đa; Qt là framework desktop mạnh nhất, đa nền tảng, in ấn/đồ hoạ tốt.
- **+** Đây là con đường "đẳng cấp Foxit" thực sự.
- **−** Tốc độ phát triển chậm, build phức tạp (CMake/vcpkg), quản lý bộ nhớ thủ công, refactor tốn công.
- **License Qt:** LGPL (dùng được miễn phí nếu link động) hoặc thương mại.
### Lựa chọn B — Rust + Tauri (hiện đại, an toàn) ⭐ *khuyến nghị nếu muốn cân bằng*
- **+** An toàn bộ nhớ (ít crash/CVE — rất hợp với phần mềm "đọc file lạ"). `pdfium-render` (binding PDFium) trưởng thành. Tauri = UI web (HTML/CSS/TS) nhẹ, bundle nhỏ.
- **+** Có thể nhúng **PDF.js** trong webview cho viewer nhanh, dùng PDFium (Rust) cho edit/save.
- **+** Cargo/tooling tuyệt vời, test dễ, cross-platform.
- **−** Ecosystem PDF Rust non hơn C++; cần FFI sang PDFium (đã có sẵn binding).
- **License:** Rust crates đa số MIT/Apache; Tauri MIT/Apache.
### Lựa chọn C — C# / .NET 8 + Avalonia (năng suất cao, Windows-first)
- **+** Năng suất phát triển cao nhất; XAML/MVVM quen thuộc; Avalonia đa nền tảng (hoặc WPF nếu chỉ Windows). Binding PDFium .NET sẵn.
- **+** Dễ tuyển/đọc code, dễ test (xUnit).
- **−** FFI sang native qua P/Invoke (ổn nhưng có chi phí marshaling); GC pause với file rất lớn cần chú ý.
- **License:** .NET & Avalonia MIT.
### Lựa chọn D — TypeScript + Electron + PDF.js (nhanh ra mắt, web-native)
- **+** Dev UI nhanh nhất, hệ sinh thái khổng lồ, dễ làm bản web sau này.
- **−** PDF.js gần như render-only → edit/save phải tự build hoặc nhúng native (pdfium qua WASM/node-addon); Electron nặng RAM; hiệu năng kém nhất với file lớn. **Yếu nhất cho tính năng EDIT** — điểm cốt lõi của dự án.
### Bảng tổng hợp
| Tiêu chí | A: C++/Qt | B: Rust/Tauri | C: C#/Avalonia | D: TS/Electron |
|---|:--:|:--:|:--:|:--:|
| Ma sát FFI PDFium | ⭐ | ✔ | ✔ | △ |
| Hiệu năng / file lớn | ⭐ | ⭐ | ✔ | △ |
| An toàn bộ nhớ | △ | ⭐ | ✔ | ✔ |
| Tốc độ phát triển | △ | ✔ | ⭐ | ⭐ |
| Sức mạnh tính năng EDIT | ⭐ | ⭐ | ✔ | △ |
| Cross-platform | ⭐ | ⭐ | ⭐ | ⭐ |
| Độ "đẳng cấp Foxit" | ⭐ | ✔ | ✔ | △ |
### ➜ Khuyến nghị
- **Cân bằng tốt nhất (khuyến nghị chính): B — Rust + Tauri.** An toàn (quan trọng khi xử lý file không tin cậy), hiệu năng cao, binding PDFium tốt, UI hiện đại nhanh phát triển, license sạch. Phù hợp dự án dài hơi do AI hỗ trợ.
- **Nếu ưu tiên "đúng chuẩn Foxit", chấp nhận chậm: A — C++/Qt.**
- **Nếu ưu tiên ra tính năng nhanh trên Windows: C — C#/Avalonia.**
- **Không khuyến nghị D** làm kiến trúc chính vì điểm yếu ở tính năng EDIT (lõi giá trị của dự án).
> **Điểm chung mọi lựa chọn:** Tầng *Engine* (PDFium + QPDF + Tesseract...) gần như không đổi. Lựa chọn ngôn ngữ chủ yếu ảnh hưởng tầng *App/UI*. Vì vậy nên **đóng gói Engine sau một API ổn định** (FFI/IPC) để có thể đổi UI mà không đập lõi.
---
## Kiến trúc đề xuất (độc lập với lựa chọn UI)
```
┌──────────────────────────────────────────┐
│ UI Layer │ (Qt / Tauri-web / Avalonia)
│ Viewer · Annotate · Edit · Organize │
├──────────────────────────────────────────┤
│ App / Commands │ undo/redo, document state, MVVM
├──────────────────────────────────────────┤
│ FoFreeXit Core API (stable) │ ← biên giới ổn định (FFI/IPC)
│ pdmodel: Page, TextRun, Image, Annot... │ ← tầng "PD" kiểu Adobe
│ cos: Object, Dict, Stream, XRef │ ← tầng "COS" kiểu Adobe
├──────────────────────────────────────────┤
│ PDFium │ QPDF │ Tesseract │ HarfBuzz │ (engine permissive)
│ render+edit│ struct │ OCR │ shaping │
└──────────────────────────────────────────┘
```
Chi tiết hoá tiếp ở [04-architecture.md](04-architecture.md) sau khi chốt stack.
+91
View File
@@ -0,0 +1,91 @@
# 03 — Lộ trình dự án (Roadmap)
## Mục tiêu tổng (North Star)
Một ứng dụng desktop có thể **xem, chú thích, tổ chức trang, và chỉnh sửa text/ảnh trực tiếp** trên PDF, kèm bảo mật cơ bản — đủ để một người dùng Foxit phổ thông **bỏ Foxit** cho công việc hằng ngày.
## Phương pháp làm việc
- **Engine-first, UI-second** trong mỗi tính năng: làm được ở tầng core + test CLI trước, rồi mới gắn UI.
- Mỗi phase kết thúc bằng **bản chạy được (demo) + test tự động + cập nhật docs**.
- **Bộ PDF mẫu** (test corpus) tích luỹ dần: file thường, file scan, file mã hoá, file hỏng, CJK/Việt/RTL, form, file nghìn trang.
- Quy ước: nhánh/commit theo phase; mỗi tính năng có "DoD" (Definition of Done) checklist.
- **Không** chuyển phase khi DoD chưa đạt và test chưa xanh.
---
## Phase 0 — Nghiên cứu & Kế hoạch ✅ (đang ở đây)
**DoD:** docs 01–03 hoàn tất; chốt ngôn ngữ/framework; dựng skeleton repo.
## Phase 1 — Nền tảng & Viewer (MVP xem được)
**Mục tiêu:** Mở và hiển thị PDF chính xác, mượt.
- Tích hợp PDFium (prebuilt) qua FFI; dựng Core API tối thiểu (`open`, `pageCount`, `renderPage`).
- Viewer: scroll liên tục, zoom/fit, thumbnails, outline/bookmarks, **tìm kiếm text**, chọn & copy text.
- Mở file lớn (lazy render + cache tile).
**DoD:** mở 20 file mẫu (gồm file 1000+ trang, CJK, Việt) không crash; render khớp mắt thường với Chrome; search & copy hoạt động; có test render-hash cho trang mẫu.
## Phase 2 — Chú thích (Annotate)
**Mục tiêu:** Bộ comment dùng hằng ngày.
- Highlight/underline/strikethrough, sticky note, text box, free draw, shapes, stamp.
- Lưu annotation vào PDF (annotation dict), import/export **XFDF**.
- Hiển thị & sửa/xoá annotation; danh sách comment ở sidebar.
**DoD:** tạo/sửa/xoá mọi loại trên; lưu & mở lại giữ nguyên; Acrobat/Foxit đọc được annotation ta ghi; export/import XFDF round-trip.
## Phase 3 — Tổ chức trang (Organize) + Lưu file vững
**Mục tiêu:** Quản lý trang & ghi file an toàn.
- Chèn/xoá/xoay/trích/thay/đảo trang; **merge & split**; crop; đánh số; header/footer; watermark/background.
- Engine ghi file: incremental update **và** full rewrite (qua QPDF); giữ không hỏng file; **undo/redo** toàn cục.
**DoD:** mọi thao tác trang round-trip đúng; merge 50 file OK; ghi không làm hỏng file mã hoá; undo/redo ổn định; test so khớp cấu trúc trang.
## Phase 4 — ⭐ Chỉnh sửa nội dung (Edit) — TÍNH NĂNG LÕI
**Mục tiêu:** Sửa text & object trực tiếp — moat chính.
- **Page Object Model**: parse content stream → cây object (text/path/image/xobject).
- Sửa **ảnh/object**: di chuyển, resize, xoá, thêm ảnh (dễ hơn → làm trước).
- Sửa **text**: gom glyph → text run/line/paragraph (heuristic theo baseline & spacing); edit inline; đo lại width theo font (FreeType/HarfBuzz); **reflow** trong text box; xử lý font nhúng/subset + font substitution; hỗ trợ **tiếng Việt** (tổ hợp dấu) sớm, CJK/RTL sau.
- Thêm text box mới, đổi font/size/màu.
**DoD:** sửa được text 1 dòng & 1 đoạn trên ≥80% file mẫu thường; thêm/xoá/di chuyển ảnh; lưu mở lại đúng; tiếng Việt hiển thị & sửa đúng dấu; có test corpus chuyên cho edit.
## Phase 5 — Bảo mật & Chữ ký
**Mục tiêu:** Tính năng đáng tiền cho doanh nghiệp.
- Mật khẩu mở/permission; mã hoá **AES-256**; xoá mật khẩu.
- **Redaction thật** (xoá nội dung + ảnh + metadata, không chỉ vẽ đè) + kiểm chứng đã xoá.
- Xoá metadata/ẩn; **chữ ký số** (PAdES, byte range, incremental update giữ chữ ký).
**DoD:** mã hoá/giải mã round-trip; redaction qua kiểm chứng không còn text bị bôi; ký số được Acrobat xác thực hợp lệ.
## Phase 6 — Form (AcroForm)
**Mục tiêu:** Điền & tạo form.
- Điền form có sẵn, lưu; flatten form.
- Tạo field: text/checkbox/radio/dropdown/button; import/export dữ liệu (FDF/CSV).
**DoD:** điền & lưu giữ dữ liệu; tạo form mới mở được ở Acrobat; export/import round-trip.
## Phase 7 — OCR & Convert
**Mục tiêu:** Hai tính năng "đáng tiền" còn lại, qua dự án mở.
- **OCR**: Tesseract + tiền xử lý ảnh + layout → tạo searchable PDF (lớp text vô hình khớp toạ độ). Đa ngôn ngữ gồm tiếng Việt.
- **Convert**: PDF→ảnh (sẵn từ PDFium); Office↔PDF qua **LibreOffice headless**; PDF→Word/Excel (giữ layout — bài toán khó, làm bản "đủ dùng" trước).
**DoD:** OCR scan tạo PDF search được (Việt + Anh); convert Office↔PDF ổn; PDF→Word giữ được text & bố cục cơ bản.
## Phase 8 — Hoàn thiện & Phát hành
- So sánh 2 PDF (compare); in ấn đúng; preferences; đa ngôn ngữ UI (Việt/Anh); accessibility.
- Installer (Windows MSI/winget; sau đó mac/Linux); auto-update; crash reporting.
- Tối ưu hiệu năng & bộ nhớ; hardening bảo mật (fuzzing parser).
**DoD:** cài đặt sạch trên máy mới; vượt bộ test hồi quy đầy đủ; tài liệu người dùng.
## Phase 9 (tuỳ chọn) — AI
- Tóm tắt, hỏi-đáp tài liệu, dịch (dùng Claude API / model cục bộ). Làm sau cùng vì không phải lý do chính thay thế Foxit.
---
## Thứ tự ưu tiên giá trị (nếu phải cắt giảm)
**1→2→3→4** là "core sản phẩm thay Foxit cho cá nhân". **5,6** thêm giá trị doanh nghiệp. **7** ngang hàng giá trị nhưng phụ thuộc dự án ngoài. **8** bắt buộc để phát hành. **9** là bonus.
## Rủi ro & giảm thiểu
| Rủi ro | Giảm thiểu |
|---|---|
| Edit text quá khó (Phase 4) | Làm object/ảnh trước; text bắt đầu từ ca đơn giản; tận dụng PDFium edit API + PDFBox tham khảo |
| Build/FFI PDFium phức tạp | Dùng binary prebuilt + binding có sẵn |
| Convert PDF→Word kém | Hạ kỳ vọng: "đủ dùng" trước; dựa LibreOffice |
| File lạ làm crash | Test corpus file hỏng + fuzzing; chọn ngôn ngữ an toàn bộ nhớ |
| Phạm vi phình to | DoD nghiêm ngặt; không nhảy phase khi chưa xanh |
## Bộ chỉ số thành công sản phẩm
- Mở đúng ≥99% file PDF đời thực trong corpus.
- Người dùng Foxit phổ thông làm được: xem, comment, sắp trang, sửa text/ảnh, đặt mật khẩu — **không cần Foxit**.
- Không thua Foxit rõ rệt về tốc độ mở & render.
+63
View File
@@ -0,0 +1,63 @@
# 04 — Kiến trúc kỹ thuật (khung — sẽ chi tiết hoá sau khi chốt stack)
> Tài liệu này sẽ được hoàn thiện ở đầu Phase 1, sau khi đã chốt ngôn ngữ/framework ở [02-tech-stack.md](02-tech-stack.md).
## Mô hình 2 tầng (học từ Adobe COS/PD và Apache PDFBox)
- **`cos` (Carousel Object System)** — tầng object thấp: `Object`, `Dictionary`, `Array`, `Stream`, `Name`, `Ref`, `XRef`. Đọc/ghi cấu trúc file PDF thô. (Chủ yếu tựa QPDF.)
- **`pdmodel`** — tầng tài liệu cao: `Document`, `Page`, `Resources`, `ContentStream`, `TextRun`, `ImageObject`, `PathObject`, `Annotation`, `AcroForm`, `Field`. (Tựa PDFium + logic riêng.)
## Các module chính
- `engine/render` — bọc PDFium render (tile, cache, đa luồng).
- `engine/text` — gom glyph → run/line/paragraph; chọn/copy; tìm kiếm.
- `engine/edit` — Page Object Model; sửa text/object; serialize content stream.
- `engine/font` — FreeType + HarfBuzz: đo width, shaping, subset, substitution.
- `engine/io` — mở/lưu (incremental + full rewrite via QPDF), repair, linearize, encrypt.
- `engine/annot` — annotation CRUD + XFDF.
- `engine/security` — mã hoá, redaction, chữ ký số (PAdES).
- `engine/form` — AcroForm điền/tạo/flatten.
- `engine/ocr` — Tesseract + tiền xử lý + tạo lớp text.
- `engine/convert` — cầu nối LibreOffice headless; PDF→ảnh; PDF→Office.
- `core-api` — biên giới ổn định (FFI/IPC) cho UI.
- `app/*` — UI theo framework đã chọn.
- `cli` — công cụ dòng lệnh cho test/automation (chạy mọi tính năng không cần UI).
## Nguyên tắc
- Engine **không phụ thuộc UI**; mọi tính năng phải gọi được từ `cli` → test tự động.
- Edit dùng **command pattern** → undo/redo.
- Mọi thao tác ghi đều có đường **không phá file gốc** (atomic write / backup).
## Test strategy
- Unit test cho `cos`/`pdmodel`.
- Golden/render-hash test cho viewer.
- Round-trip test cho annotate/organize/form/security.
- Corpus test (file đời thực, file hỏng, đa ngôn ngữ) + fuzzing parser.
## Hạng mục cần quyết khi vào Phase 1
- [x] Ngôn ngữ/UI: **Rust + Tauri** (chốt 2026-06-16).
- [ ] Phân phối PDFium: dùng prebuilt `bblanchon/pdfium-binaries` qua crate `pdfium-render` (chốt version cụ thể đầu Phase 1).
- [ ] Biên giới core-api: trong Tauri, engine Rust chạy cùng tiến trình; cân nhắc tách tiến trình parser để **sandbox** file không tin cậy (quyết định khi làm hardening).
- [ ] Bố cục repo (mono-repo Cargo workspace, xem dưới).
## Bố cục repo dự kiến (Rust + Tauri, Cargo workspace)
```
FoFreeXit/
├─ Cargo.toml # workspace
├─ crates/
│ ├─ ff-cos/ # tầng object thấp (cấu trúc PDF)
│ ├─ ff-pdmodel/ # tầng tài liệu cao (page/text/annot/...)
│ ├─ ff-engine/ # render/edit/io/font/ocr... (bọc PDFium/QPDF/Tesseract)
│ └─ ff-cli/ # CLI chạy mọi tính năng không cần UI (để test)
├─ app/ # Tauri app
│ ├─ src-tauri/ # Rust backend (gọi ff-engine)
│ └─ src/ # frontend (TS/HTML/CSS, PDF.js viewer)
├─ tests/ # integration tests
├─ corpus/ # PDF mẫu (thường/scan/mã hoá/hỏng/CJK/Việt/form)
└─ docs/
```
## Bước khởi động Phase 1 (gợi ý cho session sau)
1. Khởi tạo Cargo workspace + crate `ff-cli`, `ff-engine`.
2. Thêm `pdfium-render` + tải prebuilt PDFium; viết lệnh CLI `render <pdf> <page> <out.png>` → xác nhận engine chạy.
3. Khởi tạo Tauri app, hiển thị 1 trang PDF render từ engine.
4. Dựng `corpus/` ban đầu (vài file đa dạng) + test render-hash đầu tiên.
+63
View File
@@ -0,0 +1,63 @@
# 05 — Hướng dẫn build & chạy (dev)
> Cập nhật ở cuối Phase 1. Áp dụng cho Windows x64 (môi trường dev hiện tại).
## Yêu cầu môi trường
- **Rust** (MSVC toolchain), cài qua rustup. Kiểm: `rustc --version` (hiện 1.96.0).
- **Visual Studio Build Tools** + workload **Desktop development with C++** (cung cấp `cl.exe`/`link.exe` cho linker MSVC).
- **WebView2 Runtime** (cho Tauri; Windows 11 thường có sẵn).
- **PDFium**: `pdfium.dll` prebuilt — tải bằng `scripts/fetch-pdfium.ps1` (đặt vào gốc workspace + `pdfium/`).
- (Tauri) Node không bắt buộc: app dùng frontend tĩnh, chạy thẳng bằng `cargo run`.
## Lấy PDFium
```powershell
# Tải pdfium.dll (bblanchon/pdfium-binaries) về gốc workspace
powershell -File scripts/fetch-pdfium.ps1
```
Engine tìm `pdfium.dll` theo thứ tự: `FOFREEXIT_PDFIUM_PATH` → cwd → `./pdfium` → thư mục exe → thư viện hệ thống.
## Cấu trúc
```
crates/ff-cos # COS layer (khung)
crates/ff-pdmodel # PD layer (khung)
crates/ff-engine # render qua PDFium (Phase 1: render.rs)
crates/ff-cli # CLI "ff" (info, render)
app/src # frontend tĩnh (HTML/CSS/JS)
app/src-tauri # backend Tauri (crate riêng, workspace tách)
corpus/ # PDF mẫu để test
scripts/ # tiện ích (fetch-pdfium)
```
## CLI
```powershell
# Đếm trang
cargo run -p ff-cli -- info corpus\hello.pdf
# Render trang 0 ra PNG, rộng 800px
cargo run -p ff-cli -- render corpus\hello.pdf out.png --page 0 --width 800
# Kích thước mọi trang
cargo run -p ff-cli -- pages corpus\sample-multipage.pdf
# Trích text 1 trang
cargo run -p ff-cli -- text corpus\sample-multipage.pdf --page 1
# Tìm kiếm (in vị trí + toạ độ)
cargo run -p ff-cli -- search corpus\sample-multipage.pdf content
# Outline / bookmarks
cargo run -p ff-cli -- outline corpus\sample-multipage.pdf
```
## Test
```powershell
cargo test -p ff-engine # smoke test render (cần pdfium.dll)
```
## Chạy app desktop (Tauri)
```powershell
cd app\src-tauri
cargo run # mở cửa sổ, hiển thị corpus\hello.pdf
```
> App là một Cargo workspace **riêng** (có `[workspace]` trống trong `app/src-tauri/Cargo.toml`)
> nên lệnh `cargo` ở gốc repo không build app, và ngược lại. Đây là chủ ý để tránh
> xung đột profile build giữa thư viện và Tauri.
## Ghi chú
- Bản dev set `FOFREEXIT_PDFIUM_PATH` tự động về gốc workspace (xem `app/src-tauri/src/main.rs`).
Bản release sẽ bundle `pdfium.dll` như resource (việc của Phase 8).
+50
View File
@@ -0,0 +1,50 @@
# 06 — Tổng kết Phase 1 (Engine core + Viewer)
> Trạng thái: **HOÀN TẤT** các mục lõi. 10/10 test tự động xanh. Đã xác minh UI bằng screenshot & thao tác thực.
## 1. Đã xây dựng những gì
### Hạ tầng & toolchain
- Cài Rust 1.96 (MSVC) + Visual Studio Build Tools 2026 (workload C++) + WebView2.
- Tải PDFium prebuilt (`pdfium.dll`) qua [scripts/fetch-pdfium.ps1](../scripts/fetch-pdfium.ps1).
- **Cargo workspace**: `crates/ff-cos`, `crates/ff-pdmodel`, `crates/ff-engine`, `crates/ff-cli`.
- **App Tauri** tại `app/` (workspace tách riêng) — chạy bằng `cd app/src-tauri; cargo run`.
### Engine (`ff-engine`, dùng PDFium qua `pdfium-render`)
| Hàm | Chức năng |
|-----|-----------|
| `bind_pdfium` | Nạp thư viện PDFium động |
| `render_page` / `render_page_png` | Render trang ra ảnh (RGBA/PNG) theo chiều rộng |
| `page_count`, `page_dims` | Số trang & kích thước từng trang |
| `extract_text` | Trích text một trang |
| `search` | Tìm chuỗi (có/không phân biệt hoa thường) + **toạ độ rect** từng kết quả |
| `outline` | Đọc bookmarks + trang đích |
| `page_char_boxes` | Hộp bao từng ký tự (cho text-layer chọn/copy) |
### CLI (`ff`) — chạy mọi tính năng không cần UI (phục vụ test)
`info`, `render`, `pages`, `text`, `search`, `outline`.
### App desktop (Tauri + frontend tĩnh)
- **Mở file** qua hộp thoại native (`📂 Mở`) — lọc *.pdf.
- **Xem**: cuộn liên tục nhiều trang, **lazy render** (chỉ render trang trong tầm nhìn) → mở mượt file 1000+ trang.
- **Zoom** −/+/Fit (re-render sắc nét theo devicePixelRatio).
- **Thumbnails** sidebar (lazy-render, đánh dấu trang hiện tại).
- **Outline** sidebar (click → nhảy trang).
- **Tìm kiếm** + highlight chính xác từng kết quả, điều hướng ◀ x/y ▶, highlight bám đúng cả khi zoom.
- **Chọn & copy text**: text-layer trong suốt phủ đúng từng glyph; chọn bằng chuột/Shift+Click + Ctrl+C, hoặc nút **Copy** (cả trang).
## 2. Test tự động (10/10 xanh) — `cargo test`
- `render_smoke`: mở & render `hello.pdf`, trang không trắng trơn.
- `viewer_features` (6): `page_dims`, `extract_text`, `search` (số lượng+vị trí), phân biệt hoa thường, `outline`, `char_boxes` (ghép ký tự = nội dung + hộp nằm trong khổ trang).
- `big_doc` (3): file **1000 trang** — đúng số trang; render trang đầu/cuối < 5s & không trắng; search toàn tài liệu ra đúng 1 kết quả < 10s.
Hiệu năng đo thực tế trên `big-1000.pdf`: mở 55ms · render 1 trang 110ms · search toàn bộ 79ms.
## 3. Giới hạn đã biết của Phase 1 (sẽ tinh chỉnh)
- ~~Double-click chọn 1 ký tự~~ → **ĐÃ SỬA**: text-layer gom ký tự thành "từ", double-click chọn cả từ; copy giữ khoảng trắng (chèn text node giữa các từ). Đã verify: double-click → chọn nguyên từ "FoFreeXit".
- Chưa có: in ấn, xoay trang khi xem, nhớ file mở gần đây, kéo-thả file vào cửa sổ.
- `render_page` nạp lại tài liệu mỗi lần gọi (chưa cache document trong bộ nhớ) — đủ nhanh hiện tại, sẽ tối ưu bằng cache khi cần.
- Bản dev tìm `pdfium.dll` ở gốc workspace; bản phát hành (Phase 8) sẽ bundle.
## 4. Kiến trúc vẫn đúng định hướng
2 tầng COS/PD đã đặt khung (`ff-cos`, `ff-pdmodel`); engine tách hẳn UI, gọi được qua CLI + Tauri. Sẵn sàng cho Phase 2 (Annotate).
+76
View File
@@ -0,0 +1,76 @@
# 07 — Checklist test thủ công cho Phase 1 (dành cho người dùng)
Mục tiêu: bạn tự kiểm tra viewer, ghi lại chỗ nào chưa ổn để cải thiện **trước khi sang Phase 2**.
## Cách chạy app
```powershell
cd c:\Project\FoFreeXit\app\src-tauri
cargo run
```
(Lần đầu mở sẵn `corpus/sample-multipage.pdf`. Nếu thiếu `pdfium.dll`: chạy `scripts/fetch-pdfium.ps1`.)
## Bảng test — đánh dấu ✅/❌ và ghi chú
### A. Mở file
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| A1 | Bấm **📂 Mở**, chọn 1 PDF thật của bạn | Mở & hiển thị đúng | |
| A2 | Mở file **rất lớn** (vài trăm–1000+ trang) | Mở nhanh, không treo | |
| A3 | Mở file **scan** (ảnh, không có text) | Hiển thị được (sẽ không chọn được text — đúng) | |
| A4 | Mở file **tiếng Việt có dấu** | Chữ + dấu hiển thị đúng | |
| A5 | Mở file **mã hoá/đặt mật khẩu** | (Dự kiến lỗi — Phase 5 mới hỗ trợ) ghi lại hiện tượng | |
| A6 | Mở file **hỏng/khác thường** | Không crash; báo lỗi gọn | |
### B. Xem & điều hướng
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| B1 | Cuộn lên/xuống nhiều trang | Mượt, trang hiện dần | |
| B2 | Chỉ số "Trang x/y" khi cuộn | Cập nhật đúng trang đang xem | |
| B3 | Bấm thumbnail | Nhảy đúng trang | |
| B4 | Tab **Outline**, bấm mục | Nhảy đúng trang đích | |
| B5 | So sánh hiển thị với Foxit/Chrome cùng file | Trông giống nhau | |
### C. Zoom
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| C1 | Bấm + / − nhiều lần | Phóng to/thu nhỏ, chữ vẫn nét | |
| C2 | Bấm **Fit** | Vừa bề rộng cửa sổ | |
| C3 | Zoom rồi cuộn | Không vỡ layout, không lag nặng | |
### D. Tìm kiếm
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| D1 | Gõ 1 từ có trong file | Hiện "k/n", nhảy tới kết quả, **ô vàng/cam đúng chỗ** | |
| D2 | Bấm ◀ ▶ duyệt kết quả | Nhảy lần lượt, đổi highlight | |
| D3 | Tìm từ tiếng Việt có dấu | Tìm đúng | |
| D4 | Tìm từ không tồn tại | Hiện 0, không lỗi | |
### E. Chọn & copy text
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| E1 | **Kéo chuột** chọn 1 đoạn text | Bôi xanh đúng vùng | |
| E2 | Ctrl+C rồi dán ra Notepad | Đúng nội dung, đúng thứ tự | |
| E3 | **Double-click** 1 từ | (Hiện chỉ chọn 1 ký tự — đã biết, sẽ sửa) | |
| E4 | Nút **Copy** | Copy text cả trang hiện tại | |
| E5 | Copy đoạn **tiếng Việt có dấu** | Dấu không bị mất/lỗi | |
### F. Ổn định & hiệu năng
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| F1 | Mở/đóng/đổi nhiều file liên tiếp | Không rò bộ nhớ rõ rệt, không crash | |
| F2 | Mở file lớn rồi cuộn nhanh | RAM/CPU chấp nhận được | |
| F3 | Phóng to hết cỡ file lớn | Không treo | |
## Mẫu phản hồi gửi lại
Với mỗi mục ❌ hoặc chưa hài lòng, ghi theo mẫu:
```
[Mã test] (vd D1) — File: <tên/loại file> — Hiện tượng: <mô tả> —
Kỳ vọng: <bạn muốn gì> — Mức ưu tiên: cao/vừa/thấp
```
Ví dụ:
```
B5 — File: hop_dong.pdf — Hiện tượng: chữ mờ hơn Foxit khi zoom 100% —
Kỳ vọng: nét như Foxit — Ưu tiên: vừa
```
> Gửi danh sách phản hồi này lại, tôi sẽ sửa hết các điểm chưa đạt của Phase 1 **trước khi** bắt đầu Phase 2 (Annotate).
+42
View File
@@ -0,0 +1,42 @@
# 08 — Tổng kết Phase 2 (Annotate)
> Trạng thái: **lõi HOÀN TẤT**. 13/13 test tự động xanh. Đường GHI file (rủi ro nhất) đã round-trip + verify ảnh.
## 1. Đã làm
### Engine (`crates/ff-engine/src/annot.rs`)
- `apply_annotations(input, output, specs)`: tạo annotation rồi **lưu sang file mới** (không sửa file gốc), qua `document.save_to_file`.
- 6 loại: **Highlight, Underline, Strikeout** (text-markup, dùng QuadPoints), **Square** (khung), **FreeText** (hộp văn bản), **Note** (ghi chú dán).
- `list_annotations` / `count_annotations`: đọc lại annotation (loại, vùng, nội dung).
- Bài học: QuadPoints highlight phải theo thứ tự TL,TR,BL,BR (helper `quad_from_rect`) — `PdfQuadPoints::from_rect` của thư viện dùng thứ tự khác làm highlight bị xoắn.
### Tauri commands (`app/src-tauri/src/main.rs`)
- `apply_annotations(input, output, specs)` — map DTO (JS) → engine.
- `pick_save_pdf()` — hộp thoại Lưu.
### UI (`app/src`)
- Thanh **Chú thích**: 6 công cụ (Tô sáng, Gạch chân, Gạch ngang, Khung, Text box, Ghi chú) + chọn **màu** + nút **Lưu chú thích (n)**.
- **Vẽ bằng chuột**: kéo vùng trên trang → tạo chú thích, **preview tức thì** (lớp `.annotlayer`), toạ độ màn hình→điểm PDF chính xác (cùng phép biến đổi đã kiểm ở search/text-layer).
- Tab **Chú thích** (sidebar): danh sách chú thích chưa lưu, click để nhảy trang, ✕ để xoá.
- **Lưu**: nút → hộp thoại → ghi file mới (annotation thật) rồi mở lại để render.
- CLI: `ff annots <pdf>` liệt kê annotation; `ff chars <pdf>` (debug).
## 2. Test tự động (13/13 xanh)
- `annot_roundtrip` (3): Highlight+Square; Underline+Strikeout; FreeText+Note — ghi → mở lại → đúng loại/nội dung + render đúng màu.
- Cùng các test Phase 1 (viewer_features 6, big_doc 3, render_smoke 1).
## 3. Đã kiểm bằng tay/ảnh
- Round-trip render: highlight vàng phủ khít "content", khung đỏ (ảnh `tmp-out/annot-*.png`).
- UI: vẽ highlight/underline/strikeout/box hiển thị preview đúng vị trí, đổi màu/đổi công cụ, đếm số chú thích (ảnh `tmp-out/annot-draw.png`).
> Ghi chú kỹ thuật: tự-động-hoá chuột Win32 KHÔNG mô phỏng được thao tác "kéo" trong WebView2 (mousemove giả lập bị lọc) — nên drag-vẽ và drag-chọn-text chỉ kiểm được bằng ảnh khi events lọt, hoặc bằng thao tác rời (click/shift-click). Với chuột thật của người dùng, kéo hoạt động bình thường.
## 4. Còn lại / follow-up (chưa làm)
- **Ink** (vẽ tay tự do).
- **Sửa/di chuyển/xoá** annotation đã lưu (hiện chỉ xoá cái *chưa* lưu).
- Liệt kê annotation **đã lưu** trong tab Chú thích (hiện chỉ liệt kê cái chưa lưu).
- **Import/Export XFDF**.
- Độ trung thực cross-viewer (appearance stream) cho FreeText/Note ở Acrobat.
## 5. Cần người dùng xác nhận (vì kéo-chuột thật không tự test được)
Xem checklist: [docs/09-phase2-user-tests.md](09-phase2-user-tests.md).
+33
View File
@@ -0,0 +1,33 @@
# 09 — Checklist test thủ công Phase 2 (Annotate)
Phần kéo-chuột không tự động test được trong WebView2 (xem ghi chú ở [08-phase2-summary.md](08-phase2-summary.md)), nên cần bạn thử bằng chuột thật.
## Chạy
```powershell
cd c:\Project\FoFreeXit\app\src-tauri
cargo run
```
## Bảng test
| # | Thao tác | Kỳ vọng | KQ |
|---|----------|---------|----|
| G1 | Chọn **Tô sáng**, kéo qua một dòng chữ | Vệt vàng phủ đúng vùng kéo | |
| G2 | Chọn **Gạch chân**, kéo qua chữ | Đường kẻ dưới chữ | |
| G3 | Chọn **Gạch ngang**, kéo qua chữ | Đường kẻ giữa chữ | |
| G4 | Chọn **Khung**, kéo một vùng | Khung chữ nhật | |
| G5 | Đổi **Màu** rồi vẽ | Chú thích đúng màu mới | |
| G6 | Chọn **Text box**, kéo vùng, gõ chữ | Hộp chữ hiện nội dung | |
| G7 | Chọn **Ghi chú**, bấm lên trang, gõ chữ | Icon ghi chú 📝 (rê chuột thấy nội dung) | |
| G8 | Tab **Chú thích** (sidebar) | Liệt kê các chú thích vừa vẽ; bấm để nhảy trang; ✕ để xoá | |
| G9 | Bấm **Lưu chú thích**, chọn nơi lưu | Tạo file PDF mới; app mở lại file đó | |
| G10 | Mở file vừa lưu bằng **Foxit/Adobe/Chrome** | Annotation hiển thị đúng ở phần mềm khác | |
| G11 | Vẽ trên file **nhiều trang**, cuộn qua lại | Chú thích đúng trang, đúng vị trí khi cuộn/zoom | |
| G12 | Vẽ rồi **zoom** in/out | Chú thích co giãn theo, vẫn đúng chỗ | |
## Mẫu phản hồi
```
[Mã] (vd G10) — Phần mềm mở: <Foxit/Adobe/Chrome> — Hiện tượng: <mô tả> —
Kỳ vọng: <...> — Ưu tiên: cao/vừa/thấp
```
> Đặc biệt cần biết **G10** (file lưu ra mở ở phần mềm khác có đúng không) và **G9** (lưu có thành công không), vì đó là đường GHI file. Engine đã test round-trip nội bộ, nhưng xác nhận cross-viewer của bạn rất giá trị.
+26
View File
@@ -0,0 +1,26 @@
# FINAL TARGET & RULE — FoFreeXit
> File này là LUẬT TỐI THƯỢỢNG của dự án. Mọi task đã, đang và sẽ làm đều PHẢI tuân thủ.
## MỤC TIÊU CUỐI CÙNG
**Mục tiêu là tạo ra phần mềm có cách dùng, hiệu năng, chức năng, giao diện và trải nghiệm y hệt Foxit.**
## LUẬT BẤT BIẾN (NO-COMPROMISE RULE)
**Nếu tính năng Foxit có mà thư viện không đáp ứng được thì hãy update code cho thư viện nếu có thể, nếu không thì tìm thư viện khác hoặc tự code để xử lý vấn đề. Không thỏa hiệp. Mọi tính năng đã, đang và sẽ làm phải có trải nghiệm và chức năng tương đương Foxit, không thỏa hiệp cắt giảm tính năng vì bất kì lí do gì. Làm được bằng bất kì cách nào.**
## DIỄN GIẢI ÁP DỤNG (bắt buộc với mọi task)
1. Trước khi làm bất kỳ tính năng nào: **tìm hiểu Foxit làm tính năng đó thế nào** (UI, thao tác, tuỳ chọn, định dạng, cách sửa/xoá, màu sắc...) và lấy đó làm chuẩn nghiệm thu.
2. **Không được cắt giảm** vì "thư viện không hỗ trợ". Thứ tự xử lý khi vướng thư viện:
a. Sửa/patch/fork thư viện (vendor as path dependency) để thêm khả năng còn thiếu.
b. Nếu không được → tìm thư viện khác phù hợp hơn.
c. Nếu không được → tự viết (thao tác tầng PDF object, appearance stream, v.v.).
3. Mỗi tính năng phải đạt **cả 5 mặt**: cách dùng, hiệu năng, chức năng, giao diện, trải nghiệm — tương đương Foxit.
4. Mỗi tính năng phải có **test/nghiệm thu** chứng minh đạt chuẩn Foxit trước khi coi là xong; nêu rõ phần nào chưa đạt (nếu tạm thời) và kế hoạch đạt 100%.
5. Không dùng dialog trình duyệt (prompt/alert) thay cho UI chuyên nghiệp.
## CHECKLIST GẮN KÈM MỌI TASK
- [ ] Đã nghiên cứu Foxit làm tính năng này thế nào?
- [ ] UI/thao tác/tuỳ chọn có tương đương Foxit?
- [ ] Có vướng thư viện không? Nếu có, đã patch/đổi/tự-code (KHÔNG cắt giảm)?
- [ ] Có test/nghiệm thu so với chuẩn Foxit?
- [ ] Hiệu năng & trải nghiệm mượt như Foxit?
+24
View File
@@ -0,0 +1,24 @@
# Tải PDFium prebuilt (bblanchon/pdfium-binaries) cho Windows x64
# và đặt pdfium.dll vào thư mục gốc workspace để ff-engine nạp được.
#
# Dùng: powershell -ExecutionPolicy Bypass -File scripts/fetch-pdfium.ps1
$ErrorActionPreference = "Stop"
$root = Split-Path -Parent $PSScriptRoot
$pdfiumDir = Join-Path $root "pdfium"
$tmp = Join-Path $env:TEMP "pdfium-win-x64.tgz"
$url = "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-win-x64.tgz"
Write-Output "Tải PDFium từ: $url"
Invoke-WebRequest -Uri $url -OutFile $tmp -UseBasicParsing
New-Item -ItemType Directory -Force -Path $pdfiumDir | Out-Null
Write-Output "Giải nén vào: $pdfiumDir"
tar -xzf $tmp -C $pdfiumDir
$dll = Join-Path $pdfiumDir "bin\pdfium.dll"
if (-not (Test-Path $dll)) { throw "Không tìm thấy pdfium.dll sau khi giải nén ($dll)" }
# Copy pdfium.dll ra gốc workspace để bind_pdfium tìm thấy trong cwd.
Copy-Item $dll (Join-Path $root "pdfium.dll") -Force
Write-Output "Xong. pdfium.dll đã đặt tại gốc workspace: $(Join-Path $root 'pdfium.dll')"