use kurbo::Rect;
use pdfrum_common::{Diagnostics, Limits};
use pdfrum_object::{Dict, Resolve, names as obj_names};
use crate::ap::border::{BorderStyle, BorderStyleInfo, Dash};
use crate::ap::emit::{Content, Float, PaintOp, color_op};
use crate::ap::shapes::{self, CheckStyle};
use crate::ap::{GeneratedAp, da, resources_dict};
use crate::color::Color;
use crate::form::attr;
use crate::geom;
use crate::names;
#[must_use]
#[cfg(test)]
pub(crate) fn needs_appearance<R: Resolve>(dict: &Dict, r: &R) -> bool {
needs_appearance_in(dict, None, r)
}
#[must_use]
pub(crate) fn needs_appearance_in<R: Resolve>(dict: &Dict, catalog: Option<&Dict>, r: &R) -> bool {
if dict.byte_string(obj_names::SUBTYPE, r).as_deref() != Some(b"Widget") {
return false;
}
if !has_known_field_type(dict, r) {
return false;
}
if has_kids(dict, r) {
return false;
}
if dict.dict(names::AP, r).is_none() {
return true;
}
needs_construct_ap(catalog, r) && rebuild_would_be_seen(dict, r)
}
fn has_kids<R: Resolve>(dict: &Dict, r: &R) -> bool {
dict.array(names::KIDS, r)
.is_some_and(|kids| !kids.is_empty())
}
fn needs_construct_ap<R: Resolve>(catalog: Option<&Dict>, r: &R) -> bool {
let Some(form) = catalog.and_then(|catalog| catalog.dict(names::ACRO_FORM, r)) else {
return false;
};
form.get(names::NEED_APPEARANCES, r)
.and_then(|value| value.as_direct().and_then(pdfrum_object::Object::as_bool))
.unwrap_or(false)
}
fn rebuild_would_be_seen<R: Resolve>(dict: &Dict, r: &R) -> bool {
if !is_button(dict, r) {
return true;
}
let Some(state) = dict.byte_string(names::AS, r) else {
return false;
};
state == names::OFF.as_bytes() || state == checked_ap_state(dict, r)
}
#[must_use]
pub(crate) fn checked_ap_state<R: Resolve>(dict: &Dict, r: &R) -> Vec<u8> {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
if attr::field_attr(dict, names::OPT, r, &limits, &mut diags)
.is_some_and(|value| matches!(value, pdfrum_object::Object::Array(_)))
{
return control_index(dict, r).to_string().into_bytes();
}
let on = dict
.dict(names::AP, r)
.and_then(|ap| ap.dict(names::N, r))
.map(|normal| {
let mut keys: Vec<&[u8]> = normal
.keys()
.map(pdfrum_object::Name::as_bytes)
.filter(|key| *key != names::OFF.as_bytes())
.collect();
keys.sort_unstable();
keys.first().map_or_else(Vec::new, |key| key.to_vec())
})
.unwrap_or_default();
if on.is_empty() { b"Yes".to_vec() } else { on }
}
fn control_index<R: Resolve>(dict: &Dict, r: &R) -> usize {
let Some(kids) = dict
.dict(names::PARENT, r)
.and_then(|parent| parent.array(names::KIDS, r))
else {
return 0;
};
(0..kids.len())
.find(|index| kids.dict_at(*index, r).as_ref() == Some(dict))
.unwrap_or(0)
}
#[must_use]
pub(crate) fn generate<R: Resolve>(dict: &Dict, r: &R) -> Option<GeneratedAp> {
build(dict, None, None, LiveInput::default(), r)
}
#[must_use]
pub(crate) fn generate_with_text<R: Resolve>(
dict: &Dict,
catalog: &Dict,
font: &crate::ap::TextFont<'_>,
substitute: Option<crate::ap::Substitute<'_>>,
r: &R,
) -> Option<GeneratedAp> {
build(
dict,
Some(catalog),
Some(font),
LiveInput {
substitute,
..LiveInput::default()
},
r,
)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct LiveInput<'a> {
pub caret_and_selection: Option<&'a crate::ap::field_body::Highlight>,
pub live: Option<&'a crate::ap::field_body::LiveState<'a>>,
pub substitute: Option<crate::ap::Substitute<'a>>,
pub appearance_state: Option<&'a [u8]>,
}
#[must_use]
#[cfg(test)]
pub(crate) fn generate_with_live<R: Resolve>(
dict: &Dict,
catalog: &Dict,
font: &crate::ap::TextFont<'_>,
r: &R,
caret_and_selection: Option<&crate::ap::field_body::Highlight>,
live: Option<&crate::ap::field_body::LiveState<'_>>,
) -> Option<GeneratedAp> {
generate_with_live_faces(
dict,
catalog,
font,
r,
LiveInput {
caret_and_selection,
live,
substitute: None,
appearance_state: None,
},
)
}
#[must_use]
pub fn generate_with_live_faces<R: Resolve>(
dict: &Dict,
catalog: &Dict,
font: &crate::ap::TextFont<'_>,
r: &R,
input: LiveInput<'_>,
) -> Option<GeneratedAp> {
build(dict, Some(catalog), Some(font), input, r)
}
fn build<R: Resolve>(
dict: &Dict,
catalog: Option<&Dict>,
font: Option<&crate::ap::TextFont<'_>>,
input: LiveInput<'_>,
r: &R,
) -> Option<GeneratedAp> {
if !needs_appearance_in(dict, catalog, r) {
return None;
}
let rect = rotated_rect(dict, r);
let mk = dict.dict(names::MK, r);
let background = mk
.as_ref()
.and_then(|mk| mk.array(names::BG, r))
.map_or(Color::Transparent, |array| Color::from_array(&array));
let border_color = mk
.as_ref()
.and_then(|mk| mk.array(names::BC, r))
.map_or(Color::Transparent, |array| Color::from_array(&array));
let mut out = Content::new();
let fill = color_op(background, PaintOp::Fill);
if !fill.is_empty() {
out.raw("q\n");
out.raw(&fill);
out.rect(rect, Float::Shortest);
out.raw("re f\nQ\n");
}
let info = widget_border(dict, r);
let border = crate::ap::border::border_path(rect, info, border_color);
if !border.is_empty() {
out.raw("q\n");
out.raw(&border);
out.raw("Q\n");
}
if is_checked_with(dict, r, input.appearance_state)
&& let Some(style) = check_style(dict, r)
{
let client = geom::deflate(rect, info.width, info.width);
let color = text_color(dict, r);
out.raw(&if is_radio(dict, r) {
crate::ap::shapes::radio_button(client, style, color)
} else {
crate::ap::shapes::check_box(client, style, color)
});
}
let body = catalog.zip(font).and_then(|(catalog, font)| {
crate::ap::field_body::generate(
dict,
catalog,
font,
input.substitute,
r,
input.caret_and_selection,
input.live,
)
});
let fonts = body.as_ref().and_then(|body| body.font_resources.clone());
if let Some(body) = &body {
out.raw(&String::from_utf8_lossy(&body.stream));
}
Some(GeneratedAp {
stream: out.into_bytes(),
bbox: rect,
matrix: kurbo::Affine::IDENTITY,
resources: resources_dict(crate::ap::ext_gstate_dict(dict, false, r), fonts),
rect_override: None,
as_override: None,
})
}
#[must_use]
pub(crate) fn has_known_field_type<R: Resolve>(dict: &Dict, r: &R) -> bool {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let kind = attr::field_attr(dict, names::FT, r, &limits, &mut diags)
.map(|value| value.to_byte_string())
.unwrap_or_default();
matches!(kind.as_slice(), b"Btn" | b"Tx" | b"Ch")
}
#[must_use]
#[cfg(test)]
pub(crate) fn is_checked<R: Resolve>(dict: &Dict, r: &R) -> bool {
is_checked_with(dict, r, None)
}
#[must_use]
pub(crate) fn is_checked_with<R: Resolve>(
dict: &Dict,
r: &R,
override_state: Option<&[u8]>,
) -> bool {
match override_state {
Some(state) => state != names::OFF.as_bytes(),
None => match dict.byte_string(names::AS, r) {
Some(state) => state != names::OFF.as_bytes(),
None => false,
},
}
}
#[must_use]
pub(crate) fn check_style<R: Resolve>(dict: &Dict, r: &R) -> Option<CheckStyle> {
if !is_button(dict, r) {
return None;
}
let caption = dict
.dict(names::MK, r)
.and_then(|mk| mk.text(names::CA, r))
.unwrap_or_default();
Some(
shapes::style_from_caption(&caption).unwrap_or(if is_radio(dict, r) {
CheckStyle::Circle
} else {
CheckStyle::Check
}),
)
}
#[must_use]
pub(crate) fn is_button<R: Resolve>(dict: &Dict, r: &R) -> bool {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let kind = attr::field_attr(dict, names::FT, r, &limits, &mut diags)
.map(|value| value.to_byte_string())
.unwrap_or_default();
if kind != b"Btn" {
return false;
}
let flags = attr::field_attr(dict, names::FF, r, &limits, &mut diags)
.and_then(|value| value.as_int())
.unwrap_or(0);
flags & (1 << 16) == 0
}
#[must_use]
pub(crate) fn is_radio<R: Resolve>(dict: &Dict, r: &R) -> bool {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
let flags = attr::field_attr(dict, names::FF, r, &limits, &mut diags)
.and_then(|value| value.as_int())
.unwrap_or(0);
flags & (1 << 15) != 0
}
#[must_use]
pub(crate) fn text_color<R: Resolve>(dict: &Dict, r: &R) -> Color {
let (limits, mut diags) = (Limits::default(), Diagnostics::default());
attr::field_attr(dict, names::DA, r, &limits, &mut diags)
.map(|value| value.to_byte_string())
.and_then(|da| da::color(&da))
.unwrap_or(Color::Transparent)
}
#[must_use]
pub(crate) fn rotated_rect<R: Resolve>(dict: &Dict, r: &R) -> Rect {
let rect = dict.rect(obj_names::RECT, r);
let (width, height) = (geom::width(rect), geom::height(rect));
if widget_rotation(dict, r).swaps_axes() {
geom::rect(0.0, 0.0, height, width)
} else {
geom::rect(0.0, 0.0, width, height)
}
}
#[must_use]
pub fn widget_rotation<R: Resolve>(dict: &Dict, r: &R) -> geom::WidgetRotation {
let degrees = dict
.dict(names::MK, r)
.and_then(|mk| mk.int(names::R, r))
.unwrap_or(0);
geom::WidgetRotation::from_degrees(degrees)
}
#[must_use]
pub fn widget_border<R: Resolve>(dict: &Dict, r: &R) -> BorderStyleInfo {
let bs = dict.dict(names::BS, r);
let mut info = crate::ap::border::border_style_info(bs.as_ref(), r);
if bs.is_none() {
info = BorderStyleInfo {
width: crate::ap::border::border_width(dict, r),
style: BorderStyle::Solid,
dash: Dash::default(),
};
}
info
}
#[cfg(test)]
mod tests {
use super::{checked_ap_state, generate, needs_appearance, needs_appearance_in, rotated_rect};
use crate::geom;
use pdfrum_object::{Array, ByteSpan, Dict, Name, NoResolve, Object, Stream};
fn dict(pairs: &[(&str, Object)]) -> Dict {
Dict::from_pairs(
pairs
.iter()
.map(|(k, v)| (Name::from(*k), v.clone()))
.collect::<Vec<_>>(),
)
}
fn numbers(values: &[f32]) -> Object {
Object::Array(Array::of(values.iter().copied().map(Object::from)))
}
fn widget(extra: &[(&str, Object)]) -> Dict {
let mut pairs = vec![
("Subtype", Object::Name(Name::from("Widget"))),
("FT", Object::Name(Name::from("Btn"))),
("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
];
pairs.extend_from_slice(extra);
dict(&pairs)
}
#[test]
fn a_widget_with_no_appearance_dictionary_gets_one() {
assert!(needs_appearance(&widget(&[]), &NoResolve));
}
#[test]
fn a_field_type_the_builder_does_not_dispatch_on_gets_nothing() {
let no_type = dict(&[
("Subtype", Object::Name(Name::from("Widget"))),
("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
]);
assert!(!needs_appearance(&no_type, &NoResolve));
assert!(generate(&no_type, &NoResolve).is_none());
let signature = dict(&[
("Subtype", Object::Name(Name::from("Widget"))),
("FT", Object::Name(Name::from("Sig"))),
("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
]);
assert!(!needs_appearance(&signature, &NoResolve));
let parent = dict(&[("FT", Object::Name(Name::from("Tx")))]);
let kid = dict(&[
("Subtype", Object::Name(Name::from("Widget"))),
("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
("Parent", Object::Dict(parent)),
]);
assert!(needs_appearance(&kid, &NoResolve));
}
#[test]
fn an_appearance_that_resolves_leaves_the_widget_alone() {
let with_stream = widget(&[(
"AP",
Object::Dict(dict(&[(
"N",
Object::Stream(Box::new(Stream::new(
Dict::new(),
ByteSpan::from(b"x".to_vec()),
))),
)])),
)]);
assert!(!needs_appearance(&with_stream, &NoResolve));
}
fn form_catalog(need: Object) -> Dict {
dict(&[("AcroForm", Object::Dict(dict(&[("NeedAppearances", need)])))])
}
fn states(names: &[&str]) -> Object {
Object::Dict(dict(&[(
"N",
Object::Dict(Dict::from_pairs(
names
.iter()
.map(|state| {
(
Name::from(*state),
Object::Stream(Box::new(Stream::new(
Dict::new(),
ByteSpan::from(b"x".to_vec()),
))),
)
})
.collect::<Vec<_>>(),
)),
)]))
}
#[test]
fn need_appearances_rebuilds_a_text_field_that_already_has_one() {
let field = dict(&[
("Subtype", Object::Name(Name::from("Widget"))),
("FT", Object::Name(Name::from("Tx"))),
("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
(
"AP",
Object::Dict(dict(&[(
"N",
Object::Stream(Box::new(Stream::new(
Dict::new(),
ByteSpan::from(b"x".to_vec()),
))),
)])),
),
]);
assert!(!needs_appearance(&field, &NoResolve));
for (need, expected) in [
(Object::Bool(true), true),
(Object::Bool(false), false),
(Object::Name(Name::from("true")), false),
(
Object::Str(pdfrum_object::PdfString::literal(b"true")),
false,
),
] {
assert_eq!(
needs_appearance_in(&field, Some(&form_catalog(need.clone())), &NoResolve),
expected,
"{need:?}"
);
}
}
#[test]
fn a_rebuild_a_button_would_never_read_back_does_not_happen() {
let catalog = form_catalog(Object::Bool(true));
let button = |extra: &[(&str, Object)]| {
let mut pairs = vec![
("FT", Object::Name(Name::from("Btn"))),
("AP", states(&["Yes", "Off"])),
];
pairs.extend_from_slice(extra);
widget(&pairs)
};
let off = button(&[("AS", Object::Name(Name::from("Off")))]);
assert!(needs_appearance_in(&off, Some(&catalog), &NoResolve));
let on = button(&[("AS", Object::Name(Name::from("Yes")))]);
assert!(needs_appearance_in(&on, Some(&catalog), &NoResolve));
let elsewhere = button(&[("AS", Object::Name(Name::from("Maybe")))]);
assert!(!needs_appearance_in(&elsewhere, Some(&catalog), &NoResolve));
assert!(!needs_appearance_in(
&button(&[]),
Some(&catalog),
&NoResolve
));
}
#[test]
fn an_opt_array_makes_the_on_state_a_control_index() {
let catalog = form_catalog(Object::Bool(true));
let with_opt = widget(&[
("FT", Object::Name(Name::from("Btn"))),
("AP", states(&["1", "Off"])),
("AS", Object::Name(Name::from("1"))),
("Opt", numbers(&[0.0, 0.0])),
]);
assert_eq!(checked_ap_state(&with_opt, &NoResolve), b"0".to_vec());
assert!(!needs_appearance_in(&with_opt, Some(&catalog), &NoResolve));
let without = widget(&[
("FT", Object::Name(Name::from("Btn"))),
("AP", states(&["1", "Off"])),
("AS", Object::Name(Name::from("1"))),
]);
assert_eq!(checked_ap_state(&without, &NoResolve), b"1".to_vec());
assert!(needs_appearance_in(&without, Some(&catalog), &NoResolve));
}
#[test]
fn the_on_state_is_the_first_key_in_sorted_order_and_falls_back_to_yes() {
let sorted = widget(&[
("FT", Object::Name(Name::from("Btn"))),
("AP", states(&["Zed", "Off", "Alpha"])),
]);
assert_eq!(checked_ap_state(&sorted, &NoResolve), b"Alpha".to_vec());
let off_only = widget(&[
("FT", Object::Name(Name::from("Btn"))),
("AP", states(&["Off"])),
]);
assert_eq!(checked_ap_state(&off_only, &NoResolve), b"Yes".to_vec());
assert_eq!(
checked_ap_state(
&widget(&[("FT", Object::Name(Name::from("Btn")))]),
&NoResolve
),
b"Yes".to_vec()
);
}
#[test]
fn an_unusable_appearance_is_still_an_appearance() {
let unusable = [
(
"AP",
Object::Dict(dict(&[(
"N",
Object::Dict(dict(&[(
"Yes",
Object::Stream(Box::new(Stream::new(
Dict::new(),
ByteSpan::from(b"x".to_vec()),
))),
)])),
)])),
),
("AS", Object::Name(Name::from("Off"))),
("FT", Object::Name(Name::from("Btn"))),
];
assert!(!needs_appearance(&widget(&unusable), &NoResolve));
let mut radio_pairs = unusable.to_vec();
radio_pairs.push(("Ff", Object::Int(1 << 15)));
assert!(!needs_appearance(&widget(&radio_pairs), &NoResolve));
}
#[test]
fn a_buttons_glyph_belongs_to_its_on_state_alone() {
let base = [
("FT", Object::Name(Name::from("Btn"))),
("Ff", Object::Int(1 << 15)),
];
let mut off = base.to_vec();
off.push(("AS", Object::Name(Name::from("Off"))));
let off = generate(&widget(&off), &NoResolve).expect("is a widget");
assert!(off.stream.is_empty());
let mut on = base.to_vec();
on.push(("AS", Object::Name(Name::from("Yes"))));
let on = generate(&widget(&on), &NoResolve).expect("is a widget");
let stream = String::from_utf8_lossy(&on.stream).into_owned();
assert!(stream.contains(" c\n"), "{stream}");
assert!(stream.ends_with("f\nQ\n"), "{stream}");
}
#[test]
fn a_non_widget_is_left_alone() {
let square = dict(&[("Subtype", Object::Name(Name::from("Square")))]);
assert!(!needs_appearance(&square, &NoResolve));
assert!(generate(&square, &NoResolve).is_none());
}
#[test]
fn a_plain_widget_produces_an_empty_stream() {
let got = generate(&widget(&[]), &NoResolve).expect("is a widget");
assert!(got.stream.is_empty());
assert_eq!(got.bbox, geom::rect(0.0, 0.0, 100.0, 30.0));
assert_eq!(got.rect_override, None);
}
#[test]
fn a_background_colour_fills_the_box() {
let coloured = widget(&[(
"MK",
Object::Dict(dict(&[("BG", numbers(&[1.0, 0.0, 0.0]))])),
)]);
let got = generate(&coloured, &NoResolve).expect("is a widget");
assert_eq!(
String::from_utf8_lossy(&got.stream),
"q\n1 0 0 rg\n0 0 100 30 re f\nQ\n"
);
}
#[test]
fn a_border_colour_draws_the_border() {
let bordered = widget(&[(
"MK",
Object::Dict(dict(&[("BC", numbers(&[0.0, 0.0, 0.0]))])),
)]);
let got = generate(&bordered, &NoResolve).expect("is a widget");
let stream = String::from_utf8_lossy(&got.stream).into_owned();
assert!(stream.starts_with("q\n0 0 0 rg\n"), "{stream}");
assert!(stream.ends_with("Q\n"), "{stream}");
}
#[test]
fn a_quarter_turn_swaps_the_boxs_extents() {
let rotated = |degrees: i64| {
rotated_rect(
&widget(&[("MK", Object::Dict(dict(&[("R", Object::Int(degrees))])))]),
&NoResolve,
)
};
assert_eq!(rotated(0), geom::rect(0.0, 0.0, 100.0, 30.0));
assert_eq!(rotated(180), geom::rect(0.0, 0.0, 100.0, 30.0));
assert_eq!(rotated(90), geom::rect(0.0, 0.0, 30.0, 100.0));
assert_eq!(rotated(270), geom::rect(0.0, 0.0, 30.0, 100.0));
}
#[test]
fn a_rotation_that_is_not_a_quarter_turn_leaves_the_box_upright() {
let rotated = |degrees: i64| {
rotated_rect(
&widget(&[("MK", Object::Dict(dict(&[("R", Object::Int(degrees))])))]),
&NoResolve,
)
};
let upright = geom::rect(0.0, 0.0, 100.0, 30.0);
let turned = geom::rect(0.0, 0.0, 30.0, 100.0);
assert_eq!(rotated(45), upright);
assert_eq!(rotated(-45), upright);
assert_eq!(rotated(1), upright);
assert_eq!(rotated(-90), turned);
assert_eq!(rotated(-270), turned);
assert_eq!(rotated(-180), upright);
assert_eq!(rotated(450), turned);
}
fn text_widget(value: &str) -> Dict {
dict(&[
("Subtype", Object::Name(Name::from("Widget"))),
("FT", Object::Name(Name::from("Tx"))),
("Rect", numbers(&[100.0, 100.0, 200.0, 130.0])),
(
"DA",
Object::Str(pdfrum_object::PdfString::literal(b"0 0 0 rg /Helv 12 Tf")),
),
("V", Object::Str(pdfrum_object::PdfString::literal(value))),
])
}
fn text_catalog() -> Dict {
dict(&[(
"AcroForm",
Object::Dict(dict(&[(
"DR",
Object::Dict(dict(&[(
"Font",
Object::Dict(dict(&[(
"Helv",
Object::Dict(crate::ap::freetext::fallback_font()),
)])),
)])),
)])),
)])
}
#[test]
fn the_live_entry_point_carries_its_override_down_to_the_body() {
let cache = pdfrum_font::FontCache::new();
let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
let width = |code: u32| crate::ap::TextFont::char_width(&face, code);
let font = crate::ap::TextFont {
metrics: crate::ap::TextFont::metrics_of(&face, &width),
font: &face,
};
let (widget, catalog) = (text_widget("stored"), text_catalog());
let stream = |ap: Option<super::GeneratedAp>| {
String::from_utf8_lossy(&ap.expect("an appearance").stream).into_owned()
};
let stored = stream(super::generate_with_text(
&widget, &catalog, &font, None, &NoResolve,
));
assert!(stored.contains("(stored) Tj\n"), "{stored}");
let live = crate::ap::field_body::LiveState {
text: "typed",
..crate::ap::field_body::LiveState::default()
};
let edited = stream(super::generate_with_live(
&widget,
&catalog,
&font,
&NoResolve,
None,
Some(&live),
));
assert!(edited.contains("(typed) Tj\n"), "{edited}");
assert!(!edited.contains("stored"), "{edited}");
assert_eq!(
stored,
stream(super::generate_with_live(
&widget, &catalog, &font, &NoResolve, None, None,
))
);
}
#[test]
fn the_live_path_with_a_second_face_writes_hebrew_through_it() {
let cache = pdfrum_font::FontCache::new();
let options = pdfrum_font::SubstitutionOptions::default();
let mut ctx = pdfrum_page::BuildContext::with_substitution(options);
let catalog = text_catalog();
let fonts = crate::ap::FormFonts::load(&catalog, &NoResolve, &mut ctx);
let substitute = fonts
.substitute(pdfrum_font::Charset::Hebrew)
.expect("a Hebrew substitute");
let face = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
let charset = crate::ap::font_map::font_charset(&face);
let width = |code: u32| {
if crate::ap::font_map::da_font_writes(&face, charset, code) {
crate::ap::TextFont::char_width(&face, code)
} else {
crate::ap::font_map::substitute_width(substitute.font, code)
}
};
let font = crate::ap::TextFont {
metrics: crate::ap::TextFont::metrics_of(&face, &width),
font: &face,
};
let widget = text_widget("stored");
let state = crate::ap::field_body::LiveState {
text: "ab\u{5D0}\u{5D1}",
..crate::ap::field_body::LiveState::default()
};
let live = |substitute| {
super::generate_with_live_faces(
&widget,
&catalog,
&font,
&NoResolve,
super::LiveInput {
live: Some(&state),
substitute,
..super::LiveInput::default()
},
)
.expect("an appearance")
};
let got = live(Some(substitute));
let stream = got.stream.clone();
let has = |needle: &[u8]| stream.windows(needle.len()).any(|w| w == needle);
assert!(has(b"/Helv 12 Tf\n"), "{stream:02X?}");
let mut tf = b"/".to_vec();
tf.extend_from_slice(substitute.alias.as_bytes());
tf.extend_from_slice(b" 12 Tf\n");
assert!(has(&tf), "the second face names itself: {stream:02X?}");
assert!(has(b"\\340"), "aleph as 0xE0: {stream:02X?}");
assert!(has(b"\\341"), "bet as 0xE1: {stream:02X?}");
assert!(!has(b"\\320"), "no low-byte aleph: {stream:02X?}");
assert!(!has(b"\\321"), "no low-byte bet: {stream:02X?}");
assert!(
got.resources
.dict(crate::names::FONT, &NoResolve)
.is_some_and(|fonts| fonts.contains_key(substitute.alias)),
"the second face is in the appearance's own resources: {:?}",
got.resources
);
let plain = live(None);
assert_ne!(plain.stream, stream);
let plain_has = |needle: &[u8]| plain.stream.windows(needle.len()).any(|w| w == needle);
assert!(plain_has(b"\\320"), "{:02X?}", plain.stream);
assert_eq!(
plain.stream,
super::generate_with_live(&widget, &catalog, &font, &NoResolve, None, Some(&state),)
.expect("an appearance")
.stream,
"the old entry point is the new one with no substitute"
);
}
#[test]
fn a_sessions_appearance_state_overrides_the_dictionarys_own() {
let catalog = Dict::new();
let cache = pdfrum_font::FontCache::new();
let font = pdfrum_font::Font::load_standard(pdfrum_font::StandardFont::Helvetica, &cache);
let width = |code: u32| crate::ap::TextFont::char_width(&font, code);
let text = crate::ap::TextFont {
metrics: crate::ap::TextFont::metrics_of(&font, &width),
font: &font,
};
let radio = |state: &str| {
widget(&[
("FT", Object::Name(Name::from("Btn"))),
("Ff", Object::Int(1 << 15)),
("AS", Object::Name(Name::from(state))),
])
};
let draw = |dict: &Dict, override_state: Option<&[u8]>| {
super::generate_with_live_faces(
dict,
&catalog,
&text,
&NoResolve,
super::LiveInput {
appearance_state: override_state,
..super::LiveInput::default()
},
)
.expect("is a widget")
.stream
};
let drawn = |stream: &[u8]| String::from_utf8_lossy(stream).contains(" c\n");
let on = radio("Yes");
assert!(drawn(&draw(&on, None)), "its own /AS says Yes");
assert!(!drawn(&draw(&on, Some(b"Off"))), "the session says Off");
let off = radio("Off");
assert!(!drawn(&draw(&off, None)), "its own /AS says Off");
assert!(drawn(&draw(&off, Some(b"Yes"))), "the session says Yes");
assert!(
drawn(&draw(&off, Some(b"2"))),
"any non-Off state is on, whatever it is named"
);
assert_eq!(draw(&on, None), draw(&on, None));
assert_eq!(
draw(&on, Some(b"Yes")),
draw(&on, None),
"an override naming the state already shown changes nothing"
);
}
#[test]
fn is_checked_is_the_unoverridden_case_of_is_checked_with() {
for state in ["Off", "Yes", "2", ""] {
let dict = widget(&[("AS", Object::Name(Name::from(state)))]);
assert_eq!(
super::is_checked(&dict, &NoResolve),
super::is_checked_with(&dict, &NoResolve, None),
"{state:?}"
);
}
let bare = widget(&[]);
assert!(!super::is_checked(&bare, &NoResolve));
assert!(!super::is_checked_with(&bare, &NoResolve, Some(b"Off")));
assert!(super::is_checked_with(&bare, &NoResolve, Some(b"Yes")));
}
}