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]
43pub(crate) fn free_text<R: Resolve>(
44 dict: &Dict,
45 catalog: &Dict,
46 r: &R,
47 metrics: &vt::Metrics<'_>,
48 encode: &dyn Fn(u32) -> Vec<u8>,
49 diags: &mut Diagnostics,
50) -> Option<Generated> {
51 let form = match catalog.dict(names::ACRO_FORM, r) {
56 Some(form) => complete_form(form, r),
57 None => synthesized_form(),
58 };
59 let appearance = default_appearance(dict, &form, r)?;
60 if appearance.font_name.is_empty() {
65 diags.record(
66 Severity::Suspicious,
67 DiagKind::DefaultAppearanceMalformed,
68 None,
69 );
70 }
71 let resources = form.dict(names::DR, r)?;
73 let fonts = resources.dict(names::FONT, r)?;
74 if !valid_font_resources(&fonts, r) {
75 diags.record(Severity::Suspicious, DiagKind::FormResourcesInvalid, None);
79 return None;
80 }
81 let name = pdfrum_object::Name::new(appearance.font_name.clone());
90 let font = fonts.dict(&name, r).unwrap_or_else(fallback_font);
91 let font_resources = Dict::from_pairs([(name, Object::Dict(font))]);
92
93 let mut out = Content::new();
94 out.raw("/GS gs ");
95
96 let info = border::border_style_info(dict.dict(names::BS, r).as_ref(), r);
97 let rect = dict.rect(obj_names::RECT, r);
98 let half = info.width / 2.0;
99 let background = geom::deflate(rect, half, half);
100 let body = geom::deflate(background, half, half);
101
102 if let Some(array) = dict.array(names::C, r) {
106 out.raw("q\n");
107 out.raw(&color_op(Color::from_array(&array), PaintOp::Fill));
108 out.rect(background, Float::Shortest);
109 out.raw("re f\nQ\n");
110 }
111
112 let border_path = border::border_path(rect, info, appearance.color);
114 if !border_path.is_empty() {
115 out.raw("q\n");
116 out.raw(&border_path);
117 out.raw("Q\n");
118 }
119
120 let config = vt::Config {
121 plate: body,
122 alignment: vt::Alignment::from_quadding(dict.int(names::Q, r).unwrap_or(0)),
123 font_size: appearance.size,
126 ..vt::Config::default()
127 };
128 let text = dict.text(obj_names::CONTENTS, r).unwrap_or_default();
129 let layout = vt::layout(&text, &config, metrics);
130 let content = layout.content_rect_pdf(body);
131 let offset = (0.0, 0.0);
136
137 let written = vt::edit_ap::generate(
138 &layout,
139 &config,
140 metrics,
141 offset,
142 vt::edit_ap::Grouping::Continuous,
143 |code| vt::edit_ap::Face::single(appearance.font_name.as_slice(), encode(code)),
144 );
145 if !written.is_empty() {
146 out.raw("/Tx BMC\nq\n");
147 if geom::width(content) > geom::width(body) || geom::height(content) > geom::height(body) {
150 out.rect(body, Float::Shortest);
151 out.raw("re\nW\nn\n");
152 }
153 out.raw("BT\n");
154 out.raw(&color_op(appearance.color, PaintOp::Fill));
155 out.raw(&written);
156 out.raw("ET\n");
157 out.raw("Q\nEMC\n");
158 }
159
160 Some(Generated {
161 stream: out.into_bytes(),
162 rect_override: None,
163 is_text_markup: false,
164 blend_multiply: false,
165 font_resources: Some(font_resources),
166 })
167}
168
169#[derive(Debug, Clone, PartialEq)]
172pub struct Appearance {
173 pub font_name: Vec<u8>,
175 pub size: f32,
177 pub color: Color,
179}
180
181#[must_use]
187pub fn default_appearance<R: Resolve>(dict: &Dict, form: &Dict, r: &R) -> Option<Appearance> {
188 let (limits, mut diags) = (
189 pdfrum_common::Limits::default(),
190 pdfrum_common::Diagnostics::default(),
191 );
192 let mut string = crate::form::attr::field_attr(dict, names::DA, r, &limits, &mut diags)
193 .map(|value| value.to_byte_string())
194 .unwrap_or_default();
195 if string.is_empty() {
196 string = form.byte_string(names::DA, r).unwrap_or_default();
197 }
198 let font = da::font(&string)?;
199 Some(Appearance {
200 font_name: font.name,
201 size: font.size,
202 color: da::color(&string).unwrap_or(Color::Transparent),
205 })
206}
207
208#[must_use]
214pub(crate) fn valid_font_resources<R: Resolve>(fonts: &Dict, r: &R) -> bool {
215 fonts.keys().all(|key| {
216 fonts
217 .dict(key, r)
218 .is_some_and(|font| font.name(obj_names::TYPE) == Some(names::FONT))
219 })
220}
221
222#[must_use]
228pub(crate) fn synthesized_form() -> Dict {
229 let alias = pdfrum_object::Name::new(font_resource_alias(b"Helvetica"));
230 let mut da = Vec::from(b"/".as_slice());
231 da.extend_from_slice(alias.as_bytes());
232 da.extend_from_slice(b" 12 Tf 0 g");
233 Dict::from_pairs([
234 (
235 names::DR.clone(),
236 Object::Dict(Dict::from_pairs([(
237 names::FONT.clone(),
238 Object::Dict(Dict::from_pairs([(alias, Object::Dict(fallback_font()))])),
239 )])),
240 ),
241 (names::DA.clone(), Object::Str(PdfString::literal(da))),
242 ])
243}
244
245fn complete_form<R: Resolve>(mut form: Dict, r: &R) -> Dict {
253 let synthesized = synthesized_form();
254 match form.dict(names::DR, r) {
255 None => {
256 if let Some(resources) = synthesized.raw(names::DR) {
257 form.insert(names::DR.clone(), resources.clone());
258 }
259 }
260 Some(mut resources) => {
261 if resources.dict(names::FONT, r).is_none()
262 && let Some(fonts) = synthesized
263 .dict(names::DR, r)
264 .and_then(|dr| dr.raw(names::FONT).cloned())
265 {
266 resources.insert(names::FONT.clone(), fonts);
267 form.insert(names::DR.clone(), Object::Dict(resources));
268 }
269 }
270 }
271 if form
272 .byte_string(names::DA, r)
273 .unwrap_or_default()
274 .is_empty()
275 && let Some(da) = synthesized.raw(names::DA)
276 {
277 form.insert(names::DA.clone(), da.clone());
278 }
279 form
280}
281
282#[must_use]
288pub(crate) fn font_resource_alias(base_font_name: &[u8]) -> Vec<u8> {
289 const PLACEHOLDER: &[u8] = b"ZiTi";
290 const WIDTH: usize = 4;
291
292 let prefix: Vec<u8> = base_font_name
293 .iter()
294 .copied()
295 .filter(|byte| *byte != b' ')
296 .collect();
297 if prefix.is_empty() {
298 return PLACEHOLDER.to_vec();
299 }
300 let mut alias: Vec<u8> = prefix.iter().copied().take(WIDTH).collect();
301 let mut position = alias.len();
303 while alias.len() < WIDTH {
304 alias.push(b'0' + u8::try_from(position % 10).unwrap_or(0));
305 position += 1;
306 }
307 alias
308}
309
310#[must_use]
312pub(crate) fn fallback_font() -> Dict {
313 Dict::from_pairs([
314 (obj_names::TYPE.clone(), Object::Name(names::FONT.clone())),
315 (
316 obj_names::SUBTYPE.clone(),
317 Object::Name(names::TYPE1.clone()),
318 ),
319 (
320 names::BASE_FONT.clone(),
321 Object::Name(pdfrum_object::Name::from("Helvetica")),
322 ),
323 (
324 names::ENCODING.clone(),
325 Object::Name(obj_names::WIN_ANSI_ENCODING.clone()),
326 ),
327 ])
328}
329
330#[must_use]
336#[cfg(test)]
337pub(crate) fn resolved_size(size: f32) -> Option<f32> {
338 (!crate::geom::is_float_zero(size)).then_some(size)
339}
340
341#[must_use]
343#[cfg(test)]
344pub(crate) fn default_appearance_string(appearance: &Appearance) -> Vec<u8> {
345 let mut out = Vec::new();
346 if !appearance.font_name.is_empty() && appearance.size > 0.0 {
347 out.push(b'/');
348 out.extend_from_slice(&appearance.font_name);
349 out.push(b' ');
350 out.extend_from_slice(fmt::shortest(appearance.size).as_bytes());
351 out.extend_from_slice(b" Tf");
352 }
353 let color = color_op(appearance.color, PaintOp::Fill);
354 if !color.is_empty() {
355 if !out.is_empty() {
356 out.push(b' ');
357 }
358 out.extend_from_slice(color.trim_end().as_bytes());
359 }
360 out
361}
362
363#[cfg(test)]
364mod tests {
365 use super::{
366 default_appearance, default_appearance_string, fallback_font, free_text, resolved_size,
367 valid_font_resources,
368 };
369 use crate::color::Color;
370 use crate::vt::stub;
371 use pdfrum_common::{DiagKind, Diagnostics};
372 use pdfrum_object::{Array, Dict, Name, NoResolve, Object, PdfString};
373
374 fn dict(pairs: &[(&str, Object)]) -> Dict {
375 Dict::from_pairs(
376 pairs
377 .iter()
378 .map(|(k, v)| (Name::from(*k), v.clone()))
379 .collect::<Vec<_>>(),
380 )
381 }
382
383 fn numbers(values: &[f32]) -> Object {
384 Object::Array(Array::of(values.iter().copied().map(Object::from)))
385 }
386
387 fn catalog() -> Dict {
389 dict(&[(
390 "AcroForm",
391 Object::Dict(dict(&[(
392 "DR",
393 Object::Dict(dict(&[(
394 "Font",
395 Object::Dict(dict(&[("Helv", Object::Dict(fallback_font()))])),
396 )])),
397 )])),
398 )])
399 }
400
401 fn annot(extra: &[(&str, Object)]) -> Dict {
402 let mut pairs = vec![
403 ("Subtype", Object::Name(Name::from("FreeText"))),
404 ("Rect", numbers(&[100.0, 50.0, 150.0, 75.0])),
405 (
406 "DA",
407 Object::Str(PdfString::literal(b"0 0 0 rg /Helv 12 Tf")),
408 ),
409 ];
410 pairs.extend_from_slice(extra);
411 dict(&pairs)
412 }
413
414 fn one_byte(code: u32) -> Vec<u8> {
415 vec![u8::try_from(code).unwrap_or(b'?')]
416 }
417
418 fn generate(annot: &Dict, catalog: &Dict) -> Option<String> {
419 let mut diags = Diagnostics::default();
420 free_text(
421 annot,
422 catalog,
423 &NoResolve,
424 &stub::metrics(),
425 &one_byte,
426 &mut diags,
427 )
428 .map(|got| String::from_utf8_lossy(&got.stream).into_owned())
429 }
430
431 #[test]
435 fn a_form_without_dr_or_dr_font_is_completed_from_the_synthesized_form() {
436 let text = || annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]);
437 let bare = dict(&[("AcroForm", Object::Dict(Dict::new()))]);
438 let got = generate(&text(), &bare).expect("an AcroForm without /DR still yields one");
439 assert!(got.contains("(Hi) Tj\n"), "{got}");
440 let no_font = dict(&[(
441 "AcroForm",
442 Object::Dict(dict(&[("DR", Object::Dict(Dict::new()))])),
443 )]);
444 let got = generate(&text(), &no_font).expect("a /DR without /Font still yields one");
445 assert!(got.contains("(Hi) Tj\n"), "{got}");
446 }
447
448 #[test]
449 fn a_document_with_no_form_at_all_gets_one_synthesized() {
450 let got = generate(
454 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
455 &Dict::new(),
456 )
457 .expect("synthesizes a form");
458 assert!(got.contains("(Hi) Tj\n"), "{got}");
459 }
460
461 #[test]
465 fn the_generated_appearance_carries_the_font_its_tf_names() {
466 let got = free_text(
467 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
468 &catalog(),
469 &NoResolve,
470 &stub::metrics(),
471 &one_byte,
472 &mut Diagnostics::default(),
473 )
474 .expect("generates");
475 let fonts = got.font_resources.expect("a text generator names a font");
476 assert_eq!(fonts.keys().collect::<Vec<_>>(), [&Name::from("Helv")]);
477 assert_eq!(
478 fonts.dict(&Name::from("Helv"), &NoResolve),
479 Some(fallback_font())
480 );
481 }
482
483 #[test]
487 fn a_da_naming_an_absent_font_falls_back_rather_than_declining() {
488 let empty_dr = dict(&[(
489 "AcroForm",
490 Object::Dict(dict(&[(
491 "DR",
492 Object::Dict(dict(&[("Font", Object::Dict(Dict::new()))])),
493 )])),
494 )]);
495 let got = free_text(
496 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
497 &empty_dr,
498 &NoResolve,
499 &stub::metrics(),
500 &one_byte,
501 &mut Diagnostics::default(),
502 )
503 .expect("falls back rather than declining");
504 let fonts = got.font_resources.expect("names a font");
505 assert_eq!(
506 fonts.dict(&Name::from("Helv"), &NoResolve),
507 Some(fallback_font())
508 );
509 }
510
511 #[test]
512 fn the_synthesized_forms_font_alias_is_four_characters() {
513 assert_eq!(super::font_resource_alias(b"Helvetica"), b"Helv");
514 assert_eq!(super::font_resource_alias(b"AB"), b"AB23");
516 assert_eq!(super::font_resource_alias(b"H e l v e t"), b"Helv");
518 assert_eq!(super::font_resource_alias(b""), b"ZiTi");
520 assert_eq!(super::font_resource_alias(b" "), b"ZiTi");
521 }
522
523 #[test]
526 fn an_annotation_with_no_default_appearance_takes_the_synthesized_one() {
527 let no_da = dict(&[
528 ("Subtype", Object::Name(Name::from("FreeText"))),
529 ("Rect", numbers(&[100.0, 50.0, 150.0, 75.0])),
530 ("Contents", Object::Str(PdfString::literal(b"Hi"))),
531 ]);
532 let got = generate(&no_da, &catalog()).expect("falls back to the synthesized /DA");
533 assert!(got.contains("(Hi) Tj\n"), "{got}");
534 }
535
536 #[test]
537 fn an_empty_font_resource_dictionary_passes_vacuously() {
538 assert!(valid_font_resources(&Dict::new(), &NoResolve));
539 let good = dict(&[("Helv", Object::Dict(fallback_font()))]);
540 assert!(valid_font_resources(&good, &NoResolve));
541 let bad = dict(&[("Helv", Object::Dict(dict(&[("Type", Object::Int(4))])))]);
543 assert!(!valid_font_resources(&bad, &NoResolve));
544 let stringly = dict(&[(
546 "Helv",
547 Object::Dict(dict(&[("Type", Object::Str(PdfString::literal(b"Font")))])),
548 )]);
549 assert!(!valid_font_resources(&stringly, &NoResolve));
550 }
551
552 #[test]
553 fn the_text_reaches_the_stream_between_its_markers() {
554 let got = generate(
555 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
556 &catalog(),
557 )
558 .expect("generates");
559 assert!(got.starts_with("/GS gs "), "{got}");
560 assert!(got.contains("/Tx BMC\nq\n"), "{got}");
561 assert!(got.contains("BT\n"), "{got}");
562 assert!(got.contains("(Hi) Tj\n"), "{got}");
563 assert!(got.ends_with("ET\nQ\nEMC\n"), "{got}");
564 }
565
566 #[test]
567 fn a_short_annotation_writes_no_clip_at_all() {
568 let short = generate(
569 &annot(&[("Contents", Object::Str(PdfString::literal(b"a")))]),
570 &catalog(),
571 )
572 .expect("generates");
573 assert!(!short.contains("W\nn\n"), "{short}");
574 }
575
576 #[test]
577 fn a_background_colour_paints_before_the_border() {
578 let got = generate(
579 &annot(&[
580 ("C", numbers(&[1.0, 0.0, 0.0])),
581 ("Contents", Object::Str(PdfString::literal(b"x"))),
582 ]),
583 &catalog(),
584 )
585 .expect("generates");
586 assert!(got.contains("q\n1 0 0 rg\n"), "{got}");
587 assert!(got.contains("re f\nQ\n"), "{got}");
588 }
589
590 #[test]
591 fn a_colour_array_naming_no_space_still_writes_its_save_and_restore() {
592 let got = generate(
593 &annot(&[
594 ("C", Object::Array(Array::new())),
595 ("Contents", Object::Str(PdfString::literal(b"x"))),
596 ]),
597 &catalog(),
598 )
599 .expect("generates");
600 assert!(got.contains("q\n"), "{got}");
601 assert!(got.contains(" re f\nQ\n"), "{got}");
602 }
603
604 #[test]
605 fn empty_contents_produce_a_stream_with_no_text_block() {
606 let got = generate(&annot(&[]), &catalog()).expect("generates");
607 assert!(!got.contains("BT\n"), "{got}");
608 }
609
610 #[test]
611 fn the_default_appearance_falls_back_to_the_forms_own() {
612 let annot = dict(&[("Subtype", Object::Name(Name::from("FreeText")))]);
613 let form = dict(&[("DA", Object::Str(PdfString::literal(b"/Helv 8 Tf")))]);
614 let got = default_appearance(&annot, &form, &NoResolve).expect("has one");
615 assert_eq!(got.font_name, b"Helv");
616 assert!((got.size - 8.0).abs() < f32::EPSILON);
617 assert_eq!(got.color, Color::Transparent);
619 }
620
621 #[test]
622 fn a_zero_size_asks_for_automatic_sizing_while_a_negative_one_does_not() {
623 assert_eq!(resolved_size(0.0), None);
624 assert_eq!(resolved_size(-12.0), Some(-12.0));
625 assert_eq!(resolved_size(12.0), Some(12.0));
626 }
627
628 #[test]
629 fn the_rewritten_default_appearance_joins_its_two_halves() {
630 let got = default_appearance_string(&super::Appearance {
631 font_name: b"Helv".to_vec(),
632 size: 12.0,
633 color: Color::Rgb(1.0, 0.0, 0.0),
634 });
635 assert_eq!(got, b"/Helv 12 Tf 1 0 0 rg");
636
637 let sizeless = default_appearance_string(&super::Appearance {
639 font_name: b"Helv".to_vec(),
640 size: 0.0,
641 color: Color::Gray(0.0),
642 });
643 assert_eq!(sizeless, b"0 g");
644 }
645
646 #[test]
649 fn a_malformed_dr_font_declines_and_is_recorded() {
650 let bad_dr = dict(&[(
651 "AcroForm",
652 Object::Dict(dict(&[(
653 "DR",
654 Object::Dict(dict(&[(
655 "Font",
656 Object::Dict(dict(&[("Helv", Object::Int(7))])),
658 )])),
659 )])),
660 )]);
661 let mut diags = Diagnostics::default();
662 let got = free_text(
663 &annot(&[("Contents", Object::Str(PdfString::literal(b"Hi")))]),
664 &bad_dr,
665 &NoResolve,
666 &stub::metrics(),
667 &one_byte,
668 &mut diags,
669 );
670 assert!(got.is_none(), "no appearance can be generated");
671 assert!(diags.contains(&DiagKind::FormResourcesInvalid));
672 }
673}