use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use anyhow::{Context, Result, anyhow, bail};
use lopdf::{Dictionary, Document, Object, ObjectId, Stream};
const HELVETICA: [u16; 95] = [
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, 1015, 667, 667, 722, 722, 667,
611, 778, 722, 278, 500, 667, 556, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
667, 611, 278, 278, 278, 469, 556, 333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500,
222, 833, 556, 556, 556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584,
];
pub fn helvetica_width(bytes: &[u8], size: f64) -> f64 {
let units: u32 = bytes
.iter()
.map(|&b| {
HELVETICA
.get((b as usize).wrapping_sub(32))
.copied()
.unwrap_or(556) as u32
})
.sum();
units as f64 * size / 1000.0
}
pub fn winansi(text: &str) -> Option<Vec<u8>> {
text.chars()
.map(|c| match c as u32 {
0x20..=0x7e | 0xa0..=0xff => Some(c as u8),
_ => None,
})
.collect()
}
pub fn literal(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() + 2);
s.push('(');
for &b in bytes {
match b {
b'(' | b')' | b'\\' => {
s.push('\\');
s.push(b as char);
}
0x20..=0x7e => s.push(b as char),
_ => s.push_str(&format!("\\{b:03o}")),
}
}
s.push(')');
s
}
pub struct TextFont {
pub id: ObjectId,
embedded: Option<Embedded>,
builtin: Style,
}
struct Embedded {
data: Vec<u8>,
index: u32,
scale: f64,
subset: HashMap<u16, (u16, f64)>,
nominal: HashMap<char, u16>,
}
struct Shaped {
glyph: u16,
advance: f64,
natural: f64,
dx: f64,
dy: f64,
}
struct RawGlyph {
glyph: u16,
cluster: std::ops::Range<usize>,
advance: f64,
dx: f64,
dy: f64,
}
fn shape(face: &rustybuzz::Face<'_>, text: &str) -> Vec<RawGlyph> {
let mut out = Vec::new();
let bidi = unicode_bidi::BidiInfo::new(text, None);
for paragraph in &bidi.paragraphs {
let (levels, runs) = bidi.visual_runs(paragraph, paragraph.range.clone());
for run in runs {
let piece = &text[run.clone()];
let mut buffer = rustybuzz::UnicodeBuffer::new();
buffer.push_str(piece);
buffer.set_direction(if levels[run.start].is_rtl() {
rustybuzz::Direction::RightToLeft
} else {
rustybuzz::Direction::LeftToRight
});
buffer.guess_segment_properties();
let shaped = rustybuzz::shape(face, &[], buffer);
let mut starts: Vec<usize> = shaped
.glyph_infos()
.iter()
.map(|g| g.cluster as usize)
.collect();
starts.sort_unstable();
starts.dedup();
for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) {
let from = info.cluster as usize;
let to = starts
.iter()
.copied()
.find(|&s| s > from)
.unwrap_or(piece.len());
if piece[from..to].chars().all(char::is_control) {
continue;
}
out.push(RawGlyph {
glyph: info.glyph_id as u16,
cluster: run.start + from..run.start + to,
advance: position.x_advance as f64,
dx: position.x_offset as f64,
dy: position.y_offset as f64,
});
}
}
}
out
}
impl Embedded {
fn layout(&self, text: &str) -> Vec<Shaped> {
let Some(face) = rustybuzz::Face::from_slice(&self.data, self.index) else {
return Vec::new();
};
let raw = shape(&face, text);
let mut out = Vec::with_capacity(raw.len());
let mut i = 0;
while i < raw.len() {
let mut j = i;
while j < raw.len() && raw[j].cluster == raw[i].cluster {
j += 1;
}
let cluster = &raw[i..j];
if cluster.iter().all(|g| self.subset.contains_key(&g.glyph)) {
out.extend(cluster.iter().map(|g| {
let (glyph, natural) = self.subset[&g.glyph];
Shaped {
glyph,
advance: g.advance * self.scale,
natural,
dx: g.dx * self.scale,
dy: g.dy * self.scale,
}
}));
} else {
for c in text[cluster[0].cluster.clone()].chars() {
let plain = self.nominal.get(&c).and_then(|g| self.subset.get(g));
let (glyph, natural) = plain.copied().unwrap_or((0, 0.0));
out.push(Shaped {
glyph,
advance: natural,
natural,
dx: 0.0,
dy: 0.0,
});
}
}
i = j;
}
out
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Default, Debug)]
pub struct Style {
pub bold: bool,
pub italic: bool,
pub mono: bool,
}
const HELVETICA_BOLD: [u16; 95] = [
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, 389, 584, 278, 333, 278, 278, 556, 556, 556,
556, 556, 556, 556, 556, 556, 556, 333, 333, 584, 584, 584, 611, 975, 722, 722, 722, 722, 667,
611, 778, 722, 278, 556, 722, 611, 833, 722, 778, 667, 778, 722, 667, 611, 722, 667, 944, 667,
667, 611, 333, 278, 333, 584, 556, 333, 556, 611, 556, 611, 556, 333, 611, 611, 278, 278, 556,
278, 889, 611, 611, 611, 611, 389, 556, 333, 611, 556, 778, 556, 556, 500, 389, 280, 389, 584,
];
impl TextFont {
pub fn new(doc: &mut Document, text: &str, file: Option<&Path>) -> Result<TextFont> {
let mut wanted: Vec<char> = text.chars().filter(|c| !c.is_control()).collect();
if file.is_none() && winansi(&wanted.iter().collect::<String>()).is_some() {
return Ok(TextFont {
id: doc.add_object(helvetica()),
embedded: None,
builtin: Style::default(),
});
}
wanted.sort_unstable();
wanted.dedup();
let (data, index) = match file {
Some(path) => {
let data = std::fs::read(path)
.with_context(|| format!("cannot read font {}", path.display()))?;
let missing = missing(&data, 0, &wanted)
.ok_or_else(|| anyhow!("{} is not a usable font", path.display()))?;
if !missing.is_empty() {
bail!(
"font {} has no glyph for: {}",
path.display(),
missing.iter().collect::<String>()
);
}
(data, 0)
}
None => system_font(&wanted)?,
};
embed(doc, data, index, &wanted, text)
}
pub fn styled(doc: &mut Document, text: &str, style: Style) -> Result<TextFont> {
let mut wanted: Vec<char> = text.chars().filter(|c| !c.is_control()).collect();
if winansi(&wanted.iter().collect::<String>()).is_some() {
let family = if style.mono { "Courier" } else { "Helvetica" };
let variant = match (style.bold, style.italic) {
(false, false) => "",
(true, false) => "-Bold",
(false, true) => "-Oblique",
(true, true) => "-BoldOblique",
};
let mut font = helvetica();
font.set(
"BaseFont",
Object::Name(format!("{family}{variant}").into_bytes()),
);
return Ok(TextFont {
id: doc.add_object(font),
embedded: None,
builtin: style,
});
}
wanted.sort_unstable();
wanted.dedup();
let (data, index) = styled_system_font(&wanted, style)?;
embed(doc, data, index, &wanted, text)
}
pub fn elements(&self, text: &str) -> Vec<Object> {
let Some(embedded) = &self.embedded else {
return vec![Object::String(
winansi(text).unwrap_or_default(),
lopdf::StringFormat::Literal,
)];
};
let mut out = Vec::new();
let mut run: Vec<u8> = Vec::new();
let mut owed = 0.0f64;
for glyph in embedded.layout(text) {
owed += glyph.dx;
if owed.abs() >= 0.5 {
if !run.is_empty() {
out.push(Object::String(
std::mem::take(&mut run),
lopdf::StringFormat::Hexadecimal,
));
}
out.push(Object::Real(-owed as f32));
owed = 0.0;
}
run.extend(glyph.glyph.to_be_bytes());
owed += glyph.advance - glyph.dx - glyph.natural;
}
if !run.is_empty() {
out.push(Object::String(run, lopdf::StringFormat::Hexadecimal));
}
if owed.abs() >= 0.5 {
out.push(Object::Real(-owed as f32));
}
out
}
pub fn show(&self, text: &str, size: f64) -> String {
let Some(embedded) = &self.embedded else {
return format!("{} Tj", literal(&winansi(text).unwrap_or_default()));
};
let hex = |bytes: &[u8]| -> String { bytes.iter().map(|b| format!("{b:02X}")).collect() };
let mut out: Vec<String> = Vec::new();
let mut array: Vec<String> = Vec::new();
let mut run: Vec<u8> = Vec::new();
let mut owed = 0.0f64;
let mut rise = 0.0f64;
let flush = |array: &mut Vec<String>, run: &mut Vec<u8>, out: &mut Vec<String>| {
if !run.is_empty() {
array.push(format!("<{}>", hex(run)));
run.clear();
}
match array.as_slice() {
[] => {}
[only] if only.starts_with('<') => out.push(format!("{only} Tj")),
many => out.push(format!("[{}] TJ", many.join(" "))),
}
array.clear();
};
for glyph in embedded.layout(text) {
if (glyph.dy - rise).abs() >= 0.5 {
if owed.abs() >= 0.5 {
if !run.is_empty() {
array.push(format!("<{}>", hex(&run)));
run.clear();
}
array.push(format!("{:.0}", -owed));
owed = 0.0;
}
flush(&mut array, &mut run, &mut out);
rise = glyph.dy;
out.push(format!("{:.2} Ts", rise * size / 1000.0));
}
owed += glyph.dx;
if owed.abs() >= 0.5 {
if !run.is_empty() {
array.push(format!("<{}>", hex(&run)));
run.clear();
}
array.push(format!("{:.0}", -owed));
owed = 0.0;
}
run.extend(glyph.glyph.to_be_bytes());
owed += glyph.advance - glyph.dx - glyph.natural;
}
if owed.abs() >= 0.5 {
if !run.is_empty() {
array.push(format!("<{}>", hex(&run)));
run.clear();
}
array.push(format!("{:.0}", -owed));
}
flush(&mut array, &mut run, &mut out);
if rise != 0.0 {
out.push("0 Ts".to_string());
}
if out.is_empty() {
return "<> Tj".to_string();
}
out.join("\n")
}
pub fn is_embedded(&self) -> bool {
self.embedded.is_some()
}
pub fn width(&self, text: &str, size: f64) -> f64 {
match &self.embedded {
None => {
let bytes = winansi(text).unwrap_or_default();
if self.builtin.mono {
bytes.len() as f64 * 0.6 * size
} else if self.builtin.bold {
let units: u32 = bytes
.iter()
.map(|&b| {
HELVETICA_BOLD
.get((b as usize).wrapping_sub(32))
.copied()
.unwrap_or(556) as u32
})
.sum();
units as f64 * size / 1000.0
} else {
helvetica_width(&bytes, size)
}
}
Some(embedded) => {
embedded.layout(text).iter().map(|g| g.advance).sum::<f64>() * size / 1000.0
}
}
}
}
pub fn helvetica() -> Dictionary {
let mut font = Dictionary::new();
font.set("Type", Object::Name(b"Font".to_vec()));
font.set("Subtype", Object::Name(b"Type1".to_vec()));
font.set("BaseFont", Object::Name(b"Helvetica".to_vec()));
font.set("Encoding", Object::Name(b"WinAnsiEncoding".to_vec()));
font
}
fn missing(data: &[u8], index: u32, wanted: &[char]) -> Option<Vec<char>> {
let face = ttf_parser::Face::parse(data, index).ok()?;
Some(
wanted
.iter()
.copied()
.filter(|&c| face.glyph_index(c).is_none())
.collect(),
)
}
const USUAL_FONTS: [&str; 9] = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
"/usr/share/fonts/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
];
fn styled_system_font(wanted: &[char], style: Style) -> Result<(Vec<u8>, u32)> {
if style == Style::default() {
return system_font(wanted);
}
let covers =
|data: &[u8], index: u32| missing(data, index, wanted).is_some_and(|m| m.is_empty());
let variant = match (style.bold, style.italic) {
(false, false) => ["", "-Regular"],
(true, false) => ["-Bold", "-Bold"],
(false, true) => ["-Oblique", "-Italic"],
(true, true) => ["-BoldOblique", "-BoldItalic"],
};
for path in USUAL_FONTS {
let path = Path::new(path);
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let family = match (style.mono, stem.trim_end_matches("-Regular")) {
(true, "LiberationSans") => "LiberationMono".to_string(),
(true, family) => format!("{family}Mono"),
(false, family) => family.to_string(),
};
for suffix in variant {
let candidate = path.with_file_name(format!("{family}{suffix}.ttf"));
if let Ok(data) = std::fs::read(&candidate)
&& covers(&data, 0)
{
return Ok((data, 0));
}
}
}
let mut db = fontdb::Database::new();
db.load_system_fonts();
let query = fontdb::Query {
families: &[if style.mono {
fontdb::Family::Monospace
} else {
fontdb::Family::SansSerif
}],
weight: if style.bold {
fontdb::Weight::BOLD
} else {
fontdb::Weight::NORMAL
},
style: if style.italic {
fontdb::Style::Italic
} else {
fontdb::Style::Normal
},
..Default::default()
};
let found = db
.query(&query)
.and_then(|id| {
db.with_face_data(id, |data, index| {
covers(data, index).then(|| (data.to_vec(), index))
})
})
.flatten();
match found {
Some(hit) => Ok(hit),
None => system_font(wanted),
}
}
fn system_font(wanted: &[char]) -> Result<(Vec<u8>, u32)> {
for path in USUAL_FONTS {
if let Ok(data) = std::fs::read(path)
&& missing(&data, 0, wanted).is_some_and(|m| m.is_empty())
{
return Ok((data, 0));
}
}
let mut db = fontdb::Database::new();
db.load_system_fonts();
let preferred = db.query(&fontdb::Query {
families: &[fontdb::Family::SansSerif],
..Default::default()
});
let mut faces: Vec<&fontdb::FaceInfo> = db.faces().collect();
faces.sort_by_key(|f| {
(
Some(f.id) != preferred,
f.style != fontdb::Style::Normal,
f.weight != fontdb::Weight::NORMAL,
f.stretch != fontdb::Stretch::Normal,
f.monospaced,
f.post_script_name.clone(),
)
});
for face in faces {
let found = db.with_face_data(face.id, |data, index| {
missing(data, index, wanted)
.is_some_and(|m| m.is_empty())
.then(|| (data.to_vec(), index))
});
if let Some(Some(hit)) = found {
return Ok(hit);
}
}
bail!(
"no installed font can draw '{}'; pass a font file that can",
wanted
.iter()
.filter(|c| winansi(&c.to_string()).is_none())
.collect::<String>()
)
}
fn embed(
doc: &mut Document,
data: Vec<u8>,
index: u32,
wanted: &[char],
text: &str,
) -> Result<TextFont> {
let face =
ttf_parser::Face::parse(&data, index).map_err(|e| anyhow!("cannot parse font: {e}"))?;
let scale = 1000.0 / face.units_per_em() as f64;
let mut remapper = subsetter::GlyphRemapper::new();
let advance = |glyph: u16| {
face.glyph_hor_advance(ttf_parser::GlyphId(glyph))
.unwrap_or(0) as f64
* scale
};
let mut subset: HashMap<u16, (u16, f64)> = HashMap::new();
let mut nominal = HashMap::new();
let mut reads: BTreeMap<u16, String> = BTreeMap::new();
for &c in wanted {
if let Some(glyph) = face.glyph_index(c) {
let new = remapper.remap(glyph.0);
subset.insert(glyph.0, (new, advance(glyph.0)));
nominal.insert(c, glyph.0);
reads.entry(new).or_insert_with(|| c.to_string());
}
}
if let Some(shaper) = rustybuzz::Face::from_slice(&data, index) {
for line in text.split(char::is_control) {
let shaped = shape(&shaper, line);
let mut i = 0;
while i < shaped.len() {
let mut j = i;
while j < shaped.len() && shaped[j].cluster == shaped[i].cluster {
j += 1;
}
let mut rest: Vec<char> = line[shaped[i].cluster.clone()].chars().collect();
let mut put_in = Vec::new();
for glyph in &shaped[i..j] {
let new = remapper.remap(glyph.glyph);
subset.insert(glyph.glyph, (new, advance(glyph.glyph)));
match rest
.iter()
.position(|c| nominal.get(c) == Some(&glyph.glyph))
{
Some(at) => {
rest.remove(at);
}
None => put_in.push(new),
}
}
for (k, new) in put_in.iter().enumerate() {
let to = if k + 1 == put_in.len() {
rest.len()
} else {
(k + 1).min(rest.len())
};
let part: String = rest[k.min(rest.len())..to].iter().collect();
if !part.is_empty() {
reads.entry(*new).or_insert(part);
}
}
i = j;
}
}
}
let program = subsetter::subset(&data, index, &remapper)
.map_err(|e| anyhow!("cannot subset font: {e}"))?;
let cff_tag = ttf_parser::Tag::from_bytes(b"CFF ");
let is_cff = face.raw_face().table(cff_tag).is_some();
let program = if is_cff {
let raw = ttf_parser::RawFace::parse(&program, 0)
.map_err(|e| anyhow!("cannot read font subset: {e}"))?;
raw.table(cff_tag)
.ok_or_else(|| anyhow!("font subset has no CFF table"))?
.to_vec()
} else {
program
};
let base = face
.names()
.into_iter()
.find(|n| n.name_id == ttf_parser::name_id::POST_SCRIPT_NAME)
.and_then(|n| n.to_string())
.map(|n| {
n.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-')
.collect::<String>()
})
.filter(|n| !n.is_empty())
.unwrap_or_else(|| "Font".to_string());
let mut glyph_set: Vec<u16> = subset.keys().copied().collect();
glyph_set.sort_unstable();
let hash = glyph_set.iter().fold(0xcbf29ce484222325u64, |h, &g| {
(h ^ g as u64).wrapping_mul(0x100000001b3)
});
let tag: String = (0..6)
.map(|i| (b'A' + ((hash >> (i * 8)) % 26) as u8) as char)
.collect();
let name = Object::Name(format!("{tag}+{base}").into_bytes());
let mut file = Dictionary::new();
if is_cff {
file.set("Subtype", Object::Name(b"CIDFontType0C".to_vec()));
}
let mut file = Stream::new(file, program);
let _ = file.compress();
let file = doc.add_object(file);
let bbox = face.global_bounding_box();
let mut descriptor = Dictionary::new();
descriptor.set("Type", Object::Name(b"FontDescriptor".to_vec()));
descriptor.set("FontName", name.clone());
descriptor.set("Flags", 4);
descriptor.set(
"FontBBox",
[bbox.x_min, bbox.y_min, bbox.x_max, bbox.y_max]
.map(|v| Object::Real((v as f64 * scale) as f32))
.to_vec(),
);
descriptor.set("ItalicAngle", 0);
descriptor.set(
"Ascent",
Object::Real((face.ascender() as f64 * scale) as f32),
);
descriptor.set(
"Descent",
Object::Real((face.descender() as f64 * scale) as f32),
);
descriptor.set(
"CapHeight",
Object::Real((face.capital_height().unwrap_or(face.ascender()) as f64 * scale) as f32),
);
descriptor.set("StemV", 80);
descriptor.set(if is_cff { "FontFile3" } else { "FontFile2" }, file);
let descriptor = doc.add_object(descriptor);
let mut by_glyph: Vec<(u16, f64)> = subset.values().copied().collect();
by_glyph.sort_by_key(|g| g.0);
let mut widths = Vec::with_capacity(by_glyph.len() * 2);
for &(glyph, advance) in &by_glyph {
widths.push(Object::Integer(glyph as i64));
widths.push(Object::Array(vec![Object::Real(advance as f32)]));
}
let mut system = Dictionary::new();
system.set("Registry", Object::string_literal("Adobe"));
system.set("Ordering", Object::string_literal("Identity"));
system.set("Supplement", 0);
let mut cid = Dictionary::new();
cid.set("Type", Object::Name(b"Font".to_vec()));
cid.set(
"Subtype",
Object::Name(if is_cff {
b"CIDFontType0".to_vec()
} else {
b"CIDFontType2".to_vec()
}),
);
cid.set("BaseFont", name.clone());
cid.set("CIDSystemInfo", system);
cid.set("FontDescriptor", descriptor);
cid.set("DW", 1000);
cid.set("W", widths);
if !is_cff {
cid.set("CIDToGIDMap", Object::Name(b"Identity".to_vec()));
}
let cid = doc.add_object(cid);
let reads: Vec<(u16, String)> = reads.into_iter().collect();
let mut to_unicode = Stream::new(Dictionary::new(), to_unicode(&reads).into_bytes());
let _ = to_unicode.compress();
let to_unicode = doc.add_object(to_unicode);
let mut font = Dictionary::new();
font.set("Type", Object::Name(b"Font".to_vec()));
font.set("Subtype", Object::Name(b"Type0".to_vec()));
font.set("BaseFont", name);
font.set("Encoding", Object::Name(b"Identity-H".to_vec()));
font.set("DescendantFonts", vec![Object::Reference(cid)]);
font.set("ToUnicode", to_unicode);
Ok(TextFont {
id: doc.add_object(font),
embedded: Some(Embedded {
data,
index,
scale,
subset,
nominal,
}),
builtin: Style::default(),
})
}
fn to_unicode(glyphs: &[(u16, String)]) -> String {
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n\
/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def\n\
/CMapName /Adobe-Identity-UCS def\n/CMapType 2 def\n\
1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n",
);
for chunk in glyphs.chunks(100) {
cmap += &format!("{} beginbfchar\n", chunk.len());
for (glyph, text) in chunk {
let utf16: String = text.encode_utf16().map(|u| format!("{u:04X}")).collect();
cmap += &format!("<{glyph:04X}> <{utf16}>\n");
}
cmap += "endbfchar\n";
}
cmap + "endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn latin1_text_uses_helvetica() {
let mut doc = Document::with_version("1.7");
let font = TextFont::new(&mut doc, "Caf\u{e9} (1)", None).unwrap();
assert_eq!(font.show("a(b)\u{e9}", 10.0), "(a\\(b\\)\\351) Tj");
assert!((font.width("Hi", 10.0) - 9.44).abs() < 1e-9);
assert_eq!(
doc.get_dictionary(font.id)
.unwrap()
.get(b"BaseFont")
.unwrap()
.as_name()
.unwrap(),
b"Helvetica"
);
}
#[test]
fn to_unicode_maps_glyphs_including_astral_characters_and_ligatures() {
let cmap = to_unicode(&[
(1, "ż".to_string()),
(2, "😀".to_string()),
(3, "fi".to_string()),
]);
assert!(cmap.contains(
"3 beginbfchar\n<0001> <017C>\n<0002> <D83DDE00>\n<0003> <00660069>\nendbfchar"
));
}
fn embedded(text: &str) -> Option<(Document, TextFont)> {
let mut doc = Document::with_version("1.7");
match TextFont::new(&mut doc, text, None) {
Ok(font) => Some((doc, font)),
Err(e) => {
assert!(e.to_string().contains("no installed font"), "{e:#}");
eprintln!("skipped: {e}");
None
}
}
}
fn drawn(doc: &Document, font: &TextFont, text: &str) -> Vec<String> {
let map = doc
.get_dictionary(font.id)
.unwrap()
.get(b"ToUnicode")
.unwrap();
let map = doc
.get_object(map.as_reference().unwrap())
.unwrap()
.as_stream()
.unwrap();
let map = String::from_utf8(map.decompressed_content().unwrap()).unwrap();
font.embedded
.as_ref()
.unwrap()
.layout(text)
.iter()
.map(|g| {
let line = map
.lines()
.find(|l| l.starts_with(&format!("<{:04X}> ", g.glyph)));
let hex = line.map_or("", |l| l[8..].trim_matches(|c| c == '<' || c == '>'));
let units: Vec<u16> = (0..hex.len() / 4)
.map(|i| u16::from_str_radix(&hex[i * 4..i * 4 + 4], 16).unwrap())
.collect();
String::from_utf16(&units).unwrap()
})
.collect()
}
#[test]
fn right_to_left_text_is_drawn_from_its_right_end() {
let Some((doc, font)) = embedded("\u{5e9}\u{5dc}\u{5d5}\u{5dd} abc") else {
return;
};
assert_eq!(
drawn(&doc, &font, "\u{5e9}\u{5dc}\u{5d5}\u{5dd}"),
["\u{5dd}", "\u{5d5}", "\u{5dc}", "\u{5e9}"]
);
assert_eq!(
drawn(&doc, &font, "abc \u{5e9}\u{5dc}\u{5d5}\u{5dd}"),
[
"a", "b", "c", " ", "\u{5dd}", "\u{5d5}", "\u{5dc}", "\u{5e9}"
]
);
}
#[test]
fn arabic_is_joined_and_its_ligature_reads_as_both_letters() {
let word = "\u{633}\u{644}\u{627}\u{645}";
let Some((doc, font)) = embedded(word) else {
return;
};
let embedded = font.embedded.as_ref().unwrap();
let face = ttf_parser::Face::parse(&embedded.data, embedded.index).unwrap();
if face
.raw_face()
.table(ttf_parser::Tag::from_bytes(b"GSUB"))
.is_none()
{
eprintln!("skipped: the installed font has no shaping tables");
return;
}
assert_eq!(
drawn(&doc, &font, word),
["\u{645}", "\u{644}\u{627}", "\u{633}"]
);
let glyphs = embedded.layout(word);
let alone = embedded.subset[&embedded.nominal[&'\u{633}']].0;
assert_ne!(glyphs[2].glyph, alone);
assert_eq!(embedded.layout("\u{645}\u{633}").len(), 2);
}
#[test]
fn kerning_narrows_text_and_is_written_as_movements() {
let Some((_, font)) = embedded("\u{17c} AVAVAV") else {
return;
};
let apart = 3.0 * (font.width("A", 100.0) + font.width("V", 100.0));
let together = font.width("AVAVAV", 100.0);
if (apart - together).abs() < 1e-6 {
eprintln!("skipped: the installed font does not kern A and V");
return;
}
assert!(together < apart, "{together} {apart}");
let shown = font.show("AVAVAV", 100.0);
assert!(shown.starts_with('[') && shown.ends_with("] TJ"), "{shown}");
let moved: f64 = shown
.trim_matches(|c| c == '[' || c == ']' || c == 'T' || c == 'J' || c == ' ')
.split(' ')
.filter_map(|part| part.parse::<f64>().ok())
.sum();
assert!(
(moved / 10.0 - (apart - together)).abs() < 0.6,
"{moved} {apart} {together}"
);
assert_eq!(font.elements("AVAVAV").len(), shown.split(' ').count() - 1);
}
}