//! Text measurement and line breaking with the standard 14 fonts that stand in for the //! template's typefaces (no font programs are embedded in dynamic XFA forms, and none are //! bundled here). use crate::model::{Font, HAlign, Para, Rect, RichText, Run}; use pdfcraft_fonts::Std14; /// The standard-14 family a typeface maps to. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Family { Helvetica, Courier, Times, } impl Family { pub fn of(typeface: &str) -> Family { let t = typeface.to_ascii_lowercase(); if t.contains("courier") || t.contains("mono") { Family::Courier } else if t.contains("times") || t.contains("georgia") || t.contains("garamond") || t.contains("minion") || t.contains("serif") && !t.contains("sans") { Family::Times } else { Family::Helvetica } } /// The standard-14 font this family draws in, as `bold` and `italic` ask. pub fn std14(self, bold: bool, italic: bool) -> Std14 { match (self, bold, italic) { (Family::Helvetica, false, false) => Std14::Helvetica, (Family::Helvetica, true, false) => Std14::HelveticaBold, (Family::Helvetica, false, true) => Std14::HelveticaOblique, (Family::Helvetica, true, true) => Std14::HelveticaBoldOblique, (Family::Courier, false, false) => Std14::Courier, (Family::Courier, true, false) => Std14::CourierBold, (Family::Courier, false, true) => Std14::CourierOblique, (Family::Courier, true, true) => Std14::CourierBoldOblique, (Family::Times, false, false) => Std14::TimesRoman, (Family::Times, true, false) => Std14::TimesBold, (Family::Times, false, true) => Std14::TimesItalic, (Family::Times, true, true) => Std14::TimesBoldItalic, } } /// The PDF base font name for a face. pub fn base_font(self, bold: bool, italic: bool) -> &'static str { self.std14(bold, italic).base_font() } /// The resource name used in content streams and `/DA` strings (Acrobat's conventions). pub fn resource_name(self, bold: bool, italic: bool) -> &'static str { match (self, bold, italic) { (Family::Helvetica, false, false) => "Helv", (Family::Helvetica, true, false) => "HeBo", (Family::Helvetica, false, true) => "HeOb", (Family::Helvetica, true, true) => "HeBO", (Family::Courier, false, false) => "Cour", (Family::Courier, true, false) => "CoBo", (Family::Courier, false, true) => "CoOb", (Family::Courier, true, true) => "CoBO", (Family::Times, false, false) => "TiRo", (Family::Times, true, false) => "TiBo", (Family::Times, false, true) => "TiIt", (Family::Times, true, true) => "TiBI", } } } /// A resolved face: family, weight, posture, size and colour. #[derive(Clone, Debug, PartialEq)] pub struct Face { pub family: Family, pub bold: bool, pub italic: bool, pub size: f64, pub color: [f64; 3], pub underline: bool, /// Horizontal scale of the glyphs (`fontHorizontalScale`), as a fraction: widths are /// measured at it and the text is written with `Tz`. pub h_scale: f64, } impl Face { /// The face a resolved font is drawn in: the standard-14 family its typeface maps to, /// with its size and horizontal scale clamped to a drawable range. pub fn of(font: &Font) -> Face { Face { family: Family::of(&font.typeface), bold: font.bold, italic: font.italic, size: font.size.clamp(1.0, 300.0), color: font.color.0, underline: font.underline, h_scale: font.h_scale.clamp(0.01, 10.0), } } /// This face with what a rich-text run sets in place of its own: weight, posture, /// underline, size and horizontal scale. The family and color stay. pub fn with_run(&self, run: &Run) -> Face { Face { family: self.family, bold: run.bold.unwrap_or(self.bold), italic: run.italic.unwrap_or(self.italic), size: run.size.unwrap_or(self.size).clamp(1.0, 300.0), color: self.color, underline: run.underline.unwrap_or(self.underline), h_scale: run.h_scale.unwrap_or(self.h_scale).clamp(0.01, 10.0), } } /// This face as designed, without a horizontal scale: field text is drawn by the forms /// layer, whose appearances have none, so it is measured that way too. pub fn unscaled(self) -> Face { Face { h_scale: 1.0, ..self } } /// The resource name of this face's standard font, as content streams and `/DA` strings /// refer to it. pub fn resource_name(&self) -> &'static str { self.family.resource_name(self.bold, self.italic) } /// Advance of `s` at this face's size: the standard-14 font it is drawn in, at that font's /// own widths (ISO 32000-2 Annex D) and encoded as it is written, then the horizontal scale. pub fn width(&self, s: &str) -> f64 { #[cfg(test)] MEASURED.with(|m| m.set(m.get().saturating_add(s.len() as u64))); self.family.std14(self.bold, self.italic).text_width(s, self.size) * self.h_scale } } // Bytes of text measured on this thread, for tests that bound layout work. #[cfg(test)] thread_local! { pub(crate) static MEASURED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// Default line height: XFA's font leading for the standard faces. pub fn line_height(size: f64) -> f64 { size * 1.15 } /// One positioned piece of text, ready to emit. #[derive(Clone, Debug, PartialEq)] pub struct Span { pub x: f64, /// Baseline, top-left based (y grows downwards). pub baseline: f64, pub text: String, pub face: Face, /// Where the text links to (its run's ``), as the template writes it, shared with the /// link's other spans. pub link: Option>, } /// A laid-out block of text. #[derive(Clone, Debug, Default, PartialEq)] pub struct Block { pub spans: Vec, /// Height used, from the top of the box. pub height: f64, } #[derive(Clone, Debug)] struct Word { text: String, face: Face, width: f64, /// The link of the run the word came from, shared, not copied, so a long link over many /// words costs one address. link: Option>, } /// The width of `w` without the spaces it ends with: what it takes at the end of a line, where /// a space is never drawn. fn ink_width(w: &Word) -> f64 { let ink = w.text.trim_end_matches(' '); if ink.len() == w.text.len() { w.width } else { w.face.width(ink) } } /// Most bytes of embedded field values one text block shows, as much as a text of its own /// holds: a value embedded over and over is expanded only so far. pub(crate) const MAX_EMBEDDED: usize = 1 << 20; /// The longest prefix of `s` at most `max` bytes long that ends on a character boundary. pub fn prefix(s: &str, max: usize) -> &str { if s.len() <= max { return s; } let end = s.char_indices().map(|(i, _)| i).take_while(|i| *i <= max).last().unwrap_or(0); s.get(..end).unwrap_or_default() } /// Break the runs of a paragraph into words (spaces stay attached to the word before them). /// Embedded values take from `embed_left` bytes and are cut when it runs out; `embed` is asked /// for at most that many bytes of each. fn words(runs: &[Run], base: &Face, embed: &dyn Fn(&str, usize) -> String, embed_left: &mut usize) -> Vec { let mut out = Vec::new(); for run in runs { let face = base.with_run(run); let text: String = match &run.embed { Some(_) if *embed_left == 0 => String::new(), Some(id) => { let mut v = embed(id, *embed_left); if v.len() > *embed_left { v = prefix(&v, *embed_left).to_string(); } *embed_left = embed_left.saturating_sub(v.len()); v } None => run .text .chars() .filter_map(|c| match c { '\r' | '\n' | '\t' | '\u{2028}' | '\u{2029}' | '\u{2009}' | '\u{202F}' | '\u{3000}' => Some(' '), '\u{00AD}' | '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' => None, '\u{2010}'..='\u{2013}' => Some('-'), '\u{2018}' | '\u{2019}' => Some('\''), _ => Some(c), }) .collect(), }; let mut cur = String::new(); for ch in text.chars() { cur.push(ch); if ch == ' ' { let width = face.width(&cur); out.push(Word { text: std::mem::take(&mut cur), face: face.clone(), width, link: run.link.clone() }); } } if !cur.is_empty() { let width = face.width(&cur); out.push(Word { text: cur, face, width, link: run.link.clone() }); } } out } /// Lay `rich` out inside `rect` with `para` and `base` face. `embed` resolves embedded fields to /// their text. Returns the spans (absolute coordinates) and the height the text takes. pub fn layout(rich: &RichText, rect: Rect, para: &Para, base: &Face, embed: &dyn Fn(&str, usize) -> String) -> Block { struct Line { words: Vec, width: f64, size: f64, first: bool, } let avail = (rect.w - para.margin_left - para.margin_right).max(1.0); let mut lines: Vec<(Line, Option)> = Vec::new(); let mut total = 0.0; let mut embed_left = MAX_EMBEDDED; for (pi, p) in rich.paragraphs.iter().enumerate() { let ws = words(&p.runs, base, embed, &mut embed_left); let mut line = Line { words: Vec::new(), width: 0.0, size: base.size, first: true }; let mut first_in_para = true; let indent = |first: bool| if first { para.text_indent.max(0.0) } else { 0.0 }; for w in ws { // A word fits when what it draws does: the spaces after it would end the line. Spaces // alone draw nothing, so they always stay on the line (keeping their width, so a word // after them still starts past them). let ink = ink_width(&w); let blank = w.text.trim_end_matches(' ').is_empty(); let fits = blank || line.width + ink <= avail - indent(line.first) || line.words.is_empty() && ink <= avail - indent(line.first); if !fits && !line.words.is_empty() { let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); } if ink > avail - indent(line.first) && line.words.is_empty() { // A word longer than a line: break it by character. The piece's advance grows by // each character's (widths add up), so no prefix is measured again. let mut piece = String::new(); let mut advance = 0.0; let mut buf = [0u8; 4]; for ch in w.text.chars() { let ch_width = w.face.width(ch.encode_utf8(&mut buf)); piece.push(ch); advance += ch_width; if advance > avail - indent(line.first) && piece.len() > ch.len_utf8() { piece.pop(); let width = w.face.width(&piece); line.words.push(Word { text: std::mem::take(&mut piece), face: w.face.clone(), width, link: w.link.clone() }); line.width += width; line.size = line.size.max(w.face.size); let done = std::mem::replace(&mut line, Line { words: Vec::new(), width: 0.0, size: base.size, first: false }); lines.push((done, p.h_align)); piece.push(ch); advance = ch_width; } } if !piece.is_empty() { let width = w.face.width(&piece); line.size = line.size.max(w.face.size); line.width += width; line.words.push(Word { text: piece, face: w.face.clone(), width, link: w.link.clone() }); } first_in_para = false; continue; } line.size = line.size.max(w.face.size); line.width += w.width; line.words.push(w); first_in_para = false; } if !line.words.is_empty() || first_in_para && pi + 1 < rich.paragraphs.len() { lines.push((line, p.h_align)); } total += para.space_above + para.space_below; } let heights: Vec = lines.iter().map(|(l, _)| para.line_height.unwrap_or_else(|| line_height(l.size))).collect(); total += heights.iter().sum::(); let start_y = match para.v_align { crate::model::VAlign::Top => rect.y, crate::model::VAlign::Middle => rect.y + ((rect.h - total) / 2.0).max(0.0), crate::model::VAlign::Bottom => rect.y + (rect.h - total).max(0.0), }; let mut spans = Vec::new(); let mut y = start_y + para.space_above; for ((line, align), lh) in lines.into_iter().zip(heights) { let indent = if line.first { para.text_indent.max(0.0) } else { 0.0 }; // Trailing spaces don't count for alignment: those of the last word with ink, and any // words of spaces alone after it. let mut trailing = 0.0; for w in line.words.iter().rev() { let ink = ink_width(w); trailing += w.width - ink; if !w.text.trim_end_matches(' ').is_empty() { break; } } let used = (line.width - trailing).max(0.0); let mut x = rect.x + para.margin_left + indent; x += match align.unwrap_or(para.h_align) { HAlign::Left | HAlign::Justify => 0.0, HAlign::Center => ((avail - indent - used) / 2.0).max(0.0), HAlign::Right => (avail - indent - used).max(0.0), }; // Baseline: ascent sits about 0.8 of the size below the line top for these faces. let baseline = y + lh - (lh - line.size * 0.8).max(0.0) / 2.0 - line.size * 0.08; // Words of one face and one link make one span, so a link's text is a span of its own. let mut run: Option = None; for w in line.words { match &mut run { Some(s) if s.face == w.face && s.link == w.link => s.text.push_str(&w.text), _ => { if let Some(s) = run.take() { x += s.face.width(&s.text); spans.push(s); } run = Some(Span { x, baseline, text: w.text, face: w.face, link: w.link }); } } } if let Some(s) = run.take() { spans.push(s); } y += lh; } Block { spans, height: total } } /// The width `rich` takes with no line wrapped (its longest paragraph, with the paragraph's /// margins and first-line indent): how wide a box without an explicit width grows. pub fn natural_width(rich: &RichText, para: &Para, base: &Face, embed: &dyn Fn(&str, usize) -> String) -> f64 { let indent = para.text_indent.max(0.0); let mut embed_left = MAX_EMBEDDED; let widest = rich.paragraphs.iter().map(|p| words(&p.runs, base, embed, &mut embed_left).iter().map(|w| w.width).sum::()).fold(0.0, f64::max); (para.margin_left + indent + widest + para.margin_right).max(0.0) } /// Height `rich` needs at `width` (for boxes without an explicit height). pub fn measure_height(rich: &RichText, width: f64, para: &Para, base: &Face, embed: &dyn Fn(&str, usize) -> String) -> f64 { let p = Para { v_align: crate::model::VAlign::Top, ..para.clone() }; layout(rich, Rect::new(0.0, 0.0, width.max(1.0), 0.0), &p, base, embed).height } /// Plain text as rich text (paragraphs split on line breaks; a carriage return and line feed /// together are one break, as form data often holds them). pub fn plain(text: &str) -> RichText { RichText { paragraphs: text .replace("\r\n", "\n") .split(['\n', '\r', '\u{2028}', '\u{2029}']) .map(|l| crate::model::Paragraph { runs: vec![Run { text: l.to_string(), ..Run::default() }], h_align: None }) .collect(), } }