use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
use pdf_writer::types::TextRenderingMode;
use pdf_writer::{Content, Name, Rect, Str};
use crate::fonts::{FontEntry, encode_as_gids, font_key_buf, to_winansi_bytes};
use crate::model::{
Alignment, FormCheckbox, LineSpacing, Paragraph, ParagraphBorder, Run, TabAlignment, TabStop,
TextFill, TextOutline, TextShadow, VertAlign,
};
use super::RenderContext;
use super::color::{fill_color_or_black, stroke_color_or_black};
use super::images::EffectXObjs;
pub(super) static EMPTY_INLINE_IMAGES: LazyLock<HashMap<usize, String>> =
LazyLock::new(HashMap::new);
pub(super) static EMPTY_EFFECTS: LazyLock<HashMap<usize, EffectXObjs>> =
LazyLock::new(HashMap::new);
fn char_justify_gaps(
alignment: Alignment,
can_justify: bool,
has_cjk: bool,
char_count: usize,
) -> Option<usize> {
if !can_justify || char_count < 2 {
return None;
}
match (alignment, has_cjk) {
(Alignment::Distribute, _) => Some(char_count - 1),
(_, true) => Some(char_count),
_ => None,
}
}
fn resolve_tab_aligned_start(
stop: &TabStop,
tab_target: f32,
seg_runs: &[&Run],
seen_fonts: &HashMap<String, FontEntry>,
min_x: f32,
) -> f32 {
match stop.alignment {
TabAlignment::Left => tab_target.max(min_x),
TabAlignment::Center => {
let sw = segment_width(seg_runs, seen_fonts);
(tab_target - sw / 2.0).max(min_x)
}
TabAlignment::Right => {
let sw = segment_width(seg_runs, seen_fonts);
(tab_target - sw).max(min_x)
}
TabAlignment::Decimal => {
let bw = decimal_before_width(seg_runs, seen_fonts);
(tab_target - bw).max(min_x)
}
}
}
fn is_break_space(c: char) -> bool {
c.is_whitespace() && c != '\u{00a0}' && c != '\u{3000}'
}
fn drop_url_breaks(text: &str, breaks: &mut Vec<usize>) {
let mut urls = Vec::new();
let mut from = 0;
while let Some(rel) = [text[from..].find("://"), text[from..].find("www.")]
.into_iter()
.flatten()
.min()
{
let at = from + rel;
let start = text[..at]
.rfind(|c: char| !c.is_ascii_alphanumeric())
.map_or(0, |i| i + 1);
let end = text[at..]
.find(char::is_whitespace)
.map_or(text.len(), |i| at + i);
urls.push(start..end);
from = end;
}
breaks.retain(|&b| !urls.iter().any(|u| u.start < b && b < u.end) || text[..b].ends_with('-'));
}
fn word_pair_rule(a: char, b: char) -> Option<bool> {
if a == '-' && (b == '-' || b.is_ascii_digit()) {
return Some(true);
}
if is_break_space(a) && !is_break_space(b) {
return Some(true);
}
if a == '/' && b.is_alphanumeric() {
return Some(false);
}
if matches!(a, '\u{2024}' | '\u{2025}' | '\u{2026}') && !b.is_whitespace() {
return Some(false);
}
None
}
fn word_pair_rule_at(text: &str, i: usize) -> Option<bool> {
word_pair_rule(text[..i].chars().next_back()?, text[i..].chars().next()?)
}
fn breaks_between(a: char, b: char) -> bool {
if let Some(rule) = word_pair_rule(a, b) {
return rule;
}
let mut buf = [0u8; 8];
let la = a.encode_utf8(&mut buf).len();
let lb = b.encode_utf8(&mut buf[la..]).len();
let Ok(pair) = std::str::from_utf8(&buf[..la + lb]) else {
return false;
};
unicode_linebreak::linebreaks(pair).any(|(pos, _)| pos == la)
}
pub(super) fn split_preserving_spaces(text: &str) -> Vec<(usize, &str)> {
if text.is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut pending_spaces: usize = 0;
let mut breaks: Vec<usize> = unicode_linebreak::linebreaks(text)
.map(|(pos, _)| pos)
.collect();
drop_url_breaks(text, &mut breaks);
breaks.retain(|&b| word_pair_rule_at(text, b) != Some(false));
breaks.extend(
text.char_indices()
.map(|(i, _)| i)
.filter(|&i| word_pair_rule_at(text, i) == Some(true)),
);
breaks.sort_unstable();
let mut prev = 0;
for &brk in &breaks {
let segment = &text[prev..brk];
prev = brk;
if segment.is_empty() {
continue;
}
let leading_bytes: usize = segment
.chars()
.take_while(|c| is_break_space(*c))
.map(|c| c.len_utf8())
.sum();
let leading_count = segment[..leading_bytes].chars().count();
let trailing_bytes: usize = segment
.chars()
.rev()
.take_while(|c| is_break_space(*c))
.map(|c| c.len_utf8())
.sum();
let trailing_count = if trailing_bytes < segment.len() - leading_bytes {
segment[segment.len() - trailing_bytes..].chars().count()
} else {
0
};
pending_spaces += leading_count;
let word_start = leading_bytes;
let word_end = segment.len() - trailing_bytes;
if word_start < word_end {
result.push((pending_spaces, &segment[word_start..word_end]));
pending_spaces = trailing_count;
} else {
pending_spaces += trailing_count;
}
}
result
}
pub(super) struct WordChunk {
pub(super) pdf_font: String,
pub(super) text: String,
pub(super) font_size: f32,
pub(super) line_font_size: f32,
pub(super) color: Option<[u8; 3]>,
pub(super) highlight: Option<[u8; 3]>,
pub(super) shading: Option<[u8; 3]>,
pub(super) border: Option<ParagraphBorder>,
pub(super) x_offset: f32, pub(super) width: f32,
pub(super) underline: bool,
pub(super) double_underline: bool,
pub(super) strikethrough: bool,
pub(super) dstrike: bool,
pub(super) char_spacing: f32,
pub(super) text_scale: f32, pub(super) y_offset: f32, pub(super) raise: f32,
pub(super) hyperlink_url: Option<String>,
pub(super) inline_image_name: Option<String>,
pub(super) inline_image_height: f32,
pub(super) inline_image_extra_height: f32,
pub(super) inline_image_stroke_color: Option<[u8; 3]>,
pub(super) inline_image_stroke_width: f32,
pub(super) inline_image_shadow: Option<crate::model::ImageShadow>,
pub(super) inline_image_glow: Option<crate::model::ImageGlow>,
pub(super) inline_image_effect_xobjs: Option<super::images::EffectXObjs>,
pub(super) inline_image_clip: Option<crate::model::ShapeGeometry>,
pub(super) inline_image_size: (f32, f32),
pub(super) inline_image_rotation_deg: f32,
pub(super) inline_image_alt: Option<String>,
pub(super) inline_image_decorative: bool,
pub(super) synthetic_bold: bool,
pub(super) synthetic_italic: bool,
pub(super) text_outline: Option<TextOutline>,
pub(super) text_fill: Option<TextFill>,
pub(super) text_shadow: Option<TextShadow>,
pub(super) comment_ids: Vec<u32>,
pub(super) footnote_id: Option<u32>,
pub(super) endnote_id: Option<u32>,
pub(super) actual_text: Option<String>,
pub(super) lang: Option<Arc<str>>,
pub(super) punct_compressed: f32,
pub(super) space_after: bool,
pub(super) is_math: bool,
pub(super) formula: Option<Arc<str>>,
pub(super) kern: bool,
pub(super) checkbox: Option<FormCheckbox>,
}
pub(super) const COMMENT_HIGHLIGHT_RGB: [u8; 3] = [251, 220, 217];
type Decoration = (f32, f32, f32, f32, Option<[u8; 3]>);
fn push_decoration(
decorations: &mut Vec<Decoration>,
x: f32,
y: f32,
width: f32,
height: f32,
color: Option<[u8; 3]>,
) {
let merged = decorations.iter_mut().rev().take(3).find(|(_, dy, _, dh, dc)| {
(*dy - y).abs() < 0.01 && (*dh - height).abs() < 0.01 && *dc == color
});
if let Some(prev) = merged {
prev.2 = (x + width) - prev.0;
} else {
decorations.push((x, y, width, height, color));
}
}
impl WordChunk {
fn note(&self) -> Option<(bool, u32)> {
self.footnote_id
.map(|id| (false, id))
.or(self.endnote_id.map(|id| (true, id)))
}
pub(super) fn boundary_space(&self, entry: Option<&FontEntry>) -> bool {
self.space_after && entry.is_none_or(|e| e.has_char(' '))
}
fn text(
entry: &FontEntry,
run: &Run,
word: &str,
eff_fs: f32,
char_spacing: f32,
y_offset: f32,
x_offset: f32,
width: f32,
) -> Self {
Self {
pdf_font: entry.pdf_name.clone(),
text: word.to_string(),
font_size: eff_fs,
line_font_size: run.font_size,
color: run.color,
highlight: run.highlight,
shading: if run.shading.is_none() && !run.comment_ids.is_empty() {
Some(COMMENT_HIGHLIGHT_RGB)
} else {
run.shading
},
border: run.border.clone(),
x_offset,
width,
underline: run.underline,
double_underline: run.double_underline,
strikethrough: run.strikethrough,
dstrike: run.dstrike,
char_spacing,
text_scale: run.text_scale,
y_offset,
raise: run.position,
hyperlink_url: run.hyperlink_url.clone(),
inline_image_name: None,
inline_image_height: 0.0,
inline_image_extra_height: 0.0,
inline_image_stroke_color: None,
inline_image_stroke_width: 0.0,
inline_image_shadow: None,
inline_image_glow: None,
inline_image_effect_xobjs: None,
inline_image_clip: None,
inline_image_size: (0.0, 0.0),
inline_image_rotation_deg: 0.0,
inline_image_alt: None,
inline_image_decorative: false,
punct_compressed: 0.0,
synthetic_bold: entry.synthetic_bold,
synthetic_italic: entry.synthetic_italic,
text_outline: run.text_outline.clone(),
text_fill: run.text_fill.clone(),
text_shadow: run.text_shadow.clone(),
comment_ids: run.comment_ids.clone(),
footnote_id: run.footnote_id,
endnote_id: run.endnote_id,
actual_text: None,
lang: chunk_lang(run, word),
space_after: false,
is_math: run.is_math,
formula: run.formula.clone(),
kern: run.kerns_at(eff_fs),
checkbox: run.checkbox,
}
}
fn image(
pdf_name: &str,
font_size: f32,
x_offset: f32,
img: &crate::model::EmbeddedImage,
effect_xobjs: Option<super::images::EffectXObjs>,
) -> Self {
let (width, height) = img.layout_size();
Self {
pdf_font: String::new(),
text: String::new(),
font_size,
line_font_size: font_size,
color: None,
highlight: None,
shading: None,
border: None,
x_offset,
width,
underline: false,
double_underline: false,
strikethrough: false,
dstrike: false,
char_spacing: 0.0,
text_scale: 100.0,
y_offset: 0.0,
raise: 0.0,
hyperlink_url: None,
inline_image_name: Some(pdf_name.to_string()),
inline_image_height: height,
inline_image_extra_height: 0.0,
inline_image_stroke_color: img.stroke_color,
inline_image_stroke_width: img.stroke_width,
inline_image_shadow: img.shadow.clone(),
inline_image_glow: img.glow.clone(),
inline_image_effect_xobjs: effect_xobjs,
inline_image_clip: img.clip_geometry.clone(),
inline_image_size: (img.display_width, img.display_height),
inline_image_rotation_deg: img.rotation_deg,
inline_image_alt: img.alt.clone(),
inline_image_decorative: img.decorative,
punct_compressed: 0.0,
is_math: false,
formula: None,
kern: false,
checkbox: None,
synthetic_bold: false,
synthetic_italic: false,
text_outline: None,
text_fill: None,
text_shadow: None,
comment_ids: Vec::new(),
footnote_id: None,
endnote_id: None,
actual_text: None,
lang: None,
space_after: false,
}
}
fn leader(
entry: &FontEntry,
text: String,
font_size: f32,
color: Option<[u8; 3]>,
x_offset: f32,
width: f32,
) -> Self {
Self {
pdf_font: entry.pdf_name.clone(),
text,
font_size,
line_font_size: font_size,
color,
highlight: None,
shading: None,
border: None,
x_offset,
width,
underline: false,
double_underline: false,
strikethrough: false,
dstrike: false,
char_spacing: 0.0,
text_scale: 100.0,
y_offset: 0.0,
raise: 0.0,
hyperlink_url: None,
inline_image_name: None,
inline_image_height: 0.0,
inline_image_extra_height: 0.0,
inline_image_stroke_color: None,
inline_image_stroke_width: 0.0,
inline_image_shadow: None,
inline_image_glow: None,
inline_image_effect_xobjs: None,
inline_image_clip: None,
inline_image_size: (0.0, 0.0),
inline_image_rotation_deg: 0.0,
inline_image_alt: None,
inline_image_decorative: false,
punct_compressed: 0.0,
is_math: false,
formula: None,
kern: false,
checkbox: None,
synthetic_bold: false,
synthetic_italic: false,
text_outline: None,
text_fill: None,
text_shadow: None,
comment_ids: Vec::new(),
footnote_id: None,
endnote_id: None,
actual_text: None,
lang: None,
space_after: false,
}
}
fn tab_underline(
entry: &FontEntry,
font_size: f32,
color: Option<[u8; 3]>,
double_underline: bool,
border: Option<ParagraphBorder>,
x_offset: f32,
width: f32,
) -> Self {
Self {
pdf_font: entry.pdf_name.clone(),
text: String::new(),
font_size,
line_font_size: font_size,
color,
highlight: None,
shading: None,
border,
x_offset,
width,
underline: true,
double_underline,
strikethrough: false,
dstrike: false,
char_spacing: 0.0,
text_scale: 100.0,
y_offset: 0.0,
raise: 0.0,
hyperlink_url: None,
inline_image_name: None,
inline_image_height: 0.0,
inline_image_extra_height: 0.0,
inline_image_stroke_color: None,
inline_image_stroke_width: 0.0,
inline_image_shadow: None,
inline_image_glow: None,
inline_image_effect_xobjs: None,
inline_image_clip: None,
inline_image_size: (0.0, 0.0),
inline_image_rotation_deg: 0.0,
inline_image_alt: None,
inline_image_decorative: false,
punct_compressed: 0.0,
is_math: false,
formula: None,
kern: false,
checkbox: None,
synthetic_bold: false,
synthetic_italic: false,
text_outline: None,
text_fill: None,
text_shadow: None,
comment_ids: Vec::new(),
footnote_id: None,
endnote_id: None,
actual_text: None,
lang: None,
space_after: false,
}
}
}
pub(crate) struct LinkAnnotation {
pub(super) rect: Rect,
pub(super) url: String,
pub(super) node: Option<usize>,
pub(super) text: String,
}
pub(super) struct LinkTagger<'a> {
pub(super) tags: &'a mut super::tagging::Tags,
pub(super) page: usize,
pub(super) para: usize,
link: Option<(String, usize)>,
inline: Option<(Inline, usize)>,
}
enum Inline {
Span { lang: Option<String>, actual: bool },
Formula(Arc<str>),
}
impl<'a> LinkTagger<'a> {
pub(super) fn new(tags: &'a mut super::tagging::Tags, page: usize, para: usize) -> Self {
Self {
tags,
page,
para,
link: None,
inline: None,
}
}
fn inline(
&mut self,
content: &mut Content,
formula: Option<&Arc<str>>,
lang: Option<&str>,
actual: Option<&str>,
) -> bool {
let lang = lang.filter(|l| !self.tags.is_document_lang(l));
let same = match (&self.inline, formula) {
(Some((Inline::Formula(open), _)), Some(f)) => Arc::ptr_eq(open, f),
(Some((Inline::Span { lang: l, actual: a }, span)), None) => {
let same = l.as_deref() == lang && *a == actual.is_some();
if let (true, Some(text)) = (same, actual) {
self.tags.push_actual(*span, text);
}
same
}
(None, None) => lang.is_none() && actual.is_none(),
_ => false,
};
if same {
return false;
}
content.end_text();
let parent = self.open();
self.inline = match formula {
Some(f) => Some((Inline::Formula(f.clone()), self.tags.add_formula(parent, f))),
None if lang.is_some() || actual.is_some() => {
let span = self.tags.add_span(parent, lang, actual);
let state = Inline::Span {
lang: lang.map(str::to_string),
actual: actual.is_some(),
};
Some((state, span))
}
None => None,
};
let node = self.inline.as_ref().map_or(parent, |i| i.1);
self.tags.begin(content, self.page, node);
content.begin_text();
true
}
pub(super) fn chunk(
&mut self,
content: &mut Content,
chunk: &WordChunk,
link_url: Option<&str>,
boundary_space: bool,
) -> bool {
let mut switched = false;
if !chunk.text.is_empty() {
switched |= self.enter(content, link_url);
let actual = chunk.actual_text.as_ref().map(|t| {
if boundary_space {
format!("{t} ")
} else {
t.clone()
}
});
let formula = chunk.formula.as_ref();
let neutral = formula.is_none()
&& actual.is_none()
&& !chunk.text.chars().any(char::is_alphabetic);
let lang = match &self.inline {
None if neutral => None,
Some((
Inline::Span {
lang,
actual: false,
},
_,
)) if neutral => lang.clone(),
_ => chunk.lang.as_deref().map(str::to_string),
};
switched |= self.inline(content, formula, lang.as_deref(), actual.as_deref());
}
if let Some((endnote, id)) = chunk.note() {
let parent = self.open();
self.tags.note(endnote, id, parent);
}
switched
}
fn enter(&mut self, content: &mut Content, url: Option<&str>) -> bool {
if self.link.as_ref().map(|(u, _)| u.as_str()) == url {
return false;
}
content.end_text();
self.inline = None;
let node = match url {
Some(u) => {
let n = self.tags.add(self.para, "Link");
self.link = Some((u.to_string(), n));
n
}
None => {
self.link = None;
self.para
}
};
self.tags.begin(content, self.page, node);
content.begin_text();
true
}
fn open(&self) -> usize {
self.link.as_ref().map_or(self.para, |&(_, n)| n)
}
fn begin_picture(&mut self, content: &mut Content, alt: Option<&str>, decorative: bool) {
if decorative {
super::tagging::Tags::end(content);
} else {
let figure = self.tags.add_figure(self.open(), alt);
self.tags.begin(content, self.page, figure);
}
}
fn resume(&mut self, content: &mut Content) {
self.inline = None;
self.tags.begin(content, self.page, self.open());
}
fn artifact(&mut self, content: &mut Content, draw: impl FnOnce(&mut Content)) {
super::tagging::Tags::end(content);
draw(content);
let node = self.inline.as_ref().map_or_else(|| self.open(), |i| i.1);
self.tags.begin(content, self.page, node);
}
fn text_artifact(&mut self, content: &mut Content, draw: impl FnOnce(&mut Content)) {
content.end_text();
self.artifact(content, |content| {
content.begin_text();
draw(content);
content.end_text();
});
content.begin_text();
}
pub(super) fn finish(mut self, content: &mut Content) {
if self.link.take().is_some() | self.inline.take().is_some() {
self.tags.begin(content, self.page, self.para);
}
}
}
pub(super) struct RightRegion {
pub(super) first_chunk_idx: usize,
pub(super) region_x: f32,
pub(super) region_width: f32,
pub(super) content_width: f32,
}
#[derive(Default)]
pub(super) struct TextLine {
pub(super) chunks: Vec<WordChunk>,
pub(super) total_width: f32,
pub(super) ends_with_break: bool,
pub(super) right_region: Option<RightRegion>,
pub(super) break_font_size: Option<f32>,
pub(super) break_lhr: Option<f32>,
pub(super) justify_from: usize,
pub(super) squeezed: bool,
pub(super) natural_width: bool,
pub(super) pitch: Option<f32>,
pub(super) ascent_shift: f32,
}
pub(super) fn size_lines_by_own_runs(
lines: &mut [TextLine],
fonts: &HashMap<String, FontEntry>,
line_spacing: LineSpacing,
para_pitch: f32,
para_ascent: f32,
) {
let by_pdf_name: HashMap<&str, &FontEntry> =
fonts.values().map(|e| (e.pdf_name.as_str(), e)).collect();
for line in lines.iter_mut() {
if line.chunks.iter().any(|c| c.inline_image_name.is_some()) {
continue;
}
let (mut ascent, mut below, mut sized) = (0.0f32, 0.0f32, false);
for c in line
.chunks
.iter()
.filter(|c| !c.is_math && !c.text.trim().is_empty())
{
let Some(entry) = by_pdf_name.get(c.pdf_font.as_str()) else {
continue;
};
let (lhr, ar) = run_line_metrics(entry, &c.text);
let (lhr, ar) = (lhr.unwrap_or(1.2), ar.unwrap_or(0.75));
let pad = c.border.as_ref().map_or(0.0, |b| b.width_pt + b.space_pt);
ascent = ascent.max(c.line_font_size * ar + pad + c.raise.max(0.0));
below = below.max(c.line_font_size * (lhr - ar).max(0.0) + pad + (-c.raise).max(0.0));
sized = true;
}
if !sized {
continue;
}
let pitch = super::helpers::resolve_line_h(line_spacing, 1.0, Some(ascent + below));
if (pitch - para_pitch).abs() > 0.01 {
line.pitch = Some(pitch);
line.ascent_shift = ascent - para_ascent;
}
}
}
pub(super) fn position_stretch(runs: &[Run], ls: LineSpacing) -> f32 {
let (up, down) = runs
.iter()
.filter(|r| sizes_line(r) && !r.text.is_empty())
.fold((0.0f32, 0.0f32), |(u, d), r| {
(u.max(r.position), d.max(-r.position))
});
match ls {
LineSpacing::Auto(m) => (up + down) * m,
LineSpacing::AtLeast(_) => up + down,
LineSpacing::Exact(_) => 0.0,
}
}
pub(super) fn is_text_empty(runs: &[Run]) -> bool {
runs.iter().all(|r| {
r.vanish || (r.text.is_empty() && !r.is_tab && !r.is_line_break && r.inline_image.is_none())
})
}
fn effective_font_size(run: &Run, entry: &FontEntry) -> f32 {
let ratio = match run.vertical_align {
VertAlign::Superscript => entry.superscript_ratio,
VertAlign::Subscript => entry.subscript_ratio,
VertAlign::Baseline => return run.font_size,
};
round_half_point(run.font_size * ratio.unwrap_or(0.65))
}
fn effective_text(run: &Run) -> Cow<'_, str> {
if run.caps {
Cow::Owned(run.text.to_uppercase())
} else {
Cow::Borrowed(&run.text)
}
}
fn caps_word<'a>(run: &Run, word: &'a str) -> Cow<'a, str> {
if run.caps {
Cow::Owned(word.to_uppercase())
} else {
Cow::Borrowed(word)
}
}
fn round_half_point(pt: f32) -> f32 {
(pt * 2.0).round() / 2.0
}
fn small_caps_size(base_fs: f32) -> f32 {
round_half_point(base_fs * 0.8)
}
fn small_caps_space(prev: Option<char>, next: Option<char>) -> bool {
prev.is_some_and(char::is_lowercase) || next.is_some_and(char::is_lowercase)
}
fn word_space_width(
run: &Run,
entry: &FontEntry,
eff_fs: f32,
word: &str,
prev: &mut Option<char>,
) -> f32 {
let small = run.small_caps && small_caps_space(*prev, word.chars().next());
*prev = word.chars().next_back();
let fs = if small {
small_caps_size(eff_fs)
} else {
eff_fs
};
entry.space_width(fs) * run.text_scale / 100.0 + run.char_spacing
}
pub(super) fn smallcaps_segments(word: &str, base_fs: f32) -> Vec<(String, f32, &str)> {
let reduced = small_caps_size(base_fs);
let mut segments: Vec<(String, f32, &str)> = Vec::new();
let mut start = 0;
let mut prev = None;
let mut chars = word.char_indices().peekable();
while let Some((i, ch)) = chars.next() {
let next = chars.peek().map(|&(_, c)| c);
let is_lower = ch.is_lowercase() || (ch == ' ' && small_caps_space(prev, next));
prev = Some(ch);
let fs = if is_lower { reduced } else { base_fs };
let display: String = if is_lower {
ch.to_uppercase().collect()
} else {
ch.to_string()
};
let end = i + ch.len_utf8();
if let Some(last) = segments.last_mut()
&& (last.1 - fs).abs() < 0.001
{
last.0.push_str(&display);
last.2 = &word[start..end];
continue;
}
start = i;
segments.push((display, fs, &word[i..end]));
}
segments
}
fn fitting_prefix_len(word: &str, room: f32, width: impl Fn(&str) -> f32) -> Option<usize> {
let ends: Vec<usize> = word.char_indices().map(|(i, c)| i + c.len_utf8()).collect();
if ends.len() < 2 {
return None;
}
let fits = ends[..ends.len() - 1].partition_point(|&e| width(&word[..e]) <= room);
Some(ends[fits.saturating_sub(1)])
}
pub(super) fn word_width_for_run(
entry: &FontEntry,
run: &Run,
word: &str,
eff_fs: f32,
kern: bool,
cs: f32,
ts: f32,
) -> f32 {
if let Some(cb) = run.checkbox {
return checkbox_cell(entry, cb).0;
}
if run.small_caps {
smallcaps_segments(word, eff_fs)
.iter()
.map(|(seg, fs, _)| {
let seg_kern = run.kerns_at(*fs);
entry.word_width(seg, *fs, seg_kern) * ts + cs * seg.chars().count() as f32
})
.sum()
} else {
let char_count = word.chars().count();
entry.word_width(word, eff_fs, kern) * ts + cs * char_count as f32
}
}
fn checkbox_cell(entry: &FontEntry, cb: FormCheckbox) -> (f32, f32) {
let (line, ascent) = run_line_metrics(entry, FormCheckbox::TEXT);
let line = line.unwrap_or(1.15);
(cb.size * line, cb.size * (line - ascent.unwrap_or(0.9)))
}
fn draw_checkbox(
content: &mut Content,
entry: &FontEntry,
cb: FormCheckbox,
x: f32,
baseline: f32,
color: Option<[u8; 3]>,
) {
let (cell, descent) = checkbox_cell(entry, cb);
let side = (cell - 2.5).max(0.5);
let (left, bottom) = (x + 1.0, baseline - descent + 1.5);
content.save_state();
stroke_color_or_black(content, color);
content.set_line_width(0.75);
content.rect(left, bottom, side, side);
content.stroke();
if cb.checked {
content.set_line_width(0.5);
content.move_to(left, bottom);
content.line_to(left + side, bottom + side);
content.move_to(left, bottom + side);
content.line_to(left + side, bottom);
content.stroke();
}
content.restore_state();
}
fn mark_space_after(chunks: &mut [WordChunk]) {
if let Some(c) = chunks.iter_mut().rev().find(|c| !c.text.is_empty()) {
c.space_after = true;
}
}
#[derive(Clone, Copy, PartialEq)]
pub(super) enum Script {
Latin,
EastAsian,
Complex,
}
impl Script {
pub(super) fn of(ch: char) -> Script {
if crate::docx::is_complex_script_char(ch) {
Script::Complex
} else if crate::docx::is_east_asian_char(ch) {
Script::EastAsian
} else {
Script::Latin
}
}
}
pub(super) fn run_lang(run: &Run, script: Script) -> Option<&Arc<str>> {
match script {
Script::Latin => None,
Script::EastAsian => run.text_lang_east_asia.as_ref(),
Script::Complex => run.text_lang_bidi.as_ref(),
}
.or(run.text_lang.as_ref())
}
fn chunk_lang(run: &Run, text: &str) -> Option<Arc<str>> {
let own = |l: &Option<Arc<str>>| l.is_some() && *l != run.text_lang;
let script = if own(&run.text_lang_bidi) || own(&run.text_lang_east_asia) {
text.chars()
.map(Script::of)
.find(|&s| s != Script::Latin)
.unwrap_or(Script::Latin)
} else {
Script::Latin
};
run_lang(run, script).cloned()
}
fn push_word_chunks(
chunks: &mut Vec<WordChunk>,
entry: &FontEntry,
run: &Run,
word: &str,
original: Option<&str>,
eff_fs: f32,
cs: f32,
y_off: f32,
x_start: f32,
total_ww: f32,
) {
let actual = |shown: &str, source: &str| (shown != source).then(|| source.to_string());
if run.small_caps {
let segs = smallcaps_segments(word, eff_fs);
let mut seg_x = x_start;
for (seg_text, seg_fs, source) in &segs {
let seg_kern = run.kerns_at(*seg_fs);
let ts = run.text_scale / 100.0;
let seg_w = entry.word_width(seg_text, *seg_fs, seg_kern) * ts
+ cs * seg_text.chars().count() as f32;
let mut chunk = WordChunk::text(entry, run, seg_text, *seg_fs, cs, y_off, seg_x, seg_w);
let source = match original {
Some(o) if segs.len() == 1 => o,
_ => *source,
};
chunk.actual_text = actual(seg_text, source);
chunks.push(chunk);
seg_x += seg_w;
}
} else {
let mut chunk = WordChunk::text(entry, run, word, eff_fs, cs, y_off, x_start, total_ww);
chunk.actual_text = original.and_then(|o| actual(word, o));
chunks.push(chunk);
}
}
fn vert_y_offset(run: &Run) -> f32 {
run.position
+ match run.vertical_align {
VertAlign::Superscript => run.font_size * 0.35,
VertAlign::Subscript => -run.font_size * 0.14,
VertAlign::Baseline => 0.0,
}
}
const DEFAULT_TAB_INTERVAL: f32 = 36.0;
fn is_cjk_punctuation(ch: char) -> bool {
matches!(ch as u32,
0x3000..=0x303F | 0xFF00..=0xFFEF | 0xFE30..=0xFE4F )
}
fn finish_line(chunks: &mut Vec<WordChunk>) -> TextLine {
let total_width = chunks.last().map(|c| c.x_offset + c.width).unwrap_or(0.0);
TextLine {
chunks: std::mem::take(chunks),
total_width,
..TextLine::default()
}
}
fn finish_line_with_break(chunks: &mut Vec<WordChunk>) -> TextLine {
let mut line = finish_line(chunks);
line.ends_with_break = true;
line
}
#[derive(Clone, Copy)]
pub(super) struct CjkLayout {
pub(super) auto_space: bool,
pub(super) compress_punct: bool,
pub(super) squeeze_spaces: bool,
pub(super) expand_shift_return: bool,
}
const SPACE_SQUEEZE: f32 = 0.25;
fn spaces_before_each(chunks: &[WordChunk]) -> Vec<usize> {
chunks
.iter()
.scan((0usize, None::<(f32, bool)>), |(n, prev), c| {
if prev.is_some_and(|(end, was_space)| was_space || c.x_offset > end + 0.01) {
*n += 1;
}
let is_space = c.inline_image_name.is_none() && c.text.chars().all(is_break_space);
*prev = Some((c.x_offset + c.width, is_space));
Some(*n)
})
.collect()
}
fn line_space_width(chunks: &[WordChunk]) -> f32 {
chunks
.windows(2)
.map(|w| (w[1].x_offset - (w[0].x_offset + w[0].width)).max(0.0))
.sum()
}
fn is_compressible_punct(c: char) -> bool {
matches!(
c,
'、' | '。'
| ','
| '.'
| ':'
| ';'
| '!'
| '?'
| ')'
| ']'
| '}'
| '」'
| '』'
| '】'
| '〕'
| '〉'
| '》'
| '〙'
| '〗'
| '・'
)
}
fn is_compressible_opening(c: char) -> bool {
matches!(
c,
'(' | '[' | '{' | '「' | '『' | '【' | '〔' | '〈' | '《' | '〘' | '〖'
)
}
fn compress_punctuation(chunks: &mut [WordChunk], needed: f32) -> bool {
let marks: Vec<(usize, f32, bool)> = chunks
.iter()
.enumerate()
.filter(|(_, c)| c.inline_image_name.is_none())
.map(|(i, c)| {
let end = c.text.chars().last().is_some_and(is_compressible_punct);
let start = c.text.chars().next().is_some_and(is_compressible_opening);
(i, end as u8 as f32 + start as u8 as f32, start)
})
.filter(|&(_, sides, _)| sides > 0.0)
.collect();
let room: Vec<f32> = marks
.iter()
.map(|&(i, sides, _)| {
(chunks[i].font_size * 0.25 * sides - chunks[i].punct_compressed).max(0.0)
})
.collect();
let total: f32 = room.iter().sum();
if needed <= 0.0 || total + 0.01 < needed {
return false;
}
let scale = (needed / total).min(1.0);
let mut shift = 0.0;
let mut k = 0;
for (i, chunk) in chunks.iter_mut().enumerate() {
chunk.x_offset -= shift;
if k < marks.len() && marks[k].0 == i {
let (_, sides, start) = marks[k];
let cut = room[k] * scale;
let lead = if start { cut / sides } else { 0.0 };
chunk.x_offset -= lead;
chunk.width -= cut - lead;
chunk.punct_compressed += cut;
shift += cut;
k += 1;
}
}
true
}
pub(super) type DualRegion = (f32, f32, f32, f32);
#[derive(Default, Clone, Copy)]
pub(super) struct LineOpts<'a> {
pub(super) inline_images: Option<&'a HashMap<usize, String>>,
pub(super) effects: Option<&'a HashMap<usize, EffectXObjs>>,
pub(super) tab_stops: &'a [TabStop],
pub(super) indent_left: f32,
pub(super) indent_right: f32,
pub(super) hanging: f32,
pub(super) tab_exclusions: &'a [(f32, f32)],
pub(super) per_line_widths: Option<&'a [f32]>,
pub(super) dual: Option<&'a [DualRegion]>,
}
pub(super) fn build_lines(
runs: &[Run],
ctx: &RenderContext,
width: f32,
cjk: CjkLayout,
opts: &LineOpts<'_>,
) -> Vec<TextLine> {
let inline_images = opts.inline_images.unwrap_or(&EMPTY_INLINE_IMAGES);
let effects = opts.effects.unwrap_or(&EMPTY_EFFECTS);
if runs.iter().any(|r| r.is_tab) {
build_tabbed_line(
runs,
ctx.fonts,
opts.tab_stops,
opts.indent_left,
width,
opts.indent_right,
opts.hanging,
inline_images,
effects,
ctx.default_tab_stop,
opts.tab_exclusions,
ctx.compat_mode,
cjk.squeeze_spaces,
)
} else {
build_paragraph_lines(
runs,
ctx.fonts,
width,
opts.hanging,
inline_images,
effects,
None,
opts.per_line_widths,
opts.dual,
cjk,
)
}
}
pub(super) fn build_paragraph_lines(
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
max_width: f32,
first_line_hanging: f32,
inline_image_names: &HashMap<usize, String>,
effect_inline_names: &HashMap<usize, super::images::EffectXObjs>,
width_after_line: Option<(usize, f32)>,
per_line_widths: Option<&[f32]>,
per_line_dual: Option<&[DualRegion]>,
cjk: CjkLayout,
) -> Vec<TextLine> {
let mut lines: Vec<TextLine> = Vec::new();
let mut squeezed_lines: Vec<usize> = Vec::new();
let mut current_chunks: Vec<WordChunk> = Vec::new();
let mut current_x: f32 = 0.0;
let mut pending_space_w: f32 = 0.0;
let mut pending_real_space = false;
let mut pending_space_underline = false;
let mut pending_space_double = false;
let mut pending_space_color: Option<[u8; 3]> = None;
let mut pending_space_border: Option<ParagraphBorder> = None;
let mut key_buf = String::new();
let mut in_right_region = false;
let mut cur_right_info: Option<(usize, f32, f32)> = None; let mut prev_last_char: Option<char> = None;
let mut word_start = 0usize;
let left_max = |line_count: usize| -> f32 {
if let Some(dual) = per_line_dual
&& let Some(&(_, lw, _, _)) = dual.get(line_count)
{
return lw;
}
if let Some(widths) = per_line_widths {
if let Some(&w) = widths.get(line_count) {
return w;
}
return max_width;
}
match width_after_line {
Some((n, w)) if line_count >= n => w,
_ => max_width,
}
};
let right_region_for = |line_count: usize| -> Option<(f32, f32, f32)> {
per_line_dual.and_then(|dual| {
dual.get(line_count).and_then(|&(_, _, rx, rw)| {
let shift = if line_count == 0 {
first_line_hanging
} else {
0.0
};
let rw = rw + shift;
(rw > 0.0).then_some((rx - shift, rw, rw))
})
})
};
let finish_dual_line = |chunks: &mut Vec<WordChunk>,
in_right: &mut bool,
right_info: &mut Option<(usize, f32, f32)>|
-> TextLine {
let mut line = finish_line(chunks);
if let Some((first_idx, rx, rw)) = right_info.take() {
let content_w = line.chunks[first_idx..]
.last()
.map(|c| c.x_offset + c.width)
.unwrap_or(0.0);
line.right_region = Some(RightRegion {
first_chunk_idx: first_idx,
region_x: rx,
region_width: rw,
content_width: content_w,
});
}
*in_right = false;
line
};
for (run_idx, run) in runs.iter().enumerate() {
if run.vanish {
continue;
}
if run.is_tab {
pending_real_space = true;
continue;
}
if run.is_line_break {
mark_space_after(&mut current_chunks);
let holds_only_break = current_chunks
.iter()
.all(|c| c.text.trim().is_empty() && c.inline_image_name.is_none());
let line = finish_dual_line(
&mut current_chunks,
&mut in_right_region,
&mut cur_right_info,
);
let line = TextLine {
ends_with_break: true,
break_font_size: holds_only_break.then_some(run.font_size),
break_lhr: holds_only_break
.then(|| seen_fonts.get(&crate::fonts::font_key(run)))
.flatten()
.and_then(|e| e.line_h_ratio),
..line
};
lines.push(line);
current_x = 0.0;
pending_space_w = 0.0;
pending_real_space = false;
prev_last_char = None;
continue;
}
if let Some(img) = &run.inline_image {
if std::mem::take(&mut pending_real_space) {
mark_space_after(&mut current_chunks);
}
if let Some(pdf_name) = inline_image_names.get(&run_idx) {
let img_w = img.layout_size().0;
let proposed_x = current_x + pending_space_w;
let eff_w = left_max(lines.len());
let line_max = if lines.is_empty() && !in_right_region {
eff_w + first_line_hanging
} else {
eff_w
};
let cur_max = if in_right_region {
cur_right_info.map(|(_, _, rw)| rw).unwrap_or(eff_w)
} else {
line_max
};
if !current_chunks.is_empty() && proposed_x + img_w > cur_max {
if !in_right_region && let Some((rx, rw, _)) = right_region_for(lines.len()) {
cur_right_info = Some((current_chunks.len(), rx, rw));
in_right_region = true;
pending_space_w = 0.0;
let proposed_x2 = 0.0;
if proposed_x2 + img_w <= rw {
current_chunks.push(WordChunk::image(
pdf_name,
run.font_size,
proposed_x2,
img,
effect_inline_names.get(&run_idx).cloned(),
));
current_x = img_w;
continue;
}
}
lines.push(finish_dual_line(
&mut current_chunks,
&mut in_right_region,
&mut cur_right_info,
));
current_x = 0.0;
} else {
current_x = proposed_x;
}
pending_space_w = 0.0;
current_chunks.push(WordChunk::image(
pdf_name,
run.font_size,
current_x,
img,
effect_inline_names.get(&run_idx).cloned(),
));
current_x += img_w;
word_start = current_chunks.len();
}
continue;
}
let key = font_key_buf(run, &mut key_buf);
let entry = seen_fonts.get(key).expect("font registered");
let eff_fs = effective_font_size(run, entry);
let text = &run.text;
let y_off = vert_y_offset(run);
let cs = run.char_spacing;
let ts = run.text_scale / 100.0;
let mut is_first_word_in_run = true;
let mut prev_char = None;
let mut words: std::collections::VecDeque<_> = split_preserving_spaces(text).into();
while let Some((space_count, mut source)) = words.pop_front() {
let mut shown = caps_word(run, source);
pending_space_w +=
space_count as f32 * word_space_width(run, entry, eff_fs, source, &mut prev_char);
if space_count > 0 {
pending_real_space = true;
pending_space_underline = run.underline;
pending_space_double = run.double_underline;
pending_space_color = run.color;
pending_space_border = run.border.clone();
}
if std::mem::take(&mut pending_real_space) {
mark_space_after(&mut current_chunks);
}
if cjk.auto_space
&& let Some(prev_ch) = prev_last_char
&& let Some(first_ch) = shown.chars().next()
&& pending_space_w == 0.0
&& space_count == 0
{
let prev_ea =
crate::docx::is_east_asian_char(prev_ch) || is_cjk_punctuation(prev_ch);
let cur_ea =
crate::docx::is_east_asian_char(first_ch) || is_cjk_punctuation(first_ch);
let beside_space = prev_ch == '\u{3000}' || first_ch == '\u{3000}';
if prev_ea != cur_ea && !beside_space {
pending_space_w += eff_fs * 0.25;
}
}
let is_continuation = is_first_word_in_run
&& space_count == 0
&& pending_space_w == 0.0
&& !current_chunks.is_empty()
&& !prev_last_char
.zip(shown.chars().next())
.is_some_and(|(a, b)| breaks_between(a, b));
is_first_word_in_run = false;
let kern = run.kerns_at(eff_fs);
let width = |w: &str| word_width_for_run(entry, run, w, eff_fs, kern, cs, ts);
let mut ww = width(&shown);
let line_room = left_max(lines.len())
+ if lines.is_empty() {
first_line_hanging
} else {
0.0
};
if current_chunks.is_empty() && !in_right_region && ww > line_room {
let room = line_room - pending_space_w;
if let Some(cut) = fitting_prefix_len(source, room, |w| width(&caps_word(run, w))) {
words.push_front((0, &source[cut..]));
source = &source[..cut];
shown = caps_word(run, source);
ww = width(&shown);
}
}
let mut word: &str = &shown;
let mut original = run.caps.then_some(source);
prev_last_char = word.chars().last();
let need_space = !current_chunks.is_empty() && pending_space_w > 0.0;
let proposed_x = if need_space {
current_x + pending_space_w
} else if current_chunks.is_empty() && pending_space_w > 0.0 {
pending_space_w
} else {
current_x
};
let eff_w = left_max(lines.len());
let line_max = if lines.is_empty() && !in_right_region {
eff_w + first_line_hanging
} else {
eff_w
};
let cur_max = if in_right_region {
cur_right_info.map(|(_, _, rw)| rw).unwrap_or(eff_w)
} else {
line_max
};
let mut overflows = proposed_x + ww > cur_max + 0.05;
let (word_x, word_w) = if is_continuation && word_start < current_chunks.len() {
let x = current_chunks[word_start].x_offset;
(x, proposed_x + ww - x)
} else {
(proposed_x, ww)
};
if overflows
&& cjk.squeeze_spaces
&& (need_space || is_continuation)
&& !in_right_region
&& word_x + word_w / 2.0 <= cur_max
&& proposed_x + ww - cur_max
<= SPACE_SQUEEZE * (line_space_width(¤t_chunks) + pending_space_w)
{
overflows = false;
squeezed_lines.push(lines.len());
}
let (proposed_x, overflows) = if overflows
&& cjk.compress_punct
&& !current_chunks.is_empty()
&& !in_right_region
&& right_region_for(lines.len()).is_none()
&& compress_punctuation(&mut current_chunks, proposed_x + ww - cur_max)
{
current_x = current_chunks.last().map_or(0.0, |c| c.x_offset + c.width);
(
if need_space {
current_x + pending_space_w
} else {
current_x
},
false,
)
} else {
(proposed_x, overflows)
};
if current_chunks.is_empty()
&& overflows
&& pending_space_w > 0.0
&& !in_right_region
&& right_region_for(lines.len()).is_none()
{
lines.push(finish_dual_line(
&mut current_chunks,
&mut in_right_region,
&mut cur_right_info,
));
pending_space_w = 0.0;
word_start = 0;
push_word_chunks(
&mut current_chunks,
entry,
run,
word,
original,
eff_fs,
cs,
y_off,
0.0,
ww,
);
current_x = ww;
continue;
}
if is_continuation
&& overflows
&& word_start < current_chunks.len()
&& !in_right_region
&& right_region_for(lines.len()).is_none()
{
let dx = current_chunks[word_start].x_offset;
if word_start > 0 {
let carried: Vec<WordChunk> = current_chunks.drain(word_start..).collect();
lines.push(finish_dual_line(
&mut current_chunks,
&mut in_right_region,
&mut cur_right_info,
));
current_chunks.extend(carried.into_iter().map(|mut c| {
c.x_offset -= dx;
c
}));
current_x -= dx;
word_start = 0;
}
let room = left_max(lines.len()) - current_x;
if ww > room + 0.05 {
let cut = fitting_prefix_len(source, room, |w| width(&caps_word(run, w)))
.filter(|&c| width(&caps_word(run, &source[..c])) <= room + 0.05);
if let Some(cut) = cut {
words.push_front((0, &source[cut..]));
source = &source[..cut];
shown = caps_word(run, source);
word = &shown;
original = run.caps.then_some(source);
ww = width(word);
prev_last_char = word.chars().last();
} else {
words.push_front((0, source));
continue;
}
}
push_word_chunks(
&mut current_chunks,
entry,
run,
word,
original,
eff_fs,
cs,
y_off,
current_x,
ww,
);
current_x += ww;
continue;
}
let first_word_overflow = current_chunks.is_empty()
&& !in_right_region
&& (cur_max <= 0.0 || overflows)
&& right_region_for(lines.len()).is_some();
if (!current_chunks.is_empty() && overflows && !is_continuation) || first_word_overflow
{
if !in_right_region {
if let Some((rx, rw, _)) = right_region_for(lines.len()) {
cur_right_info = Some((current_chunks.len(), rx, rw));
in_right_region = true;
let start_x = if current_chunks.is_empty()
&& pending_space_w > 0.0
&& pending_space_w + ww <= rw
{
pending_space_w
} else {
0.0
};
pending_space_w = 0.0;
word_start = current_chunks.len();
push_word_chunks(
&mut current_chunks,
entry,
run,
word,
original,
eff_fs,
cs,
y_off,
start_x,
ww,
);
current_x = start_x + ww;
continue;
}
}
lines.push(finish_dual_line(
&mut current_chunks,
&mut in_right_region,
&mut cur_right_info,
));
current_x = 0.0;
let room = left_max(lines.len());
let right = right_region_for(lines.len());
if ww > room
&& right.is_none()
&& let Some(cut) =
fitting_prefix_len(source, room, |w| width(&caps_word(run, w)))
{
words.push_front((0, &source[cut..]));
source = &source[..cut];
shown = caps_word(run, source);
word = &shown;
original = run.caps.then_some(source);
ww = width(word);
prev_last_char = word.chars().last();
}
if let Some((rx, rw, _)) = right {
if room <= 0.0 || ww > room {
cur_right_info = Some((0, rx, rw));
in_right_region = true;
pending_space_w = 0.0;
word_start = 0;
push_word_chunks(
&mut current_chunks,
entry,
run,
word,
original,
eff_fs,
cs,
y_off,
0.0,
ww,
);
current_x = ww;
continue;
}
}
} else {
current_x = proposed_x;
}
if pending_space_underline && !current_chunks.is_empty() && pending_space_w > 0.0 {
current_chunks.push(WordChunk::tab_underline(
entry,
eff_fs,
pending_space_color,
pending_space_double,
pending_space_border.clone(),
current_x - pending_space_w,
pending_space_w,
));
}
pending_space_w = 0.0;
if !is_continuation {
word_start = current_chunks.len();
}
push_word_chunks(
&mut current_chunks,
entry,
run,
word,
original,
eff_fs,
cs,
y_off,
current_x,
ww,
);
current_x += ww;
}
let trailing_spaces = text
.chars()
.rev()
.take_while(|c| is_break_space(*c))
.count();
if trailing_spaces > 0 {
pending_space_w +=
trailing_spaces as f32 * word_space_width(run, entry, eff_fs, "", &mut prev_char);
pending_real_space = true;
pending_space_underline = run.underline;
pending_space_double = run.double_underline;
pending_space_color = run.color;
pending_space_border = run.border.clone();
}
}
if !current_chunks.is_empty() {
lines.push(finish_dual_line(
&mut current_chunks,
&mut in_right_region,
&mut cur_right_info,
));
}
if lines.last().is_some_and(|l| l.ends_with_break) {
let break_fs = runs
.iter()
.rev()
.find(|r| r.is_line_break)
.map(|r| r.font_size);
lines.push(TextLine {
break_font_size: break_fs,
..TextLine::default()
});
}
if lines.is_empty() {
lines.push(TextLine::default());
}
for i in squeezed_lines {
if let Some(line) = lines.get_mut(i) {
line.squeezed = true;
}
}
if !cjk.expand_shift_return {
for line in lines.iter_mut().filter(|l| l.ends_with_break) {
line.natural_width = true;
}
}
lines
}
fn find_next_tab_stop(
current_x: f32,
tab_stops: &[TabStop],
indent_left: f32,
default_tab_interval: f32,
) -> (TabStop, bool) {
let abs_x = current_x + indent_left;
if let Some(s) = tab_stops.iter().find(|s| s.position > abs_x + 0.5) {
return (s.clone(), true);
}
let interval = if default_tab_interval > 0.0 {
default_tab_interval
} else {
DEFAULT_TAB_INTERVAL
};
let next = ((abs_x / interval).floor() + 1.0) * interval;
let default = TabStop {
position: next,
alignment: TabAlignment::Left,
leader: None,
};
(default, false)
}
fn segment_width(runs: &[&Run], seen_fonts: &HashMap<String, FontEntry>) -> f32 {
let mut w: f32 = 0.0;
let mut first = true;
let mut key_buf = String::new();
for run in runs {
let key = font_key_buf(run, &mut key_buf);
let entry = seen_fonts.get(key).expect("font registered");
let eff_fs = effective_font_size(run, entry);
let ts = run.text_scale / 100.0;
let cs = run.char_spacing;
let text = effective_text(run);
let mut prev_char = None;
for (i, word) in text.split_whitespace().enumerate() {
let space_w = word_space_width(run, entry, eff_fs, word, &mut prev_char);
if !first || i > 0 {
w += space_w;
}
w += word_width_for_run(entry, run, word, eff_fs, run.kerns_at(eff_fs), cs, ts);
first = false;
}
}
w
}
fn first_word_width(runs: &[&Run], seen_fonts: &HashMap<String, FontEntry>) -> f32 {
let mut w = 0.0;
let mut key_buf = String::new();
for run in runs {
if run.is_line_break || run.inline_image.is_some() {
break;
}
let text = effective_text(run);
let word = text.split(is_break_space).next().unwrap_or("");
let key = font_key_buf(run, &mut key_buf);
let entry = seen_fonts.get(key).expect("font registered");
let eff_fs = effective_font_size(run, entry);
let (cs, ts) = (run.char_spacing, run.text_scale / 100.0);
w += word_width_for_run(entry, run, word, eff_fs, run.kerns_at(eff_fs), cs, ts);
if word.len() < text.len() {
break;
}
}
w
}
fn decimal_before_width(runs: &[&Run], seen_fonts: &HashMap<String, FontEntry>) -> f32 {
let texts: Vec<Cow<'_, str>> = runs.iter().map(|r| effective_text(r)).collect();
let full_text: String = texts.iter().map(|t| t.as_ref()).collect();
let before = if let Some(dot_pos) = full_text.find('.') {
&full_text[..dot_pos]
} else {
&full_text
};
let mut w: f32 = 0.0;
let mut chars_remaining = before.len();
let mut key_buf = String::new();
for (run, text) in runs.iter().zip(texts.iter()) {
let key = font_key_buf(run, &mut key_buf);
let entry = seen_fonts.get(key).expect("font registered");
let eff_fs = effective_font_size(run, entry);
let ts = run.text_scale / 100.0;
let cs = run.char_spacing;
let text_to_measure = if text.len() <= chars_remaining {
chars_remaining -= text.len();
text.as_ref()
} else {
let s = &text[..chars_remaining];
chars_remaining = 0;
s
};
let kern = run.kerns_at(eff_fs);
w += entry.word_width(text_to_measure, eff_fs, kern) * ts
+ cs * text_to_measure.chars().count() as f32;
if chars_remaining == 0 {
break;
}
}
w
}
pub(super) fn build_tabbed_line(
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
tab_stops: &[TabStop],
indent_left: f32,
max_width: f32,
indent_right: f32,
first_line_hanging: f32,
inline_image_names: &HashMap<usize, String>,
effect_inline_names: &HashMap<usize, super::images::EffectXObjs>,
default_tab_stop: f32,
tab_exclusions: &[(f32, f32)],
compat_mode: u32,
squeeze_spaces: bool,
) -> Vec<TextLine> {
let mut segments: Vec<(Vec<&Run>, Vec<usize>, Option<TabStop>, Option<&Run>)> = Vec::new();
let mut current_seg: Vec<&Run> = Vec::new();
let mut current_indices: Vec<usize> = Vec::new();
let mut pending_tab: Option<TabStop> = None;
let mut pending_tab_run: Option<&Run> = None;
for (global_idx, run) in runs.iter().enumerate() {
if run.vanish {
continue;
}
if run.is_tab {
segments.push((
std::mem::take(&mut current_seg),
std::mem::take(&mut current_indices),
pending_tab.take(),
pending_tab_run.take(),
));
pending_tab = Some(TabStop {
position: 0.0, alignment: TabAlignment::Left,
leader: None,
});
pending_tab_run = Some(run);
} else {
current_seg.push(run);
current_indices.push(global_idx);
}
}
segments.push((
std::mem::take(&mut current_seg),
std::mem::take(&mut current_indices),
pending_tab.take(),
pending_tab_run.take(),
));
let mut result_lines: Vec<TextLine> = Vec::new();
let mut squeezed_lines: Vec<usize> = Vec::new();
let mut justify_from = 0usize;
let mut all_chunks: Vec<WordChunk> = Vec::new();
let mut current_x: f32 = 0.0;
let mut pending_space_w: f32 = 0.0;
let mut pending_space_underline = false;
let mut pending_space_double = false;
let mut pending_space_color: Option<[u8; 3]> = None;
let mut pending_space_border: Option<ParagraphBorder> = None;
let mut key_buf = String::new();
let mut is_first_line = true;
let mut tab_wrapped_line = false;
let mut line_reach = 0.0f32;
for (seg_idx, (seg_runs, seg_indices, tab_before, tab_run_before)) in
segments.iter().enumerate()
{
let line_max = if is_first_line {
max_width + first_line_hanging
} else {
max_width
};
let line_indent = if is_first_line {
indent_left - first_line_hanging
} else {
indent_left
};
let mut tab_stop_pos: Option<f32> = None;
if seg_idx > 0 {
mark_space_after(&mut all_chunks);
let abs_x_no_spaces = current_x + line_indent;
let has_explicit_after = tab_stops.iter().any(|s| s.position > abs_x_no_spaces + 0.5);
if !has_explicit_after {
current_x += pending_space_w;
}
pending_space_w = 0.0;
let ptab_align = tab_run_before.and_then(|r| r.ptab_alignment);
let (stop, mut effective_tab_target, explicit) = if let Some(palign) = ptab_align {
let target = match palign {
TabAlignment::Center => max_width / 2.0,
TabAlignment::Right => max_width,
_ => 0.0,
};
(
TabStop {
position: target + line_indent,
alignment: palign,
leader: None,
},
target,
false,
)
} else {
let (mut s, mut explicit) =
find_next_tab_stop(current_x, tab_stops, line_indent, default_tab_stop);
loop {
let bumped = tab_exclusions
.iter()
.find(|&&(ex_l, ex_r)| s.position > ex_l + 0.5 && s.position < ex_r - 0.5);
match bumped {
Some(&(_, ex_r)) => {
(s, explicit) = find_next_tab_stop(
ex_r - line_indent,
tab_stops,
line_indent,
default_tab_stop,
);
}
None => break,
}
}
let t = s.position - line_indent;
(s, t, explicit)
};
let mut seg_start = resolve_tab_aligned_start(
&stop,
effective_tab_target,
seg_runs,
seen_fonts,
current_x,
);
let mut resolved_leader = stop.leader;
let wrap_limit = line_max + indent_right;
let stop_past = effective_tab_target > wrap_limit;
if explicit && stop_past && compat_mode < 15 {
line_reach = f32::INFINITY;
} else if explicit && stop_past && stop.alignment != TabAlignment::Left {
effective_tab_target = wrap_limit;
seg_start =
resolve_tab_aligned_start(&stop, wrap_limit, seg_runs, seen_fonts, current_x);
} else if (seg_start > wrap_limit
|| (stop.alignment == TabAlignment::Left
&& seg_start + first_word_width(seg_runs, seen_fonts) > line_max))
&& (!all_chunks.is_empty() || current_x > 0.0)
{
result_lines.push(TextLine {
justify_from: std::mem::take(&mut justify_from),
..finish_line(&mut all_chunks)
});
tab_wrapped_line = true;
line_reach = 0.0;
current_x = 0.0;
is_first_line = false;
let (new_stop, _) =
find_next_tab_stop(0.0, tab_stops, indent_left, default_tab_stop);
let new_target = new_stop.position - indent_left;
seg_start =
resolve_tab_aligned_start(&new_stop, new_target, seg_runs, seen_fonts, 0.0);
resolved_leader = new_stop.leader;
effective_tab_target = new_target;
}
if stop.alignment != TabAlignment::Left {
line_reach = line_reach.max(effective_tab_target);
}
if tab_before.is_some() {
let leader = resolved_leader;
if let Some(tab_run) = tab_run_before
&& tab_run.underline
&& seg_start > current_x + 0.01
{
let font_run: &Run = seg_runs
.first()
.copied()
.or_else(|| runs.iter().find(|r| !r.font_name.is_empty()))
.unwrap_or(tab_run);
let key = font_key_buf(font_run, &mut key_buf);
let entry = seen_fonts.get(key).expect("font registered");
let eff_fs = effective_font_size(tab_run, entry).max(font_run.font_size);
all_chunks.push(WordChunk::tab_underline(
entry,
eff_fs,
tab_run.color,
tab_run.double_underline,
tab_run.border.clone(),
current_x,
seg_start - current_x,
));
}
if let Some(leader_char) = leader {
let font_run: Option<&Run> = seg_runs
.first()
.copied()
.or_else(|| {
segments[..seg_idx]
.iter()
.rev()
.flat_map(|(r, _, _, _)| r.last().copied())
.next()
})
.or_else(|| {
runs.iter().find(|r| !r.font_name.is_empty())
});
if let Some(run) = font_run {
let key = font_key_buf(run, &mut key_buf);
let entry = seen_fonts.get(key).expect("font registered");
let eff_fs = effective_font_size(run, entry);
let char_w = entry.char_width_1000(leader_char) * eff_fs / 1000.0;
let leader_gap = seg_start - current_x;
if char_w > 0.0 && leader_gap > char_w * 2.0 {
let count = ((leader_gap - char_w) / char_w).floor() as usize;
if count > 0 {
let leader_text: String =
std::iter::repeat_n(leader_char, count).collect();
let leader_w = count as f32 * char_w;
let leader_start = seg_start - leader_w;
all_chunks.push(WordChunk::leader(
entry,
leader_text,
eff_fs,
run.color,
leader_start,
leader_w,
));
}
}
}
}
}
current_x = seg_start;
tab_stop_pos = Some(effective_tab_target);
justify_from = all_chunks.len();
}
for (local_idx, run) in seg_runs.iter().enumerate() {
if run.is_line_break {
mark_space_after(&mut all_chunks);
result_lines.push(TextLine {
justify_from: std::mem::take(&mut justify_from),
..finish_line_with_break(&mut all_chunks)
});
tab_wrapped_line = false;
line_reach = 0.0;
current_x = 0.0;
is_first_line = false;
pending_space_w = 0.0;
continue;
}
if let Some(img) = &run.inline_image {
if let Some(pdf_name) = inline_image_names.get(&seg_indices[local_idx]) {
all_chunks.push(WordChunk::image(
pdf_name,
run.font_size,
current_x,
img,
effect_inline_names.get(&seg_indices[local_idx]).cloned(),
));
current_x += img.layout_size().0;
}
continue;
}
let key = font_key_buf(run, &mut key_buf);
let entry = seen_fonts.get(key).expect("font registered");
let eff_fs = effective_font_size(run, entry);
let y_off = vert_y_offset(run);
let text = &run.text;
let cs = run.char_spacing;
let ts = run.text_scale / 100.0;
let segments = split_preserving_spaces(text);
let mut prev_char = None;
for (seg_idx, &(space_count, source)) in segments.iter().enumerate() {
let shown = caps_word(run, source);
let word: &str = &shown;
let original = run.caps.then_some(source);
let kern = run.kerns_at(eff_fs);
let ww = word_width_for_run(entry, run, word, eff_fs, kern, cs, ts);
pending_space_w += space_count as f32
* word_space_width(run, entry, eff_fs, source, &mut prev_char);
if space_count > 0 {
pending_space_underline = run.underline;
pending_space_double = run.double_underline;
pending_space_color = run.color;
pending_space_border = run.border.clone();
}
let applied_space = pending_space_w > 0.0;
if applied_space {
mark_space_after(&mut all_chunks);
if pending_space_underline && !all_chunks.is_empty() {
all_chunks.push(WordChunk::tab_underline(
entry,
eff_fs,
pending_space_color,
pending_space_double,
pending_space_border.clone(),
current_x,
pending_space_w,
));
}
current_x += pending_space_w;
pending_space_w = 0.0;
}
let is_continuation = seg_idx == 0 && !applied_space && !all_chunks.is_empty();
let cur_line_max = if is_first_line {
max_width + first_line_hanging
} else {
max_width
};
let mut overflows = current_x + ww > cur_line_max.max(line_reach)
&& !all_chunks.is_empty()
&& !is_continuation;
if overflows && squeeze_spaces && cur_line_max >= line_reach {
let after_tab = all_chunks.get(justify_from..).unwrap_or(&[]);
let spaces = after_tab.last().map_or(0.0, |last| {
line_space_width(after_tab)
+ (current_x - last.x_offset - last.width).max(0.0)
});
if current_x + ww / 2.0 <= cur_line_max
&& current_x + ww - cur_line_max <= SPACE_SQUEEZE * spaces
{
overflows = false;
squeezed_lines.push(result_lines.len());
}
}
if overflows {
result_lines.push(TextLine {
justify_from: std::mem::take(&mut justify_from),
..finish_line(&mut all_chunks)
});
tab_wrapped_line = false;
line_reach = 0.0;
current_x = 0.0;
is_first_line = false;
}
push_word_chunks(
&mut all_chunks,
entry,
run,
word,
original,
eff_fs,
cs,
y_off,
current_x,
ww,
);
current_x += ww;
}
let trailing_spaces = text
.chars()
.rev()
.take_while(|c| is_break_space(*c))
.count();
if trailing_spaces > 0 {
pending_space_w += trailing_spaces as f32
* word_space_width(run, entry, eff_fs, "", &mut prev_char);
pending_space_underline = run.underline;
pending_space_double = run.double_underline;
pending_space_color = run.color;
pending_space_border = run.border.clone();
}
}
if let Some(ts_pos) = tab_stop_pos {
current_x = current_x.max(ts_pos);
}
}
if !all_chunks.is_empty() {
result_lines.push(TextLine {
justify_from: std::mem::take(&mut justify_from),
..finish_line(&mut all_chunks)
});
} else if result_lines.is_empty() || tab_wrapped_line {
result_lines.push(TextLine::default());
}
for i in squeezed_lines {
if let Some(line) = result_lines.get_mut(i) {
line.squeezed = true;
}
}
if result_lines.last().is_some_and(|l| l.ends_with_break) {
let break_fs = runs
.iter()
.rev()
.find(|r| r.is_line_break)
.map(|r| r.font_size);
result_lines.push(TextLine {
break_font_size: break_fs,
..TextLine::default()
});
}
result_lines
}
pub(super) fn os2_strike(entry: &FontEntry, font_size: f32) -> Option<(f32, f32)> {
let (pos, th) = entry.strikeout?;
let q = |v: f32| (v * 4.0).round() / 4.0;
Some((q(pos * font_size), q(th * font_size).max(0.25)))
}
fn show_kerned(content: &mut Content, entry: &FontEntry, text: &str, boundary_space: bool) {
let mut tj = content.show_positioned();
let mut items = tj.items();
let mut run = String::new();
let mut prev: Option<char> = None;
for ch in text.chars() {
let k = prev.map_or(0.0, |p| entry.kern_1000(p, ch));
if k != 0.0 {
items.show(Str(&entry.encode(&run)));
run.clear();
items.adjust(-k);
}
run.push(ch);
prev = Some(ch);
}
if boundary_space {
run.push(' ');
}
items.show(Str(&entry.encode(&run)));
}
pub(super) fn encode_text_for_pdf(
text: &str,
pdf_font: &str,
pdf_name_to_entry: &HashMap<&str, &FontEntry>,
) -> Vec<u8> {
match pdf_name_to_entry.get(pdf_font) {
Some(e) => e.encode(text),
None => to_winansi_bytes(text),
}
}
pub(super) struct LineNumberArg<'a> {
pub counter: &'a mut u32,
pub start: i32,
pub count_by: u32,
pub continuous_offset: u32,
pub right_x: f32,
}
pub(super) fn line_max_image_h(line: &TextLine) -> f32 {
line.chunks
.iter()
.map(|c| c.inline_image_height + c.inline_image_extra_height)
.fold(0.0f32, f32::max)
}
pub(super) fn runs_max_image_h(runs: &[Run]) -> f32 {
runs.iter()
.filter_map(|r| r.inline_image.as_ref())
.map(|img| img.display_height + img.layout_extra_height)
.fold(0.0f32, f32::max)
}
pub(super) fn inline_image_line_extra(line: &TextLine, ascent: f32) -> f32 {
(line_max_image_h(line) - ascent).max(0.0)
}
fn inline_line_advance(line: &TextLine, line_pitch: f32, (ascent, descent): (f32, f32)) -> f32 {
let line_pitch = line.pitch.unwrap_or(line_pitch);
let img_h = line_max_image_h(line);
if img_h > ascent {
line_pitch.max(img_h + descent)
} else {
line_pitch
}
}
pub(super) fn lines_height(lines: &[TextLine], line_pitch: f32, metrics: (f32, f32)) -> f32 {
if lines.is_empty() {
line_pitch
} else {
lines
.iter()
.map(|l| inline_line_advance(l, line_pitch, metrics))
.sum()
}
}
fn begin_synthetic_bold(content: &mut Content, chunk: &WordChunk) {
content.set_line_width(chunk.font_size * 0.02);
stroke_color_or_black(content, chunk.color);
content.set_text_rendering_mode(TextRenderingMode::FillStroke);
}
pub(super) fn descender_ratio(lhr: Option<f32>, ar: Option<f32>) -> f32 {
lhr.zip(ar).map_or(0.25, |(l, a)| (l - a).max(0.0))
}
pub(super) fn render_paragraph_lines(
content: &mut Content,
lines: &[TextLine],
alignment: &Alignment,
margin_left: f32,
text_width: f32,
first_baseline_y: f32,
line_pitch: f32,
text_metrics: (f32, f32),
total_line_count: usize,
first_line_index: usize,
links: &mut Vec<LinkAnnotation>,
first_line_hanging: f32,
seen_fonts: &HashMap<String, FontEntry>,
line_geometry: Option<&[(f32, f32)]>,
gradient_specs: &mut Vec<super::GradientSpec>,
mut comment_anchors: Option<&mut Vec<(u32, f32, f32, f32)>>,
mut line_numbering: Option<LineNumberArg<'_>>,
mut link_tags: Option<LinkTagger<'_>>,
) {
let mut current_color: Option<[u8; 3]> = None;
let mut pattern_fill_active = false;
let mut cur_font_name = String::new();
let mut cur_font_size: f32 = -1.0;
let mut cur_char_spacing: f32 = 0.0;
let mut cur_text_scale: f32 = 100.0;
let mut cur_synthetic_bold = false;
let mut has_text_outline = false;
let pdf_name_to_entry: HashMap<&str, &FontEntry> = seen_fonts
.values()
.map(|e| (e.pdf_name.as_str(), e))
.collect();
let mut line_y_offsets: Vec<f32> = Vec::with_capacity(lines.len());
let mut line_top = 0.0f32;
for line in lines {
line_y_offsets
.push(line_top + line.ascent_shift + inline_image_line_extra(line, text_metrics.0));
line_top += inline_line_advance(line, line_pitch, text_metrics);
}
let last_line_idx = total_line_count.saturating_sub(1);
for (line_num, line) in lines.iter().enumerate() {
let y = first_baseline_y - line_y_offsets[line_num];
let global_line_idx = first_line_index + line_num;
let (base_margin, base_width) = line_geometry
.and_then(|g| g.get(global_line_idx))
.copied()
.unwrap_or((margin_left, text_width));
let (eff_margin, eff_width) = if global_line_idx == 0 && first_line_hanging.abs() > 0.001 {
(
base_margin - first_line_hanging,
base_width + first_line_hanging,
)
} else {
(base_margin, base_width)
};
if let Some(ln) = line_numbering.as_mut() {
let idx = *ln.counter;
*ln.counter = idx + 1;
let value = ln.start + ln.continuous_offset as i32 + idx as i32;
let show = value >= 1 && (ln.count_by <= 1 || value % ln.count_by as i32 == 0);
if show
&& let Some((font, fs)) = line
.chunks
.iter()
.find(|c| c.inline_image_name.is_none() && !c.text.is_empty())
.map(|c| (c.pdf_font.clone(), c.font_size))
{
let s = value.to_string();
let w = pdf_name_to_entry
.get(font.as_str())
.map(|e| e.word_width(&s, fs, false))
.unwrap_or(fs * 0.5 * s.chars().count() as f32);
let bytes = encode_text_for_pdf(&s, &font, &pdf_name_to_entry);
let x = ln.right_x - w;
let draw = |content: &mut Content| {
content.save_state();
content.set_char_spacing(0.0);
content.set_horizontal_scaling(100.0);
fill_color_or_black(content, None);
content.begin_text();
content.set_font(Name(font.as_bytes()), fs);
content.next_line(x, y);
content.show(Str(&bytes));
content.end_text();
content.restore_state();
};
match link_tags.as_mut() {
Some(lt) => lt.artifact(content, draw),
None => draw(content),
}
}
}
let left_content_width = if let Some(ref rr) = line.right_region {
line.chunks[..rr.first_chunk_idx]
.last()
.map(|c| c.x_offset + c.width)
.unwrap_or(0.0)
} else {
line.total_width
};
let left_chunk_count = line
.right_region
.as_ref()
.map(|rr| rr.first_chunk_idx)
.unwrap_or(line.chunks.len());
let gaps_before = spaces_before_each(&line.chunks[..left_chunk_count]);
let jf = line.justify_from.min(left_chunk_count.saturating_sub(1));
let tab_gaps = gaps_before.get(jf).copied().unwrap_or(0);
let gaps_before: Vec<usize> = gaps_before
.iter()
.enumerate()
.map(|(i, &g)| if i < jf { 0 } else { g - tab_gaps })
.collect();
let left_gaps = gaps_before.last().copied().unwrap_or(0);
let left_char_count: usize = line.chunks[..left_chunk_count]
.iter()
.map(|c| c.text.chars().count())
.sum();
let has_cjk_content = line.chunks[..left_chunk_count]
.iter()
.any(|c| c.text.chars().any(crate::docx::is_east_asian_char));
let squeezed = line.squeezed && left_gaps > 0;
let can_justify = match *alignment {
Alignment::Justify => {
(global_line_idx != last_line_idx || squeezed) && !line.natural_width
}
Alignment::Distribute => true,
_ => false,
};
let char_justify_gaps =
char_justify_gaps(*alignment, can_justify, has_cjk_content, left_char_count);
let is_char_justified = char_justify_gaps.is_some();
let is_justified = is_char_justified || (can_justify && left_gaps > 0);
let line_start_x = match alignment {
Alignment::Center => eff_margin + (eff_width - left_content_width) / 2.0,
Alignment::Right => eff_margin + eff_width - left_content_width,
Alignment::Left | Alignment::Justify | Alignment::Distribute => eff_margin,
};
let justify_tc = match char_justify_gaps {
Some(gaps) => (eff_width - left_content_width) / gaps as f32,
None => 0.0,
};
let extra_per_gap = if is_justified && !is_char_justified {
let per_gap = (eff_width - left_content_width) / left_gaps.max(1) as f32;
if squeezed { per_gap } else { per_gap.max(0.0) }
} else {
0.0
};
let (right_start_x, right_extra_per_gap) = if let Some(ref rr) = line.right_region {
let right_chunks = line.chunks.len() - rr.first_chunk_idx;
let rx = match alignment {
Alignment::Center => rr.region_x + (rr.region_width - rr.content_width) / 2.0,
Alignment::Right => rr.region_x + rr.region_width - rr.content_width,
Alignment::Left | Alignment::Justify | Alignment::Distribute => rr.region_x,
};
let rgap = if is_justified && right_chunks > 1 {
((rr.region_width - rr.content_width) / (right_chunks - 1) as f32).max(0.0)
} else {
0.0
};
(rx, rgap)
} else {
(0.0, 0.0)
};
let chunk_abs_x = |chunk_idx: usize, chunk: &WordChunk| -> f32 {
if let Some(ref rr) = line.right_region
&& chunk_idx >= rr.first_chunk_idx
{
let local_idx = chunk_idx - rr.first_chunk_idx;
return right_start_x + chunk.x_offset + local_idx as f32 * right_extra_per_gap;
}
if is_char_justified {
let chars_before: usize = line.chunks[..chunk_idx]
.iter()
.map(|c| c.text.chars().count())
.sum();
line_start_x + chunk.x_offset + chars_before as f32 * justify_tc
} else {
line_start_x + chunk.x_offset + gaps_before[chunk_idx] as f32 * extra_per_gap
}
};
let mut decorations: Vec<Decoration> = Vec::new();
let draw_run_backgrounds =
|content: &mut Content, accessor: fn(&WordChunk) -> Option<[u8; 3]>| {
let mut bg_start_x = 0.0f32;
let mut bg_color: Option<[u8; 3]> = None;
let mut bg_end_x = 0.0f32;
let mut bg_fs = 0.0f32;
let flush =
|content: &mut Content, color: [u8; 3], sx: f32, ex: f32, fs: f32, y: f32| {
let bg_bottom = y - fs * 0.2;
let bg_height = fs * 1.15;
content.save_state();
fill_color_or_black(content, Some(color));
content.rect(sx, bg_bottom, ex - sx, bg_height);
content.fill_nonzero();
content.restore_state();
};
for (chunk_idx, chunk) in line.chunks.iter().enumerate() {
let x = chunk_abs_x(chunk_idx, chunk);
let chunk_color = accessor(chunk);
if chunk_color == bg_color && bg_color.is_some() {
bg_end_x = x + chunk.width;
bg_fs = bg_fs.max(chunk.font_size);
} else {
if let Some(c) = bg_color {
flush(content, c, bg_start_x, bg_end_x, bg_fs, y);
}
if let Some(c) = chunk_color {
bg_start_x = x;
bg_end_x = x + chunk.width;
bg_fs = chunk.font_size;
bg_color = Some(c);
} else {
bg_color = None;
}
}
}
if let Some(c) = bg_color {
flush(content, c, bg_start_x, bg_end_x, bg_fs, y);
}
};
draw_run_backgrounds(content, |c| c.shading);
draw_run_backgrounds(content, |c| c.highlight);
let mut border_start_x = 0.0f32;
let mut border_end_x = 0.0f32;
let mut border_fs = 0.0f32;
let mut active_border: Option<ParagraphBorder> = None;
let flush_border =
|content: &mut Content, border: &ParagraphBorder, sx: f32, ex: f32, fs: f32, y: f32| {
let pad = border.space_pt;
let bottom = y - fs * 0.2 - pad;
let height = fs * 1.15 + pad * 2.0;
content.save_state();
content.set_line_width(border.width_pt.max(0.1));
stroke_color_or_black(content, Some(border.color));
content.rect(sx - pad, bottom, (ex - sx) + pad * 2.0, height);
content.stroke();
content.restore_state();
};
for (chunk_idx, chunk) in line.chunks.iter().enumerate() {
let x = chunk_abs_x(chunk_idx, chunk);
match (&active_border, &chunk.border) {
(Some(active), Some(next)) if active == next => {
border_end_x = x + chunk.width;
border_fs = border_fs.max(chunk.font_size);
}
(Some(active), next) => {
flush_border(content, active, border_start_x, border_end_x, border_fs, y);
active_border = next.clone();
if chunk.border.is_some() {
border_start_x = x;
border_end_x = x + chunk.width;
border_fs = chunk.font_size;
}
}
(None, Some(next)) => {
active_border = Some(next.clone());
border_start_x = x;
border_end_x = x + chunk.width;
border_fs = chunk.font_size;
}
(None, None) => {}
}
}
if let Some(active) = &active_border {
flush_border(content, active, border_start_x, border_end_x, border_fs, y);
}
for (chunk_idx, chunk) in line.chunks.iter().enumerate() {
if let Some(cb) = chunk.checkbox
&& let Some(entry) = pdf_name_to_entry.get(chunk.pdf_font.as_str())
{
let x = chunk_abs_x(chunk_idx, chunk);
draw_checkbox(content, entry, cb, x, y + chunk.y_offset, chunk.color);
}
}
if let Some(ref mut anchors) = comment_anchors {
for (chunk_idx, chunk) in line.chunks.iter().enumerate() {
if chunk.comment_ids.is_empty() {
continue;
}
let end_x = chunk_abs_x(chunk_idx, chunk) + chunk.width;
let anchor_y = y - chunk.font_size * 0.2 + chunk.font_size * 1.15;
for &cid in &chunk.comment_ids {
if let Some(entry) = anchors.iter_mut().find(|(id, _, _, _)| *id == cid) {
entry.1 = end_x;
entry.2 = anchor_y;
entry.3 = chunk.font_size;
} else {
anchors.push((cid, end_x, anchor_y, chunk.font_size));
}
}
}
}
let has_text_chunks = line
.chunks
.iter()
.any(|c| c.inline_image_name.is_none() && (!c.text.is_empty() || c.underline));
if has_text_chunks {
content.begin_text();
let mut td_x = 0.0_f32;
let mut td_y = 0.0_f32;
let mut cur_shear = 0.0_f32;
for (chunk_idx, chunk) in line.chunks.iter().enumerate() {
if chunk.inline_image_name.is_some() || chunk.checkbox.is_some() {
continue;
}
let note = chunk.note();
let note_url = note.map(|(endnote, id)| {
format!("#{}", super::footnotes::note_anchor(endnote, id))
});
let link_url = chunk.hyperlink_url.as_deref().or(note_url.as_deref());
let primary_entry = pdf_name_to_entry.get(chunk.pdf_font.as_str());
let boundary_space = chunk.boundary_space(primary_entry.copied());
if let Some(lt) = link_tags.as_mut()
&& lt.chunk(content, chunk, link_url, boundary_space)
{
td_x = 0.0;
td_y = 0.0;
}
let x = chunk_abs_x(chunk_idx, chunk);
let cy = y + chunk.y_offset;
let mut chunk_uses_gradient = false;
if let Some(TextFill::Gradient {
ref stops,
angle_deg,
}) = chunk.text_fill
{
let pat_name = format!("Grd{}", gradient_specs.len());
let y_bottom = cy - chunk.font_size * 0.2;
gradient_specs.push(super::GradientSpec {
pattern_name: pat_name.clone(),
stops: stops.clone(),
angle_deg,
x,
y: y_bottom,
w: chunk.width.max(1.0),
h: chunk.font_size,
});
content.set_fill_color_space(pdf_writer::types::ColorSpaceOperand::Pattern);
content.set_fill_pattern([], Name(pat_name.as_bytes()));
pattern_fill_active = true;
current_color = None;
chunk_uses_gradient = true;
}
let effective_color = match chunk.text_fill {
Some(TextFill::Solid(c)) => Some(c),
_ => chunk.color,
};
if !chunk_uses_gradient && (pattern_fill_active || effective_color != current_color)
{
fill_color_or_black(content, effective_color);
current_color = effective_color;
pattern_fill_active = false;
}
if let Some(ref outline) = chunk.text_outline {
if !has_text_outline {
super::wordart::apply_text_outline(
content,
outline,
chunk.text_fill.as_ref(),
);
has_text_outline = true;
}
} else if has_text_outline {
super::wordart::reset_text_outline(content);
has_text_outline = false;
if cur_synthetic_bold {
begin_synthetic_bold(content, chunk);
}
}
if !has_text_outline && chunk.synthetic_bold != cur_synthetic_bold {
if chunk.synthetic_bold {
begin_synthetic_bold(content, chunk);
} else {
content.set_text_rendering_mode(TextRenderingMode::Fill);
}
cur_synthetic_bold = chunk.synthetic_bold;
}
let effective_cs = chunk.char_spacing + justify_tc;
if effective_cs != cur_char_spacing {
content.set_char_spacing(effective_cs);
cur_char_spacing = effective_cs;
}
if chunk.text_scale != cur_text_scale {
content.set_horizontal_scaling(chunk.text_scale);
cur_text_scale = chunk.text_scale;
}
if cur_font_name != chunk.pdf_font || cur_font_size != chunk.font_size {
content.set_font(Name(chunk.pdf_font.as_bytes()), chunk.font_size);
cur_font_name.clone_from(&chunk.pdf_font);
cur_font_size = chunk.font_size;
}
let shear = if chunk.synthetic_italic {
87.0 / 256.0
} else {
0.0
};
if let Some(ref sh) = chunk.text_shadow {
let (sx, sy) = (x + sh.offset_x, cy + sh.offset_y);
let bytes =
encode_text_for_pdf(&chunk.text, &chunk.pdf_font, &pdf_name_to_entry);
let draw_shadow = |content: &mut Content| {
content.set_text_matrix([1.0, 0.0, shear, 1.0, sx, sy]);
fill_color_or_black(content, Some(sh.color));
content.show(Str(&bytes));
fill_color_or_black(content, current_color);
};
(td_x, td_y, cur_shear) = match link_tags.as_mut() {
Some(lt) => {
lt.text_artifact(content, draw_shadow);
(0.0, 0.0, 0.0)
}
None => {
draw_shadow(content);
(sx, sy, shear)
}
};
}
let mut move_to = |content: &mut Content, mx: f32, my: f32| {
if shear != 0.0 || cur_shear != 0.0 {
content.set_text_matrix([1.0, 0.0, shear, 1.0, mx, my]);
cur_shear = shear;
} else {
content.next_line(mx - td_x, my - td_y);
}
td_x = mx;
td_y = my;
};
move_to(content, x, cy);
let has_missing = primary_entry.is_some_and(|e| !e.missing_cjk_chars.is_empty());
let fallback_entry = has_missing
.then(|| seen_fonts.get("__cjk_fallback"))
.flatten();
if let (Some(primary), Some(fallback)) = (primary_entry, fallback_entry) {
let fallback_gids = fallback.char_to_gid.as_ref();
let show_seg = |content: &mut Content, seg: &[char], in_fallback: bool| {
let seg: String = seg.iter().collect();
if in_fallback {
if let Some(map) = fallback_gids {
content
.set_font(Name(fallback.pdf_name.as_bytes()), chunk.font_size);
content.show(Str(&encode_as_gids(&seg, map)));
content.set_font(Name(chunk.pdf_font.as_bytes()), chunk.font_size);
}
} else {
let bytes =
encode_text_for_pdf(&seg, &chunk.pdf_font, &pdf_name_to_entry);
content.show(Str(&bytes));
}
};
let mut seg_start = 0;
let mut in_fallback = false;
let chars: Vec<char> = chunk.text.chars().collect();
for (i, &ch) in chars.iter().enumerate() {
let needs_fb = primary.missing_cjk_chars.contains(&ch);
if i == 0 {
in_fallback = needs_fb;
} else if needs_fb != in_fallback {
show_seg(content, &chars[seg_start..i], in_fallback);
seg_start = i;
in_fallback = needs_fb;
}
}
show_seg(content, &chars[seg_start..], in_fallback);
if boundary_space {
content.show(Str(&encode_text_for_pdf(
" ",
&chunk.pdf_font,
&pdf_name_to_entry,
)));
}
} else if let Some(entry) = pdf_name_to_entry
.get(chunk.pdf_font.as_str())
.filter(|e| chunk.kern && e.kern_pairs.is_some())
{
show_kerned(content, entry, &chunk.text, boundary_space);
} else {
let mut text_bytes =
encode_text_for_pdf(&chunk.text, &chunk.pdf_font, &pdf_name_to_entry);
if boundary_space {
text_bytes.extend(encode_text_for_pdf(
" ",
&chunk.pdf_font,
&pdf_name_to_entry,
));
}
content.show(Str(&text_bytes));
};
let thick = (chunk.font_size * 0.05).max(0.5);
let post_ul = (!chunk.double_underline)
.then(|| pdf_name_to_entry.get(chunk.pdf_font.as_str()))
.flatten()
.and_then(|e| e.underline)
.map(|(pos, th)| {
let q = |v: f32| (v * 4.0).round() / 4.0;
(q(pos * chunk.font_size), q(th * chunk.font_size).max(0.25))
});
if let (true, Some((offset, ul_thick))) = (chunk.underline, post_ul) {
let bottom = y - offset - ul_thick;
push_decoration(
&mut decorations,
x,
bottom,
chunk.width,
ul_thick,
chunk.color,
);
} else if chunk.underline {
let ul_y = if chunk.hyperlink_url.is_some() {
y - chunk.font_size * 0.08
} else {
y - chunk.font_size * 0.12
};
let ul_top = ul_y - thick;
push_decoration(&mut decorations, x, ul_top, chunk.width, thick, chunk.color);
if chunk.double_underline {
let gap = (thick * 1.5).max(1.0);
push_decoration(
&mut decorations,
x,
ul_top - thick - gap,
chunk.width,
thick,
chunk.color,
);
}
}
if chunk.strikethrough {
let strike = pdf_name_to_entry
.get(chunk.pdf_font.as_str())
.and_then(|e| os2_strike(e, chunk.font_size))
.map(|(top, th)| (y + top - th, th));
let (st_y, st_thick) = strike.unwrap_or((y + chunk.font_size * 0.3, thick));
push_decoration(&mut decorations, x, st_y, chunk.width, st_thick, chunk.color);
}
if chunk.dstrike {
let gap = thick * 1.5;
let mid_y = y + chunk.font_size * 0.3;
decorations.push((x, mid_y - gap / 2.0, chunk.width, thick, chunk.color));
decorations.push((x, mid_y + gap / 2.0, chunk.width, thick, chunk.color));
}
if let Some(url) = link_url {
let bottom = y - chunk.font_size * 0.2;
let top = y + chunk.font_size * 0.8;
let node = link_tags
.as_ref()
.and_then(|lt| lt.link.as_ref().map(|&(_, n)| n));
let merged = links
.last_mut()
.filter(|prev| prev.url == url && (prev.rect.y1 - bottom).abs() < 1.0);
let link = match merged {
Some(prev) => {
prev.rect.x2 = x + chunk.width;
prev
}
None => {
let text = match note.filter(|_| chunk.hyperlink_url.is_none()) {
Some((true, _)) => "Endnote ",
Some((false, _)) => "Footnote ",
None => "",
};
links.push(LinkAnnotation {
rect: Rect::new(x, bottom, x + chunk.width, top),
url: url.to_string(),
node,
text: text.to_string(),
});
links.last_mut().unwrap()
}
};
link.text.push_str(&chunk.text);
if chunk.space_after {
link.text.push(' ');
}
}
}
if cur_synthetic_bold {
content.set_text_rendering_mode(TextRenderingMode::Fill);
cur_synthetic_bold = false;
}
if has_text_outline {
super::wordart::reset_text_outline(content);
has_text_outline = false;
}
if cur_char_spacing != 0.0 {
content.set_char_spacing(0.0);
cur_char_spacing = 0.0;
}
if cur_text_scale != 100.0 {
content.set_horizontal_scaling(100.0);
cur_text_scale = 100.0;
}
content.end_text();
if pattern_fill_active {
fill_color_or_black(content, None);
current_color = Some([0, 0, 0]);
pattern_fill_active = false;
}
}
for (chunk_idx, chunk) in line.chunks.iter().enumerate() {
if let Some(ref img_name) = chunk.inline_image_name {
if let Some(lt) = link_tags.as_mut() {
lt.begin_picture(
content,
chunk.inline_image_alt.as_deref(),
chunk.inline_image_decorative,
);
}
let box_x = chunk_abs_x(chunk_idx, chunk);
let box_bottom = y + chunk.y_offset;
let (w, h) = chunk.inline_image_size;
let x = box_x + (chunk.width - w) / 2.0;
let img_bottom = box_bottom + (chunk.inline_image_height - h) / 2.0;
let turned = chunk.inline_image_rotation_deg.abs() > 0.01;
if turned {
content.save_state();
super::positioning::push_center_rotation(
content,
box_x,
box_bottom,
chunk.width,
chunk.inline_image_height,
chunk.inline_image_rotation_deg,
);
}
let chunk_fx = chunk.inline_image_effect_xobjs.as_ref();
if let Some(ref shadow) = chunk.inline_image_shadow {
super::color::draw_image_shadow(
content,
shadow,
x,
img_bottom,
w,
h,
chunk_fx.and_then(|fx| fx.shadow.as_deref()),
);
}
if let Some(ref glow) = chunk.inline_image_glow {
super::color::draw_image_glow(
content,
glow,
x,
img_bottom,
w,
h,
chunk_fx.and_then(|fx| fx.glow.as_deref()),
);
}
super::smartart::render_image_with_clip(
content,
img_name,
x,
img_bottom,
w,
h,
chunk.inline_image_clip.as_ref(),
);
if let Some(sc) = chunk.inline_image_stroke_color {
super::smartart::stroke_image_border(
content,
x,
img_bottom,
w,
h,
sc,
chunk.inline_image_stroke_width,
chunk.inline_image_clip.as_ref(),
);
}
if turned {
content.restore_state();
}
if let Some(lt) = link_tags.as_mut() {
lt.resume(content);
}
}
}
for &(dx, dy, dw, dh, dcolor) in &decorations {
if dcolor != current_color {
fill_color_or_black(content, dcolor);
current_color = dcolor;
}
content.rect(dx, dy, dw, dh).fill_nonzero();
}
}
if current_color.is_some() {
content.set_fill_gray(0.0);
}
if let Some(lt) = link_tags {
lt.finish(content);
}
}
pub(super) fn tallest_run_metrics(
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
) -> (f32, Option<f32>, Option<f32>) {
tallest_by_ascent(runs.iter().filter(|r| !r.is_tab), seen_fonts)
.or_else(|| tallest_by_ascent(runs.iter(), seen_fonts))
.unwrap_or((runs.first().map_or(12.0, |r| r.font_size), None, None))
}
pub(super) fn tallest_glyph_run_metrics(
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
) -> (f32, Option<f32>, Option<f32>) {
tallest_by_ascent(runs.iter().filter(|r| sizes_line(r)), seen_fonts).unwrap_or((
runs.first().map_or(12.0, |r| r.font_size),
None,
None,
))
}
pub(super) fn boxed_line_ascent(
ls: LineSpacing,
line_h: f32,
font_size: f32,
lhr: Option<f32>,
ar: Option<f32>,
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
) -> Option<f32> {
if matches!(ls, LineSpacing::Exact(_))
&& tallest_glyph_run_half_leading(runs, seen_fonts) == 0.0
{
return Some(line_h * 0.8);
}
let (lhr, ar) = (lhr?, ar?);
let bottom_aligned = match ls {
LineSpacing::Exact(_) => true,
LineSpacing::AtLeast(min) => min > font_size * lhr,
LineSpacing::Auto(_) => false,
};
(lhr > ar && bottom_aligned).then(|| {
let half_lead = tallest_glyph_run_half_leading(runs, seen_fonts);
line_h - font_size * (lhr - ar - half_lead)
})
}
pub(super) fn tallest_glyph_run_half_leading(
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
) -> f32 {
let mut best = (0.0f32, 0.0f32);
let mut key_buf = String::new();
for run in runs
.iter()
.filter(|r| sizes_line(r) && !r.is_line_break && !r.is_math)
{
let Some(entry) = seen_fonts.get(font_key_buf(run, &mut key_buf)) else {
continue;
};
let (lhr, ar) = run_line_metrics(entry, &run.text);
let ascent = run.font_size * ar.unwrap_or(0.75);
if ascent > best.0 {
let cjk_box = entry.east_asian && ar == entry.ascender_ratio;
best = (
ascent,
if cjk_box {
lhr.unwrap_or(0.0) * 0.3 / 2.6
} else {
0.0
},
);
}
}
best.1
}
fn tallest_by_ascent<'a>(
runs: impl Iterator<Item = &'a Run>,
seen_fonts: &HashMap<String, FontEntry>,
) -> Option<(f32, Option<f32>, Option<f32>)> {
let mut best: Option<(f32, Option<f32>, Option<f32>)> = None;
let mut best_ascent = 0.0f32;
let mut key_buf = String::new();
for run in runs {
if run.is_line_break {
continue;
}
let entry = seen_fonts.get(font_key_buf(run, &mut key_buf));
let (lhr, ascender_ratio) = if run.is_math {
(None, None)
} else {
entry.map_or((None, None), |e| run_line_metrics(e, &run.text))
};
let ascent = run.font_size * ascender_ratio.unwrap_or(0.75);
if ascent > best_ascent {
best_ascent = ascent;
best = Some((run.font_size, lhr, ascender_ratio));
}
}
best
}
pub(super) fn picture_line_bottom(
runs: &[Run],
para: &Paragraph,
seen_fonts: &HashMap<String, FontEntry>,
ls: LineSpacing,
) -> f32 {
let text_runs = || {
runs.iter()
.filter(|r| r.inline_image.is_none() && !r.vanish)
};
let mark = || {
para.paragraph_mark_font_size.map(|fs| {
let lhr = para
.paragraph_mark_font_name
.as_deref()
.and_then(|n| seen_fonts.get(n))
.and_then(|e| run_line_metrics(e, "").0);
(fs, lhr, None)
})
};
let leading = tallest_by_ascent(text_runs(), seen_fonts)
.or_else(mark)
.map_or(0.0, |(fs, lhr, _)| {
(super::helpers::resolve_line_h(ls, fs, lhr) - fs * lhr.unwrap_or(1.2)).max(0.0)
});
let glyph_runs = text_runs().filter(|r| !r.text.trim().is_empty());
let descent = tallest_by_ascent(glyph_runs, seen_fonts)
.map_or(0.0, |(fs, lhr, ar)| fs * descender_ratio(lhr, ar));
descent + leading
}
pub(super) fn run_line_metrics(entry: &FontEntry, text: &str) -> (Option<f32>, Option<f32>) {
if east_asian_leading(entry, text) {
(entry.line_h_ratio, entry.ascender_ratio)
} else {
(entry.plain_line_h_ratio, entry.plain_ascender_ratio)
}
}
pub(super) fn east_asian_leading(entry: &FontEntry, text: &str) -> bool {
entry.east_asian && (text.is_empty() || !text.chars().all(is_break_space))
}
fn sizes_line(r: &Run) -> bool {
!r.is_tab && (r.text.is_empty() || !r.text.trim().is_empty())
}
pub(super) fn grid_snapped_line_h(
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
effective_ls: crate::model::LineSpacing,
line_h: f32,
pitch: f32,
) -> f32 {
let mut grid_h = 0.0f32;
let mut key_buf = String::new();
for run in runs {
if run.is_line_break || run.is_math {
continue;
}
if let Some(e) = seen_fonts.get(font_key_buf(run, &mut key_buf))
&& let Some(t) = e.grid_line_ratio
{
grid_h = grid_h.max(effective_font_size(run, e) * t);
}
}
let cells = |h: f32| ((h / pitch) - 0.02).ceil().max(1.0) * pitch;
match effective_ls {
crate::model::LineSpacing::Auto(m) if grid_h > 0.0 => cells(grid_h).max(m * pitch),
_ => cells(line_h),
}
}
pub(super) fn grid_baseline_offset(
runs: &[Run],
seen_fonts: &HashMap<String, FontEntry>,
cell_h: f32,
) -> Option<f32> {
let mut key_buf = String::new();
runs.iter()
.filter(|r| {
r.inline_image.is_none() && !r.vanish && !r.is_line_break && !r.is_math && sizes_line(r)
})
.filter_map(|r| {
let e = seen_fonts.get(font_key_buf(r, &mut key_buf))?;
Some(e.grid_baseline_shift? * effective_font_size(r, e))
})
.reduce(f32::max)
.map(|shift| cell_h / 2.0 + shift)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::VertAlign;
#[test]
fn overwide_word_cuts_at_the_last_fitting_char() {
let width = |w: &str| w.chars().count() as f32 * 3.0;
assert_eq!(fitting_prefix_len("..........", 10.0, width), Some(3));
assert_eq!(fitting_prefix_len("....", 1.0, width), Some(1));
assert_eq!(fitting_prefix_len("é…é", 6.0, width), Some("é…".len()));
assert_eq!(fitting_prefix_len(".", 1.0, width), None);
}
#[test]
fn justify_counts_word_spaces_not_run_joins() {
let entry = stub_font_entry();
let chunk = |text: &str, x: f32, w: f32| {
let mut c = WordChunk::tab_underline(&entry, 12.0, None, false, None, x, w);
c.text = text.to_string();
c
};
let chunks = vec![
chunk("a", 0.0, 6.0),
chunk("b", 6.0, 6.0),
chunk("cd", 15.0, 12.0),
chunk("", 27.0, 3.0),
chunk("ef", 30.0, 12.0),
];
assert_eq!(spaces_before_each(&chunks), vec![0, 0, 1, 1, 2]);
}
#[test]
fn compress_punctuation_trims_marks_evenly_and_shifts_followers() {
let entry = stub_font_entry();
let chunk = |text: &str, x: f32| {
let mut c = WordChunk::tab_underline(&entry, 16.0, None, false, None, x, 32.0);
c.text = text.to_string();
c
};
let mut chunks = vec![
chunk("任,", 0.0),
chunk("負", 32.0),
chunk("事、", 64.0),
chunk("公", 96.0),
];
assert!(compress_punctuation(&mut chunks, 4.0));
assert!((chunks[0].width - 30.0).abs() < 1e-4 && (chunks[2].width - 30.0).abs() < 1e-4);
assert!((chunks[1].x_offset - 30.0).abs() < 1e-4);
assert!((chunks[2].x_offset - 62.0).abs() < 1e-4);
assert!((chunks[3].x_offset - 92.0).abs() < 1e-4);
assert!(!compress_punctuation(&mut chunks, 5.0));
assert!((chunks[0].width - 30.0).abs() < 1e-4);
let mut plain = vec![chunk("任負", 0.0)];
assert!(!compress_punctuation(&mut plain, 1.0));
}
#[test]
fn test_char_justify_gaps() {
assert_eq!(char_justify_gaps(Alignment::Justify, true, false, 10), None);
assert_eq!(
char_justify_gaps(Alignment::Justify, true, true, 10),
Some(10)
);
assert_eq!(
char_justify_gaps(Alignment::Distribute, true, false, 10),
Some(9)
);
assert_eq!(
char_justify_gaps(Alignment::Distribute, true, true, 10),
Some(9)
);
assert_eq!(
char_justify_gaps(Alignment::Distribute, true, false, 1),
None
);
assert_eq!(char_justify_gaps(Alignment::Justify, false, true, 10), None);
assert_eq!(char_justify_gaps(Alignment::Left, false, true, 10), None);
}
#[test]
fn url_wraps_only_after_hyphens() {
let words: Vec<&str> =
split_preserving_spaces("see https://example.com/foo/bar?x=1&y=2#frag after")
.into_iter()
.map(|(_, w)| w)
.collect();
assert_eq!(
words,
vec!["see", "https://example.com/foo/bar?x=1&y=2#frag", "after"]
);
let words: Vec<&str> = split_preserving_spaces("www.gov.hr/pristup-informacijama/ x")
.into_iter()
.map(|(_, w)| w)
.collect();
assert_eq!(words, vec!["www.gov.hr/pristup-", "informacijama/", "x"]);
}
#[test]
fn test_no_break_after_ellipsis_inside_token() {
let words: Vec<&str> = split_preserving_spaces("Preparation………45 done")
.into_iter()
.map(|(_, w)| w)
.collect();
assert_eq!(words, vec!["Preparation………45", "done"]);
let words: Vec<&str> = split_preserving_spaces("wait… go")
.into_iter()
.map(|(_, w)| w)
.collect();
assert_eq!(words, vec!["wait…", "go"]);
}
#[test]
fn test_is_break_space() {
assert!(is_break_space(' '));
assert!(is_break_space('\t'));
assert!(is_break_space('\n'));
assert!(!is_break_space('\u{00a0}'));
assert!(!is_break_space('\u{3000}'));
assert!(!is_break_space('a'));
assert!(!is_break_space('1'));
}
fn make_run(font_size: f32, valign: VertAlign, small_caps: bool) -> Run {
Run {
font_name: "Arial".to_string(),
font_size,
vertical_align: valign,
small_caps,
text_scale: 100.0,
..Run::default()
}
}
#[test]
fn breaks_between_matches_splitting_inside_a_run() {
let chars = [
'a', 'Z', '-', '/', ',', '(', ')', '…', '⁞', '中', '文', '。', '「', 'é', '1', '%', '$',
];
for a in chars {
for b in chars {
let split = split_preserving_spaces(&format!("{a}{b}")).len() > 1;
assert_eq!(breaks_between(a, b), split, "{a:?}{b:?}");
}
}
}
#[test]
fn hyphen_runs_break_between_hyphens() {
assert_eq!(
split_preserving_spaces("a---b"),
vec![(0, "a-"), (0, "-"), (0, "-"), (0, "b")]
);
}
#[test]
fn hyphen_breaks_before_a_digit() {
assert_eq!(
split_preserving_spaces("2019-2024"),
vec![(0, "2019-"), (0, "2024")]
);
}
#[test]
fn word_split_over_runs_wraps_as_a_whole() {
let mut fonts = HashMap::new();
fonts.insert("Arial".to_string(), stub_font_entry());
let text_run = |text: &str| Run {
text: text.to_string(),
..make_run(10.0, VertAlign::Baseline, false)
};
let runs = [text_run("aaaa bbb"), text_run("ccc")];
let cjk = CjkLayout {
auto_space: false,
compress_punct: false,
squeeze_spaces: false,
expand_shift_return: true,
};
let lines = build_paragraph_lines(
&runs,
&fonts,
40.0,
0.0,
&HashMap::new(),
&HashMap::new(),
None,
None,
None,
cjk,
);
let texts: Vec<Vec<&str>> = lines
.iter()
.map(|l| l.chunks.iter().map(|c| c.text.as_str()).collect())
.collect();
assert_eq!(texts, vec![vec!["aaaa"], vec!["bbb", "ccc"]]);
assert_eq!(lines[1].chunks[0].x_offset, 0.0);
}
#[test]
fn overwide_word_after_text_breaks_at_the_new_lines_margin() {
let mut fonts = HashMap::new();
fonts.insert("Arial".to_string(), stub_font_entry());
let runs = [Run {
text: "aa bbbbbbbbbb".to_string(),
..make_run(10.0, VertAlign::Baseline, false)
}];
let cjk = CjkLayout {
auto_space: false,
compress_punct: false,
squeeze_spaces: false,
expand_shift_return: true,
};
let lines = build_paragraph_lines(
&runs,
&fonts,
40.0,
0.0,
&HashMap::new(),
&HashMap::new(),
None,
None,
None,
cjk,
);
let texts: Vec<String> = lines
.iter()
.map(|l| l.chunks.iter().map(|c| c.text.as_str()).collect())
.collect();
assert_eq!(texts, vec!["aa", "bbbbbbbb", "bb"]);
}
#[test]
fn grid_line_spacing_multiple_scales_the_cells() {
let mut fonts = HashMap::new();
fonts.insert(
"Arial".to_string(),
FontEntry {
grid_line_ratio: Some(1.06),
..stub_font_entry()
},
);
let runs = [Run {
text: "Hxgp".to_string(),
..make_run(12.0, VertAlign::Baseline, false)
}];
let h = |ls| grid_snapped_line_h(&runs, &fonts, ls, 13.8, 18.0);
assert_eq!(h(crate::model::LineSpacing::Auto(1.0)), 18.0);
assert_eq!(h(crate::model::LineSpacing::Auto(1.5)), 27.0);
let big = [Run {
text: "Hxgp".to_string(),
..make_run(22.0, VertAlign::Baseline, false)
}];
let h2 = |ls| grid_snapped_line_h(&big, &fonts, ls, 25.3, 15.6);
assert_eq!(h2(crate::model::LineSpacing::Auto(1.5)), 31.2);
assert_eq!(h2(crate::model::LineSpacing::Auto(3.0)), 15.6 * 3.0);
}
#[test]
fn grid_baseline_centres_the_largest_run_in_its_cell() {
let mut fonts = HashMap::new();
fonts.insert(
"Arial".to_string(),
FontEntry {
grid_baseline_shift: Some(0.3594),
..stub_font_entry()
},
);
let text_run = |text: &str, font_size: f32| Run {
text: text.to_string(),
..make_run(font_size, VertAlign::Baseline, false)
};
let runs = [
text_run("a", 10.0),
text_run("b", 16.0),
text_run(" ", 30.0),
];
let offset = grid_baseline_offset(&runs, &fonts, 36.0).unwrap();
assert!((offset - 23.75).abs() < 0.01, "{offset}");
}
fn stub_font_entry() -> FontEntry {
FontEntry {
pdf_name: "F1".to_string(),
font_ref: pdf_writer::Ref::new(1),
widths_1000: vec![500.0; 224],
line_h_ratio: None,
ascender_ratio: None,
grid_line_ratio: None,
plain_line_h_ratio: None,
grid_baseline_shift: None,
superscript_ratio: None,
subscript_ratio: None,
underline: None,
strikeout: None,
east_asian: false,
plain_ascender_ratio: None,
char_to_gid: None,
char_widths_1000: None,
kern_pairs: None,
synthetic_bold: false,
synthetic_italic: false,
is_substituted: false,
missing_cjk_chars: Default::default(),
drew_notdef: Default::default(),
font_path: None,
face_index: 0,
}
}
#[test]
fn list_text_tabs_past_label_and_first_line_indent() {
let mut fonts = HashMap::new();
fonts.insert("Arial".into(), stub_font_entry());
let mut para = crate::model::Paragraph {
runs: vec![make_run(10.0, VertAlign::Baseline, false)],
list_label: "1.".into(),
indent_first_line: 28.35,
tab_stops: vec![TabStop { position: 21.3, alignment: TabAlignment::Left, leader: None },
TabStop { position: 42.55, alignment: TabAlignment::Left, leader: None }],
..Default::default()
};
let hanging = |p: &crate::model::Paragraph| super::super::list_label::text_hanging(p, 36.0, &fonts);
assert!((hanging(¶) + 42.55).abs() < 0.01);
para.indent_first_line = 0.0;
para.indent_left = -5.0;
para.indent_hanging = 0.25;
para.tab_stops[0].position = 8.55;
assert!((hanging(¶) + 13.55).abs() < 0.01);
para.indent_left = 0.0;
para.indent_hanging = 0.0;
para.tab_stops.clear();
assert!((hanging(¶) + 36.0).abs() < 0.01);
para.indent_left = 36.0;
para.indent_hanging = 18.0;
para.tab_stops.push(TabStop { position: 20.1, alignment: TabAlignment::Left, leader: None });
assert!(hanging(¶).abs() < 0.01);
para.indent_left = 0.0;
para.indent_hanging = 0.0;
para.tab_stops.clear();
para.list_label = "12345678.".into();
assert!((hanging(¶) + 72.0).abs() < 0.01);
}
#[test]
fn test_tabbed_line_applies_whitespace_only_run_after_tab() {
let text_run = |text: &str, font_size: f32| Run {
text: text.to_string(),
..make_run(font_size, VertAlign::Baseline, false)
};
let tab = Run {
is_tab: true,
..make_run(10.0, VertAlign::Baseline, false)
};
let runs = [tab, text_run(" ", 10.0), text_run("x", 9.0)];
let mut fonts = HashMap::new();
fonts.insert("Arial".to_string(), stub_font_entry());
let stops = [TabStop {
position: 100.0,
alignment: TabAlignment::Left,
leader: None,
}];
let lines = build_tabbed_line(
&runs,
&fonts,
&stops,
0.0,
400.0,
0.0,
0.0,
&HashMap::new(),
&HashMap::new(),
36.0,
&[],
15,
false,
);
assert_eq!(lines.len(), 1);
let word = lines[0]
.chunks
.iter()
.find(|c| c.text == "x")
.expect("word chunk");
let expected = 100.0 + 3.0 * fonts["Arial"].space_width(10.0);
assert!(
(word.x_offset - expected).abs() < 0.01,
"x_offset {} != {expected}",
word.x_offset
);
}
#[test]
fn tabbed_justified_line_squeezes_spaces_after_the_tab() {
let tab = Run {
is_tab: true,
..make_run(10.0, VertAlign::Baseline, false)
};
let text = Run {
text: "aa aa aa aa aa".to_string(),
..make_run(10.0, VertAlign::Baseline, false)
};
let runs = [tab, text];
let mut fonts = HashMap::new();
fonts.insert("Arial".to_string(), stub_font_entry());
let stops = [TabStop {
position: 10.0,
alignment: TabAlignment::Left,
leader: None,
}];
let lines = |squeeze| {
build_tabbed_line(
&runs,
&fonts,
&stops,
0.0,
77.0,
0.0,
0.0,
&HashMap::new(),
&HashMap::new(),
36.0,
&[],
15,
squeeze,
)
};
let squeezed = lines(true);
assert_eq!(squeezed.len(), 1);
assert!(squeezed[0].squeezed);
assert_eq!(lines(false).len(), 2);
}
#[test]
fn test_effective_font_size_baseline() {
let run = make_run(12.0, VertAlign::Baseline, false);
assert_eq!(effective_font_size(&run, &stub_font_entry()), 12.0);
}
#[test]
fn test_effective_font_size_superscript() {
let mut entry = stub_font_entry();
entry.superscript_ratio = Some(0.6);
let run = make_run(12.0, VertAlign::Superscript, false);
assert_eq!(effective_font_size(&run, &entry), 7.0);
entry.superscript_ratio = Some(0.65);
assert_eq!(effective_font_size(&run, &entry), 8.0);
}
#[test]
fn test_effective_font_size_subscript() {
let mut entry = stub_font_entry();
entry.subscript_ratio = Some(0.65);
let run = make_run(9.5, VertAlign::Subscript, false);
assert_eq!(effective_font_size(&run, &entry), 6.0);
}
#[test]
fn test_effective_font_size_ignores_small_caps() {
let run = make_run(12.0, VertAlign::Baseline, true);
assert_eq!(effective_font_size(&run, &stub_font_entry()), 12.0);
}
#[test]
fn test_smallcaps_segments_mixed() {
let segs = smallcaps_segments("Hello", 12.0);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0], ("H".to_string(), 12.0, "H")); assert_eq!(segs[1], ("ELLO".to_string(), 9.5, "ello")); }
#[test]
fn test_smallcaps_segments_all_upper() {
let segs = smallcaps_segments("ABC", 12.0);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0], ("ABC".to_string(), 12.0, "ABC"));
}
#[test]
fn test_smallcaps_segments_all_lower() {
let segs = smallcaps_segments("abc", 12.0);
assert_eq!(segs.len(), 1);
assert_eq!(segs[0], ("ABC".to_string(), 9.5, "abc"));
}
#[test]
fn small_caps_spaces_follow_their_neighbours() {
let segs = smallcaps_segments("def X. 12", 12.0);
assert_eq!(segs[0], ("DEF ".to_string(), 9.5, "def "));
assert_eq!(segs[1], ("X. 12".to_string(), 12.0, "X. 12"));
}
#[test]
fn test_smallcaps_segments_with_nonletters() {
let segs = smallcaps_segments("A1b", 12.0);
assert_eq!(segs.len(), 2);
assert_eq!(segs[0], ("A1".to_string(), 12.0, "A1")); assert_eq!(segs[1], ("B".to_string(), 9.5, "b")); }
#[test]
fn caps_and_small_caps_keep_their_source_letters() {
let entry = stub_font_entry();
let chunks_for = |run: &Run, word: &str, original: Option<&str>| {
let mut chunks = Vec::new();
push_word_chunks(
&mut chunks,
&entry,
run,
word,
original,
12.0,
0.0,
0.0,
0.0,
30.0,
);
chunks
.into_iter()
.map(|c| (c.text, c.actual_text))
.collect::<Vec<_>>()
};
let small_caps = Run {
small_caps: true,
..Run::default()
};
assert_eq!(
chunks_for(&small_caps, "Hello", None),
[("H".into(), None), ("ELLO".into(), Some("ello".into()))]
);
let caps = Run {
caps: true,
..Run::default()
};
assert_eq!(caps_word(&caps, "Pirmasis"), "PIRMASIS");
assert_eq!(
chunks_for(&caps, "PIRMASIS", Some("Pirmasis")),
[("PIRMASIS".into(), Some("Pirmasis".into()))]
);
let both = Run {
caps: true,
small_caps: true,
..Run::default()
};
assert_eq!(
chunks_for(&both, "PIRMASIS", Some("Pirmasis")),
[("PIRMASIS".into(), Some("Pirmasis".into()))]
);
assert_eq!(
chunks_for(&Run::default(), "plain", None),
[("plain".into(), None)]
);
}
#[test]
fn caps_span_groups_chunks_and_returns_to_the_paragraph() {
use super::super::tagging::{self, ROOT, Tags};
let mut tags = Tags::new();
let p = tags.add(ROOT, "P");
let mut content = tagging::artifact_content();
tags.begin(&mut content, 0, p);
content.begin_text();
let mut lt = LinkTagger::new(&mut tags, 0, p);
assert!(
lt.inline(&mut content, None, None, Some("Pirmasis ")),
"opens a Span"
);
assert!(
!lt.inline(&mut content, None, None, Some("skirsnis")),
"extends it"
);
assert!(
lt.inline(&mut content, None, None, None),
"back to the paragraph"
);
content.end_text();
lt.finish(&mut content);
let stream = String::from_utf8_lossy(&content.finish()).into_owned();
assert_eq!(stream.matches("/Span").count(), 1);
assert_eq!(stream.matches("BDC").count(), 3, "P, Span, P again");
let mut pdf = pdf_writer::Pdf::new();
let mut next = 1;
let mut alloc = || {
next += 1;
pdf_writer::Ref::new(next)
};
tags.write(&mut pdf, &mut alloc, &[pdf_writer::Ref::new(1)], &[]);
let bytes = pdf.finish();
assert!(bytes.windows(19).any(|w| w == b"(Pirmasis skirsnis)"));
}
#[test]
fn complex_script_words_take_the_bidi_language() {
let run = Run {
text_lang: Some("en-US".into()),
text_lang_east_asia: Some("ja-JP".into()),
text_lang_bidi: Some("ar-SA".into()),
..Run::default()
};
let lang = |text| chunk_lang(&run, text).map(|l| l.to_string());
assert_eq!(lang("الأرز").as_deref(), Some("ar-SA"));
assert_eq!(lang("日本").as_deref(), Some("ja-JP"));
assert_eq!(lang("rice").as_deref(), Some("en-US"));
let no_bidi = Run {
text_lang_bidi: None,
..run.clone()
};
assert_eq!(
chunk_lang(&no_bidi, "الأرز").as_deref(),
Some("en-US"),
"without @bidi the run's language stands"
);
}
#[test]
fn another_language_gets_a_lang_span() {
use super::super::tagging::{self, ROOT, Tags};
let mut tags = Tags::new();
tags.lang = "en-US".into();
let p = tags.add(ROOT, "P");
let mut content = tagging::artifact_content();
tags.begin(&mut content, 0, p);
content.begin_text();
let mut lt = LinkTagger::new(&mut tags, 0, p);
assert!(
!lt.inline(&mut content, None, Some("en-GB"), None),
"same language, no Span"
);
assert!(
lt.inline(&mut content, None, Some("fr-FR"), None),
"French opens one"
);
assert!(
!lt.inline(&mut content, None, Some("fr-FR"), None),
"and keeps it"
);
assert!(
lt.inline(&mut content, None, Some("fr-FR"), Some("Bonjour")),
"caps need their own"
);
assert!(
lt.inline(&mut content, None, None, None),
"back to the paragraph"
);
content.end_text();
lt.finish(&mut content);
let mut pdf = pdf_writer::Pdf::new();
let mut next = 1;
let mut alloc = || {
next += 1;
pdf_writer::Ref::new(next)
};
tags.write(&mut pdf, &mut alloc, &[pdf_writer::Ref::new(1)], &[]);
let bytes = pdf.finish();
let count = |pat: &[u8]| bytes.windows(pat.len()).filter(|w| *w == pat).count();
assert_eq!(count(b"/Lang (fr-FR)"), 2);
assert_eq!(count(b"/ActualText (Bonjour)"), 1);
}
#[test]
fn test_vert_y_offset_baseline() {
let run = make_run(12.0, VertAlign::Baseline, false);
assert_eq!(vert_y_offset(&run), 0.0);
}
#[test]
fn test_vert_y_offset_superscript() {
let run = make_run(12.0, VertAlign::Superscript, false);
let expected = 12.0 * 0.35; assert!((vert_y_offset(&run) - expected).abs() < 0.01);
}
#[test]
fn test_vert_y_offset_subscript() {
let run = make_run(12.0, VertAlign::Subscript, false);
let expected = -12.0 * 0.14; assert!((vert_y_offset(&run) - expected).abs() < 0.01);
}
#[test]
fn test_vert_y_offset_adds_position() {
let mut run = make_run(12.0, VertAlign::Superscript, false);
run.position = -3.0;
assert!((vert_y_offset(&run) - (12.0 * 0.35 - 3.0)).abs() < 0.01);
}
}