mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-30 04:58:34 -06:00
Add text wrote Arabic as "?" (standard-14 fonts, WinAnsi). Items with Arabic are now shaped with the craft-fonts Arab face through harfrust (already in the tree via egui) and laid out line by line in display order with unicode-bidi (UAX #9), so Arabic mixed with Latin, digits and brackets reads correctly. Each cluster (a letter with its dots or marks) is one glyph of a Type 3 font drawn from the face's outlines, with a ToUnicode entry, so text extraction, copy and search get every letter once and no font file is embedded. - fonts: shape_arabic / arabic_glyph / arabic_has / document_arabic_font; japanese_glyph's bounded outline code is shared as bounded_outline. - edit: draw_arabic and type3_arabic; wrapping measures shaped words. Fonts get names free on the page (a full save renumbers objects, not resource names) and are removed when the item is updated or deleted. Outlines are read before anything is written. - Without the face, adding Arabic is a clear error that changes nothing; an existing item holding Arabic still moves, drawn as "?" as before. - Non-Arabic items are drawn exactly as before. Refs #250, #280, #369. Co-authored-by: fares <aw674860@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
261 lines
13 KiB
Rust
261 lines
13 KiB
Rust
//! Fonts from the optional craft-fonts build input (<https://github.com/storytold/craft-fonts>).
|
|
//!
|
|
//! `build.rs` embeds every font in craft-fonts' manifest when PdfCraft is built with
|
|
//! `CRAFT_FONTS_DIR=<checkout>`; otherwise [`CRAFT_FONTS`] is empty and everything here returns
|
|
//! nothing. Callers must work either way.
|
|
|
|
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`).
|
|
pub struct CraftFont {
|
|
pub family: &'static str,
|
|
pub style: &'static str,
|
|
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
|
|
pub scripts: &'static [&'static str],
|
|
pub bytes: &'static [u8],
|
|
}
|
|
|
|
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
|
|
|
impl CraftFont {
|
|
/// Whether the font is meant for `script` (ISO 15924, e.g. `"Jpan"`).
|
|
pub fn covers(&self, script: &str) -> bool {
|
|
self.scripts.contains(&script)
|
|
}
|
|
|
|
/// A unique name for registering the face with a font system ("BIZ UDPGothic Bold").
|
|
pub fn name(&self) -> String {
|
|
format!("{} {}", self.family, self.style)
|
|
}
|
|
}
|
|
|
|
/// The `Jpan` craft-fonts faces in the order the interface prefers them: BIZ UDPGothic (a UI
|
|
/// face) first, then the rest in manifest order. Empty without craft-fonts.
|
|
pub fn ui_japanese_fonts() -> Vec<&'static CraftFont> {
|
|
let mut fonts: Vec<&CraftFont> = CRAFT_FONTS.iter().filter(|f| f.covers("Jpan")).collect();
|
|
// Stable: manifest order within each group.
|
|
fonts.sort_by_key(|f| f.family != "BIZ UDPGothic");
|
|
fonts
|
|
}
|
|
|
|
/// The `Hans` craft-fonts faces for Simplified Chinese interface text, in manifest order.
|
|
/// Empty when built without craft-fonts (Chinese text then shows the font system's
|
|
/// replacement glyph, the same degraded mode as Japanese without craft-fonts).
|
|
pub fn ui_chinese_fonts() -> Vec<&'static CraftFont> {
|
|
CRAFT_FONTS.iter().filter(|f| f.covers("Hans")).collect()
|
|
}
|
|
|
|
/// The `Arab` craft-fonts faces for Arabic-script interface text (file names, document titles),
|
|
/// in manifest order. Empty when built without craft-fonts or when it has no Arabic face.
|
|
pub fn ui_arabic_fonts() -> Vec<&'static CraftFont> {
|
|
arabic(CRAFT_FONTS.iter())
|
|
}
|
|
|
|
/// The face for Arabic text written into PDFs: the first `Arab` face. `None` when built without
|
|
/// craft-fonts or when its revision has no Arabic face.
|
|
pub fn document_arabic_font() -> Option<&'static CraftFont> {
|
|
ui_arabic_fonts().into_iter().next()
|
|
}
|
|
|
|
fn arabic<'a>(faces: impl IntoIterator<Item = &'a CraftFont>) -> Vec<&'a CraftFont> {
|
|
faces.into_iter().filter(|f| f.covers("Arab")).collect()
|
|
}
|
|
|
|
/// The `Telu` craft-fonts faces for Telugu interface text (the Telugu catalog, file names,
|
|
/// document titles), in manifest order. Empty when built without craft-fonts or when it has no
|
|
/// Telugu face.
|
|
pub fn ui_telugu_fonts() -> Vec<&'static CraftFont> {
|
|
CRAFT_FONTS.iter().filter(|f| f.covers("Telu")).collect()
|
|
}
|
|
|
|
/// Interface CJK faces in fallback order for the UI language: Simplified Chinese first when
|
|
/// `prefer_hans`, otherwise Japanese first (the historical default).
|
|
///
|
|
/// The order matters beyond glyph shapes. egui renders each character with the FIRST face that
|
|
/// has its glyph, so with Japanese first a Simplified-only character (e.g. U+6B22 欢, absent
|
|
/// from Japanese faces) lands in a different face than its neighbours; the mixed vertical
|
|
/// metrics then sink it below the line. Preferring the UI language's face keeps one line in
|
|
/// one face with one baseline.
|
|
pub fn ui_cjk_fonts(prefer_hans: bool) -> Vec<&'static CraftFont> {
|
|
order_cjk(CRAFT_FONTS.iter(), prefer_hans)
|
|
}
|
|
|
|
fn order_cjk<'a>(faces: impl IntoIterator<Item = &'a CraftFont>, prefer_hans: bool) -> Vec<&'a CraftFont> {
|
|
let mut out: Vec<&'a CraftFont> = faces.into_iter().filter(|f| f.covers("Hans") || f.covers("Jpan")).collect();
|
|
let first = if prefer_hans { "Hans" } else { "Jpan" };
|
|
// The preferred script's faces first (a face covering both counts as preferred); within each
|
|
// group the Japanese UI face (BIZ UDPGothic, not a serif) leads, otherwise manifest order
|
|
// (the sort is stable).
|
|
out.sort_by_key(|f| (!f.covers(first), f.family != "BIZ UDPGothic"));
|
|
out
|
|
}
|
|
|
|
/// The face for Japanese text written into PDFs (serif document text): Shippori Mincho, then
|
|
/// BIZ UDMincho, then any other regular `Jpan` face. `None` without craft-fonts.
|
|
pub fn document_japanese_font() -> Option<&'static CraftFont> {
|
|
document_face(CRAFT_FONTS, true, false)
|
|
}
|
|
|
|
/// A real Japanese document face matching serif/sans and weight where available.
|
|
/// Sans text prefers BIZ UDPGothic Bold or Regular. Missing weights fall back to Regular;
|
|
/// serif text keeps the document Mincho preference. No synthetic weight or slant is applied.
|
|
/// `None` without craft-fonts. The small web input currently has only Gothic Regular.
|
|
pub fn document_japanese_font_for_style(serif: bool, bold: bool) -> Option<&'static CraftFont> {
|
|
document_face(CRAFT_FONTS, serif, bold)
|
|
}
|
|
|
|
/// Every `Jpan` face that can stand in for the document text, the best match for the style first
|
|
/// (the face [`document_japanese_font_for_style`] returns), then the others. Not every face has
|
|
/// every glyph (their coverage of e.g. Cyrillic differs), so a caller can move on to the next one.
|
|
/// Empty without craft-fonts.
|
|
pub fn document_japanese_fonts_for_style(serif: bool, bold: bool) -> Vec<&'static CraftFont> {
|
|
document_faces(CRAFT_FONTS, serif, bold)
|
|
}
|
|
|
|
fn document_face(faces: &[CraftFont], serif: bool, bold: bool) -> Option<&CraftFont> {
|
|
document_faces(faces, serif, bold).into_iter().next()
|
|
}
|
|
|
|
fn document_faces(faces: &[CraftFont], serif: bool, bold: bool) -> Vec<&CraftFont> {
|
|
let jpan = || faces.iter().filter(|f| f.covers("Jpan"));
|
|
let mut preferred = Vec::new();
|
|
if !serif {
|
|
let style = if bold { "Bold" } else { "Regular" };
|
|
preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == style));
|
|
preferred.extend(jpan().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular"));
|
|
}
|
|
for family in ["Shippori Mincho", "BIZ UDMincho"] {
|
|
preferred.extend(jpan().find(|f| f.family == family && f.style == "Regular"));
|
|
}
|
|
let mut out: Vec<&CraftFont> = Vec::new();
|
|
for face in preferred.into_iter().chain(jpan().filter(|f| f.style == "Regular")).chain(jpan()) {
|
|
if !out.iter().any(|f| std::ptr::eq(*f, face)) {
|
|
out.push(face);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
const fn str_eq(a: &str, b: &str) -> bool {
|
|
let (a, b) = (a.as_bytes(), b.as_bytes());
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
let mut i = 0;
|
|
while i < a.len() {
|
|
if a[i] != b[i] {
|
|
return false;
|
|
}
|
|
i += 1;
|
|
}
|
|
true
|
|
}
|
|
|
|
const fn find(family: &str, style: &str) -> Option<&'static [u8]> {
|
|
let mut i = 0;
|
|
while i < CRAFT_FONTS.len() {
|
|
let f = &CRAFT_FONTS[i];
|
|
if str_eq(f.family, family) && str_eq(f.style, style) {
|
|
return Some(f.bytes);
|
|
}
|
|
i += 1;
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Shippori Mincho Regular from craft-fonts, the preferred face for Japanese document text.
|
|
/// `None` when PdfCraft was built without craft-fonts.
|
|
pub static SHIPPORI_MINCHO: Option<&[u8]> = find("Shippori Mincho", "Regular");
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn cjk_fallback_order_follows_the_ui_language() {
|
|
// Synthetic faces: order_cjk must not depend on the real build input.
|
|
static BYTES: &[u8] = b"fake";
|
|
static LATN: &[&str] = &["Latn"];
|
|
static JPAN_LATN: &[&str] = &["Jpan", "Latn"];
|
|
static HANS_LATN: &[&str] = &["Hans", "Latn"];
|
|
let biz_bold = CraftFont { family: "BIZ UDPGothic", style: "Bold", scripts: JPAN_LATN, bytes: BYTES };
|
|
let biz = CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: JPAN_LATN, bytes: BYTES };
|
|
let hans = CraftFont { family: "FakeHans", style: "Regular", scripts: HANS_LATN, bytes: BYTES };
|
|
let inter = CraftFont { family: "Inter", style: "Regular", scripts: LATN, bytes: BYTES };
|
|
let faces = [biz_bold, biz, hans, inter];
|
|
// Chinese mode: the Hans group first (manifest order), then Japanese.
|
|
let zh: Vec<&str> = order_cjk([&faces[0], &faces[2], &faces[1]], true).iter().map(|f| f.family).collect();
|
|
assert_eq!(zh, ["FakeHans", "BIZ UDPGothic", "BIZ UDPGothic"]);
|
|
// Japanese mode keeps the historical default: the Japanese UI face first.
|
|
let ja: Vec<&str> = order_cjk([&faces[0], &faces[2], &faces[1]], false).iter().map(|f| f.family).collect();
|
|
assert_eq!(ja, ["BIZ UDPGothic", "BIZ UDPGothic", "FakeHans"]);
|
|
// Latin-only faces are never interface CJK fallbacks.
|
|
assert!(order_cjk(&faces, true).iter().all(|f| f.covers("Hans") || f.covers("Jpan")));
|
|
// A serif listed first in the manifest never leads the Japanese group, in either mode.
|
|
let mincho = CraftFont { family: "Shippori Mincho", style: "Regular", scripts: JPAN_LATN, bytes: BYTES };
|
|
let zh: Vec<&str> = order_cjk([&mincho, &faces[2], &faces[1]], true).iter().map(|f| f.family).collect();
|
|
assert_eq!(zh, ["FakeHans", "BIZ UDPGothic", "Shippori Mincho"]);
|
|
let ja: Vec<&str> = order_cjk([&mincho, &faces[2], &faces[1]], false).iter().map(|f| f.family).collect();
|
|
assert_eq!(ja, ["BIZ UDPGothic", "Shippori Mincho", "FakeHans"]);
|
|
}
|
|
|
|
#[test]
|
|
fn document_faces_match_style_without_inventing_missing_weights() {
|
|
let faces = [
|
|
CraftFont { family: "Shippori Mincho", style: "Regular", scripts: &["Jpan"], bytes: b"serif" },
|
|
CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: &["Jpan"], bytes: b"sans" },
|
|
CraftFont { family: "BIZ UDPGothic", style: "Bold", scripts: &["Jpan"], bytes: b"bold" },
|
|
CraftFont { family: "BIZ UDPGothic", style: "Bold", scripts: &["Latn"], bytes: b"not-japanese" },
|
|
];
|
|
assert_eq!(document_face(&faces, false, false).unwrap().bytes, b"sans");
|
|
assert_eq!(document_face(&faces, false, true).unwrap().bytes, b"bold");
|
|
assert_eq!(document_face(&faces, true, false).unwrap().bytes, b"serif");
|
|
assert_eq!(document_face(&faces, true, true).unwrap().bytes, b"serif");
|
|
assert_eq!(document_face(&faces[..2], false, true).unwrap().bytes, b"sans");
|
|
assert_eq!(document_face(&faces[..1], false, true).unwrap().bytes, b"serif");
|
|
// Every Japanese face is a candidate, the style's match first and none twice.
|
|
let order: Vec<&[u8]> = document_faces(&faces, false, true).iter().map(|f| f.bytes).collect();
|
|
assert_eq!(order, [b"bold".as_slice(), b"sans", b"serif"]);
|
|
let order: Vec<&[u8]> = document_faces(&faces, true, false).iter().map(|f| f.bytes).collect();
|
|
assert_eq!(order, [b"serif".as_slice(), b"sans", b"bold"]);
|
|
assert!(document_face(&faces[3..], false, true).is_none());
|
|
assert!(document_face(&[], false, true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn arabic_faces_are_picked_by_script_in_manifest_order() {
|
|
// Synthetic faces: the filter must not depend on the real build input.
|
|
static BYTES: &[u8] = b"fake";
|
|
static ARAB_LATN: &[&str] = &["Arab", "Latn"];
|
|
static JPAN: &[&str] = &["Jpan"];
|
|
let naskh = CraftFont { family: "FakeNaskh", style: "Regular", scripts: ARAB_LATN, bytes: BYTES };
|
|
let biz = CraftFont { family: "BIZ UDPGothic", style: "Regular", scripts: JPAN, bytes: BYTES };
|
|
let kufi = CraftFont { family: "FakeKufi", style: "Regular", scripts: ARAB_LATN, bytes: BYTES };
|
|
let faces = [naskh, biz, kufi];
|
|
let ar: Vec<&str> = arabic(&faces).iter().map(|f| f.family).collect();
|
|
assert_eq!(ar, ["FakeNaskh", "FakeKufi"]);
|
|
// An Arabic face is never a CJK fallback, and the other way round.
|
|
assert!(order_cjk(&faces, false).iter().all(|f| f.family == "BIZ UDPGothic"));
|
|
assert_eq!(ui_arabic_fonts().len(), CRAFT_FONTS.iter().filter(|f| f.covers("Arab")).count());
|
|
}
|
|
|
|
#[test]
|
|
fn craft_fonts_are_optional_and_consistent() {
|
|
// Holds with or without CRAFT_FONTS_DIR.
|
|
assert_eq!(SHIPPORI_MINCHO.is_some(), CRAFT_FONTS.iter().any(|f| f.family == "Shippori Mincho" && f.style == "Regular"));
|
|
assert_eq!(document_japanese_font().is_some(), CRAFT_FONTS.iter().any(|f| f.covers("Jpan")));
|
|
let ui = ui_japanese_fonts();
|
|
assert_eq!(ui.len(), CRAFT_FONTS.iter().filter(|f| f.covers("Jpan")).count());
|
|
let ui_zh = ui_chinese_fonts();
|
|
assert_eq!(ui_zh.len(), CRAFT_FONTS.iter().filter(|f| f.covers("Hans")).count());
|
|
if CRAFT_FONTS.is_empty() {
|
|
eprintln!("built without craft-fonts (CRAFT_FONTS_DIR unset): no Japanese faces, as expected");
|
|
assert!(SHIPPORI_MINCHO.is_none() && ui.is_empty() && ui_zh.is_empty());
|
|
return;
|
|
}
|
|
assert!(CRAFT_FONTS.iter().all(|f| !f.bytes.is_empty()));
|
|
if CRAFT_FONTS.iter().any(|f| f.family == "BIZ UDPGothic") {
|
|
assert_eq!(ui.first().map(|f| f.family), Some("BIZ UDPGothic"));
|
|
}
|
|
assert_eq!(document_japanese_font().map(|f| f.family), Some("Shippori Mincho"));
|
|
}
|
|
}
|