DONG GOI bo font Noto theo app: khong thoa hiep font he thong o bat ky tang nao
Build Windows portable / build (push) Canceled after 0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 12:12:26 +07:00
co-authored by Claude Fable 5
parent f6c1bc2070
commit f10cb7df2b
6 changed files with 174 additions and 6 deletions
+14
View File
@@ -41,6 +41,18 @@ jobs:
shell: pwsh shell: pwsh
run: powershell -ExecutionPolicy Bypass -File scripts/fetch-qpdf.ps1 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. # Test OCR cần tesseract thật (eng + vie). Chocolatey có sẵn trên runner.
- name: Install Tesseract OCR (eng + vie) - name: Install Tesseract OCR (eng + vie)
shell: pwsh shell: pwsh
@@ -75,6 +87,8 @@ jobs:
Copy-Item "qpdf/bin/*.exe" "$dist/" Copy-Item "qpdf/bin/*.exe" "$dist/"
Copy-Item "qpdf/bin/*.dll" "$dist/" Copy-Item "qpdf/bin/*.dll" "$dist/"
Copy-Item "scripts/portable-readme.txt" "$dist/DOC-doc-truoc-khi-chay.txt" 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 Compress-Archive -Path "$dist/*" -DestinationPath "FoFreeXit-portable-win64.zip" -Force
- name: Upload artifact - name: Upload artifact
+3
View File
@@ -27,3 +27,6 @@ Cargo.lock.bak
# (corpus/ đã track sẵn nên không ảnh hưởng). # (corpus/ đã track sẵn nên không ảnh hưởng).
/pdf-file-to-test/ /pdf-file-to-test/
/*.pdf /*.pdf
# Bo font Noto dong goi (tai bang scripts/fetch-fonts.ps1)
/fonts/
+42
View File
@@ -703,6 +703,31 @@ fn edit_list_objects(path: String, page: u16, password: Option<String>) -> Resul
.collect()) .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<String, String> {
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 /// 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. /// hiển thị đúng font tài liệu thay vì font hệ thống gần giống.
#[tauri::command] #[tauri::command]
@@ -1408,6 +1433,22 @@ fn pick_image(app: tauri::AppHandle) -> Option<String> {
} }
fn main() { 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() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@@ -1435,6 +1476,7 @@ fn main() {
preview_header_footer, preview_header_footer,
edit_list_objects, edit_list_objects,
edit_font_data, edit_font_data,
app_font_data,
edit_apply, edit_apply,
edit_apply_to_temp, edit_apply_to_temp,
edit_preview, edit_preview,
+53 -6
View File
@@ -2340,10 +2340,12 @@ async function loadEditPage() {
img.src = url; img.src = url;
img.style.width = stageW + "px"; img.style.width = stageW + "px";
state.editObjects = objs; state.editObjects = objs;
// Nạp trước font nhúng của mọi run text (dedup theo tên font) — đến lúc // Nạp trước font nhúng của mọi run text (dedup theo tên font) + font Noto
// đúp mở ô sửa thì face đã sẵn, đo đạc đúng ngay từ đầu. // đó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) { 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(); buildEditOverlay();
$("pageInput").value = state.editPage + 1; $("pageInput").value = state.editPage + 1;
@@ -2634,12 +2636,57 @@ function ensureDocFont(run, path, page) {
docFonts.set(name, p); docFonts.set(name, p);
} }
// Stack font cho 1 run trong ô sửa: font nhúng (nếu đã nạp xong) đứng đầu, // ---- Tầng đỡ: bộ font ĐÓNG GÓI theo app (Noto, phủ tiếng Việt) ----
// stack hệ thống theo sau làm lưới an toàn. // 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) { function editFontStack(run, fallbackFamily) {
const base = cssFontStack(fallbackFamily); const base = cssFontStack(fallbackFamily);
const mid = '"ff-app-' + fontClassOf(fallbackFamily) + '", ';
const fam = run && run.fontName ? docFonts.get(run.fontName) : null; 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) { function cssFontStack(family) {
+15
View File
@@ -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 /// 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. /// của family khác — để FreeText giữ đúng đậm/nghiêng như Foxit.
pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> { pub(crate) fn find_font_bytes(bold: bool, italic: bool) -> Option<Vec<u8>> {
// 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)] #[cfg(windows)]
{ {
// [regular, bold, italic, bold-italic] cho từng family phổ biến trên Windows. // [regular, bold, italic, bold-italic] cho từng family phổ biến trên Windows.
+47
View File
@@ -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"