1use pdfrum_common::{DiagKind, Diagnostics, Severity};
13use pdfrum_object::{Dict, Object, PdfString, Resolve, names as obj_names};
14
15use crate::ap::border;
16use crate::ap::da;
17use crate::ap::emit::{Content, Float, PaintOp, color_op};
18#[cfg(test)]
19use crate::ap::fmt;
20use crate::ap::markup::Generated;
21use crate::color::Color;
22use crate::geom;
23use crate::names;
24use crate::vt;
25
26#[must_use]
32pub(crate) fn free_text<R: Resolve>(
33 dict: &Dict,
34 catalog: &Dict,
35 r: &R,
36 metrics: &vt::Metrics<'_>,
37 encode: &dyn Fn(u32) -> Vec<u8>,
38 diags: &mut Diagnostics,
39) -> Option<Generated> {
40 let form = match catalog.dict(names::ACRO_FORM, r) {
45 Some(form) => form,
46 None => synthesized_form(),
47 };
48 let appearance = default_appearance(dict, &form, r)?;
49 if appearance.font_name.is_empty() {
54 diags.record(
55 Severity::Suspicious,
56 DiagKind::DefaultAppearanceMalformed,
57 None,
58 );
59 }
60 let resources = form.dict(names::DR, r)?;
61 let fonts = resources.dict(names::FONT, r)?;
62 if !valid_font_resources(&fonts, r) {
63 diags.record(Severity::Suspicious, DiagKind::FormResourcesInvalid, None);
67 return None;
68 }
69 let name = pdfrum_object::Name::new(appearance.font_name.clone());
78 let font = fonts.dict(&name, r).unwrap_or_else(fallback_font);
79 let font_resources = Dict::from_pairs([(name, Object::Dict(font))]);
80
81 let mut out = Content::new();
82 out.raw("/GS gs ");
83
84 let info = border::border_style_info(dict.dict(names::BS, r).as_ref(), r);
85 let rect = dict.rect(obj_names::RECT, r);
86 let half = info.width / 2.0;
87 let background = geom::deflate(rect, half, half);
88 let body = geom::deflate(background, half, half);
89
90 if let Some(array) = dict.array(names::C, r) {
94 out.raw("q\n");
95 out.raw(&color_op(Color::from_array(&array), PaintOp::Fill));
96 out.rect(background, Float::Shortest);
97 out.raw("re f\nQ\n");
98 }
99
100 let border_path = border::border_path(rect, info, appearance.color);
102 if !border_path.is_empty() {
103 out.raw("q\n");
104 out.raw(&border_path);
105 out.raw("Q\n");
106 }
107
108 let config = vt::Config {
109 plate: body,
110 alignment: vt::Alignment::from_quadding(dict.int(names::Q, r).unwrap_or(0)),
111 font_size: appearance.size,
114 ..vt::Config::default()
115 };
116 let text = dict.text(obj_names::CONTENTS, r).unwrap_or_default();
117 let layout = vt::layout(&text, &config, metrics);
118 let content = layout.content_rect_pdf(body);
119 let offset = (0.0, (geom::height(content) - geom::height(body)) / 2.0);
120
121 let written = vt::edit_ap::generate(
122 &layout,
123 &config,
124 metrics,
125 offset,
126 vt::edit_ap::Grouping::Continuous,
127 |code| vt::edit_ap::Face::single(appearance.font_name.as_slice(), encode(code)),
128 );
129 if !written.is_empty() {
130 out.raw("/Tx BMC\nq\n");
131 if geom::width(content) > geom::width(body) || geom::height(content) > geom::height(body) {
134 out.rect(body, Float::Shortest);
135 out.raw("re\nW\nn\n");
136 }
137 out.raw("BT\n");
138 out.raw(&color_op(appearance.color, PaintOp::Fill));
139 out.raw(&written);
140 out.raw("ET\n");
141 out.raw("Q\nEMC\n");
142 }
143
144 Some(Generated {
145 stream: out.into_bytes(),
146 rect_override: None,
147 is_text_markup: false,
148 blend_multiply: false,
149 font_resources: Some(font_resources),
150 })
151}
152
153#[derive(Debug, Clone, PartialEq)]
156pub struct Appearance {
157 pub font_name: Vec<u8>,
159 pub size: f32,
161 pub color: Color,
163}
164
165#[must_use]
171pub fn default_appearance<R: Resolve>(dict: &Dict, form: &Dict, r: &R) -> Option<Appearance> {
172 let (limits, mut diags) = (
173 pdfrum_common::Limits::default(),
174 pdfrum_common::Diagnostics::default(),
175 );
176 let mut string = crate::form::attr::field_attr(dict, names::DA, r, &limits, &mut diags)
177 .map(|value| value.to_byte_string())
178 .unwrap_or_default();
179 if string.is_empty() {
180 string = form.byte_string(names::DA, r).unwrap_or_default();
181 }
182 let font = da::font(&string)?;
183 Some(Appearance {
184 font_name: font.name,
185 size: font.size,
186 color: da::color(&string).unwrap_or(Color::Transparent),
189 })
190}
191
192#[must_use]
198pub(crate) fn valid_font_resources<R: Resolve>(fonts: &Dict, r: &R) -> bool {
199 fonts.keys().all(|key| {
200 fonts
201 .dict(key, r)
202 .is_some_and(|font| font.name(obj_names::TYPE) == Some(names::FONT))
203 })
204}
205
206#[must_use]
212pub(crate) fn synthesized_form() -> Dict {
213 let alias = pdfrum_object::Name::new(font_resource_alias(b"Helvetica"));
214 let mut da = Vec::from(b"/".as_slice());
215 da.extend_from_slice(alias.as_bytes());
216 da.extend_from_slice(b" 12 Tf 0 g");
217 Dict::from_pairs([
218 (
219 names::DR.clone(),
220 Object::Dict(Dict::from_pairs([(
221 names::FONT.clone(),
222 Object::Dict(Dict::from_pairs([(alias, Object::Dict(fallback_font()))])),
223 )])),
224 ),
225 (names::DA.clone(), Object::Str(PdfString::literal(da))),
226 ])
227}
228
229#[must_use]
235pub(crate) fn font_resource_alias(base_font_name: &[u8]) -> Vec<u8> {
236 const PLACEHOLDER: &[u8] = b"ZiTi";
237 const WIDTH: usize = 4;
238
239 let prefix: Vec<u8> = base_font_name
240 .iter()
241 .copied()
242 .filter(|byte| *byte != b' ')
243 .collect();
244 if prefix.is_empty() {
245 return PLACEHOLDER.to_vec();
246 }
247 let mut alias: Vec<u8> = prefix.iter().copied().take(WIDTH).collect();
248 let mut position = alias.len();
250 while alias.len() < WIDTH {
251 alias.push(b'0' + u8::try_from(position % 10).unwrap_or(0));
252 position += 1;
253 }
254 alias
255}
256
257#[must_use]
259pub(crate) fn fallback_font() -> Dict {
260 Dict::from_pairs([
261 (obj_names::TYPE.clone(), Object::Name(names::FONT.clone())),
262 (
263 obj_names::SUBTYPE.clone(),
264 Object::Name(names::TYPE1.clone()),
265 ),
266 (
267 names::BASE_FONT.clone(),
268 Object::Name(pdfrum_object::Name::from("Helvetica")),
269 ),
270 (
271 names::ENCODING.clone(),
272 Object::Name(obj_names::WIN_ANSI_ENCODING.clone()),
273 ),
274 ])
275}
276
277#[must_use]
283#[cfg(test)]
284pub(crate) fn resolved_size(size: f32) -> Option<f32> {
285 (!crate::geom::is_float_zero(size)).then_some(size)
286}
287
288#[must_use]
290#[cfg(test)]
291pub(crate) fn default_appearance_string(appearance: &Appearance) -> Vec<u8> {
292 let mut out = Vec::new();
293 if !appearance.font_name.is_empty() && appearance.size > 0.0 {
294 out.push(b'/');
295 out.extend_from_slice(&appearance.font_name);
296 out.push(b' ');
297 out.extend_from_slice(fmt::shortest(appearance.size).as_bytes());
298 out.extend_from_slice(b" Tf");
299 }
300 let color = color_op(appearance.color, PaintOp::Fill);
301 if !color.is_empty() {
302 if !out.is_empty() {
303 out.push(b' ');
304 }
305 out.extend_from_slice(color.trim_end().as_bytes());
306 }
307 out
308}
309
310#[cfg(test)]
311mod tests {
312 use super::{
313 default_appearance, default_appearance_string, fallback_font, free_text, resolved_size,
314 valid_font_resources,
315 };
316 use crate::color::Color;
317 use crate::vt::stub;
318 use pdfrum_common::{DiagKind, Diagnostics};
319 use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
320
321 fn dict(pairs: &[(&str, Object)]) -> Dict {
322 Dict::from_pairs(
323 pairs
324 .iter()
325 .map(|(k, v)| (Name::from(*k), v.clone()))
326 .collect::<Vec<_>>(),
327 )
328 }
329
330 fn numbers(values: &[f32]) -> Object {
331 Object::Array(Array::of(values.iter().copied().map(Object::from)))
332 }
333
334 fn catalog() -> Dict {
336 dict(&[(
337 "AcroForm",
338 Object::Dict(dict(&[(
339 "DR",
340 Object::Dict(dict(&[(
341 "Font",
342 Object::Dict(dict(&[("Helv", Object::Dict(fallback_font()))])),
343 )])),
344 )])),
345 )])
346 }
347
348 fn annot(extra: &[(&str, Object)]) -> Dict {
349 let mut pairs = vec![
350 ("Subtype", Object::Name(Name::from("FreeText"))),
351 ("Rect", numbers(&[100.0, 50.0, 150.0, 75.0])),
352 (
353 "DA",
354 Object::Str(PdfString::literal(b"0 0 0 rg /Helv 12 Tf")),
355 ),
356 ];
357 pairs.extend_from_slice(extra);
358 dict(&pairs)
359 }
360
361 fn one_byte(code: u32) -> Vec<u8> {
362 vec![u8::try_from(code).unwrap_or(b'?')]
363 }
364
365 fn generate(annot: &Dict, catalog: &Dict) -> Option<String> {
366 let mut diags = Diagnostics::default();
367 free_text(
368 annot,
369 catalog,
370 &NoResolve,
371 &stub::metrics(),
372 &one_byte,
373 &mut diags,
374 )
375 .map(|got| String::from_utf8_lossy(&got.stream).into_owned())
376 }
377
378 #[test]
379 fn a_missing_precondition_declines_rather_than_producing_an_empty_stream() {
380 let bare = dict(&[("AcroForm", Object::Dict(Dict::new()))]);
385 assert!(generate(&annot(&[]), &bare).is_none());
386 let no_font = dict(&[(
387 "AcroForm",
388 Object::Dict(dict(&[("DR", Object::Dict(Dict::new()))])),
389 )]);
390 assert!(generate(&annot(&[]), &no_font).is_none());
391 }
392
393 #[test]
394 fn a_document_with_no_form_at_all_gets_one_synthesized() {
395 let got = generate(
399 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
400 &Dict::new(),
401 )
402 .expect("synthesizes a form");
403 assert!(got.contains("(Hi) Tj\n"), "{got}");
404 }
405
406 #[test]
410 fn the_generated_appearance_carries_the_font_its_tf_names() {
411 let got = free_text(
412 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
413 &catalog(),
414 &NoResolve,
415 &stub::metrics(),
416 &one_byte,
417 &mut Diagnostics::default(),
418 )
419 .expect("generates");
420 let fonts = got.font_resources.expect("a text generator names a font");
421 assert_eq!(fonts.keys().collect::<Vec<_>>(), [&Name::from("Helv")]);
422 assert_eq!(
423 fonts.dict(&Name::from("Helv"), &NoResolve),
424 Some(fallback_font())
425 );
426 }
427
428 #[test]
432 fn a_da_naming_an_absent_font_falls_back_rather_than_declining() {
433 let empty_dr = dict(&[(
434 "AcroForm",
435 Object::Dict(dict(&[(
436 "DR",
437 Object::Dict(dict(&[("Font", Object::Dict(Dict::new()))])),
438 )])),
439 )]);
440 let got = free_text(
441 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
442 &empty_dr,
443 &NoResolve,
444 &stub::metrics(),
445 &one_byte,
446 &mut Diagnostics::default(),
447 )
448 .expect("falls back rather than declining");
449 let fonts = got.font_resources.expect("names a font");
450 assert_eq!(
451 fonts.dict(&Name::from("Helv"), &NoResolve),
452 Some(fallback_font())
453 );
454 }
455
456 #[test]
457 fn the_synthesized_forms_font_alias_is_four_characters() {
458 assert_eq!(super::font_resource_alias(b"Helvetica"), b"Helv");
459 assert_eq!(super::font_resource_alias(b"AB"), b"AB23");
461 assert_eq!(super::font_resource_alias(b"H e l v e t"), b"Helv");
463 assert_eq!(super::font_resource_alias(b""), b"ZiTi");
465 assert_eq!(super::font_resource_alias(b" "), b"ZiTi");
466 }
467
468 #[test]
469 fn an_annotation_with_no_default_appearance_declines() {
470 let no_da = dict(&[
471 ("Subtype", Object::Name(Name::from("FreeText"))),
472 ("Rect", numbers(&[100.0, 50.0, 150.0, 75.0])),
473 ]);
474 assert!(generate(&no_da, &catalog()).is_none());
475 }
476
477 #[test]
478 fn an_empty_font_resource_dictionary_passes_vacuously() {
479 assert!(valid_font_resources(&Dict::new(), &NoResolve));
480 let good = dict(&[("Helv", Object::Dict(fallback_font()))]);
481 assert!(valid_font_resources(&good, &NoResolve));
482 let bad = dict(&[("Helv", Object::Dict(dict(&[("Type", Object::Int(4))])))]);
484 assert!(!valid_font_resources(&bad, &NoResolve));
485 let stringly = dict(&[(
487 "Helv",
488 Object::Dict(dict(&[("Type", Object::Str(PdfString::literal(b"Font")))])),
489 )]);
490 assert!(!valid_font_resources(&stringly, &NoResolve));
491 }
492
493 #[test]
494 fn the_text_reaches_the_stream_between_its_markers() {
495 let got = generate(
496 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
497 &catalog(),
498 )
499 .expect("generates");
500 assert!(got.starts_with("/GS gs "), "{got}");
501 assert!(got.contains("/Tx BMC\nq\n"), "{got}");
502 assert!(got.contains("BT\n"), "{got}");
503 assert!(got.contains("(Hi) Tj\n"), "{got}");
504 assert!(got.ends_with("ET\nQ\nEMC\n"), "{got}");
505 }
506
507 #[test]
508 fn a_short_annotation_writes_no_clip_at_all() {
509 let short = generate(
510 &annot(&[("Contents", Object::Str(PdfString::literal(b"a")))]),
511 &catalog(),
512 )
513 .expect("generates");
514 assert!(!short.contains("W\nn\n"), "{short}");
515 }
516
517 #[test]
518 fn a_background_colour_paints_before_the_border() {
519 let got = generate(
520 &annot(&[
521 ("C", numbers(&[1.0, 0.0, 0.0])),
522 ("Contents", Object::Str(PdfString::literal(b"x"))),
523 ]),
524 &catalog(),
525 )
526 .expect("generates");
527 assert!(got.contains("q\n1 0 0 rg\n"), "{got}");
528 assert!(got.contains("re f\nQ\n"), "{got}");
529 }
530
531 #[test]
532 fn a_colour_array_naming_no_space_still_writes_its_save_and_restore() {
533 let got = generate(
534 &annot(&[
535 ("C", Object::Array(Array::new())),
536 ("Contents", Object::Str(PdfString::literal(b"x"))),
537 ]),
538 &catalog(),
539 )
540 .expect("generates");
541 assert!(got.contains("q\n"), "{got}");
542 assert!(got.contains(" re f\nQ\n"), "{got}");
543 }
544
545 #[test]
546 fn empty_contents_produce_a_stream_with_no_text_block() {
547 let got = generate(&annot(&[]), &catalog()).expect("generates");
548 assert!(!got.contains("BT\n"), "{got}");
549 }
550
551 #[test]
552 fn the_default_appearance_falls_back_to_the_forms_own() {
553 let annot = dict(&[("Subtype", Object::Name(Name::from("FreeText")))]);
554 let form = dict(&[("DA", Object::Str(PdfString::literal(b"/Helv 8 Tf")))]);
555 let got = default_appearance(&annot, &form, &NoResolve).expect("has one");
556 assert_eq!(got.font_name, b"Helv");
557 assert!((got.size - 8.0).abs() < f32::EPSILON);
558 assert_eq!(got.color, Color::Transparent);
560 }
561
562 #[test]
563 fn a_zero_size_asks_for_automatic_sizing_while_a_negative_one_does_not() {
564 assert_eq!(resolved_size(0.0), None);
565 assert_eq!(resolved_size(-12.0), Some(-12.0));
566 assert_eq!(resolved_size(12.0), Some(12.0));
567 }
568
569 #[test]
570 fn the_rewritten_default_appearance_joins_its_two_halves() {
571 let got = default_appearance_string(&super::Appearance {
572 font_name: b"Helv".to_vec(),
573 size: 12.0,
574 color: Color::Rgb(1.0, 0.0, 0.0),
575 });
576 assert_eq!(got, b"/Helv 12 Tf 1 0 0 rg");
577
578 let sizeless = default_appearance_string(&super::Appearance {
580 font_name: b"Helv".to_vec(),
581 size: 0.0,
582 color: Color::Gray(0.0),
583 });
584 assert_eq!(sizeless, b"0 g");
585 }
586
587 #[test]
590 fn a_malformed_dr_font_declines_and_is_recorded() {
591 let bad_dr = dict(&[(
592 "AcroForm",
593 Object::Dict(dict(&[(
594 "DR",
595 Object::Dict(dict(&[(
596 "Font",
597 Object::Dict(dict(&[("Helv", Object::Int(7))])),
599 )])),
600 )])),
601 )]);
602 let mut diags = Diagnostics::default();
603 let got = free_text(
604 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
605 &bad_dr,
606 &NoResolve,
607 &stub::metrics(),
608 &one_byte,
609 &mut diags,
610 );
611 assert!(got.is_none(), "no appearance can be generated");
612 assert!(diags.contains(&DiagKind::FormResourcesInvalid));
613 }
614}