use std::collections::HashMap;
use crate::model::{self, FirstLineIndent, LineSpacing};
use crate::render::dimension::Pt;
use crate::render::geometry::PtSize;
use crate::render::layout::build::BuildState;
use crate::render::layout::fragment::Fragment;
use crate::render::layout::measurer::TextMeasurer;
use crate::render::layout::paragraph::TabStopDef;
use crate::render::layout::paragraph::{
BorderLine, LineSpacingRule, ParagraphBorderStyle, ParagraphStyle,
};
use crate::render::layout::table::{
CellBorderOverride, TableBorderConfig, TableBorderLine, TableBorderStyle,
};
use crate::render::resolve::color::{resolve_color, ColorContext, RgbColor};
use crate::render::resolve::fonts::effective_font;
use crate::render::resolve::images::MediaEntry;
use crate::render::resolve::properties::merge_paragraph_properties;
use crate::render::resolve::ResolvedDocument;
use super::{BuildContext, SPEC_DEFAULT_FONT_SIZE, SPEC_FALLBACK_FONT};
pub(super) fn resolve_paragraph_defaults(
para: &model::Paragraph,
resolved: &ResolvedDocument,
defer_doc_defaults: bool,
base_color: Option<RgbColor>,
base_family: Option<&str>,
) -> (
String,
Pt,
RgbColor,
model::ParagraphProperties,
model::RunProperties,
) {
let mut para_props = para.properties.clone();
let mut run_defaults = resolved.doc_defaults_run.clone();
let mut default_family = match base_family.filter(|s| !s.is_empty()) {
Some(f) => f.to_string(),
None => resolved
.theme
.as_ref()
.map(|t| t.minor_font.latin.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(SPEC_FALLBACK_FONT)
.to_string(),
};
let mut default_size = resolved
.doc_defaults_run
.font_size
.map(Pt::from)
.unwrap_or(SPEC_DEFAULT_FONT_SIZE);
let mut default_color = base_color.unwrap_or(RgbColor::BLACK);
let effective_style_id = para
.style_id
.as_ref()
.or(resolved.default_paragraph_style_id.as_ref());
if let Some(style_id) = effective_style_id {
if let Some(resolved_style) = resolved.styles.get(style_id) {
merge_paragraph_properties(&mut para_props, &resolved_style.paragraph);
run_defaults = resolved_style.run.clone();
}
}
if !defer_doc_defaults {
merge_paragraph_properties(&mut para_props, &resolved.doc_defaults_paragraph);
}
if let Some(f) = effective_font(&run_defaults.fonts) {
default_family = f.to_string();
}
if let Some(fs) = run_defaults.font_size {
default_size = Pt::from(fs);
}
if let Some(c) = run_defaults.color {
default_color = resolve_color(c, ColorContext::Text);
}
(
default_family,
default_size,
default_color,
para_props,
run_defaults,
)
}
pub(super) fn paragraph_locale(
para: &model::Paragraph,
resolved: &ResolvedDocument,
) -> crate::render::resolve::locale::Locale {
let style_run = para
.style_id
.as_ref()
.or(resolved.default_paragraph_style_id.as_ref())
.and_then(|id| resolved.styles.get(id))
.map(|s| &s.run);
crate::render::resolve::locale::Locale::from_cascade(
[
para.mark_run_properties.as_ref(),
style_run,
Some(&resolved.doc_defaults_run),
]
.into_iter()
.flatten(),
)
}
pub(super) fn paragraph_outline(
para: &model::Paragraph,
props: &model::ParagraphProperties,
state: &mut BuildState,
) -> Option<crate::render::layout::draw_command::OutlineHeading> {
let level = props.outline_level?;
let title = outline_title(¶.content);
if title.is_empty() {
return None;
}
let node_id = state.next_outline_node_id()?;
Some(crate::render::layout::draw_command::OutlineHeading {
node_id,
level,
title: std::rc::Rc::from(title.as_str()),
})
}
fn outline_title(inlines: &[model::Inline]) -> String {
fn walk(inlines: &[model::Inline], out: &mut String) {
for inline in inlines {
match inline {
model::Inline::TextRun(run) => {
for element in &run.content {
if let model::RunElement::Text(text) = element {
out.push_str(text);
}
}
}
model::Inline::Symbol(sym) => {
out.push(char::from_u32(sym.char_code as u32).unwrap_or('\u{FFFD}'));
}
model::Inline::Hyperlink(link) => walk(&link.content, out),
model::Inline::Field(field) => walk(&field.content, out),
_ => {}
}
}
}
let mut out = String::new();
walk(inlines, &mut out);
out.trim().to_string()
}
pub(super) fn doc_font_family(ctx: &BuildContext) -> String {
ctx.resolved
.theme
.as_ref()
.map(|t| t.minor_font.latin.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(SPEC_FALLBACK_FONT)
.to_string()
}
pub(super) fn doc_font_size(ctx: &BuildContext) -> Pt {
ctx.resolved
.doc_defaults_run
.font_size
.map(Pt::from)
.unwrap_or(SPEC_DEFAULT_FONT_SIZE)
}
pub(super) fn paragraph_style_from_props(
props: &model::ParagraphProperties,
default_tab_stop: Pt,
auto_fit: crate::render::layout::ShapeAutoFit,
locale: crate::render::resolve::locale::Locale,
outline: Option<crate::render::layout::draw_command::OutlineHeading>,
) -> ParagraphStyle {
let indent_left = props
.indentation
.and_then(|i| i.start)
.map(Pt::from)
.unwrap_or(Pt::ZERO);
let indent_right = props
.indentation
.and_then(|i| i.end)
.map(Pt::from)
.unwrap_or(Pt::ZERO);
let indent_first_line = props
.indentation
.and_then(|i| i.first_line)
.map(|fl| match fl {
FirstLineIndent::FirstLine(v) => Pt::from(v),
FirstLineIndent::Hanging(v) => -Pt::from(v),
FirstLineIndent::None => Pt::ZERO,
})
.unwrap_or(Pt::ZERO);
let space_before = if props.spacing.and_then(|s| s.before_auto_spacing) == Some(true) {
Pt::new(14.0)
} else {
props
.spacing
.and_then(|s| s.before)
.map(Pt::from)
.unwrap_or(Pt::ZERO)
};
let space_after = if props.spacing.and_then(|s| s.after_auto_spacing) == Some(true) {
Pt::new(14.0)
} else {
props
.spacing
.and_then(|s| s.after)
.map(Pt::from)
.unwrap_or(Pt::ZERO)
};
let line_spacing = props
.spacing
.and_then(|s| s.line)
.map(|ls| match ls {
LineSpacing::Auto(v) => LineSpacingRule::Auto(Pt::from(v).raw() / 12.0),
LineSpacing::Exact(v) => LineSpacingRule::Exact(Pt::from(v)),
LineSpacing::AtLeast(v) => LineSpacingRule::AtLeast(Pt::from(v)),
})
.unwrap_or(LineSpacingRule::Auto(1.0));
let tabs: Vec<TabStopDef> = props
.tabs
.iter()
.filter(|t| t.alignment != model::TabAlignment::Clear)
.map(|t| TabStopDef {
position: Pt::from(t.position),
alignment: t.alignment,
leader: t.leader,
})
.collect();
ParagraphStyle {
auto_fit,
alignment: props.alignment.unwrap_or(model::Alignment::Start),
space_before,
space_after,
indent_left,
indent_right,
indent_first_line,
line_spacing,
tabs,
default_tab_stop,
locale,
outline,
drop_cap: None,
borders: resolve_paragraph_borders(props),
shading: props
.shading
.as_ref()
.map(|s| resolve_color(s.fill, ColorContext::Background)),
keep_next: props.keep_next.unwrap_or(false),
keep_lines: props.keep_lines.unwrap_or(false),
widow_control: props.widow_control.unwrap_or(true),
contextual_spacing: props.contextual_spacing.unwrap_or(false),
style_id: None, page_floats: Vec::new(),
page_y: crate::render::dimension::Pt::ZERO,
page_x: crate::render::dimension::Pt::ZERO,
page_content_width: crate::render::dimension::Pt::ZERO,
}
}
pub(super) fn resolve_paragraph_borders(
props: &model::ParagraphProperties,
) -> Option<ParagraphBorderStyle> {
let pbdr = props.borders.as_ref()?;
let convert = |b: &model::Border| -> Option<BorderLine> {
if b.style.draws_nothing() {
return None;
}
Some(BorderLine {
width: Pt::from(b.width),
color: resolve_color(b.color, ColorContext::Text),
space: Pt::from(b.space),
})
};
let style = ParagraphBorderStyle {
top: pbdr.top.as_ref().and_then(convert),
bottom: pbdr.bottom.as_ref().and_then(convert),
left: pbdr.left.as_ref().and_then(convert),
right: pbdr.right.as_ref().and_then(convert),
};
if style.top.is_some()
|| style.bottom.is_some()
|| style.left.is_some()
|| style.right.is_some()
{
Some(style)
} else {
None
}
}
fn convert_model_border(b: &model::Border, state: &mut BuildState) -> TableBorderLine {
let style = match b.style {
model::BorderStyle::Double => TableBorderStyle::Double,
model::BorderStyle::Single => TableBorderStyle::Single,
other => {
if state.warned_border_styles.insert(other) {
log::warn!(
"[build] §17.4.38 border style {other:?} is not drawn; \
approximating with a single solid line"
);
}
TableBorderStyle::Single
}
};
TableBorderLine {
width: Pt::from(b.width),
color: resolve_color(b.color, ColorContext::Text),
style,
}
}
pub(super) fn convert_cell_border_override(
b: &Option<model::Border>,
state: &mut BuildState,
) -> Option<CellBorderOverride> {
b.as_ref().and_then(|b| match b.style {
model::BorderStyle::Nil => Some(CellBorderOverride::Suppress),
model::BorderStyle::None => None,
_ => Some(CellBorderOverride::Border(convert_model_border(b, state))),
})
}
pub(super) fn merge_table_borders(
direct: &model::TableBorders,
style: &model::TableBorders,
) -> model::TableBorders {
model::TableBorders {
top: direct.top.or(style.top),
bottom: direct.bottom.or(style.bottom),
left: direct.left.or(style.left),
right: direct.right.or(style.right),
inside_h: direct.inside_h.or(style.inside_h),
inside_v: direct.inside_v.or(style.inside_v),
}
}
pub(super) fn convert_table_border_config(
b: &model::TableBorders,
state: &mut BuildState,
) -> TableBorderConfig {
let mut convert = |border: &Option<model::Border>| -> Option<TableBorderLine> {
border.as_ref().and_then(|b| {
if b.style.draws_nothing() {
None
} else {
Some(convert_model_border(b, state))
}
})
};
TableBorderConfig {
top: convert(&b.top),
bottom: convert(&b.bottom),
left: convert(&b.left),
right: convert(&b.right),
inside_h: convert(&b.inside_h),
inside_v: convert(&b.inside_v),
}
}
pub(super) fn populate_image_data(
fragments: &mut [Fragment],
media: &HashMap<model::RelId, MediaEntry>,
) {
for frag in fragments.iter_mut() {
if let Fragment::Image {
rel_id, image_data, ..
} = frag
{
if image_data.is_none() {
if let Some(entry) = media.get(&model::RelId::new(rel_id.as_str())) {
*image_data = Some(entry.clone());
}
}
}
}
}
pub(super) fn remap_legacy_font_chars(
text: &str,
font_family: &str,
fallback_family: &str,
) -> (String, String) {
let is_symbol = font_family.eq_ignore_ascii_case("Symbol");
let is_wingdings = font_family.eq_ignore_ascii_case("Wingdings");
if !is_symbol && !is_wingdings {
let family = if font_family.is_empty() {
fallback_family
} else {
font_family
};
return (text.to_string(), family.to_string());
}
let mut remapped = String::with_capacity(text.len());
let mut unmapped_pua = false;
for ch in text.chars() {
let code = ch as u32;
if !LEGACY_PUA_RANGE.contains(&code) {
remapped.push(ch);
continue;
}
let mapped = if is_symbol {
symbol_pua_to_unicode(code)
} else {
wingdings_pua_to_unicode(code)
};
match mapped {
Some(unicode) => remapped.push(unicode),
None => {
unmapped_pua = true;
remapped.push(ch);
}
}
}
let family = if unmapped_pua {
font_family
} else {
fallback_family
};
(remapped, family.to_string())
}
const LEGACY_PUA_RANGE: std::ops::RangeInclusive<u32> = 0xF020..=0xF0FF;
fn symbol_pua_to_unicode(code: u32) -> Option<char> {
const GREEK_UPPER: [char; 26] = [
'\u{0391}', '\u{0392}', '\u{03A7}', '\u{0394}', '\u{0395}', '\u{03A6}', '\u{0393}', '\u{0397}', '\u{0399}', '\u{03D1}', '\u{039A}', '\u{039B}', '\u{039C}', '\u{039D}', '\u{039F}', '\u{03A0}', '\u{0398}', '\u{03A1}', '\u{03A3}', '\u{03A4}', '\u{03A5}', '\u{03C2}', '\u{03A9}', '\u{039E}', '\u{03A8}', '\u{0396}', ];
const GREEK_LOWER: [char; 26] = [
'\u{03B1}', '\u{03B2}', '\u{03C7}', '\u{03B4}', '\u{03B5}', '\u{03C6}', '\u{03B3}', '\u{03B7}', '\u{03B9}', '\u{03D5}', '\u{03BA}', '\u{03BB}', '\u{03BC}', '\u{03BD}', '\u{03BF}', '\u{03C0}', '\u{03B8}', '\u{03C1}', '\u{03C3}', '\u{03C4}', '\u{03C5}', '\u{03D6}', '\u{03C9}', '\u{03BE}', '\u{03C8}', '\u{03B6}', ];
Some(match code {
0xF020 => '\u{0020}', 0xF021 => '\u{0021}', 0xF025 => '\u{0025}', 0xF028 => '\u{0028}', 0xF029 => '\u{0029}', 0xF02B => '\u{002B}', 0xF02E => '\u{002E}', 0xF030..=0xF039 => char::from_u32(code - 0xF000)?, 0xF03C => '\u{003C}', 0xF03D => '\u{003D}', 0xF03E => '\u{003E}', 0xF041..=0xF05A => GREEK_UPPER[(code - 0xF041) as usize],
0xF05B => '\u{005B}', 0xF05D => '\u{005D}', 0xF061..=0xF07A => GREEK_LOWER[(code - 0xF061) as usize],
0xF07B => '\u{007B}', 0xF07C => '\u{007C}', 0xF07D => '\u{007D}', 0xF07E => '\u{223C}', 0xF0A0 => '\u{20AC}', 0xF0A5 => '\u{221E}', 0xF0A7 => '\u{2663}', 0xF0A8 => '\u{2666}', 0xF0A9 => '\u{2665}', 0xF0AA => '\u{2660}', 0xF0AB => '\u{2194}', 0xF0AC => '\u{2190}', 0xF0AD => '\u{2191}', 0xF0AE => '\u{2192}', 0xF0AF => '\u{2193}', 0xF0B0 => '\u{00B0}', 0xF0B1 => '\u{00B1}', 0xF0B2 => '\u{2033}', 0xF0B3 => '\u{2265}', 0xF0B4 => '\u{00D7}', 0xF0B5 => '\u{221D}', 0xF0B7 => '\u{2022}', 0xF0B8 => '\u{00F7}', 0xF0B9 => '\u{2260}', 0xF0BA => '\u{2261}', 0xF0BB => '\u{2248}', 0xF0BC => '\u{2026}', 0xF0C0 => '\u{2135}', 0xF0C1 => '\u{2111}', 0xF0C2 => '\u{211C}', 0xF0C3 => '\u{2118}', 0xF0C5 => '\u{2297}', 0xF0C6 => '\u{2295}', 0xF0C7 => '\u{2205}', 0xF0C8 => '\u{2229}', 0xF0C9 => '\u{222A}', 0xF0CB => '\u{2283}', 0xF0CC => '\u{2287}', 0xF0CD => '\u{2284}', 0xF0CE => '\u{2282}', 0xF0CF => '\u{2286}', 0xF0D0 => '\u{2208}', 0xF0D1 => '\u{2209}', 0xF0D5 => '\u{220F}', 0xF0D6 => '\u{221A}', 0xF0D7 => '\u{22C5}', 0xF0D8 => '\u{00AC}', 0xF0D9 => '\u{2227}', 0xF0DA => '\u{2228}', 0xF0E0 => '\u{21D0}', 0xF0E1 => '\u{21D1}', 0xF0E2 => '\u{21D2}', 0xF0E3 => '\u{21D3}', 0xF0E4 => '\u{21D4}', 0xF0E5 => '\u{2329}', 0xF0F1 => '\u{232A}', 0xF0F2 => '\u{222B}', _ => return None,
})
}
fn wingdings_pua_to_unicode(code: u32) -> Option<char> {
Some(match code {
0xF021 => '\u{270E}', 0xF022 => '\u{2702}', 0xF023 => '\u{2701}', 0xF028 => '\u{1F4CB}', 0xF029 => '\u{1F4CB}', 0xF041 => '\u{FE4E}', 0xF046 => '\u{1F44D}', 0xF04A => '\u{263A}', 0xF04C => '\u{2639}', 0xF06C => '\u{25CF}', 0xF06D => '\u{274D}', 0xF06E => '\u{25A0}', 0xF06F => '\u{25A1}', 0xF070 => '\u{25A1}', 0xF071 => '\u{2751}', 0xF072 => '\u{2752}', 0xF073 => '\u{25C6}', 0xF074 => '\u{2756}', 0xF076 => '\u{2756}', 0xF09F => '\u{2708}', 0xF0A1 => '\u{270C}', 0xF0A4 => '\u{261C}', 0xF0A5 => '\u{261E}', 0xF0A7 => '\u{25AA}', 0xF0A8 => '\u{25FB}', 0xF0D5 => '\u{232B}', 0xF0D8 => '\u{27A2}', 0xF0E8 => '\u{2B22}', 0xF0F0 => '\u{2B1A}', 0xF0FB => '\u{2718}', 0xF0FC => '\u{2714}', 0xF0FE => '\u{2612}', _ => return None,
})
}
pub(super) fn populate_underline_metrics(fragments: &mut [Fragment], measurer: &TextMeasurer) {
for frag in fragments.iter_mut() {
if let Fragment::Text { font, .. } = frag {
if font.underline {
let fp = std::rc::Rc::make_mut(font);
let (pos, thickness) = measurer.underline_metrics(fp);
fp.underline_position = pos;
fp.underline_thickness = thickness;
}
}
}
}
pub(super) fn pic_bullet_size(bullet: &model::NumPicBullet) -> PtSize {
let default = PtSize::new(Pt::new(9.0), Pt::new(9.0));
let shape = match bullet.pict.as_ref().and_then(|p| p.shapes().next()) {
Some(s) => s,
None => return default,
};
let w = shape
.common
.style
.width
.and_then(vml_style_length_to_pt)
.unwrap_or(default.width);
let h = shape
.common
.style
.height
.and_then(vml_style_length_to_pt)
.unwrap_or(default.height);
PtSize::new(w, h)
}
pub(super) fn vml_style_length_to_pt(len: model::VmlLength) -> Option<Pt> {
use crate::model::VmlLengthUnit;
if let Some(pt) = len.to_absolute_points() {
return Some(Pt::new(pt));
}
match len.unit {
VmlLengthUnit::None => Some(Pt::new(len.value as f32 / 914400.0 * 72.0)),
VmlLengthUnit::Em | VmlLengthUnit::Percent => None,
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::dimension::Dimension;
use crate::model::{Border, BorderStyle, Color, ParagraphBorders, ParagraphProperties};
use crate::render::resolve::color::rgb_from_u32;
fn empty_resolved() -> ResolvedDocument {
use std::collections::HashMap;
ResolvedDocument {
sections: Vec::new(),
styles: HashMap::new(),
numbering: HashMap::new(),
font_families: Vec::new(),
media: HashMap::new(),
embedded_fonts: Vec::new(),
pic_bullets: HashMap::new(),
theme: None,
doc_defaults_paragraph: ParagraphProperties::default(),
doc_defaults_run: model::RunProperties::default(),
default_paragraph_style_id: None,
footnotes: HashMap::new(),
endnotes: HashMap::new(),
even_and_odd_headers: false,
default_tab_stop: Dimension::new(720),
}
}
fn bare_para() -> model::Paragraph {
model::Paragraph {
style_id: None,
properties: ParagraphProperties::default(),
mark_run_properties: None,
content: Vec::new(),
rsids: model::ParagraphRevisionIds::default(),
}
}
#[test]
fn shape_font_ref_base_color_and_family_apply_as_defaults() {
let resolved = empty_resolved();
let para = bare_para();
let (_, _, color, _, _) =
resolve_paragraph_defaults(¶, &resolved, false, Some(rgb_from_u32(0xFF0000)), None);
assert_eq!(color, rgb_from_u32(0xFF0000), "fontRef base color applies");
let (_, _, black, _, _) = resolve_paragraph_defaults(¶, &resolved, false, None, None);
assert_eq!(black, rgb_from_u32(0x000000));
let (family, _, _, _, _) =
resolve_paragraph_defaults(¶, &resolved, false, None, Some("Foo Sans"));
assert_eq!(family, "Foo Sans");
}
fn border_with_style(style: BorderStyle) -> Border {
Border {
style,
width: Dimension::new(0),
space: Dimension::new(0),
color: Color::Auto,
}
}
#[test]
fn paragraph_borders_explicit_none_yields_no_render_borders() {
let props = ParagraphProperties {
borders: Some(ParagraphBorders {
top: Some(border_with_style(BorderStyle::None)),
bottom: Some(border_with_style(BorderStyle::None)),
left: Some(border_with_style(BorderStyle::None)),
right: Some(border_with_style(BorderStyle::None)),
between: Some(border_with_style(BorderStyle::None)),
}),
..Default::default()
};
assert!(
resolve_paragraph_borders(&props).is_none(),
"all-sides-nil pPr must produce no ParagraphBorderStyle"
);
}
#[test]
fn paragraph_borders_mixed_keeps_only_actual_sides() {
let props = ParagraphProperties {
borders: Some(ParagraphBorders {
top: Some(border_with_style(BorderStyle::Single)),
bottom: Some(border_with_style(BorderStyle::None)),
left: Some(border_with_style(BorderStyle::None)),
right: Some(border_with_style(BorderStyle::None)),
between: None,
}),
..Default::default()
};
let resolved = resolve_paragraph_borders(&props).expect("top side must survive");
assert!(resolved.top.is_some());
assert!(resolved.bottom.is_none());
assert!(resolved.left.is_none());
assert!(resolved.right.is_none());
}
#[test]
fn paragraph_borders_all_single_round_trips() {
let props = ParagraphProperties {
borders: Some(ParagraphBorders {
top: Some(border_with_style(BorderStyle::Single)),
bottom: Some(border_with_style(BorderStyle::Single)),
left: Some(border_with_style(BorderStyle::Single)),
right: Some(border_with_style(BorderStyle::Single)),
between: None,
}),
..Default::default()
};
let resolved = resolve_paragraph_borders(&props).expect("must produce Some");
assert!(resolved.top.is_some());
assert!(resolved.bottom.is_some());
assert!(resolved.left.is_some());
assert!(resolved.right.is_some());
}
#[test]
fn paragraph_borders_absent_yields_none() {
let props = ParagraphProperties::default();
assert!(resolve_paragraph_borders(&props).is_none());
}
#[test]
fn symbol_pua_maps_the_greek_alphabet() {
for (code, expected) in [
(0xF041, '\u{0391}'), (0xF044, '\u{0394}'), (0xF05A, '\u{0396}'), (0xF061, '\u{03B1}'), (0xF06D, '\u{03BC}'), (0xF07A, '\u{03B6}'), ] {
assert_eq!(symbol_pua_to_unicode(code), Some(expected), "U+{code:04X}");
}
}
#[test]
fn symbol_pua_greek_variant_slots_are_not_alphabetical() {
for (code, expected, what) in [
(0xF04A, '\u{03D1}', "J is theta1, not Iota-then-Kappa order"),
(0xF056, '\u{03C2}', "V is final sigma, not Upsilon+1"),
(0xF06A, '\u{03D5}', "j is phi1 (phi symbol)"),
(0xF076, '\u{03D6}', "v is omega1 (pi symbol), not omega"),
] {
assert_eq!(symbol_pua_to_unicode(code), Some(expected), "{what}");
}
assert_eq!(
symbol_pua_to_unicode(0xF051),
Some('\u{0398}'),
"Q is Theta"
);
assert_eq!(symbol_pua_to_unicode(0xF066), Some('\u{03C6}'), "f is phi");
assert_eq!(
symbol_pua_to_unicode(0xF077),
Some('\u{03C9}'),
"w is omega"
);
}
#[test]
fn symbol_pua_keeps_non_greek_mappings() {
for (code, expected) in [
(0xF05B, '\u{005B}'), (0xF05D, '\u{005D}'), (0xF07B, '\u{007B}'), (0xF0B7, '\u{2022}'), (0xF030, '\u{0030}'), (0xF0F2, '\u{222B}'), ] {
assert_eq!(symbol_pua_to_unicode(code), Some(expected), "U+{code:04X}");
}
}
#[test]
fn unmapped_pua_is_none() {
assert_eq!(symbol_pua_to_unicode(0xF0FF), None);
assert_eq!(wingdings_pua_to_unicode(0xF0FF), None);
}
#[test]
fn fully_mapped_text_switches_to_the_fallback_font() {
let (text, family) = remap_legacy_font_chars("\u{F0B7}", "Symbol", "Calibri");
assert_eq!(text, "\u{2022}", "bullet remapped");
assert_eq!(family, "Calibri");
}
#[test]
fn unmapped_pua_keeps_the_legacy_font() {
let (text, family) = remap_legacy_font_chars("\u{F0FF}", "Symbol", "Calibri");
assert_eq!(text, "\u{F0FF}", "unmapped codepoint survives unchanged");
assert_eq!(
family, "Symbol",
"keep the legacy face — it is the only one that can render this"
);
}
#[test]
fn one_unmapped_char_keeps_the_legacy_font_for_the_whole_label() {
let (_, family) = remap_legacy_font_chars("\u{F0B7}\u{F0FF}", "Symbol", "Calibri");
assert_eq!(family, "Symbol");
}
#[test]
fn non_legacy_family_is_untouched() {
let (text, family) = remap_legacy_font_chars("abc", "Arial", "Calibri");
assert_eq!(text, "abc");
assert_eq!(family, "Arial");
}
fn vml_len(value: f64, unit: model::VmlLengthUnit) -> model::VmlLength {
model::VmlLength { value, unit }
}
#[test]
fn style_length_passes_absolute_units_through() {
use model::VmlLengthUnit;
assert_eq!(
vml_style_length_to_pt(vml_len(9.0, VmlLengthUnit::Pt)),
Some(Pt::new(9.0))
);
assert_eq!(
vml_style_length_to_pt(vml_len(1.0, VmlLengthUnit::In)),
Some(Pt::new(72.0))
);
}
#[test]
fn style_length_reads_a_bare_number_as_emu() {
let got = vml_style_length_to_pt(vml_len(914400.0, model::VmlLengthUnit::None))
.expect("unitless resolves in a style measurement");
assert!(
(got.raw() - 72.0).abs() < 1e-3,
"914400 EMU is one inch, got {}",
got.raw()
);
}
#[test]
fn style_length_rejects_units_needing_a_container() {
for unit in [model::VmlLengthUnit::Percent, model::VmlLengthUnit::Em] {
assert_eq!(
vml_style_length_to_pt(vml_len(50.0, unit)),
None,
"{unit:?} needs a containing box / font size"
);
}
}
}
#[cfg(test)]
mod border_style_tests {
use super::*;
fn border(style: model::BorderStyle) -> model::Border {
model::Border {
style,
width: crate::model::dimension::Dimension::new(8),
color: model::Color::Auto,
space: crate::model::dimension::Dimension::new(0),
}
}
#[test]
fn unsupported_styles_collapse_to_single_preserving_width_and_colour() {
let mut state = BuildState::default();
for style in [
model::BorderStyle::Dotted,
model::BorderStyle::Dashed,
model::BorderStyle::Triple,
model::BorderStyle::Wave,
model::BorderStyle::DashDotStroked,
model::BorderStyle::ThinThickLargeGap,
] {
let line = convert_model_border(&border(style), &mut state);
assert_eq!(
line.style,
TableBorderStyle::Single,
"{style:?} should approximate as a single line"
);
assert_eq!(line.width, Pt::from(border(style).width), "width preserved");
}
}
#[test]
fn supported_styles_are_not_approximated() {
let mut state = BuildState::default();
assert_eq!(
convert_model_border(&border(model::BorderStyle::Double), &mut state).style,
TableBorderStyle::Double
);
assert_eq!(
convert_model_border(&border(model::BorderStyle::Single), &mut state).style,
TableBorderStyle::Single
);
assert!(
state.warned_border_styles.is_empty(),
"styles that render faithfully must not be reported as approximated"
);
}
#[test]
fn each_unsupported_style_is_recorded_exactly_once() {
let mut state = BuildState::default();
for _ in 0..50 {
convert_model_border(&border(model::BorderStyle::Dotted), &mut state);
convert_model_border(&border(model::BorderStyle::Dashed), &mut state);
}
assert_eq!(
state.warned_border_styles.len(),
2,
"one entry per distinct style, regardless of occurrence count"
);
assert!(state
.warned_border_styles
.contains(&model::BorderStyle::Dotted));
assert!(state
.warned_border_styles
.contains(&model::BorderStyle::Dashed));
}
}