Dot 1 "edit chuan moi PDF": CID/Identity-H font, format TUNG CHU, edit OCR, bu metrics, splash, Open-with
Build Windows portable / build (push) Canceled after 0s
Build Windows portable / build (push) Canceled after 0s
1) fontfix v2 (PDF tu Word/InDesign): tong hop bang name/post khi subset
thieu (OTS bat buoc); font CID/Identity-H KHONG co cmap -> dung cmap
(3,1) fmt4 tu /ToUnicode x /CIDToGIDMap (Identity: GID=CID; stream 2B/CID
parse tu descendant font); chan GID >= numGlyphs (maxp). tounicode.rs tra
FontMapping {code_to_uni, is_cid, cid_to_gid}.
2) Format TUNG CHU trong o sua (chuan Word/Foxit): boi den -> B/I (nut
toolbar khong cuop focus + Ctrl+B/I). UI tach span theo vung chon, dat
data-bold/italic-override; commit gui override tung doan (RichSegDto
bold/italic). Engine: bang style rich key theo (run nguon, override) -
doi bien the thi lay font BIEN THE THAT cung ho (fontmatch -> builtin ->
substitute), khong dung lai font goc. Test:
rich_seg_bold_italic_override_uses_variant_font.
3) Edit file OCR (scan + text an): khoi toan run render-mode INVISIBLE ->
ve rect TRANG che vung chu cu tren anh + ve text moi HIEN mau den (ca 4
duong ve: rich chinh/fallback tu, 1-style chinh/fallback). Test:
ocr_invisible_block_becomes_visible_with_cover.
4) Bu metrics kieu Adobe Sans MM trong o sua: span mang data-pdfw (be rong
that tren trang); fitEditLinesToPdf them pass letter-spacing de moi nhom
style chiem DUNG quang goc khi font thay the lech metrics (>2%); kep
+/-18% co chu.
5) Khoi dong het "do": cua so tao AN (visible:false), UI show() ngay khi
frontend san sang + man splash che phan nap dau; luoi an toan Rust hien
cua so sau 4s neu JS chet som.
6) Open with FoFreeXit.exe: co che argv (initial_file) van hoat dong - loi
nam o registry Applications tro exe CU (ban portable bi di chuyen/ghi
de) lam Windows hien lai hop chon app. Fix tu chua: moi lan khoi dong
dang ky lai HKCU (Applications command + ProgID FoFreeXit.pdf +
OpenWithProgids .pdf) tro exe hien tai, chay nen qua reg.exe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -703,6 +703,62 @@ fn edit_list_objects(path: String, page: u16, password: Option<String>) -> Resul
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// TỰ ĐĂNG KÝ "Open with" mỗi lần khởi động (HKCU — không cần admin): bản
|
||||||
|
/// portable hay bị di chuyển/ghi đè → entry Applications trong registry trỏ
|
||||||
|
/// đường dẫn exe CŨ, Windows không khởi chạy được và hiện lại hộp "chọn ứng
|
||||||
|
/// dụng khác". Ghi lại command trỏ exe HIỆN TẠI + ProgID cho .pdf để
|
||||||
|
/// FoFreeXit luôn có mặt (và chạy được) trong menu Open with.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn register_open_with() {
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||||
|
let Ok(exe) = std::env::current_exe() else { return };
|
||||||
|
let exe_s = exe.to_string_lossy().to_string();
|
||||||
|
let open_cmd = format!("\"{exe_s}\" \"%1\"");
|
||||||
|
let icon = format!("\"{exe_s}\",0");
|
||||||
|
let reg = |args: &[&str]| {
|
||||||
|
let _ = std::process::Command::new("reg")
|
||||||
|
.args(args)
|
||||||
|
.creation_flags(CREATE_NO_WINDOW)
|
||||||
|
.status();
|
||||||
|
};
|
||||||
|
// Entry của picker "Choose another app" (Windows tạo khi user browse exe).
|
||||||
|
reg(&[
|
||||||
|
"add",
|
||||||
|
r"HKCU\Software\Classes\Applications\FoFreeXit.exe\shell\open\command",
|
||||||
|
"/ve", "/t", "REG_SZ", "/d", &open_cmd, "/f",
|
||||||
|
]);
|
||||||
|
reg(&[
|
||||||
|
"add",
|
||||||
|
r"HKCU\Software\Classes\Applications\FoFreeXit.exe",
|
||||||
|
"/v", "FriendlyAppName", "/t", "REG_SZ", "/d", "FoFreeXit", "/f",
|
||||||
|
]);
|
||||||
|
// ProgID riêng + gắn vào danh sách Open With của .pdf.
|
||||||
|
reg(&[
|
||||||
|
"add",
|
||||||
|
r"HKCU\Software\Classes\FoFreeXit.pdf\shell\open\command",
|
||||||
|
"/ve", "/t", "REG_SZ", "/d", &open_cmd, "/f",
|
||||||
|
]);
|
||||||
|
reg(&[
|
||||||
|
"add",
|
||||||
|
r"HKCU\Software\Classes\FoFreeXit.pdf",
|
||||||
|
"/ve", "/t", "REG_SZ", "/d", "Tài liệu PDF (FoFreeXit)", "/f",
|
||||||
|
]);
|
||||||
|
reg(&[
|
||||||
|
"add",
|
||||||
|
r"HKCU\Software\Classes\FoFreeXit.pdf\DefaultIcon",
|
||||||
|
"/ve", "/t", "REG_SZ", "/d", &icon, "/f",
|
||||||
|
]);
|
||||||
|
reg(&[
|
||||||
|
"add",
|
||||||
|
r"HKCU\Software\Classes\.pdf\OpenWithProgids",
|
||||||
|
"/v", "FoFreeXit.pdf", "/t", "REG_SZ", "/d", "", "/f",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn register_open_with() {}
|
||||||
|
|
||||||
/// Bytes (base64) của font ĐÓNG GÓI theo app (Noto) — lưới đỡ WYSIWYG cho ô
|
/// Bytes (base64) của font ĐÓNG GÓI theo app (Noto) — lưới đỡ WYSIWYG cho ô
|
||||||
/// sửa: glyph không có trong subset nhúng (ký tự mới gõ) hay font không
|
/// sửa: glyph không có trong subset nhúng (ký tự mới gõ) hay font không
|
||||||
/// nhúng đều rơi về font này, KHÔNG rơi về font hệ điều hành.
|
/// nhúng đều rơi về font này, KHÔNG rơi về font hệ điều hành.
|
||||||
@@ -782,12 +838,17 @@ struct EditOpDto {
|
|||||||
rich_lines: Option<Vec<Vec<RichSegDto>>>,
|
rich_lines: Option<Vec<Vec<RichSegDto>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 1 đoạn cùng style trong reflow nhiều-style.
|
/// 1 đoạn cùng style trong reflow nhiều-style. `bold`/`italic`: override
|
||||||
|
/// người dùng đặt riêng cho đoạn (bôi đen 1 chữ bấm B/I) — None = theo run.
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct RichSegDto {
|
struct RichSegDto {
|
||||||
text: String,
|
text: String,
|
||||||
style: u16,
|
style: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
bold: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
italic: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn one() -> f32 {
|
fn one() -> f32 {
|
||||||
@@ -826,7 +887,12 @@ fn edit_op_from_dto(d: EditOpDto) -> Result<ff_engine::EditOp, String> {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|line| {
|
.map(|line| {
|
||||||
line.into_iter()
|
line.into_iter()
|
||||||
.map(|s| ff_engine::RichSeg { text: s.text, style: s.style })
|
.map(|s| ff_engine::RichSeg {
|
||||||
|
text: s.text,
|
||||||
|
style: s.style,
|
||||||
|
bold: s.bold,
|
||||||
|
italic: s.italic,
|
||||||
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -1451,6 +1517,24 @@ fn main() {
|
|||||||
}
|
}
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.setup(|app| {
|
||||||
|
// Cửa sổ tạo ẨN (tauri.conf.json visible:false) — UI gọi show()
|
||||||
|
// khi frontend SẴN SÀNG để user không thấy khung trắng đơ lúc
|
||||||
|
// WebView khởi tạo. Lưới an toàn: JS lỗi sớm thì vẫn hiện sau 4s.
|
||||||
|
use tauri::Manager;
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(4));
|
||||||
|
if let Some(w) = handle.get_webview_window("main") {
|
||||||
|
if !w.is_visible().unwrap_or(true) {
|
||||||
|
let _ = w.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Đăng ký Open-with ở nền — không chặn khởi động.
|
||||||
|
std::thread::spawn(register_open_with);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
open_document,
|
open_document,
|
||||||
render_page,
|
render_page,
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"label": "main",
|
"label": "main",
|
||||||
"title": "FoFreeXit — PDF Viewer (Phase 1)",
|
"title": "FoFreeXit — PDF Viewer (Phase 1)",
|
||||||
"width": 1100,
|
"width": 1100,
|
||||||
"height": 850
|
"height": 850,
|
||||||
|
"visible": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"security": {
|
"security": {
|
||||||
|
|||||||
@@ -188,6 +188,13 @@
|
|||||||
<div id="modalBox" class="modal-box"></div>
|
<div id="modalBox" class="modal-box"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Màn hình khởi động: che UI đến khi mọi thứ sẵn sàng (không thấy đơ). -->
|
||||||
|
<div id="bootSplash" class="boot-splash">
|
||||||
|
<div class="boot-logo">FoFreeXit</div>
|
||||||
|
<div class="boot-spin"></div>
|
||||||
|
<div class="boot-note">Đang khởi động…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="main.js"></script>
|
<script src="main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+147
-5
@@ -126,9 +126,22 @@ async function loadDocument(path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
|
// Cửa sổ được tạo ẨN (tauri.conf.json visible:false) để user không thấy
|
||||||
|
// khung trắng ĐƠ trong lúc WebView khởi tạo — hiện NGAY khi UI chạy tới
|
||||||
|
// đây (đã tương tác được), splash che nốt phần nạp tài liệu đầu tiên.
|
||||||
|
try {
|
||||||
|
const w = window.__TAURI__.window.getCurrentWindow();
|
||||||
|
await w.show();
|
||||||
|
await w.setFocus();
|
||||||
|
} catch (_) { /* chạy ngoài Tauri (harness test) */ }
|
||||||
// Nếu app được mở qua "Open with FoFreeXit" / double-click file PDF trong
|
// Nếu app được mở qua "Open with FoFreeXit" / double-click file PDF trong
|
||||||
// Explorer, Windows truyền đường dẫn file qua command-line — ưu tiên mở nó.
|
// Explorer, Windows truyền đường dẫn file qua command-line — ưu tiên mở nó.
|
||||||
const fromExplorer = await invoke("initial_file");
|
const fromExplorer = await invoke("initial_file");
|
||||||
|
const splash = $("bootSplash");
|
||||||
|
if (splash) {
|
||||||
|
splash.classList.add("hide");
|
||||||
|
setTimeout(() => splash.remove(), 400);
|
||||||
|
}
|
||||||
loadDocument(fromExplorer || (await invoke("default_pdf")));
|
loadDocument(fromExplorer || (await invoke("default_pdf")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2864,9 +2877,12 @@ function styleGroupsOfLine(runs) {
|
|||||||
const last = groups[groups.length - 1];
|
const last = groups[groups.length - 1];
|
||||||
if (last && last.key === key) {
|
if (last && last.key === key) {
|
||||||
last.text += sep + t;
|
last.text += sep + t;
|
||||||
|
last.right = Math.max(last.right, r.rect.right);
|
||||||
} else {
|
} else {
|
||||||
if (last && sep) last.text += " ";
|
if (last && sep) last.text += " ";
|
||||||
groups.push({ key, rep: r, text: t });
|
// left/right: quãng chiếm chỗ THẬT của nhóm trên trang (pt) — dùng bù
|
||||||
|
// bề rộng khi ô sửa phải thay font (metrics khác font gốc).
|
||||||
|
groups.push({ key, rep: r, text: t, left: r.rect.left, right: r.rect.right });
|
||||||
}
|
}
|
||||||
prev = r;
|
prev = r;
|
||||||
}
|
}
|
||||||
@@ -3052,6 +3068,8 @@ function startBlockTextEdit(o, lines, ev) {
|
|||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.textContent = g.text;
|
span.textContent = g.text;
|
||||||
span.dataset.styleRun = String(g.rep.index);
|
span.dataset.styleRun = String(g.rep.index);
|
||||||
|
// Bề rộng nhóm trên trang (pt) — pass bù metrics trong fitEditLinesToPdf.
|
||||||
|
if (g.right > g.left) span.dataset.pdfw = String(g.right - g.left);
|
||||||
span.style.fontFamily = editFontStack(g.rep, g.rep.fontFamily || o.fontFamily);
|
span.style.fontFamily = editFontStack(g.rep, g.rep.fontFamily || o.fontFamily);
|
||||||
span.style.fontWeight = g.rep.fontBold ? "bold" : "normal";
|
span.style.fontWeight = g.rep.fontBold ? "bold" : "normal";
|
||||||
span.style.fontStyle = g.rep.fontItalic ? "italic" : "normal";
|
span.style.fontStyle = g.rep.fontItalic ? "italic" : "normal";
|
||||||
@@ -3128,8 +3146,11 @@ function startBlockTextEdit(o, lines, ev) {
|
|||||||
sel.addRange(r);
|
sel.addRange(r);
|
||||||
}
|
}
|
||||||
ce.scrollIntoView({ block: "center", behavior: "instant" });
|
ce.scrollIntoView({ block: "center", behavior: "instant" });
|
||||||
|
// Trong ô sửa luôn cho phép B/I trên phần bôi đen (chuẩn Word).
|
||||||
|
$("edBold").disabled = false;
|
||||||
|
$("edItalic").disabled = false;
|
||||||
$("editHint").textContent =
|
$("editHint").textContent =
|
||||||
"Sửa cả đoạn — Enter: xuống dòng · Ctrl+Enter hoặc bấm ra ngoài: áp dụng · Esc: huỷ";
|
"Sửa cả đoạn — Enter: xuống dòng · Bôi đen + B/I (Ctrl+B/I): đậm/nghiêng từng chữ · Ctrl+Enter: áp dụng · Esc: huỷ";
|
||||||
|
|
||||||
// Đọc nội dung theo DÒNG từ contenteditable (div/br → \n).
|
// Đọc nội dung theo DÒNG từ contenteditable (div/br → \n).
|
||||||
const readText = () => {
|
const readText = () => {
|
||||||
@@ -3148,8 +3169,13 @@ function startBlockTextEdit(o, lines, ev) {
|
|||||||
const txt = (node.textContent || "").replace(/\u00a0/g, " ");
|
const txt = (node.textContent || "").replace(/\u00a0/g, " ");
|
||||||
if (!txt || node.nodeName === "BR") continue;
|
if (!txt || node.nodeName === "BR") continue;
|
||||||
let si = null;
|
let si = null;
|
||||||
|
let bold = null;
|
||||||
|
let italic = null;
|
||||||
if (node.nodeType === 1 && node.dataset && node.dataset.styleRun) {
|
if (node.nodeType === 1 && node.dataset && node.dataset.styleRun) {
|
||||||
si = Number(node.dataset.styleRun);
|
si = Number(node.dataset.styleRun);
|
||||||
|
// Override B/I ng\u01b0\u1eddi d\u00f9ng \u0111\u1eb7t cho ri\u00eang \u0111o\u1ea1n (b\u00f4i \u0111en \u2192 B/I).
|
||||||
|
if (node.dataset.boldOverride != null) bold = node.dataset.boldOverride === "1";
|
||||||
|
if (node.dataset.italicOverride != null) italic = node.dataset.italicOverride === "1";
|
||||||
} else {
|
} else {
|
||||||
const pick = (sib) =>
|
const pick = (sib) =>
|
||||||
sib && sib.nodeType === 1 && sib.dataset && sib.dataset.styleRun
|
sib && sib.nodeType === 1 && sib.dataset && sib.dataset.styleRun
|
||||||
@@ -3158,8 +3184,12 @@ function startBlockTextEdit(o, lines, ev) {
|
|||||||
const p = pick(node.previousSibling);
|
const p = pick(node.previousSibling);
|
||||||
si = p != null ? p : pick(node.nextSibling);
|
si = p != null ? p : pick(node.nextSibling);
|
||||||
}
|
}
|
||||||
if (segs.length && segs[segs.length - 1].style === si) segs[segs.length - 1].text += txt;
|
const last = segs[segs.length - 1];
|
||||||
else segs.push({ text: txt, style: si });
|
if (last && last.style === si && last.bold === bold && last.italic === italic) {
|
||||||
|
last.text += txt;
|
||||||
|
} else {
|
||||||
|
segs.push({ text: txt, style: si, bold, italic });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const known = segs.find((g) => g.style != null);
|
const known = segs.find((g) => g.style != null);
|
||||||
for (const g of segs) {
|
for (const g of segs) {
|
||||||
@@ -3191,11 +3221,13 @@ function startBlockTextEdit(o, lines, ev) {
|
|||||||
const styleCount = new Set(
|
const styleCount = new Set(
|
||||||
rich.flatMap((line) => line.map((g) => styleKeyOf(state.editObjects.find((x) => x.index === g.style) || {})))
|
rich.flatMap((line) => line.map((g) => styleKeyOf(state.editObjects.find((x) => x.index === g.style) || {})))
|
||||||
).size;
|
).size;
|
||||||
|
// Có override B/I từng đoạn → bắt buộc đi đường rich dù khối 1 style.
|
||||||
|
const hasOverride = rich.some((line) => line.some((g) => g.bold != null || g.italic != null));
|
||||||
stageEditOp({
|
stageEditOp({
|
||||||
op: "reflowText",
|
op: "reflowText",
|
||||||
indices: allRuns.map((r) => r.index),
|
indices: allRuns.map((r) => r.index),
|
||||||
text,
|
text,
|
||||||
richLines: styleCount > 1 ? rich : null,
|
richLines: styleCount > 1 || hasOverride ? rich : null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
$("editHint").textContent = "";
|
$("editHint").textContent = "";
|
||||||
@@ -3272,6 +3304,28 @@ function fitEditLinesToPdf(ce, lines, s, page) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// (1b) BỀ RỘNG từng nhóm style — kiểu Adobe Sans MM: khi font trong ô sửa
|
||||||
|
// không phải font gốc (thay bằng Noto đóng gói…), bề rộng chữ lệch so với
|
||||||
|
// trang → bù bằng letter-spacing để mỗi nhóm chiếm ĐÚNG quãng gốc (chữ nằm
|
||||||
|
// trùng chữ, con trỏ đặt đúng chỗ). Font nhúng đúng thì lệch <2%, bỏ qua.
|
||||||
|
ce.querySelectorAll("span[data-style-run][data-pdfw]").forEach((sp) => {
|
||||||
|
const text = sp.textContent || "";
|
||||||
|
if (text.trim().length < 2) return;
|
||||||
|
const target = parseFloat(sp.dataset.pdfw) * s;
|
||||||
|
if (!(target > 4)) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(sp);
|
||||||
|
const actual = range.getBoundingClientRect().width;
|
||||||
|
if (!(actual > 1)) return;
|
||||||
|
const diff = target - actual;
|
||||||
|
if (Math.abs(diff) / target <= 0.02) return;
|
||||||
|
const fs = parseFloat(sp.style.fontSize) || 12;
|
||||||
|
const perChar = diff / text.length;
|
||||||
|
// Kẹp ±18% cỡ chữ — lệch quá mức là dữ liệu bất thường, đừng phá layout.
|
||||||
|
const ls = Math.max(-0.18 * fs, Math.min(0.18 * fs, perChar));
|
||||||
|
if (Math.abs(ls) > 0.05) sp.style.letterSpacing = ls.toFixed(2) + "px";
|
||||||
|
});
|
||||||
|
|
||||||
// (2) Vị trí từng dòng (đo lại sau khi đã chỉnh cỡ).
|
// (2) Vị trí từng dòng (đo lại sau khi đã chỉnh cỡ).
|
||||||
const imgR = img.getBoundingClientRect();
|
const imgR = img.getBoundingClientRect();
|
||||||
for (let i = 0; i < divs.length && i < lines.length; i++) {
|
for (let i = 0; i < divs.length && i < lines.length; i++) {
|
||||||
@@ -3314,6 +3368,77 @@ function firstTextRect(div) {
|
|||||||
return rects.length ? rects[0] : null;
|
return rects.length ? rects[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Format TỪNG CHỮ trong ô sửa (bôi đen → B/I, chuẩn Word/Foxit) ----
|
||||||
|
// Tách span[data-style-run] theo vùng chọn; phần được chọn thành span riêng
|
||||||
|
// mang override (data-bold-override / data-italic-override) — commit gửi
|
||||||
|
// override từng đoạn để engine lấy font BIẾN THỂ thật (không giả lập đậm).
|
||||||
|
function toggleInlineFormat(prop) {
|
||||||
|
const ce = $("editOverlay").querySelector(".edit-inline");
|
||||||
|
if (!ce) return false;
|
||||||
|
const sel = window.getSelection();
|
||||||
|
if (!sel.rangeCount || sel.isCollapsed) return false;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
if (!ce.contains(range.commonAncestorContainer)) return false;
|
||||||
|
const walker = document.createTreeWalker(ce, NodeFilter.SHOW_TEXT);
|
||||||
|
const parts = [];
|
||||||
|
let n;
|
||||||
|
while ((n = walker.nextNode())) {
|
||||||
|
if (!range.intersectsNode(n)) continue;
|
||||||
|
const start = n === range.startContainer ? range.startOffset : 0;
|
||||||
|
const end = n === range.endContainer ? range.endOffset : (n.textContent || "").length;
|
||||||
|
if (end > start) parts.push({ node: n, start, end });
|
||||||
|
}
|
||||||
|
const spanOf = (node) =>
|
||||||
|
node.parentElement && node.parentElement.closest("span[data-style-run]");
|
||||||
|
const targets = parts.filter((p) => spanOf(p.node));
|
||||||
|
if (!targets.length) return false;
|
||||||
|
const isOn = (sp) =>
|
||||||
|
prop === "bold"
|
||||||
|
? sp.style.fontWeight === "bold" || parseInt(sp.style.fontWeight, 10) >= 600
|
||||||
|
: sp.style.fontStyle === "italic";
|
||||||
|
// Chuẩn Word: tất cả phần chọn đã ở trạng thái đó → tắt; còn lại → bật.
|
||||||
|
const turnOn = !targets.every((p) => isOn(spanOf(p.node)));
|
||||||
|
for (const p of targets) {
|
||||||
|
const sp = spanOf(p.node);
|
||||||
|
const whole =
|
||||||
|
p.start === 0 && p.end === (p.node.textContent || "").length && sp.childNodes.length === 1;
|
||||||
|
if (whole) {
|
||||||
|
applyInlineOverride(sp, prop, turnOn);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Tách text node thành [trước][chọn][sau], mỗi phần bọc span cùng gốc.
|
||||||
|
let mid = p.node;
|
||||||
|
if (p.start > 0) mid = mid.splitText(p.start);
|
||||||
|
if (p.end - p.start < (mid.textContent || "").length) mid.splitText(p.end - p.start);
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const child of [...sp.childNodes]) {
|
||||||
|
const piece = sp.cloneNode(false);
|
||||||
|
delete piece.dataset.pdfw; // đã tách — bề rộng gốc không còn ứng với đoạn
|
||||||
|
piece.textContent = child.textContent;
|
||||||
|
if (child === mid) applyInlineOverride(piece, prop, turnOn);
|
||||||
|
frag.appendChild(piece);
|
||||||
|
}
|
||||||
|
sp.replaceWith(frag);
|
||||||
|
}
|
||||||
|
sel.removeAllRanges(); // DOM đã đổi — bỏ vùng chọn, giữ focus trong ô
|
||||||
|
ce.focus();
|
||||||
|
$("editHint").textContent = turnOn
|
||||||
|
? (prop === "bold" ? "Đã in đậm phần bôi đen" : "Đã in nghiêng phần bôi đen") +
|
||||||
|
" — Ctrl+Enter để áp dụng"
|
||||||
|
: "Đã bỏ " + (prop === "bold" ? "in đậm" : "in nghiêng") + " phần bôi đen";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyInlineOverride(sp, prop, on) {
|
||||||
|
if (prop === "bold") {
|
||||||
|
sp.style.fontWeight = on ? "bold" : "normal";
|
||||||
|
sp.dataset.boldOverride = on ? "1" : "0";
|
||||||
|
} else {
|
||||||
|
sp.style.fontStyle = on ? "italic" : "normal";
|
||||||
|
sp.dataset.italicOverride = on ? "1" : "0";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
|
// Đổi thuộc tính chữ cho CẢ DÒNG đang chọn: setText từng run (giữ nội dung
|
||||||
// từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN.
|
// từng run), `part` chỉ chứa field muốn đổi — field khác null = GIỮ NGUYÊN.
|
||||||
function applyTextPropToSelected(part) {
|
function applyTextPropToSelected(part) {
|
||||||
@@ -4263,11 +4388,21 @@ $("edFontFamily").addEventListener("change", () => {
|
|||||||
const fam = $("edFontFamily").value;
|
const fam = $("edFontFamily").value;
|
||||||
if (fam) applyTextPropToSelected({ fontFamily: fam });
|
if (fam) applyTextPropToSelected({ fontFamily: fam });
|
||||||
});
|
});
|
||||||
|
// B/I: đang mở ô sửa + có bôi đen → format RIÊNG phần bôi đen; ngoài ô sửa →
|
||||||
|
// đổi cả dòng đang chọn như cũ. mousedown preventDefault để bấm nút không
|
||||||
|
// cướp focus của ô sửa (blur = commit sớm, mất vùng chọn).
|
||||||
|
for (const id of ["edBold", "edItalic"]) {
|
||||||
|
$(id).addEventListener("mousedown", (e) => {
|
||||||
|
if ($("editOverlay").querySelector(".edit-inline")) e.preventDefault();
|
||||||
|
});
|
||||||
|
}
|
||||||
$("edBold").addEventListener("click", () => {
|
$("edBold").addEventListener("click", () => {
|
||||||
|
if (toggleInlineFormat("bold")) return;
|
||||||
const o = state.editObjects.find((x) => x.index === state.editSel);
|
const o = state.editObjects.find((x) => x.index === state.editSel);
|
||||||
if (o) applyTextPropToSelected({ bold: !o.fontBold });
|
if (o) applyTextPropToSelected({ bold: !o.fontBold });
|
||||||
});
|
});
|
||||||
$("edItalic").addEventListener("click", () => {
|
$("edItalic").addEventListener("click", () => {
|
||||||
|
if (toggleInlineFormat("italic")) return;
|
||||||
const o = state.editObjects.find((x) => x.index === state.editSel);
|
const o = state.editObjects.find((x) => x.index === state.editSel);
|
||||||
if (o) applyTextPropToSelected({ italic: !o.fontItalic });
|
if (o) applyTextPropToSelected({ italic: !o.fontItalic });
|
||||||
});
|
});
|
||||||
@@ -4349,6 +4484,13 @@ window.addEventListener("keydown", (e) => {
|
|||||||
finishEditing(); closeNotePopup(); closeColorPopover(); deselectAnnot(); setTool(null);
|
finishEditing(); closeNotePopup(); closeColorPopover(); deselectAnnot(); setTool(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Ctrl+B / Ctrl+I trong ô sửa: format phần bôi đen (chuẩn Word).
|
||||||
|
if (e.ctrlKey && !e.altKey && state.editMode && (e.key === "b" || e.key === "B")) {
|
||||||
|
if (toggleInlineFormat("bold")) { e.preventDefault(); return; }
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && !e.altKey && state.editMode && (e.key === "i" || e.key === "I")) {
|
||||||
|
if (toggleInlineFormat("italic")) { e.preventDefault(); return; }
|
||||||
|
}
|
||||||
if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.editMode && state.editSel != null) {
|
if ((e.key === "Delete" || e.key === "Backspace") && !typing && state.editMode && state.editSel != null) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
deleteSelectedEditObject();
|
deleteSelectedEditObject();
|
||||||
|
|||||||
@@ -367,6 +367,32 @@ body.tool-active .annotlayer * { pointer-events: none; }
|
|||||||
#edDiscard { background: #8d3030; }
|
#edDiscard { background: #8d3030; }
|
||||||
#edDiscard:disabled { background: #555; }
|
#edDiscard:disabled { background: #555; }
|
||||||
|
|
||||||
|
/* Màn hình khởi động — hiện NGAY khi HTML paint, mờ dần khi app sẵn sàng. */
|
||||||
|
.boot-splash {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
background: #2b2b2b;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 14px;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
.boot-splash.hide { opacity: 0; pointer-events: none; }
|
||||||
|
.boot-splash .boot-logo { font-size: 28px; font-weight: 700; color: #7aa2ff; letter-spacing: 1px; }
|
||||||
|
.boot-splash .boot-note { font-size: 12px; color: #999; }
|
||||||
|
.boot-spin {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 3px solid #555;
|
||||||
|
border-top-color: #7aa2ff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: bootspin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes bootspin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
.edit-stage {
|
.edit-stage {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
|||||||
+153
-25
@@ -155,6 +155,26 @@ pub struct RichSeg {
|
|||||||
pub text: String,
|
pub text: String,
|
||||||
/// Index (danh sách phẳng) của run mang style cho đoạn này.
|
/// Index (danh sách phẳng) của run mang style cho đoạn này.
|
||||||
pub style: u16,
|
pub style: u16,
|
||||||
|
/// Override đậm/nghiêng NGƯỜI DÙNG đặt cho riêng đoạn này (bôi đen 1 chữ
|
||||||
|
/// rồi bấm B/I trong ô sửa). None = giữ theo run mang style. Khi override
|
||||||
|
/// đổi biến thể, engine lấy font biến thể THẬT cùng họ (fontmatch), không
|
||||||
|
/// dùng lại font gốc.
|
||||||
|
pub bold: Option<bool>,
|
||||||
|
pub italic: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Khoá style hiệu dụng của 1 đoạn rich: run nguồn + override đậm/nghiêng.
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
|
||||||
|
struct RichKey {
|
||||||
|
style: u16,
|
||||||
|
bold: Option<bool>,
|
||||||
|
italic: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RichKey {
|
||||||
|
fn of(seg: &RichSeg) -> Self {
|
||||||
|
RichKey { style: seg.style, bold: seg.bold, italic: seg.italic }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
|
fn quad_to_rect(q: &PdfQuadPoints) -> Rect {
|
||||||
@@ -529,8 +549,8 @@ pub fn font_data(
|
|||||||
let (_, bold, italic) = text_object_style(t);
|
let (_, bold, italic) = text_object_style(t);
|
||||||
let raw_name = t.font().name();
|
let raw_name = t.font().name();
|
||||||
let fname = if raw_name.trim().is_empty() { t.font().family() } else { raw_name };
|
let fname = if raw_name.trim().is_empty() { t.font().family() } else { raw_name };
|
||||||
let uni = crate::tounicode::code_to_unicode(input, page_index, &fname);
|
let mapping = crate::tounicode::font_mapping(input, page_index, &fname);
|
||||||
Ok(crate::fontfix::web_compatible_font(&data, bold, italic, uni.as_ref()))
|
Ok(crate::fontfix::web_compatible_font(&data, bold, italic, mapping.as_ref()))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn list_objects(
|
pub fn list_objects(
|
||||||
@@ -708,8 +728,12 @@ struct ReflowPlan {
|
|||||||
/// đúng cỡ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép về 1 cỡ).
|
/// đúng cỡ dòng gốc thứ i (tiêu đề 22.5/20pt không bị ép về 1 cỡ).
|
||||||
line_styles: Vec<LineStyle>,
|
line_styles: Vec<LineStyle>,
|
||||||
color: PdfColor,
|
color: PdfColor,
|
||||||
/// Reflow NHIỀU STYLE: các dòng cứng theo đoạn + bảng style đã resolve.
|
/// Reflow NHIỀU STYLE: các dòng cứng theo đoạn + bảng style đã resolve
|
||||||
rich: Option<(Vec<Vec<RichSeg>>, HashMap<u16, RichStyle>)>,
|
/// (khoá theo run nguồn + override đậm/nghiêng của đoạn).
|
||||||
|
rich: Option<(Vec<Vec<RichSeg>>, HashMap<RichKey, RichStyle>)>,
|
||||||
|
/// Khối OCR (mọi run là text ẨN trên ảnh scan): bbox vùng chữ cũ — vẽ
|
||||||
|
/// rect TRẮNG che ảnh rồi vẽ text mới HIỆN màu đen.
|
||||||
|
ocr_cover: Option<Rect>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Style đã resolve cho 1 đoạn rich: font vẽ + bytes đo + màu + cỡ.
|
/// Style đã resolve cho 1 đoạn rich: font vẽ + bytes đo + màu + cỡ.
|
||||||
@@ -1045,6 +1069,43 @@ pub fn apply_edits(
|
|||||||
}
|
}
|
||||||
idxs.sort_unstable();
|
idxs.sort_unstable();
|
||||||
}
|
}
|
||||||
|
// OCR: mọi run của khối là text ẨN (lớp OCR đè trên ảnh
|
||||||
|
// scan) → khi vẽ lại phải CHE vùng chữ cũ trên ảnh (rect
|
||||||
|
// trắng) và vẽ text mới HIỆN màu đen — hành vi "sửa tài
|
||||||
|
// liệu scan" chuẩn Foxit.
|
||||||
|
let ocr_cover: Option<Rect> = {
|
||||||
|
let mut all_inv = !idxs.is_empty();
|
||||||
|
let mut bb = Rect {
|
||||||
|
left: f32::INFINITY,
|
||||||
|
bottom: f32::INFINITY,
|
||||||
|
right: f32::NEG_INFINITY,
|
||||||
|
top: f32::NEG_INFINITY,
|
||||||
|
};
|
||||||
|
for &i in &idxs {
|
||||||
|
let entry = &entries[i as usize];
|
||||||
|
let Ok(obj) = object_at_path(&page, &entry.path) else {
|
||||||
|
all_inv = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let Some(t) = obj.as_text_object() else {
|
||||||
|
all_inv = false;
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
if t.render_mode() != PdfPageTextRenderMode::Invisible {
|
||||||
|
all_inv = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Ok(q) = obj.bounds() {
|
||||||
|
let r = mat_rect(entry.acc, &quad_to_rect(&q));
|
||||||
|
bb.left = bb.left.min(r.left);
|
||||||
|
bb.bottom = bb.bottom.min(r.bottom);
|
||||||
|
bb.right = bb.right.max(r.right);
|
||||||
|
bb.top = bb.top.max(r.top);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if all_inv && bb.left.is_finite() { Some(bb) } else { None }
|
||||||
|
};
|
||||||
|
|
||||||
// Run neo: baseline cao nhất, trái nhất.
|
// Run neo: baseline cao nhất, trái nhất.
|
||||||
let anchor_idx = geos
|
let anchor_idx = geos
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1192,20 +1253,22 @@ pub fn apply_edits(
|
|||||||
(ReflowFont::Loaded(key), Some(bytes))
|
(ReflowFont::Loaded(key), Some(bytes))
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reflow NHIỀU STYLE: resolve font/màu/cỡ TỪNG style-run
|
// Reflow NHIỀU STYLE: resolve font/màu/cỡ TỪNG khoá style
|
||||||
// (thang giữ-font như trên, tính theo text của style đó).
|
// (run nguồn + override đậm/nghiêng của đoạn — bôi đen 1
|
||||||
let rich_resolved: Option<(Vec<Vec<RichSeg>>, HashMap<u16, RichStyle>)> =
|
// chữ bấm B/I tạo khoá riêng với font BIẾN THỂ thật).
|
||||||
|
let rich_resolved: Option<(Vec<Vec<RichSeg>>, HashMap<RichKey, RichStyle>)> =
|
||||||
if let Some(rich_lines) = rich {
|
if let Some(rich_lines) = rich {
|
||||||
let mut style_ids: Vec<u16> =
|
let mut style_ids: Vec<RichKey> =
|
||||||
rich_lines.iter().flatten().map(|s| s.style).collect();
|
rich_lines.iter().flatten().map(RichKey::of).collect();
|
||||||
style_ids.sort_unstable();
|
style_ids.sort_unstable_by_key(|k| (k.style, k.bold, k.italic));
|
||||||
style_ids.dedup();
|
style_ids.dedup();
|
||||||
let mut table: HashMap<u16, RichStyle> = HashMap::new();
|
let mut table: HashMap<RichKey, RichStyle> = HashMap::new();
|
||||||
for sid in style_ids {
|
for rk in style_ids {
|
||||||
|
let sid = rk.style;
|
||||||
let style_text: String = rich_lines
|
let style_text: String = rich_lines
|
||||||
.iter()
|
.iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter(|s| s.style == sid)
|
.filter(|s| RichKey::of(s) == rk)
|
||||||
.map(|s| s.text.as_str())
|
.map(|s| s.text.as_str())
|
||||||
.collect();
|
.collect();
|
||||||
let src = if valid(sid) { sid } else { anchor_idx };
|
let src = if valid(sid) { sid } else { anchor_idx };
|
||||||
@@ -1245,6 +1308,52 @@ pub fn apply_edits(
|
|||||||
let s_ascii = style_text
|
let s_ascii = style_text
|
||||||
.chars()
|
.chars()
|
||||||
.all(|c| c.is_control() || (' '..='~').contains(&c));
|
.all(|c| c.is_control() || (' '..='~').contains(&c));
|
||||||
|
let skey = fontmatch::normalize_key(&sfam);
|
||||||
|
// Override đổi biến thể: font gốc/nhúng là MẶT
|
||||||
|
// CŨ — phải lấy biến thể thật (cùng họ →
|
||||||
|
// builtin → substitute), không dùng lại.
|
||||||
|
let eff_bold = rk.bold.unwrap_or(sbold);
|
||||||
|
let eff_italic = rk.italic.unwrap_or(sitalic);
|
||||||
|
if (eff_bold, eff_italic) != (sbold, sitalic) {
|
||||||
|
let (sdraw, smeasure) = if let Some(bytes) =
|
||||||
|
fontmatch::find_family_font_bytes(&sfam, eff_bold, eff_italic)
|
||||||
|
.filter(|b| fontmatch::coverage_ok(b, &style_text))
|
||||||
|
{
|
||||||
|
let key = (format!("var:{skey}"), eff_bold, eff_italic);
|
||||||
|
font_needed
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert(FontLoad::Bytes(bytes.clone()));
|
||||||
|
(ReflowFont::Loaded(key), Some(bytes))
|
||||||
|
} else if let (Some(builtin), true) =
|
||||||
|
(builtin_for(&skey, eff_bold, eff_italic), s_ascii)
|
||||||
|
{
|
||||||
|
let measure = find_font_bytes(eff_bold, eff_italic);
|
||||||
|
let key = (format!("b14:{skey}"), eff_bold, eff_italic);
|
||||||
|
font_needed
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert(FontLoad::Builtin(builtin));
|
||||||
|
(ReflowFont::Loaded(key), measure)
|
||||||
|
} else {
|
||||||
|
let (key, bytes) = resolve_substitute_font(
|
||||||
|
&sfam, eff_bold, eff_italic, &style_text,
|
||||||
|
)?;
|
||||||
|
font_needed
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert(FontLoad::Bytes(bytes.clone()));
|
||||||
|
(ReflowFont::Loaded(key), Some(bytes))
|
||||||
|
};
|
||||||
|
table.insert(
|
||||||
|
rk,
|
||||||
|
RichStyle {
|
||||||
|
draw: sdraw,
|
||||||
|
measure: smeasure,
|
||||||
|
color: scolor,
|
||||||
|
tf: stf,
|
||||||
|
scaled: sscaled,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let s_orig_ok = !block_has_nested
|
let s_orig_ok = !block_has_nested
|
||||||
&& !snested
|
&& !snested
|
||||||
&& semb
|
&& semb
|
||||||
@@ -1254,7 +1363,6 @@ pub fn apply_edits(
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
let skey = fontmatch::normalize_key(&sfam);
|
|
||||||
let (sdraw, smeasure) = if s_orig_ok {
|
let (sdraw, smeasure) = if s_orig_ok {
|
||||||
(
|
(
|
||||||
ReflowFont::Original(stoken),
|
ReflowFont::Original(stoken),
|
||||||
@@ -1287,7 +1395,7 @@ pub fn apply_edits(
|
|||||||
(ReflowFont::Loaded(key), Some(bytes))
|
(ReflowFont::Loaded(key), Some(bytes))
|
||||||
};
|
};
|
||||||
table.insert(
|
table.insert(
|
||||||
sid,
|
rk,
|
||||||
RichStyle {
|
RichStyle {
|
||||||
draw: sdraw,
|
draw: sdraw,
|
||||||
measure: smeasure,
|
measure: smeasure,
|
||||||
@@ -1319,6 +1427,7 @@ pub fn apply_edits(
|
|||||||
line_styles,
|
line_styles,
|
||||||
color,
|
color,
|
||||||
rich: rich_resolved,
|
rich: rich_resolved,
|
||||||
|
ocr_cover,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1694,6 +1803,25 @@ pub fn apply_edits(
|
|||||||
};
|
};
|
||||||
let grow_limit = (plan.width * 1.35).min(avail.max(plan.width * 1.02));
|
let grow_limit = (plan.width * 1.35).min(avail.max(plan.width * 1.02));
|
||||||
|
|
||||||
|
// Khối OCR: che vùng chữ scan cũ bằng rect TRẮNG (nới
|
||||||
|
// 1.5pt) trước khi vẽ; text mới vẽ HIỆN màu đen.
|
||||||
|
if let Some(cv) = &plan.ocr_cover {
|
||||||
|
page.objects_mut()
|
||||||
|
.create_path_object_rect(
|
||||||
|
PdfRect::new_from_values(
|
||||||
|
cv.bottom - 1.5,
|
||||||
|
cv.left - 1.5,
|
||||||
|
cv.top + 1.5,
|
||||||
|
cv.right + 1.5,
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(PdfColor::new(255, 255, 255, 255)),
|
||||||
|
)
|
||||||
|
.map_err(err)?;
|
||||||
|
}
|
||||||
|
let ocr_black = plan.ocr_cover.is_some();
|
||||||
|
|
||||||
// ---- Vẽ NHIỀU STYLE (dòng lẫn thường + đậm/mono…) ----
|
// ---- Vẽ NHIỀU STYLE (dòng lẫn thường + đậm/mono…) ----
|
||||||
// Token hoá theo TỪ giữ style; bẻ dòng chung cả khối; mỗi
|
// Token hoá theo TỪ giữ style; bẻ dòng chung cả khối; mỗi
|
||||||
// cụm từ liền kề cùng style = 1 text object với đúng
|
// cụm từ liền kề cùng style = 1 text object với đúng
|
||||||
@@ -1701,23 +1829,23 @@ pub fn apply_edits(
|
|||||||
if let Some((rich_lines, styles)) = &plan.rich {
|
if let Some((rich_lines, styles)) = &plan.rich {
|
||||||
struct Piece {
|
struct Piece {
|
||||||
text: String,
|
text: String,
|
||||||
style: u16,
|
style: RichKey,
|
||||||
lead: f32,
|
lead: f32,
|
||||||
}
|
}
|
||||||
let faces: HashMap<u16, Option<ttf_parser::Face>> = styles
|
let faces: HashMap<RichKey, Option<ttf_parser::Face>> = styles
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(k, s)| {
|
.map(|(k, s)| {
|
||||||
(*k, s.measure.as_deref().and_then(|b| ttf_parser::Face::parse(b, 0).ok()))
|
(*k, s.measure.as_deref().and_then(|b| ttf_parser::Face::parse(b, 0).ok()))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let measure_c = |sid: u16, c: char| -> f32 {
|
let measure_c = |sid: RichKey, c: char| -> f32 {
|
||||||
let sc = styles.get(&sid).map(|s| s.scaled).unwrap_or(plan.scaled);
|
let sc = styles.get(&sid).map(|s| s.scaled).unwrap_or(plan.scaled);
|
||||||
match faces.get(&sid).and_then(|f| f.as_ref()) {
|
match faces.get(&sid).and_then(|f| f.as_ref()) {
|
||||||
Some(f) => fontmatch::char_advance(f, c, sc),
|
Some(f) => fontmatch::char_advance(f, c, sc),
|
||||||
None => sc * 0.5,
|
None => sc * 0.5,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let measure_s = |sid: u16, s: &str| -> f32 {
|
let measure_s = |sid: RichKey, s: &str| -> f32 {
|
||||||
s.chars().map(|c| measure_c(sid, c)).sum()
|
s.chars().map(|c| measure_c(sid, c)).sum()
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1727,7 +1855,7 @@ pub fn apply_edits(
|
|||||||
// Token hoá: tách từ, giữ "glue" khi 2 đoạn dính liền giữa từ.
|
// Token hoá: tách từ, giữ "glue" khi 2 đoạn dính liền giữa từ.
|
||||||
struct Tok {
|
struct Tok {
|
||||||
text: String,
|
text: String,
|
||||||
style: u16,
|
style: RichKey,
|
||||||
glue: bool,
|
glue: bool,
|
||||||
}
|
}
|
||||||
let mut toks: Vec<Tok> = Vec::new();
|
let mut toks: Vec<Tok> = Vec::new();
|
||||||
@@ -1737,7 +1865,7 @@ pub fn apply_edits(
|
|||||||
let mut first = true;
|
let mut first = true;
|
||||||
for w in seg.text.split_whitespace() {
|
for w in seg.text.split_whitespace() {
|
||||||
let glue = first && !starts_ws && !prev_ends_ws && !toks.is_empty();
|
let glue = first && !starts_ws && !prev_ends_ws && !toks.is_empty();
|
||||||
toks.push(Tok { text: w.to_string(), style: seg.style, glue });
|
toks.push(Tok { text: w.to_string(), style: RichKey::of(seg), glue });
|
||||||
first = false;
|
first = false;
|
||||||
}
|
}
|
||||||
prev_ends_ws = seg.text.trim().is_empty()
|
prev_ends_ws = seg.text.trim().is_empty()
|
||||||
@@ -1835,7 +1963,7 @@ pub fn apply_edits(
|
|||||||
if ok {
|
if ok {
|
||||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
|
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
obj.set_fill_color(st.color).map_err(err)?;
|
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { st.color }).map_err(err)?;
|
||||||
ir = obj.bounds().ok().map(|q| q.right().value);
|
ir = obj.bounds().ok().map(|q| q.right().value);
|
||||||
}
|
}
|
||||||
(ok, ir)
|
(ok, ir)
|
||||||
@@ -1862,7 +1990,7 @@ pub fn apply_edits(
|
|||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
|
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
obj.set_fill_color(st.color).map_err(err)?;
|
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { st.color }).map_err(err)?;
|
||||||
let wir =
|
let wir =
|
||||||
obj.bounds().ok().map(|q| q.right().value);
|
obj.bounds().ok().map(|q| q.right().value);
|
||||||
cx = (cx + measure_s(p.style, word))
|
cx = (cx + measure_s(p.style, word))
|
||||||
@@ -1940,7 +2068,7 @@ pub fn apply_edits(
|
|||||||
if ok {
|
if ok {
|
||||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
|
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, x, y))
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
obj.set_fill_color(plan.color).map_err(err)?;
|
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { plan.color }).map_err(err)?;
|
||||||
}
|
}
|
||||||
ok
|
ok
|
||||||
};
|
};
|
||||||
@@ -1966,7 +2094,7 @@ pub fn apply_edits(
|
|||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
|
obj.apply_matrix(PdfMatrix::new(a, b, c2, d, cx, y))
|
||||||
.map_err(err)?;
|
.map_err(err)?;
|
||||||
obj.set_fill_color(plan.color).map_err(err)?;
|
obj.set_fill_color(if ocr_black { PdfColor::new(0, 0, 0, 255) } else { plan.color }).map_err(err)?;
|
||||||
cx += word.chars().map(|c| measure_at(c, style.scaled)).sum::<f32>()
|
cx += word.chars().map(|c| measure_at(c, style.scaled)).sum::<f32>()
|
||||||
+ space_w;
|
+ space_w;
|
||||||
}
|
}
|
||||||
|
|||||||
+216
-19
@@ -17,17 +17,18 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::tounicode::FontMapping;
|
||||||
|
|
||||||
/// Bytes font sẵn sàng cho FontFace: vá nếu cần, còn lại giữ nguyên.
|
/// Bytes font sẵn sàng cho FontFace: vá nếu cần, còn lại giữ nguyên.
|
||||||
/// `code_to_uni`: map mã→Unicode từ /ToUnicode của PDF (cho các mã tuỳ biến
|
/// `mapping`: giải mã từ PDF (/ToUnicode + CIDToGIDMap) — cho mã tuỳ biến của
|
||||||
/// ngoài ASCII — chữ Việt trong subset LibreOffice); None → chỉ ASCII
|
/// subset LibreOffice lẫn font CID/Identity-H (Word/InDesign) KHÔNG có cmap.
|
||||||
/// identity + MacRoman.
|
|
||||||
pub(crate) fn web_compatible_font(
|
pub(crate) fn web_compatible_font(
|
||||||
data: &[u8],
|
data: &[u8],
|
||||||
bold: bool,
|
bold: bool,
|
||||||
italic: bool,
|
italic: bool,
|
||||||
code_to_uni: Option<&HashMap<u32, u32>>,
|
mapping: Option<&FontMapping>,
|
||||||
) -> Vec<u8> {
|
) -> Vec<u8> {
|
||||||
try_fix(data, bold, italic, code_to_uni).unwrap_or_else(|| data.to_vec())
|
try_fix(data, bold, italic, mapping).unwrap_or_else(|| data.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_TRUE: u32 = u32::from_be_bytes(*b"true");
|
const TAG_TRUE: u32 = u32::from_be_bytes(*b"true");
|
||||||
@@ -37,21 +38,23 @@ fn try_fix(
|
|||||||
data: &[u8],
|
data: &[u8],
|
||||||
bold: bool,
|
bold: bool,
|
||||||
italic: bool,
|
italic: bool,
|
||||||
code_to_uni: Option<&HashMap<u32, u32>>,
|
mapping: Option<&FontMapping>,
|
||||||
) -> Option<Vec<u8>> {
|
) -> Option<Vec<u8>> {
|
||||||
if data.len() < 12 {
|
if data.len() < 12 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let ver = u32::from_be_bytes(data[0..4].try_into().ok()?);
|
let ver = u32::from_be_bytes(data[0..4].try_into().ok()?);
|
||||||
if ver != 0x0001_0000 && ver != TAG_TRUE && ver != TAG_OTTO {
|
if ver != 0x0001_0000 && ver != TAG_TRUE && ver != TAG_OTTO {
|
||||||
return None; // không phải sfnt (CFF trần...) — không đụng vào
|
return None; // không phải sfnt — CFF trần đi đường wrap_bare_cff
|
||||||
}
|
}
|
||||||
let num = u16::from_be_bytes(data[4..6].try_into().ok()?) as usize;
|
let num = u16::from_be_bytes(data[4..6].try_into().ok()?) as usize;
|
||||||
if num == 0 || data.len() < 12 + num * 16 {
|
if num == 0 || data.len() < 12 + num * 16 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut tables: Vec<([u8; 4], Vec<u8>)> = Vec::with_capacity(num + 1);
|
let mut tables: Vec<([u8; 4], Vec<u8>)> = Vec::with_capacity(num + 4);
|
||||||
let mut has_os2 = false;
|
let mut has_os2 = false;
|
||||||
|
let mut has_name = false;
|
||||||
|
let mut has_post = false;
|
||||||
for i in 0..num {
|
for i in 0..num {
|
||||||
let o = 12 + i * 16;
|
let o = 12 + i * 16;
|
||||||
let tag: [u8; 4] = data[o..o + 4].try_into().ok()?;
|
let tag: [u8; 4] = data[o..o + 4].try_into().ok()?;
|
||||||
@@ -60,30 +63,164 @@ fn try_fix(
|
|||||||
if off.checked_add(len)? > data.len() {
|
if off.checked_add(len)? > data.len() {
|
||||||
return None; // danh mục hỏng — trả nguyên trạng cho lành
|
return None; // danh mục hỏng — trả nguyên trạng cho lành
|
||||||
}
|
}
|
||||||
if &tag == b"OS/2" {
|
match &tag {
|
||||||
has_os2 = true;
|
b"OS/2" => has_os2 = true,
|
||||||
|
b"name" => has_name = true,
|
||||||
|
b"post" => has_post = true,
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
tables.push((tag, data[off..off + len].to_vec()));
|
tables.push((tag, data[off..off + len].to_vec()));
|
||||||
}
|
}
|
||||||
// cmap (1,0) format 0/6 — OTS không hỗ trợ → chuyển sang (3,1) format 4.
|
let num_glyphs = read_num_glyphs(&tables);
|
||||||
|
// cmap: (1,0) fmt 0/6 → chuyển sang (3,1) fmt 4; KHÔNG có cmap (font
|
||||||
|
// CID/Identity-H của Word) → dựng mới từ /ToUnicode × CIDToGIDMap.
|
||||||
let mut cmap_changed = false;
|
let mut cmap_changed = false;
|
||||||
if let Some(ci) = tables.iter().position(|(t, _)| t == b"cmap") {
|
match tables.iter().position(|(t, _)| t == b"cmap") {
|
||||||
if let Some(converted) = convert_cmap(&tables[ci].1, code_to_uni) {
|
Some(ci) => {
|
||||||
tables[ci].1 = converted;
|
if let Some(converted) =
|
||||||
cmap_changed = true;
|
convert_cmap(&tables[ci].1, mapping.map(|m| &m.code_to_uni))
|
||||||
|
{
|
||||||
|
tables[ci].1 = converted;
|
||||||
|
cmap_changed = true;
|
||||||
|
} else if !cmap_has_unicode_subtable(&tables[ci].1) {
|
||||||
|
if let Some(cm) = mapping.and_then(|m| build_cid_cmap(m, num_glyphs)) {
|
||||||
|
tables[ci].1 = cm;
|
||||||
|
cmap_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Some(cm) = mapping.and_then(|m| build_cid_cmap(m, num_glyphs)) {
|
||||||
|
tables.push((*b"cmap", cm));
|
||||||
|
cmap_changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if has_os2 && ver == 0x0001_0000 && !cmap_changed {
|
if has_os2 && has_name && has_post && ver == 0x0001_0000 && !cmap_changed {
|
||||||
return None; // đã chuẩn sẵn — giữ nguyên bytes gốc
|
return None; // đã chuẩn sẵn — giữ nguyên bytes gốc
|
||||||
}
|
}
|
||||||
if !has_os2 {
|
if !has_os2 {
|
||||||
let os2 = build_os2(&tables, bold, italic)?;
|
let os2 = build_os2(&tables, bold, italic)?;
|
||||||
tables.push((*b"OS/2", os2));
|
tables.push((*b"OS/2", os2));
|
||||||
}
|
}
|
||||||
|
// OTS cũng BẮT BUỘC name + post — subset CID hay bỏ cả hai.
|
||||||
|
if !has_name {
|
||||||
|
tables.push((*b"name", build_name()));
|
||||||
|
}
|
||||||
|
if !has_post {
|
||||||
|
tables.push((*b"post", build_post()));
|
||||||
|
}
|
||||||
tables.sort_by(|a, b| a.0.cmp(&b.0));
|
tables.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
Some(assemble(ver, tables))
|
Some(assemble(ver, tables))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// numGlyphs từ maxp (chặn GID rác khi dựng cmap); 0 = không rõ, không chặn.
|
||||||
|
fn read_num_glyphs(tables: &[([u8; 4], Vec<u8>)]) -> u16 {
|
||||||
|
tables
|
||||||
|
.iter()
|
||||||
|
.find(|(t, _)| t == b"maxp")
|
||||||
|
.and_then(|(_, b)| b.get(4..6))
|
||||||
|
.and_then(|s| s.try_into().ok())
|
||||||
|
.map(u16::from_be_bytes)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cmap đã có subtable Unicode OTS chấp nhận chưa ((0,*), (3,1), (3,10))?
|
||||||
|
fn cmap_has_unicode_subtable(cmap: &[u8]) -> bool {
|
||||||
|
let Some(n) = cmap.get(2..4).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for i in 0..n as usize {
|
||||||
|
let o = 4 + i * 8;
|
||||||
|
let Some(plat) = cmap.get(o..o + 2).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(enc) =
|
||||||
|
cmap.get(o + 2..o + 4).and_then(|s| s.try_into().ok()).map(u16::from_be_bytes)
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if plat == 0 || (plat == 3 && (enc == 1 || enc == 10)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// cmap (3,1) format 4 cho font CID/Identity-H: Unicode ← /ToUnicode(mã),
|
||||||
|
/// GID ← CIDToGIDMap(mã) (Identity = chính mã). Bỏ mapping ngoài BMP/GID rác.
|
||||||
|
fn build_cid_cmap(m: &FontMapping, num_glyphs: u16) -> Option<Vec<u8>> {
|
||||||
|
if !m.is_cid || m.code_to_uni.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut pairs: Vec<(u32, u16)> = Vec::new();
|
||||||
|
for (&code, &uni) in &m.code_to_uni {
|
||||||
|
if uni == 0 || uni > 0xFFFF {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let gid = match &m.cid_to_gid {
|
||||||
|
Some(map) => *map.get(code as usize).unwrap_or(&0),
|
||||||
|
None => {
|
||||||
|
if code > 0xFFFF {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
code as u16
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if gid == 0 || (num_glyphs != 0 && gid >= num_glyphs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pairs.push((uni, gid));
|
||||||
|
}
|
||||||
|
if pairs.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pairs.sort_by_key(|p| p.0);
|
||||||
|
pairs.dedup_by_key(|p| p.0);
|
||||||
|
Some(build_cmap_format4(&pairs))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// name tối thiểu (format 0, family + subfamily, Windows Unicode en-US) — OTS
|
||||||
|
/// bắt buộc có bảng name; nội dung không ảnh hưởng hiển thị (CSS đặt alias).
|
||||||
|
fn build_name() -> Vec<u8> {
|
||||||
|
let entries: [(u16, &str); 2] = [(1, "FFX Embedded"), (2, "Regular")];
|
||||||
|
let mut strings: Vec<u8> = Vec::new();
|
||||||
|
let mut records: Vec<u8> = Vec::new();
|
||||||
|
for (id, s) in entries {
|
||||||
|
let start = strings.len() as u16;
|
||||||
|
for u in s.encode_utf16() {
|
||||||
|
strings.extend_from_slice(&u.to_be_bytes());
|
||||||
|
}
|
||||||
|
let len = strings.len() as u16 - start;
|
||||||
|
records.extend_from_slice(&3u16.to_be_bytes()); // platform Windows
|
||||||
|
records.extend_from_slice(&1u16.to_be_bytes()); // encoding Unicode BMP
|
||||||
|
records.extend_from_slice(&0x0409u16.to_be_bytes()); // en-US
|
||||||
|
records.extend_from_slice(&id.to_be_bytes());
|
||||||
|
records.extend_from_slice(&len.to_be_bytes());
|
||||||
|
records.extend_from_slice(&start.to_be_bytes());
|
||||||
|
}
|
||||||
|
let mut out = Vec::with_capacity(6 + records.len() + strings.len());
|
||||||
|
out.extend_from_slice(&0u16.to_be_bytes()); // format 0
|
||||||
|
out.extend_from_slice(&(entries.len() as u16).to_be_bytes());
|
||||||
|
out.extend_from_slice(&((6 + records.len()) as u16).to_be_bytes()); // stringOffset
|
||||||
|
out.extend_from_slice(&records);
|
||||||
|
out.extend_from_slice(&strings);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// post version 3.0 (32 byte, không tên glyph) — đủ cho OTS.
|
||||||
|
fn build_post() -> Vec<u8> {
|
||||||
|
let mut v = Vec::with_capacity(32);
|
||||||
|
v.extend_from_slice(&0x0003_0000u32.to_be_bytes()); // version 3.0
|
||||||
|
v.extend_from_slice(&0i32.to_be_bytes()); // italicAngle (Fixed)
|
||||||
|
v.extend_from_slice(&(-100i16).to_be_bytes()); // underlinePosition
|
||||||
|
v.extend_from_slice(&50i16.to_be_bytes()); // underlineThickness
|
||||||
|
v.extend_from_slice(&0u32.to_be_bytes()); // isFixedPitch
|
||||||
|
v.extend_from_slice(&[0u8; 16]); // min/max mem Type42/Type1
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
/// MacRoman 0x80–0xFF → Unicode (bảng chuẩn Apple) — fallback cho mã cao khi
|
/// MacRoman 0x80–0xFF → Unicode (bảng chuẩn Apple) — fallback cho mã cao khi
|
||||||
/// không có /ToUnicode.
|
/// không có /ToUnicode.
|
||||||
const MACROMAN_HI: [u16; 128] = [
|
const MACROMAN_HI: [u16; 128] = [
|
||||||
@@ -412,11 +549,15 @@ mod tests {
|
|||||||
let subset = fake_subset();
|
let subset = fake_subset();
|
||||||
let fixed = web_compatible_font(&subset, true, false, None);
|
let fixed = web_compatible_font(&subset, true, false, None);
|
||||||
assert_ne!(fixed, subset, "phải được vá");
|
assert_ne!(fixed, subset, "phải được vá");
|
||||||
// Tag chuẩn hoá + có OS/2 + danh mục sắp theo tag.
|
// Tag chuẩn hoá + có OS/2 + name/post tổng hợp + danh mục sắp theo tag.
|
||||||
assert_eq!(&fixed[0..4], &0x0001_0000u32.to_be_bytes());
|
assert_eq!(&fixed[0..4], &0x0001_0000u32.to_be_bytes());
|
||||||
let dir = table_dir(&fixed);
|
let dir = table_dir(&fixed);
|
||||||
let tags: Vec<&str> = dir.iter().map(|(t, _, _)| t.as_str()).collect();
|
let tags: Vec<&str> = dir.iter().map(|(t, _, _)| t.as_str()).collect();
|
||||||
assert_eq!(tags, vec!["OS/2", "glyf", "head", "hhea"], "sắp theo tag: {tags:?}");
|
assert_eq!(
|
||||||
|
tags,
|
||||||
|
vec!["OS/2", "glyf", "head", "hhea", "name", "post"],
|
||||||
|
"sắp theo tag: {tags:?}"
|
||||||
|
);
|
||||||
// OS/2 đọc lại đúng nội dung suy ra.
|
// OS/2 đọc lại đúng nội dung suy ra.
|
||||||
let (_, off, len) = dir[0].clone();
|
let (_, off, len) = dir[0].clone();
|
||||||
let os2 = &fixed[off..off + len];
|
let os2 = &fixed[off..off + len];
|
||||||
@@ -482,12 +623,17 @@ mod tests {
|
|||||||
0
|
0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mapping(uni: HashMap<u32, u32>, is_cid: bool, cid_to_gid: Option<Vec<u16>>) -> FontMapping {
|
||||||
|
FontMapping { code_to_uni: uni, is_cid, cid_to_gid }
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unsupported_cmap_is_converted_to_windows_format4() {
|
fn unsupported_cmap_is_converted_to_windows_format4() {
|
||||||
let subset = fake_subset_with_cmap();
|
let subset = fake_subset_with_cmap();
|
||||||
let mut uni = HashMap::new();
|
let mut uni = HashMap::new();
|
||||||
uni.insert(1u32, 0x1EA3u32); // mã 1 = 'ả'
|
uni.insert(1u32, 0x1EA3u32); // mã 1 = 'ả'
|
||||||
let fixed = web_compatible_font(&subset, false, false, Some(&uni));
|
let m = mapping(uni, false, None);
|
||||||
|
let fixed = web_compatible_font(&subset, false, false, Some(&m));
|
||||||
assert_ne!(fixed, subset);
|
assert_ne!(fixed, subset);
|
||||||
// Tìm bảng cmap mới.
|
// Tìm bảng cmap mới.
|
||||||
let num = u16::from_be_bytes(fixed[4..6].try_into().unwrap()) as usize;
|
let num = u16::from_be_bytes(fixed[4..6].try_into().unwrap()) as usize;
|
||||||
@@ -511,4 +657,55 @@ mod tests {
|
|||||||
.collect();
|
.collect();
|
||||||
assert!(tags.contains(&"OS/2".to_string()), "tags: {tags:?}");
|
assert!(tags.contains(&"OS/2".to_string()), "tags: {tags:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Font CID/Identity-H kiểu Word: KHÔNG có cmap, mã = CID, GID = CID
|
||||||
|
/// (Identity) hoặc qua bảng CIDToGIDMap → dựng cmap (3,1) fmt 4 mới.
|
||||||
|
fn fake_cid_subset() -> Vec<u8> {
|
||||||
|
let mut head = vec![0u8; 54];
|
||||||
|
head[18..20].copy_from_slice(&2048u16.to_be_bytes());
|
||||||
|
let mut hhea = vec![0u8; 36];
|
||||||
|
hhea[4..6].copy_from_slice(&1638i16.to_be_bytes());
|
||||||
|
hhea[6..8].copy_from_slice(&(-410i16).to_be_bytes());
|
||||||
|
let mut maxp = vec![0u8; 32];
|
||||||
|
maxp[4..6].copy_from_slice(&50u16.to_be_bytes()); // numGlyphs = 50
|
||||||
|
let tables: Vec<([u8; 4], Vec<u8>)> =
|
||||||
|
vec![(*b"glyf", vec![0u8; 8]), (*b"head", head), (*b"hhea", hhea), (*b"maxp", maxp)];
|
||||||
|
assemble(0x0001_0000, tables)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cid_font_without_cmap_gets_synthesized_cmap() {
|
||||||
|
let subset = fake_cid_subset();
|
||||||
|
let mut uni = HashMap::new();
|
||||||
|
uni.insert(5u32, 0x1EA3u32); // CID 5 = 'ả'
|
||||||
|
uni.insert(6u32, 0x0041u32); // CID 6 = 'A'
|
||||||
|
uni.insert(99u32, 0x0042u32); // CID 99 ≥ numGlyphs → phải bị bỏ
|
||||||
|
// Identity: GID = CID.
|
||||||
|
let m = mapping(uni.clone(), true, None);
|
||||||
|
let fixed = web_compatible_font(&subset, false, false, Some(&m));
|
||||||
|
assert_ne!(fixed, subset, "phải được vá");
|
||||||
|
let dir = table_dir(&fixed);
|
||||||
|
let tags: Vec<&str> = dir.iter().map(|(t, _, _)| t.as_str()).collect();
|
||||||
|
for need in ["cmap", "OS/2", "name", "post"] {
|
||||||
|
assert!(tags.contains(&need), "thiếu {need}: {tags:?}");
|
||||||
|
}
|
||||||
|
let (_, off, len) = dir[tags.iter().position(|t| *t == "cmap").unwrap()].clone();
|
||||||
|
let cm = fixed[off..off + len].to_vec();
|
||||||
|
assert_eq!(lookup_f4(&cm, 0x1EA3), 5, "'ả' → GID 5 (Identity)");
|
||||||
|
assert_eq!(lookup_f4(&cm, 0x41), 6, "'A' → GID 6");
|
||||||
|
assert_eq!(lookup_f4(&cm, 0x42), 0, "GID ngoài numGlyphs bị bỏ");
|
||||||
|
|
||||||
|
// Có bảng CIDToGIDMap: CID 5 → GID 7.
|
||||||
|
let mut c2g = vec![0u16; 10];
|
||||||
|
c2g[5] = 7;
|
||||||
|
c2g[6] = 8;
|
||||||
|
let m2 = mapping(uni, true, Some(c2g));
|
||||||
|
let fixed2 = web_compatible_font(&subset, false, false, Some(&m2));
|
||||||
|
let dir2 = table_dir(&fixed2);
|
||||||
|
let tags2: Vec<&str> = dir2.iter().map(|(t, _, _)| t.as_str()).collect();
|
||||||
|
let (_, o2, l2) = dir2[tags2.iter().position(|t| *t == "cmap").unwrap()].clone();
|
||||||
|
let cm2 = fixed2[o2..o2 + l2].to_vec();
|
||||||
|
assert_eq!(lookup_f4(&cm2, 0x1EA3), 7, "'ả' → GID 7 theo CIDToGIDMap");
|
||||||
|
assert_eq!(lookup_f4(&cm2, 0x41), 8);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,26 @@ use lopdf::{Dictionary, Document as LoDoc, Object};
|
|||||||
|
|
||||||
use crate::formsurgery::{deref, stream_bytes};
|
use crate::formsurgery::{deref, stream_bytes};
|
||||||
|
|
||||||
/// Map mã→Unicode của font có BaseFont khớp `font_name` (so phần sau prefix
|
/// Thông tin giải mã của 1 font trong PDF — đủ để fontfix dựng cmap Unicode.
|
||||||
/// subset "ABCDEF+") trên trang `page_index`; tìm cả trong resources của Form
|
pub(crate) struct FontMapping {
|
||||||
|
/// Mã trong content stream → Unicode (từ /ToUnicode).
|
||||||
|
pub code_to_uni: HashMap<u32, u32>,
|
||||||
|
/// Font CID (Type0/Identity-H): mã = CID. `cid_to_gid`: None = Identity
|
||||||
|
/// (GID = CID — trường hợp phổ biến của Word/InDesign), Some = bảng tra
|
||||||
|
/// từ stream /CIDToGIDMap (2 byte big-endian mỗi CID).
|
||||||
|
pub is_cid: bool,
|
||||||
|
pub cid_to_gid: Option<Vec<u16>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mapping của font có BaseFont khớp `font_name` (so phần sau prefix subset
|
||||||
|
/// "ABCDEF+") trên trang `page_index`; tìm cả trong resources của Form
|
||||||
/// XObject (sâu tối đa 3). Không thấy/không parse được → None (fontfix rơi về
|
/// XObject (sâu tối đa 3). Không thấy/không parse được → None (fontfix rơi về
|
||||||
/// ASCII identity + MacRoman).
|
/// ASCII identity + MacRoman).
|
||||||
pub(crate) fn code_to_unicode(
|
pub(crate) fn font_mapping(
|
||||||
input: &Path,
|
input: &Path,
|
||||||
page_index: u16,
|
page_index: u16,
|
||||||
font_name: &str,
|
font_name: &str,
|
||||||
) -> Option<HashMap<u32, u32>> {
|
) -> Option<FontMapping> {
|
||||||
let want = font_name.rsplit('+').next().unwrap_or(font_name).trim();
|
let want = font_name.rsplit('+').next().unwrap_or(font_name).trim();
|
||||||
if want.is_empty() {
|
if want.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -36,7 +47,7 @@ fn scan_resources(
|
|||||||
res: &Dictionary,
|
res: &Dictionary,
|
||||||
want: &str,
|
want: &str,
|
||||||
depth: usize,
|
depth: usize,
|
||||||
) -> Option<HashMap<u32, u32>> {
|
) -> Option<FontMapping> {
|
||||||
if depth > 3 {
|
if depth > 3 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -54,14 +65,51 @@ fn scan_resources(
|
|||||||
if suffix != want {
|
if suffix != want {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Ok(tu) = fd.get(b"ToUnicode") else { continue };
|
let mut code_to_uni = HashMap::new();
|
||||||
if let Object::Stream(s) = deref(doc, tu) {
|
if let Ok(tu) = fd.get(b"ToUnicode") {
|
||||||
if let Some(m) = parse_cmap(&stream_bytes(s)) {
|
if let Object::Stream(s) = deref(doc, tu) {
|
||||||
if !m.is_empty() {
|
if let Some(m) = parse_cmap(&stream_bytes(s)) {
|
||||||
return Some(m);
|
code_to_uni = m;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Type0 (Identity-H): mã = CID; GID tra qua /CIDToGIDMap của
|
||||||
|
// descendant font (Name "Identity" hoặc stream 2B/CID).
|
||||||
|
let subtype = fd
|
||||||
|
.get(b"Subtype")
|
||||||
|
.ok()
|
||||||
|
.and_then(|o| deref(doc, o).as_name().ok())
|
||||||
|
.map(|n| n.to_vec())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let is_cid = subtype == b"Type0";
|
||||||
|
let mut cid_to_gid = None;
|
||||||
|
if is_cid {
|
||||||
|
if let Ok(desc) = fd.get(b"DescendantFonts") {
|
||||||
|
let desc = deref(doc, desc);
|
||||||
|
let first = match desc {
|
||||||
|
Object::Array(a) => a.first().map(|o| deref(doc, o)),
|
||||||
|
other => Some(other),
|
||||||
|
};
|
||||||
|
if let Some(Ok(dd)) = first.map(|o| o.as_dict()) {
|
||||||
|
if let Ok(map) = dd.get(b"CIDToGIDMap") {
|
||||||
|
if let Object::Stream(s) = deref(doc, map) {
|
||||||
|
let raw = stream_bytes(s);
|
||||||
|
cid_to_gid = Some(
|
||||||
|
raw.chunks(2)
|
||||||
|
.map(|c| {
|
||||||
|
u16::from_be_bytes([c[0], *c.get(1).unwrap_or(&0)])
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if code_to_uni.is_empty() {
|
||||||
|
continue; // không có ToUnicode — thử font trùng tên khác
|
||||||
|
}
|
||||||
|
return Some(FontMapping { code_to_uni, is_cid, cid_to_gid });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1546,8 +1546,18 @@ fn rich_reflow_preserves_mixed_styles() {
|
|||||||
indices: vec![idx_a, idx_b],
|
indices: vec![idx_a, idx_b],
|
||||||
text: "Trang quan tri hien thi MOI windowsInitialPassword".into(),
|
text: "Trang quan tri hien thi MOI windowsInitialPassword".into(),
|
||||||
rich: Some(vec![vec![
|
rich: Some(vec![vec![
|
||||||
RichSeg { text: "Trang quan tri hien thi MOI ".into(), style: idx_a },
|
RichSeg {
|
||||||
RichSeg { text: "windowsInitialPassword".into(), style: idx_b },
|
text: "Trang quan tri hien thi MOI ".into(),
|
||||||
|
style: idx_a,
|
||||||
|
bold: None,
|
||||||
|
italic: None,
|
||||||
|
},
|
||||||
|
RichSeg {
|
||||||
|
text: "windowsInitialPassword".into(),
|
||||||
|
style: idx_b,
|
||||||
|
bold: None,
|
||||||
|
italic: None,
|
||||||
|
},
|
||||||
]]),
|
]]),
|
||||||
}],
|
}],
|
||||||
&out,
|
&out,
|
||||||
@@ -1593,3 +1603,135 @@ fn rich_reflow_preserves_mixed_styles() {
|
|||||||
plain.rect
|
plain.rect
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fixture kiểu file đã OCR: text render mode 3 (INVISIBLE — lớp chữ ẩn đè
|
||||||
|
/// trên ảnh scan để tìm kiếm/copy).
|
||||||
|
fn build_invisible_text_pdf(path: &std::path::Path) {
|
||||||
|
use lopdf::{dictionary, Document, Object, Stream};
|
||||||
|
let mut doc = Document::with_version("1.5");
|
||||||
|
let pages_id = doc.new_object_id();
|
||||||
|
let f1 = doc.add_object(dictionary! {
|
||||||
|
"Type" => "Font",
|
||||||
|
"Subtype" => "Type1",
|
||||||
|
"BaseFont" => "Helvetica",
|
||||||
|
});
|
||||||
|
let content_id = doc.add_object(Stream::new(
|
||||||
|
dictionary! {},
|
||||||
|
b"BT /F1 14 Tf 3 Tr 72 700 Td (Van ban OCR nhan dien tu anh) Tj ET".to_vec(),
|
||||||
|
));
|
||||||
|
let page_id = doc.add_object(dictionary! {
|
||||||
|
"Type" => "Page",
|
||||||
|
"Parent" => pages_id,
|
||||||
|
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||||
|
"Resources" => dictionary! { "Font" => dictionary! { "F1" => f1 } },
|
||||||
|
"Contents" => content_id,
|
||||||
|
});
|
||||||
|
doc.objects.insert(
|
||||||
|
pages_id,
|
||||||
|
Object::Dictionary(dictionary! {
|
||||||
|
"Type" => "Pages",
|
||||||
|
"Kids" => vec![page_id.into()],
|
||||||
|
"Count" => 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let catalog_id = doc.add_object(dictionary! { "Type" => "Catalog", "Pages" => pages_id });
|
||||||
|
doc.trailer.set("Root", catalog_id);
|
||||||
|
doc.save(path).expect("lưu fixture OCR");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sửa khối text ẨN (file scan đã OCR): engine phải CHE vùng chữ cũ bằng rect
|
||||||
|
/// trắng và vẽ text mới HIỆN màu đen — như "edit scanned document" của Foxit.
|
||||||
|
#[test]
|
||||||
|
fn ocr_invisible_block_becomes_visible_with_cover() {
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = tmp("ff_edit_ocr_fx.pdf");
|
||||||
|
build_invisible_text_pdf(&input);
|
||||||
|
let out = tmp("ff_edit_ocr_out.pdf");
|
||||||
|
let idx = find_text_index(&pdf, &input, "Van ban OCR");
|
||||||
|
let paths_before = ff_engine::list_objects(&pdf, &input, 0, None)
|
||||||
|
.expect("list in")
|
||||||
|
.iter()
|
||||||
|
.filter(|o| o.kind == ObjectKind::Path)
|
||||||
|
.count();
|
||||||
|
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&input,
|
||||||
|
0,
|
||||||
|
&[EditOp::ReflowText {
|
||||||
|
indices: vec![idx],
|
||||||
|
text: "Van ban OCR DA SUA lai noi dung".into(),
|
||||||
|
rich: None,
|
||||||
|
}],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("reflow khối OCR");
|
||||||
|
|
||||||
|
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||||
|
let paths_after = objs.iter().filter(|o| o.kind == ObjectKind::Path).count();
|
||||||
|
assert!(paths_after > paths_before, "phải có rect trắng che vùng chữ scan cũ");
|
||||||
|
let t = objs
|
||||||
|
.iter()
|
||||||
|
.find(|o| o.text.as_deref().map(|t| t.contains("DA SUA")).unwrap_or(false))
|
||||||
|
.unwrap_or_else(|| panic!("thiếu text mới: {objs:?}"));
|
||||||
|
assert_eq!(t.color, Some([0, 0, 0, 255]), "chữ mới phải màu đen (hiện)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format TỪNG CHỮ: bôi đen 1 từ bấm B → seg mang override bold=true; engine
|
||||||
|
/// phải vẽ từ đó bằng BIẾN THỂ đậm thật (không phải giả lập), các từ quanh
|
||||||
|
/// giữ nguyên; italic tương tự.
|
||||||
|
#[test]
|
||||||
|
fn rich_seg_bold_italic_override_uses_variant_font() {
|
||||||
|
use ff_engine::RichSeg;
|
||||||
|
let pdf = pdfium();
|
||||||
|
let input = tmp("ff_edit_rich_override.pdf");
|
||||||
|
build_mixed_style_pdf(&input);
|
||||||
|
let out = tmp("ff_edit_rich_override_out.pdf");
|
||||||
|
let idx_a = find_text_index(&pdf, &input, "Trang quan tri");
|
||||||
|
|
||||||
|
ff_engine::apply_edits(
|
||||||
|
&pdf,
|
||||||
|
&input,
|
||||||
|
0,
|
||||||
|
&[EditOp::ReflowText {
|
||||||
|
indices: vec![idx_a],
|
||||||
|
text: "Trang quan tri hien thi".into(),
|
||||||
|
rich: Some(vec![vec![
|
||||||
|
RichSeg { text: "Trang ".into(), style: idx_a, bold: None, italic: None },
|
||||||
|
RichSeg { text: "quan tri ".into(), style: idx_a, bold: Some(true), italic: None },
|
||||||
|
RichSeg { text: "hien ".into(), style: idx_a, bold: None, italic: Some(true) },
|
||||||
|
RichSeg { text: "thi".into(), style: idx_a, bold: None, italic: None },
|
||||||
|
]]),
|
||||||
|
}],
|
||||||
|
&out,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("apply_edits rich override");
|
||||||
|
|
||||||
|
let objs = ff_engine::list_objects(&pdf, &out, 0, None).expect("list out");
|
||||||
|
let find = |needle: &str| {
|
||||||
|
objs.iter()
|
||||||
|
.find(|o| o.text.as_deref().map(|t| t.contains(needle)).unwrap_or(false))
|
||||||
|
.unwrap_or_else(|| panic!("thiếu run '{needle}': {objs:?}"))
|
||||||
|
};
|
||||||
|
let plain = find("Trang");
|
||||||
|
let bolded = find("quan tri");
|
||||||
|
let italicized = find("hien");
|
||||||
|
assert_eq!(plain.font_bold, Some(false), "'Trang' giữ thường: {:?}", plain.font_name);
|
||||||
|
assert_eq!(
|
||||||
|
bolded.font_bold,
|
||||||
|
Some(true),
|
||||||
|
"'quan tri' phải thành ĐẬM thật: {:?}",
|
||||||
|
bolded.font_name
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
italicized.font_italic,
|
||||||
|
Some(true),
|
||||||
|
"'hien' phải thành NGHIÊNG thật: {:?}",
|
||||||
|
italicized.font_name
|
||||||
|
);
|
||||||
|
// Cùng baseline, thứ tự trái→phải giữ nguyên.
|
||||||
|
assert!((bolded.rect.bottom - plain.rect.bottom).abs() < 4.0);
|
||||||
|
assert!(bolded.rect.left > plain.rect.left && italicized.rect.left > bolded.rect.left);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user