From f10cb7df2bfd1587f593cc9ce5401e6766f60d4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20=C4=90=C3=ACnh=20Nam?= Date: Mon, 10 Aug 2026 12:12:26 +0700 Subject: [PATCH] DONG GOI bo font Noto theo app: khong thoa hiep font he thong o bat ky tang nao MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User yeu cau: khach hang khong co font chuan san — phan mem phai tu chuan bi font, khong duoc thay bang font "gan giong". Truoc do font nhung cua PDF da dung cho o sua (build 35), nhung con 3 khe ho roi ve font he thong: font khong nhung trong PDF, font CFF khong va duoc, va KY TU MOI GO VAO khong co trong subset (subset chi chua glyph da dung). - scripts/fetch-fonts.ps1: tai 10 file Noto hinted static (Sans/Serif/Mono, du Regular/Bold/Italic/BoldItalic, phu tieng Viet, ~6.7MB) + OFL license vao fonts/ (gitignore; CI cache theo hash script). - CI: fetch fonts + dong fonts/ vao zip portable (canh exe). - Tauri: main() dat FOFREEXIT_FONTS_PATH (fonts/ canh exe, dev thi goc workspace); command app_font_data(kind) tra base64 (whitelist 10 kind). - UI: FontFace "ff-app-{sans|serif|mono}" dang ky theo weight/style, dung GIUA stack: font nhung cua PDF -> Noto dong goi -> stack he thong (luoi cuoi). Phan loai serif/sans/mono tu cssFontStack. Nap truoc cung font nhung khi vao trang sua. - Engine find_font_bytes (duong ghi file/reflow can font coverage): uu tien tuyet doi fonts/ dong goi qua env — ket qua LUU ra giong nhau tren moi may (truoc: lay Arial/Segoe cua Windows, may khach thieu font la khac ket qua). E2E ke tiep (build 36): go ky tu ngoai subset trong o sua -> 100% glyph phai tu font custom (nhung/dong goi), 0 glyph he thong. Co-Authored-By: Claude Fable 5 --- .github/workflows/build-windows.yml | 14 +++++++ .gitignore | 3 ++ app/src-tauri/src/main.rs | 42 ++++++++++++++++++++ app/src/main.js | 59 ++++++++++++++++++++++++++--- crates/ff-engine/src/annot.rs | 15 ++++++++ scripts/fetch-fonts.ps1 | 47 +++++++++++++++++++++++ 6 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 scripts/fetch-fonts.ps1 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 7d0b92d..f14d94b 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -41,6 +41,18 @@ jobs: shell: pwsh run: powershell -ExecutionPolicy Bypass -File scripts/fetch-qpdf.ps1 + # Bộ font Noto đóng gói theo app (phủ tiếng Việt) — hiển thị/ghi file + # giống nhau trên mọi máy khách, không phụ thuộc font hệ điều hành. + - name: Cache bundled fonts + uses: actions/cache@v4 + with: + path: fonts + key: noto-fonts-${{ hashFiles('scripts/fetch-fonts.ps1') }} + + - name: Fetch bundled fonts (Noto) + shell: pwsh + run: powershell -ExecutionPolicy Bypass -File scripts/fetch-fonts.ps1 + # Test OCR cần tesseract thật (eng + vie). Chocolatey có sẵn trên runner. - name: Install Tesseract OCR (eng + vie) shell: pwsh @@ -75,6 +87,8 @@ jobs: Copy-Item "qpdf/bin/*.exe" "$dist/" Copy-Item "qpdf/bin/*.dll" "$dist/" Copy-Item "scripts/portable-readme.txt" "$dist/DOC-doc-truoc-khi-chay.txt" + New-Item -ItemType Directory -Force -Path "$dist/fonts" | Out-Null + Copy-Item "fonts/*" "$dist/fonts/" Compress-Archive -Path "$dist/*" -DestinationPath "FoFreeXit-portable-win64.zip" -Force - name: Upload artifact diff --git a/.gitignore b/.gitignore index 2c6102b..978ed2d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ Cargo.lock.bak # (corpus/ đã track sẵn nên không ảnh hưởng). /pdf-file-to-test/ /*.pdf + +# Bo font Noto dong goi (tai bang scripts/fetch-fonts.ps1) +/fonts/ diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs index 6d17561..870a8c3 100644 --- a/app/src-tauri/src/main.rs +++ b/app/src-tauri/src/main.rs @@ -703,6 +703,31 @@ fn edit_list_objects(path: String, page: u16, password: Option) -> Resul .collect()) } +/// 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 +/// nhúng đều rơi về font này, KHÔNG rơi về font hệ điều hành. +#[tauri::command] +fn app_font_data(kind: String) -> Result { + let name = match kind.as_str() { + "sans-regular" => "NotoSans-Regular.ttf", + "sans-bold" => "NotoSans-Bold.ttf", + "sans-italic" => "NotoSans-Italic.ttf", + "sans-bolditalic" => "NotoSans-BoldItalic.ttf", + "mono-regular" => "NotoSansMono-Regular.ttf", + "mono-bold" => "NotoSansMono-Bold.ttf", + "serif-regular" => "NotoSerif-Regular.ttf", + "serif-bold" => "NotoSerif-Bold.ttf", + "serif-italic" => "NotoSerif-Italic.ttf", + "serif-bolditalic" => "NotoSerif-BoldItalic.ttf", + _ => return Err(format!("kind font không hợp lệ: {kind}")), + }; + let dir = std::env::var("FOFREEXIT_FONTS_PATH") + .map_err(|_| "app không kèm bộ font (thiếu thư mục fonts/)".to_string())?; + let bytes = std::fs::read(std::path::Path::new(&dir).join(name)) + .map_err(|e| format!("đọc font {name}: {e}"))?; + Ok(base64::engine::general_purpose::STANDARD.encode(&bytes)) +} + /// Bytes font NHÚNG của 1 text run (base64) — UI đăng ký FontFace để ô sửa /// hiển thị đúng font tài liệu thay vì font hệ thống gần giống. #[tauri::command] @@ -1408,6 +1433,22 @@ fn pick_image(app: tauri::AppHandle) -> Option { } fn main() { + // Bộ font ĐÓNG GÓI theo app (Noto): fonts/ cạnh exe (bản portable) hoặc ở + // gốc workspace (dev). Engine (find_font_bytes) + command app_font_data + // đọc qua env — hiển thị/ghi file giống nhau trên mọi máy khách. + let exe_fonts = std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("fonts"))); + let fonts_dir = match exe_fonts { + Some(d) if d.join("NotoSans-Regular.ttf").exists() => Some(d), + _ => { + let w = workspace_root().join("fonts"); + if w.join("NotoSans-Regular.ttf").exists() { Some(w) } else { None } + } + }; + if let Some(d) = fonts_dir { + std::env::set_var("FOFREEXIT_FONTS_PATH", &d); + } tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .invoke_handler(tauri::generate_handler![ @@ -1435,6 +1476,7 @@ fn main() { preview_header_footer, edit_list_objects, edit_font_data, + app_font_data, edit_apply, edit_apply_to_temp, edit_preview, diff --git a/app/src/main.js b/app/src/main.js index a2b3c77..53ca569 100644 --- a/app/src/main.js +++ b/app/src/main.js @@ -2340,10 +2340,12 @@ async function loadEditPage() { img.src = url; img.style.width = stageW + "px"; state.editObjects = objs; - // Nạp trước font nhúng của mọi run text (dedup theo tên font) — đến lúc - // đúp mở ô sửa thì face đã sẵn, đo đạc đúng ngay từ đầu. + // Nạp trước font nhúng của mọi run text (dedup theo tên font) + font Noto + // đóng gói cùng phân loại — đến lúc đúp mở ô sửa mọi face đã sẵn. for (const o of objs) { - if (o.kind === "text") ensureDocFont(o, state.editBase, state.editPage); + if (o.kind !== "text") continue; + ensureDocFont(o, state.editBase, state.editPage); + ensureAppFont(fontClassOf(o.fontFamily), o.fontBold, o.fontItalic); } buildEditOverlay(); $("pageInput").value = state.editPage + 1; @@ -2634,12 +2636,57 @@ function ensureDocFont(run, path, page) { docFonts.set(name, p); } -// Stack font cho 1 run trong ô sửa: font nhúng (nếu đã nạp xong) đứng đầu, -// stack hệ thống theo sau làm lưới an toàn. +// ---- Tầng đỡ: bộ font ĐÓNG GÓI theo app (Noto, phủ tiếng Việt) ---- +// Glyph không có trong subset nhúng (ký tự MỚI gõ vào), font không nhúng hay +// không vá được → rơi xuống font Noto đóng gói — GIỐNG NHAU trên mọi máy, +// không rơi về font hệ điều hành "gần giống". +const appFonts = new Map(); // kind -> true | false | Promise (bền theo app) + +function appFontKind(cls, bold, italic) { + if (cls === "mono") return "mono-" + (bold ? "bold" : "regular"); // mono không có italic + const style = bold && italic ? "bolditalic" : bold ? "bold" : italic ? "italic" : "regular"; + return cls + "-" + style; +} + +// Phân loại serif/sans/mono theo stack CSS đã suy cho family của run. +function fontClassOf(family) { + const stack = cssFontStack(family).toLowerCase(); + if (stack.endsWith("monospace")) return "mono"; + if (/[^-]serif$/.test(stack)) return "serif"; + return "sans"; +} + +function ensureAppFont(cls, bold, italic) { + const kind = appFontKind(cls, !!bold, !!italic); + if (appFonts.has(kind)) return; + const p = (async () => { + try { + const b64 = await invoke("app_font_data", { kind }); + const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)); + const face = new FontFace("ff-app-" + cls, bytes.buffer, { + weight: bold ? "700" : "400", + style: cls !== "mono" && italic ? "italic" : "normal", + }); + await face.load(); + document.fonts.add(face); + appFonts.set(kind, true); + return true; + } catch (_) { + appFonts.set(kind, false); // bản build không kèm fonts/ → stack cũ + return false; + } + })(); + appFonts.set(kind, p); +} + +// Stack font cho 1 run trong ô sửa: font NHÚNG của tài liệu đứng đầu → font +// Noto đóng gói theo app → stack hệ thống (lưới cuối cùng). function editFontStack(run, fallbackFamily) { const base = cssFontStack(fallbackFamily); + const mid = '"ff-app-' + fontClassOf(fallbackFamily) + '", '; const fam = run && run.fontName ? docFonts.get(run.fontName) : null; - return typeof fam === "string" && fam ? '"' + fam + '", ' + base : base; + const head = typeof fam === "string" && fam ? '"' + fam + '", ' : ""; + return head + mid + base; } function cssFontStack(family) { diff --git a/crates/ff-engine/src/annot.rs b/crates/ff-engine/src/annot.rs index 42b118e..9bc602f 100644 --- a/crates/ff-engine/src/annot.rs +++ b/crates/ff-engine/src/annot.rs @@ -293,6 +293,21 @@ fn pdf_text_string(s: &str) -> LoObj { /// 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. pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option> { + // Bộ font ĐÓNG GÓI theo app (Noto — phủ tiếng Việt): ưu tiên tuyệt đối để + // hiển thị/ghi file GIỐNG NHAU trên mọi máy khách, không phụ thuộc font + // của hệ điều hành (không thoả hiệp thay bằng font "gần giống"). + // FOFREEXIT_FONTS_PATH do app đặt lúc khởi động (fonts/ cạnh exe). + if let Ok(dir) = std::env::var("FOFREEXIT_FONTS_PATH") { + let name = match (bold, italic) { + (false, false) => "NotoSans-Regular.ttf", + (true, false) => "NotoSans-Bold.ttf", + (false, true) => "NotoSans-Italic.ttf", + (true, true) => "NotoSans-BoldItalic.ttf", + }; + if let Ok(bytes) = std::fs::read(std::path::Path::new(&dir).join(name)) { + return Some(bytes); + } + } #[cfg(windows)] { // [regular, bold, italic, bold-italic] cho từng family phổ biến trên Windows. diff --git a/scripts/fetch-fonts.ps1 b/scripts/fetch-fonts.ps1 new file mode 100644 index 0000000..c5b8905 --- /dev/null +++ b/scripts/fetch-fonts.ps1 @@ -0,0 +1,47 @@ +# Tai bo font Noto (hinted static TTF, phu tieng Viet) vao fonts/ o goc +# workspace - app DONG GOI bo font nay de moi may khach hien thi/ghi file +# giong het nhau, khong phu thuoc font cai tren he dieu hanh (chuan Foxit: +# khong thoa hiep thay bang font "gan giong"). +# +# Dung: powershell -ExecutionPolicy Bypass -File scripts/fetch-fonts.ps1 + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$fontsDir = Join-Path $root "fonts" +New-Item -ItemType Directory -Force -Path $fontsDir | Out-Null + +# notofonts.github.io la repo phat hanh chinh thuc cac ban static hinted. +$base = "https://github.com/notofonts/notofonts.github.io/raw/main/fonts" +$files = @( + "NotoSans/hinted/ttf/NotoSans-Regular.ttf", + "NotoSans/hinted/ttf/NotoSans-Bold.ttf", + "NotoSans/hinted/ttf/NotoSans-Italic.ttf", + "NotoSans/hinted/ttf/NotoSans-BoldItalic.ttf", + "NotoSansMono/hinted/ttf/NotoSansMono-Regular.ttf", + "NotoSansMono/hinted/ttf/NotoSansMono-Bold.ttf", + "NotoSerif/hinted/ttf/NotoSerif-Regular.ttf", + "NotoSerif/hinted/ttf/NotoSerif-Bold.ttf", + "NotoSerif/hinted/ttf/NotoSerif-Italic.ttf", + "NotoSerif/hinted/ttf/NotoSerif-BoldItalic.ttf" +) + +foreach ($f in $files) { + $name = Split-Path -Leaf $f + $dest = Join-Path $fontsDir $name + if ((Test-Path $dest) -and ((Get-Item $dest).Length -gt 100000)) { + Write-Output "Da co: $name" + continue + } + Write-Output "Tai $name..." + Invoke-WebRequest -Uri "$base/$f" -OutFile $dest -UseBasicParsing + if ((Get-Item $dest).Length -lt 100000) { throw "File $name qua nho - tai hong?" } +} + +# Giay phep OFL di kem khi phan phoi font. +$ofl = Join-Path $fontsDir "OFL-LICENSE.txt" +if (-not (Test-Path $ofl)) { + Invoke-WebRequest -Uri "https://raw.githubusercontent.com/google/fonts/main/ofl/notosans/OFL.txt" -OutFile $ofl -UseBasicParsing +} + +Get-ChildItem $fontsDir | ForEach-Object { "{0} {1:N0} bytes" -f $_.Name, $_.Length } | Write-Output +Write-Output "Xong. Fonts tai: $fontsDir"