1use std::collections::{BTreeMap, BTreeSet};
2
3use pdf_content::{Operation, ParsedPageContent, parse_content_stream, parse_page_contents};
4use pdf_graphics::{Matrix, Quad, Rect};
5use pdf_objects::{
6 ObjectRef, PageInfo, PdfDictionary, PdfError, PdfFile, PdfResult, PdfValue, decode_stream,
7 document::get_stream,
8};
9use serde::{Deserialize, Serialize};
10
11const MAX_FORM_XOBJECT_DEPTH: usize = 16;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct TextItem {
17 pub text: String,
18 pub bbox: Rect,
19 pub quad: Option<Quad>,
20 pub char_start: Option<usize>,
21 pub char_end: Option<usize>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct TextMatch {
26 pub text: String,
27 pub page_index: usize,
28 pub quads: Vec<Quad>,
29}
30
31#[derive(Debug, Clone)]
32pub enum GlyphLocation {
33 Direct {
34 operand_index: usize,
35 byte_start: usize,
36 byte_end: usize,
37 },
38 Array {
39 operand_index: usize,
40 element_index: usize,
41 byte_start: usize,
42 byte_end: usize,
43 },
44}
45
46#[derive(Debug, Clone)]
47pub struct Glyph {
48 pub text: char,
49 pub bbox: Rect,
50 pub quad: Quad,
51 pub page_char_index: usize,
52 pub operation_index: usize,
53 pub location: GlyphLocation,
54 pub visible: bool,
58 pub width_units: f64,
61 pub source_form: Option<ObjectRef>,
68}
69
70#[derive(Debug, Clone)]
71pub struct ExtractedPageText {
72 pub page_index: usize,
73 pub text: String,
74 pub items: Vec<TextItem>,
75 pub glyphs: Vec<Glyph>,
76}
77
78#[derive(Debug, Clone)]
79pub struct PageSearchIndex {
80 normalized_text: String,
81 normalized_to_display: Vec<usize>,
82 display_chars: Vec<char>,
83 display_to_glyph: Vec<Option<usize>>,
84}
85
86pub fn analyze_page_text(
87 file: &PdfFile,
88 page_index: usize,
89 page: &PageInfo,
90) -> PdfResult<ExtractedPageText> {
91 let parsed = parse_page_contents(file, page)?;
92 let (fonts, extgstate_fonts) = load_fonts(file, &page.resources)?;
93 interpret_page_text(file, page_index, page, &parsed, &fonts, &extgstate_fonts)
94}
95
96pub fn search_page_text(page: &ExtractedPageText, query: &str) -> Vec<TextMatch> {
97 if query.is_empty() {
98 return Vec::new();
99 }
100 let index = build_search_index(page);
101 let normalized_query = normalize_search_text(query);
102 if normalized_query.is_empty() {
103 return Vec::new();
104 }
105
106 let mut matches = Vec::new();
107 let mut search_offset = 0usize;
108 while let Some(position) = index.normalized_text[search_offset..].find(&normalized_query) {
109 let normalized_start = search_offset + position;
110 let normalized_end = normalized_start + normalized_query.len();
111 let display_start = *index
112 .normalized_to_display
113 .get(normalized_start)
114 .unwrap_or(&0);
115 let display_end = index
116 .normalized_to_display
117 .get(normalized_end.saturating_sub(1))
118 .copied()
119 .unwrap_or(display_start)
120 + 1;
121 let glyph_indices = (display_start..display_end)
122 .filter_map(|display_index| index.normalized_to_glyph(display_index))
123 .collect::<BTreeSet<_>>()
124 .into_iter()
125 .collect::<Vec<_>>();
126 let quads = coalesce_match_quads(
127 &glyph_indices
128 .iter()
129 .filter_map(|glyph_index| page.glyphs.get(*glyph_index))
130 .map(|glyph| glyph.quad)
131 .collect::<Vec<_>>(),
132 );
133 if !quads.is_empty() {
134 matches.push(TextMatch {
135 text: index
136 .display_chars
137 .iter()
138 .skip(display_start)
139 .take(display_end.saturating_sub(display_start))
140 .collect::<String>()
141 .trim()
142 .to_string(),
143 page_index: page.page_index,
144 quads,
145 });
146 }
147 search_offset = normalized_end;
148 if search_offset >= index.normalized_text.len() {
149 break;
150 }
151 }
152 matches
153}
154
155fn coalesce_match_quads(quads: &[Quad]) -> Vec<Quad> {
156 let mut rects = quads
157 .iter()
158 .map(|quad| quad.bounding_rect())
159 .collect::<Vec<_>>();
160 rects.sort_by(|left, right| {
161 let y_delta = (left.y - right.y).abs();
162 if y_delta > 1.5 {
163 right
164 .y
165 .partial_cmp(&left.y)
166 .unwrap_or(std::cmp::Ordering::Equal)
167 } else {
168 left.x
169 .partial_cmp(&right.x)
170 .unwrap_or(std::cmp::Ordering::Equal)
171 }
172 });
173
174 let mut merged = Vec::<Rect>::new();
175 for rect in rects {
176 let Some(previous) = merged.last_mut() else {
177 merged.push(rect);
178 continue;
179 };
180 if should_merge_match_rects(*previous, rect) {
181 let next_x = previous.x.min(rect.x);
182 let next_y = previous.y.min(rect.y);
183 let next_max_x = previous.max_x().max(rect.max_x());
184 let next_max_y = previous.max_y().max(rect.max_y());
185 *previous = Rect {
186 x: next_x,
187 y: next_y,
188 width: next_max_x - next_x,
189 height: next_max_y - next_y,
190 };
191 } else {
192 merged.push(rect);
193 }
194 }
195
196 merged
197 .into_iter()
198 .map(expand_match_rect)
199 .map(Rect::to_quad)
200 .collect()
201}
202
203fn should_merge_match_rects(left: Rect, right: Rect) -> bool {
204 let vertical_overlap = left.max_y().min(right.max_y()) - left.y.max(right.y);
205 let minimum_height = left.height.min(right.height).max(1.0);
206 let horizontal_gap = right.x - left.max_x();
207 vertical_overlap >= minimum_height * 0.45 && horizontal_gap <= minimum_height * 0.8
208}
209
210fn expand_match_rect(rect: Rect) -> Rect {
211 rect
215}
216
217fn build_search_index(page: &ExtractedPageText) -> PageSearchIndex {
218 let (display_chars, display_to_glyph) = build_visual_display(page);
219 let mut normalized_text = String::new();
220 let mut normalized_to_display = Vec::new();
221 let mut previous_was_whitespace = false;
222 for (display_index, character) in display_chars.iter().copied().enumerate() {
223 if character.is_whitespace() {
224 if !previous_was_whitespace {
225 normalized_text.push(' ');
226 for _ in 0..' '.len_utf8() {
228 normalized_to_display.push(display_index);
229 }
230 previous_was_whitespace = true;
231 }
232 } else {
233 for folded in character.to_lowercase() {
234 normalized_text.push(folded);
235 for _ in 0..folded.len_utf8() {
236 normalized_to_display.push(display_index);
237 }
238 }
239 previous_was_whitespace = false;
240 }
241 }
242 PageSearchIndex {
243 normalized_text,
244 normalized_to_display,
245 display_chars,
246 display_to_glyph,
247 }
248}
249
250impl PageSearchIndex {
251 fn normalized_to_glyph(&self, display_index: usize) -> Option<usize> {
252 self.display_to_glyph.get(display_index).copied().flatten()
253 }
254}
255
256fn build_visual_display(page: &ExtractedPageText) -> (Vec<char>, Vec<Option<usize>>) {
257 let mut lines = build_visual_lines(page);
258 for line in &mut lines {
259 line.sort_by(|left, right| {
260 page.glyphs[*left]
261 .bbox
262 .x
263 .partial_cmp(&page.glyphs[*right].bbox.x)
264 .unwrap_or(std::cmp::Ordering::Equal)
265 });
266 }
267 lines.sort_by(|left, right| {
268 let left_y = average_line_center_y(page, left);
269 let right_y = average_line_center_y(page, right);
270 right_y
271 .partial_cmp(&left_y)
272 .unwrap_or(std::cmp::Ordering::Equal)
273 });
274
275 let mut display_chars = Vec::new();
276 let mut display_to_glyph = Vec::new();
277 for (line_index, line) in lines.iter().enumerate() {
278 if line_index > 0 {
279 display_chars.push('\n');
280 display_to_glyph.push(None);
281 }
282 let mut previous_rect: Option<Rect> = None;
283 for glyph_index in line {
284 let glyph = &page.glyphs[*glyph_index];
285 if let Some(previous) = previous_rect {
286 let gap = glyph.bbox.x - previous.max_x();
287 let threshold = previous.height.min(glyph.bbox.height).max(1.0) * 0.3;
288 if gap > threshold {
289 display_chars.push(' ');
290 display_to_glyph.push(None);
291 }
292 }
293 display_chars.push(glyph.text);
294 display_to_glyph.push(Some(*glyph_index));
295 previous_rect = Some(glyph.bbox);
296 }
297 }
298 (display_chars, display_to_glyph)
299}
300
301struct LineCluster {
308 indices: Vec<usize>,
309 anchor_y: f64,
310 height_ref: f64,
311}
312
313fn build_visual_lines(page: &ExtractedPageText) -> Vec<Vec<usize>> {
314 let mut indices = (0..page.glyphs.len())
315 .filter(|i| page.glyphs[*i].visible)
316 .collect::<Vec<_>>();
317 indices.sort_by(|left, right| {
326 let left_center = glyph_center_y(&page.glyphs[*left]);
327 let right_center = glyph_center_y(&page.glyphs[*right]);
328 right_center
329 .partial_cmp(&left_center)
330 .unwrap_or(std::cmp::Ordering::Equal)
331 .then_with(|| {
332 page.glyphs[*left]
333 .bbox
334 .x
335 .partial_cmp(&page.glyphs[*right].bbox.x)
336 .unwrap_or(std::cmp::Ordering::Equal)
337 })
338 });
339
340 let mut clusters: Vec<LineCluster> = Vec::new();
341 for glyph_index in indices {
342 let glyph = &page.glyphs[glyph_index];
343 let glyph_center = glyph_center_y(glyph);
344 let glyph_height = glyph.bbox.height;
345 let glyph_x = glyph.bbox.x;
346
347 let target = clusters.iter_mut().find(|cluster| {
348 let last_x = cluster.indices.last().map(|i| page.glyphs[*i].bbox.x);
349 glyph_fits_line(cluster, glyph_center, glyph_height, glyph_x, last_x)
350 });
351
352 match target {
353 Some(cluster) => {
354 cluster.indices.push(glyph_index);
355 if glyph_height > cluster.height_ref {
356 cluster.height_ref = glyph_height;
357 }
358 }
359 None => clusters.push(LineCluster {
360 indices: vec![glyph_index],
361 anchor_y: glyph_center,
362 height_ref: glyph_height.max(1.0),
366 }),
367 }
368 }
369 clusters.into_iter().map(|c| c.indices).collect()
370}
371
372fn glyph_fits_line(
379 cluster: &LineCluster,
380 glyph_center: f64,
381 glyph_height: f64,
382 glyph_bbox_x: f64,
383 last_bbox_x: Option<f64>,
384) -> bool {
385 let new_height_ref = cluster.height_ref.max(glyph_height);
386 let y_tolerance = new_height_ref * 0.10;
387 if (glyph_center - cluster.anchor_y).abs() + 1e-6 >= y_tolerance {
388 return false;
389 }
390 if let Some(last_x) = last_bbox_x {
401 let kerning_slack = 1.0;
402 if glyph_bbox_x + kerning_slack < last_x {
403 return false;
404 }
405 }
406 true
407}
408
409fn average_line_center_y(page: &ExtractedPageText, line: &[usize]) -> f64 {
410 let total = line
411 .iter()
412 .map(|glyph_index| glyph_center_y(&page.glyphs[*glyph_index]))
413 .sum::<f64>();
414 total / line.len().max(1) as f64
415}
416
417fn glyph_center_y(glyph: &Glyph) -> f64 {
418 glyph.bbox.y + glyph.bbox.height / 2.0
419}
420
421fn normalize_search_text(input: &str) -> String {
422 let mut output = String::new();
423 let mut previous_was_whitespace = false;
424 for character in input.chars() {
425 if character.is_whitespace() {
426 if !previous_was_whitespace {
427 output.push(' ');
428 previous_was_whitespace = true;
429 }
430 } else {
431 for folded in character.to_lowercase() {
432 output.push(folded);
433 }
434 previous_was_whitespace = false;
435 }
436 }
437 output.trim().to_string()
438}
439
440fn interpret_page_text(
441 file: &PdfFile,
442 page_index: usize,
443 page: &PageInfo,
444 parsed: &ParsedPageContent,
445 fonts: &BTreeMap<String, LoadedFont>,
446 extgstate_fonts: &ExtGStateFontMap,
447) -> PdfResult<ExtractedPageText> {
448 let page_transform = page.page_box.normalized_transform();
449 let mut context = TextContext::new(page_index);
450 let mut ctm = Matrix::identity();
451 let mut ctm_stack: Vec<(Matrix, RuntimeTextState)> = Vec::new();
452 let mut text_state = RuntimeTextState::default();
453 let mut xobject_stack: BTreeSet<ObjectRef> = BTreeSet::new();
454
455 run_operations(
456 file,
457 &parsed.operations,
458 fonts,
459 extgstate_fonts,
460 &page.resources,
461 page_transform,
462 &mut ctm,
463 &mut ctm_stack,
464 &mut text_state,
465 &mut context,
466 &mut xobject_stack,
467 0,
468 )?;
469
470 Ok(ExtractedPageText {
471 page_index,
472 text: context.text,
473 items: context.items,
474 glyphs: context.glyphs,
475 })
476}
477
478#[allow(clippy::too_many_arguments)]
479fn run_operations(
480 file: &PdfFile,
481 operations: &[Operation],
482 fonts: &BTreeMap<String, LoadedFont>,
483 extgstate_fonts: &ExtGStateFontMap,
484 resources: &PdfDictionary,
485 page_transform: Matrix,
486 ctm: &mut Matrix,
487 ctm_stack: &mut Vec<(Matrix, RuntimeTextState)>,
488 text_state: &mut RuntimeTextState,
489 context: &mut TextContext,
490 xobject_stack: &mut BTreeSet<ObjectRef>,
491 depth: usize,
492) -> PdfResult<()> {
493 for (operation_index, operation) in operations.iter().enumerate() {
494 match operation.operator.as_str() {
495 "q" => ctm_stack.push((*ctm, text_state.clone())),
496 "Q" => {
497 let (saved_ctm, saved_text_state) = ctm_stack
498 .pop()
499 .unwrap_or((Matrix::identity(), RuntimeTextState::default()));
500 *ctm = saved_ctm;
501 *text_state = saved_text_state;
502 }
503 "gs" => {
504 let gs_name = operand_name(operation, 0)?;
505 if let Some((font_key, font_size)) = extgstate_fonts.get(gs_name) {
506 text_state.font = Some(font_key.clone());
507 text_state.font_size = *font_size;
508 }
509 }
510 "cm" => {
511 let matrix = matrix_from_operands(&operation.operands)?;
512 *ctm = matrix.multiply(*ctm);
513 }
514 "BT" => {
515 text_state.text_matrix = Matrix::identity();
516 text_state.line_matrix = Matrix::identity();
517 }
518 "ET" => {}
519 "Tf" => {
520 let resource_name = operand_name(operation, 0)?;
521 text_state.font = Some(resource_name.to_string());
522 text_state.font_size = operand_number(operation, 1)?;
523 }
524 "Tm" => {
525 text_state.text_matrix = matrix_from_operands(&operation.operands)?;
526 text_state.line_matrix = text_state.text_matrix;
527 }
528 "Td" => {
529 let tx = operand_number(operation, 0)?;
530 let ty = operand_number(operation, 1)?;
531 text_state.line_matrix = Matrix::translate(tx, ty).multiply(text_state.line_matrix);
537 text_state.text_matrix = text_state.line_matrix;
538 if ty.abs() > f64::EPSILON {
539 context.pending_line_break = true;
540 }
541 }
542 "TD" => {
543 let tx = operand_number(operation, 0)?;
544 let ty = operand_number(operation, 1)?;
545 text_state.leading = -ty;
546 text_state.line_matrix = Matrix::translate(tx, ty).multiply(text_state.line_matrix);
547 text_state.text_matrix = text_state.line_matrix;
548 context.pending_line_break = true;
549 }
550 "T*" => {
551 text_state.line_matrix =
552 Matrix::translate(0.0, -text_state.leading).multiply(text_state.line_matrix);
553 text_state.text_matrix = text_state.line_matrix;
554 context.pending_line_break = true;
555 }
556 "Tc" => text_state.character_spacing = operand_number(operation, 0)?,
557 "Tw" => text_state.word_spacing = operand_number(operation, 0)?,
558 "TL" => text_state.leading = operand_number(operation, 0)?,
559 "Tr" => {
560 text_state.text_render_mode = operation
561 .operands
562 .first()
563 .and_then(PdfValue::as_integer)
564 .unwrap_or(0);
565 }
566 "Ts" => text_state.text_rise = operand_number(operation, 0)?,
567 "Tz" => text_state.horizontal_scaling = operand_number(operation, 0)?,
568 "Tj" => {
569 let string = operand_string(operation, 0)?;
570 show_text(
571 context,
572 operation_index,
573 ShowOperand::Direct { operand_index: 0 },
574 string,
575 text_state,
576 fonts,
577 *ctm,
578 page_transform,
579 )?;
580 }
581 "'" => {
582 text_state.line_matrix =
583 Matrix::translate(0.0, -text_state.leading).multiply(text_state.line_matrix);
584 text_state.text_matrix = text_state.line_matrix;
585 context.pending_line_break = true;
586 let string = operand_string(operation, 0)?;
587 show_text(
588 context,
589 operation_index,
590 ShowOperand::Direct { operand_index: 0 },
591 string,
592 text_state,
593 fonts,
594 *ctm,
595 page_transform,
596 )?;
597 }
598 "\"" => {
599 text_state.word_spacing = operand_number(operation, 0)?;
600 text_state.character_spacing = operand_number(operation, 1)?;
601 text_state.line_matrix =
602 Matrix::translate(0.0, -text_state.leading).multiply(text_state.line_matrix);
603 text_state.text_matrix = text_state.line_matrix;
604 context.pending_line_break = true;
605 let string = operand_string(operation, 2)?;
606 show_text(
607 context,
608 operation_index,
609 ShowOperand::Direct { operand_index: 2 },
610 string,
611 text_state,
612 fonts,
613 *ctm,
614 page_transform,
615 )?;
616 }
617 "TJ" => {
618 let segments = operation
619 .operands
620 .first()
621 .and_then(PdfValue::as_array)
622 .ok_or_else(|| PdfError::Corrupt("TJ expects an array operand".to_string()))?;
623 for (element_index, segment) in segments.iter().enumerate() {
624 match segment {
625 PdfValue::String(string) => show_text(
626 context,
627 operation_index,
628 ShowOperand::Array {
629 operand_index: 0,
630 element_index,
631 },
632 string,
633 text_state,
634 fonts,
635 *ctm,
636 page_transform,
637 )?,
638 value => {
639 let adjustment = value.as_number().ok_or_else(|| {
640 PdfError::Corrupt("TJ array contains unsupported value".to_string())
641 })?;
642 let scaled = -(adjustment / 1000.0)
643 * text_state.font_size
644 * (text_state.horizontal_scaling / 100.0);
645 text_state.text_matrix =
646 Matrix::translate(scaled, 0.0).multiply(text_state.text_matrix);
647 }
648 }
649 }
650 }
651 "Do" => {
652 let name = operand_name(operation, 0)?;
653 enter_form_xobject(
654 file,
655 name,
656 fonts,
657 extgstate_fonts,
658 resources,
659 page_transform,
660 ctm,
661 ctm_stack,
662 text_state,
663 context,
664 xobject_stack,
665 depth,
666 )?;
667 }
668 _ => {}
669 }
670 }
671 Ok(())
672}
673
674#[allow(clippy::too_many_arguments)]
675fn enter_form_xobject(
676 file: &PdfFile,
677 name: &str,
678 outer_fonts: &BTreeMap<String, LoadedFont>,
679 outer_extgstate: &ExtGStateFontMap,
680 outer_resources: &PdfDictionary,
681 page_transform: Matrix,
682 ctm: &mut Matrix,
683 ctm_stack: &mut Vec<(Matrix, RuntimeTextState)>,
684 text_state: &mut RuntimeTextState,
685 context: &mut TextContext,
686 xobject_stack: &mut BTreeSet<ObjectRef>,
687 depth: usize,
688) -> PdfResult<()> {
689 if depth + 1 > MAX_FORM_XOBJECT_DEPTH {
690 return Ok(());
691 }
692 let Some(xobject_ref) = lookup_xobject_ref(outer_resources, name) else {
693 return Ok(());
694 };
695 if !xobject_stack.insert(xobject_ref) {
696 return Ok(());
698 }
699
700 let result = (|| -> PdfResult<()> {
701 let stream = get_stream(file, xobject_ref)?;
702 if stream.dict.get("Subtype").and_then(PdfValue::as_name) != Some("Form") {
703 return Ok(());
705 }
706
707 let form_matrix = stream
708 .dict
709 .get("Matrix")
710 .and_then(PdfValue::as_array)
711 .map(matrix_from_pdf_values)
712 .transpose()?
713 .unwrap_or_else(Matrix::identity);
714
715 let form_resources_owned: PdfDictionary = stream
716 .dict
717 .get("Resources")
718 .map(|value| file.resolve_dict(value).cloned())
719 .transpose()?
720 .unwrap_or_else(|| outer_resources.clone());
721
722 let (form_fonts, form_extgstate) = load_fonts(file, &form_resources_owned)?;
723 let mut effective_fonts: BTreeMap<String, LoadedFont> = outer_fonts.clone();
728 for (key, value) in form_fonts {
729 effective_fonts.insert(key, value);
730 }
731 let mut effective_extgstate: ExtGStateFontMap = outer_extgstate.clone();
732 for (key, value) in form_extgstate {
733 effective_extgstate.insert(key, value);
734 }
735
736 let decoded = decode_stream(stream)?;
737 let form_operations = parse_content_stream(&decoded)?.operations;
738
739 let saved_ctm = *ctm;
747 let saved_text_state = text_state.clone();
748 let saved_form = context.current_form;
749 *ctm = form_matrix.multiply(saved_ctm);
750 context.current_form = Some(xobject_ref);
751
752 run_operations(
753 file,
754 &form_operations,
755 &effective_fonts,
756 &effective_extgstate,
757 &form_resources_owned,
758 page_transform,
759 ctm,
760 ctm_stack,
761 text_state,
762 context,
763 xobject_stack,
764 depth + 1,
765 )?;
766
767 *ctm = saved_ctm;
768 *text_state = saved_text_state;
769 context.current_form = saved_form;
770 Ok(())
771 })();
772
773 xobject_stack.remove(&xobject_ref);
774 result
775}
776
777fn lookup_xobject_ref(resources: &PdfDictionary, name: &str) -> Option<ObjectRef> {
778 let xobjects = match resources.get("XObject")? {
779 PdfValue::Dictionary(dict) => dict,
780 _ => return None,
781 };
782 match xobjects.get(name)? {
783 PdfValue::Reference(object_ref) => Some(*object_ref),
784 _ => None,
785 }
786}
787
788fn matrix_from_pdf_values(values: &[PdfValue]) -> PdfResult<Matrix> {
789 if values.len() != 6 {
790 return Err(PdfError::Corrupt(
791 "Matrix array must have six numeric entries".to_string(),
792 ));
793 }
794 let mut numbers = [0.0; 6];
795 for (slot, value) in numbers.iter_mut().zip(values.iter()) {
796 *slot = value
797 .as_number()
798 .ok_or_else(|| PdfError::Corrupt("Matrix entry is not a number".to_string()))?;
799 }
800 Ok(Matrix {
801 a: numbers[0],
802 b: numbers[1],
803 c: numbers[2],
804 d: numbers[3],
805 e: numbers[4],
806 f: numbers[5],
807 })
808}
809
810#[derive(Debug, Clone, Copy)]
811enum ShowOperand {
812 Direct {
813 operand_index: usize,
814 },
815 Array {
816 operand_index: usize,
817 element_index: usize,
818 },
819}
820
821#[derive(Debug, Clone)]
822struct SimpleFont {
823 widths: Vec<f64>,
824 first_char: u16,
825 unicode_map: BTreeMap<u16, String>,
826 encoding: SimpleEncoding,
827}
828
829#[derive(Debug, Clone, Default, PartialEq)]
830struct SimpleEncoding {
831 base: SimpleEncodingBase,
834 differences: BTreeMap<u8, String>,
837}
838
839#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
840enum SimpleEncodingBase {
841 #[default]
844 Identity,
845 WinAnsi,
848 MacRoman,
853 Standard,
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
862enum CompositeEncoding {
863 IdentityH,
866 Ucs2H,
870 Utf16H,
875}
876
877#[derive(Debug, Clone)]
878struct CompositeFont {
879 encoding: CompositeEncoding,
880 default_width: f64,
881 widths: BTreeMap<u16, f64>,
882 unicode_map: BTreeMap<u16, String>,
883}
884
885#[derive(Debug, Clone)]
886enum LoadedFont {
887 Simple(SimpleFont),
888 Composite(CompositeFont),
889}
890
891#[derive(Debug, Clone)]
892struct DecodedGlyph {
893 text: String,
894 width_units: f64,
895 byte_start: usize,
896 byte_end: usize,
897}
898
899#[derive(Debug, Clone)]
900struct RuntimeTextState {
901 text_matrix: Matrix,
902 line_matrix: Matrix,
903 font_size: f64,
904 character_spacing: f64,
905 word_spacing: f64,
906 text_rise: f64,
907 horizontal_scaling: f64,
908 leading: f64,
909 font: Option<String>,
910 text_render_mode: i64,
912}
913
914impl Default for RuntimeTextState {
915 fn default() -> Self {
916 Self {
917 text_matrix: Matrix::identity(),
918 line_matrix: Matrix::identity(),
919 font_size: 12.0,
920 character_spacing: 0.0,
921 word_spacing: 0.0,
922 text_rise: 0.0,
923 horizontal_scaling: 100.0,
924 leading: 0.0,
925 font: None,
926 text_render_mode: 0,
927 }
928 }
929}
930
931struct TextContext {
932 text: String,
933 items: Vec<TextItem>,
934 glyphs: Vec<Glyph>,
935 pending_line_break: bool,
936 current_form: Option<ObjectRef>,
942}
943
944impl TextContext {
945 fn new(page_index: usize) -> Self {
946 let _ = page_index;
947 Self {
948 text: String::new(),
949 items: Vec::new(),
950 glyphs: Vec::new(),
951 pending_line_break: false,
952 current_form: None,
953 }
954 }
955}
956
957type ExtGStateFontMap = BTreeMap<String, (String, f64)>;
959
960fn load_single_font(
961 file: &PdfFile,
962 font_dict: &pdf_objects::PdfDictionary,
963) -> PdfResult<LoadedFont> {
964 let subtype = font_dict
965 .get("Subtype")
966 .and_then(PdfValue::as_name)
967 .unwrap_or("");
968 match subtype {
969 "Type1" | "TrueType" => {
970 let first_char = font_dict
971 .get("FirstChar")
972 .and_then(PdfValue::as_integer)
973 .unwrap_or(0) as u16;
974 let widths = font_dict
975 .get("Widths")
976 .and_then(PdfValue::as_array)
977 .map(|widths| {
978 widths
979 .iter()
980 .map(|value| value.as_number().unwrap_or(600.0))
981 .collect::<Vec<_>>()
982 })
983 .unwrap_or_default();
984 let unicode_map = load_to_unicode_map(file, font_dict)?;
985 let encoding = parse_simple_encoding(file, font_dict);
986 Ok(LoadedFont::Simple(SimpleFont {
987 widths,
988 first_char,
989 unicode_map,
990 encoding,
991 }))
992 }
993 "Type0" => Ok(LoadedFont::Composite(load_composite_font(file, font_dict)?)),
994 other => Err(PdfError::Unsupported(format!(
995 "font subtype {other} is not supported"
996 ))),
997 }
998}
999
1000fn load_fonts(
1001 file: &PdfFile,
1002 resources: &pdf_objects::PdfDictionary,
1003) -> PdfResult<(BTreeMap<String, LoadedFont>, ExtGStateFontMap)> {
1004 let mut fonts = BTreeMap::new();
1005 let mut extgstate_fonts = BTreeMap::new();
1006
1007 if let Some(fonts_value) = resources.get("Font") {
1009 let fonts_dict = file.resolve_dict(fonts_value)?;
1010 for (name, font_value) in fonts_dict {
1011 let font_dict = file.resolve_dict(font_value)?;
1012 let font = load_single_font(file, font_dict)?;
1013 fonts.insert(name.clone(), font);
1014 }
1015 }
1016
1017 if let Some(extgstate_value) = resources.get("ExtGState") {
1019 if let Ok(extgstate_dict) = file.resolve_dict(extgstate_value) {
1020 for (gs_name, gs_value) in extgstate_dict {
1021 let Ok(gs_dict) = file.resolve_dict(gs_value) else {
1022 continue;
1023 };
1024 let Some(font_array) = gs_dict.get("Font").and_then(PdfValue::as_array) else {
1025 continue;
1026 };
1027 if font_array.len() < 2 {
1028 continue;
1029 }
1030 let font_size = font_array[1].as_number().unwrap_or(12.0);
1031 let Ok(font_dict) = file.resolve_dict(&font_array[0]) else {
1032 continue;
1033 };
1034 let Ok(font) = load_single_font(file, font_dict) else {
1035 continue;
1036 };
1037 let synthetic_key = format!("__gs:{gs_name}");
1038 fonts.insert(synthetic_key.clone(), font);
1039 extgstate_fonts.insert(gs_name.clone(), (synthetic_key, font_size));
1040 }
1041 }
1042 }
1043
1044 Ok((fonts, extgstate_fonts))
1045}
1046
1047#[allow(clippy::too_many_arguments)]
1048fn show_text(
1049 context: &mut TextContext,
1050 operation_index: usize,
1051 show_operand: ShowOperand,
1052 string: &pdf_objects::PdfString,
1053 text_state: &mut RuntimeTextState,
1054 fonts: &BTreeMap<String, LoadedFont>,
1055 ctm: Matrix,
1056 page_transform: Matrix,
1057) -> PdfResult<()> {
1058 if context.pending_line_break && !context.text.is_empty() {
1059 context.text.push('\n');
1060 context.pending_line_break = false;
1061 }
1062
1063 let font_name = text_state.font.clone().ok_or_else(|| {
1064 PdfError::Unsupported("text-showing operator used without selected font".to_string())
1065 })?;
1066 let font = fonts.get(&font_name).ok_or_else(|| {
1067 PdfError::Unsupported(format!("font resource /{font_name} could not be resolved"))
1068 })?;
1069 let scaling = text_state.horizontal_scaling / 100.0;
1070 let item_start = context.text.chars().count();
1071 let mut item_quad: Option<Rect> = None;
1072 let mut item_text = String::new();
1073 let decoded_glyphs = decode_font_glyphs(font, &string.0)?;
1074
1075 for decoded in decoded_glyphs {
1076 let advance = ((decoded.width_units / 1000.0) * text_state.font_size
1077 + text_state.character_spacing
1078 + if decoded.text == " " {
1079 text_state.word_spacing
1080 } else {
1081 0.0
1082 })
1083 * scaling;
1084 let text_to_page = text_state
1085 .text_matrix
1086 .multiply(ctm)
1087 .multiply(page_transform);
1088 let font_size = text_state.font_size.max(0.0);
1091 let local_rect = Rect {
1092 x: 0.0,
1093 y: text_state.text_rise - font_size * 0.12,
1094 width: advance.max(0.0),
1095 height: font_size * 0.8,
1096 };
1097 let quad = local_rect.to_quad().transform(text_to_page);
1098 let bbox = quad.bounding_rect();
1099 item_quad = Some(match item_quad {
1100 Some(existing) => existing.union(&bbox),
1101 None => bbox,
1102 });
1103 for character in decoded.text.chars() {
1104 let page_char_index = context.text.chars().count();
1105 let visible = text_state.text_render_mode != 3;
1106 context.glyphs.push(Glyph {
1107 text: character,
1108 bbox,
1109 quad,
1110 page_char_index,
1111 operation_index,
1112 source_form: context.current_form,
1113 location: match show_operand {
1114 ShowOperand::Direct { operand_index } => GlyphLocation::Direct {
1115 operand_index,
1116 byte_start: decoded.byte_start,
1117 byte_end: decoded.byte_end,
1118 },
1119 ShowOperand::Array {
1120 operand_index,
1121 element_index,
1122 } => GlyphLocation::Array {
1123 operand_index,
1124 element_index,
1125 byte_start: decoded.byte_start,
1126 byte_end: decoded.byte_end,
1127 },
1128 },
1129 visible,
1130 width_units: decoded.width_units,
1131 });
1132 if visible {
1133 context.text.push(character);
1134 item_text.push(character);
1135 }
1136 }
1137 text_state.text_matrix = Matrix::translate(advance, 0.0).multiply(text_state.text_matrix);
1138 }
1139
1140 if !item_text.is_empty() {
1141 let item_end = context.text.chars().count();
1142 context.items.push(TextItem {
1143 text: item_text,
1144 bbox: item_quad.unwrap_or(Rect {
1145 x: 0.0,
1146 y: 0.0,
1147 width: 0.0,
1148 height: 0.0,
1149 }),
1150 quad: item_quad.map(Rect::to_quad),
1151 char_start: Some(item_start),
1152 char_end: Some(item_end),
1153 });
1154 }
1155 Ok(())
1156}
1157
1158fn decode_simple_byte(font: &SimpleFont, byte: u8) -> String {
1159 if let Some(mapped) = font.unicode_map.get(&u16::from(byte)) {
1160 if !mapped.is_empty() {
1161 return mapped.clone();
1162 }
1163 }
1164 if let Some(glyph_name) = font.encoding.differences.get(&byte) {
1165 if let Some(character) = glyph_name_to_char(glyph_name) {
1166 return character.to_string();
1167 }
1168 return '\u{FFFD}'.to_string();
1169 }
1170 let character = match font.encoding.base {
1171 SimpleEncodingBase::WinAnsi => winansi_byte_to_char(byte),
1172 SimpleEncodingBase::MacRoman => mac_roman_byte_to_char(byte),
1173 SimpleEncodingBase::Standard => standard_byte_to_char(byte),
1174 SimpleEncodingBase::Identity => identity_byte_to_char(byte),
1175 };
1176 character
1177 .map(|character| character.to_string())
1178 .unwrap_or_else(|| '\u{FFFD}'.to_string())
1179}
1180
1181fn identity_byte_to_char(byte: u8) -> Option<char> {
1182 if byte.is_ascii() {
1183 Some(byte as char)
1184 } else {
1185 None
1186 }
1187}
1188
1189fn glyph_name_to_char(name: &str) -> Option<char> {
1195 let mapped = match name {
1196 "space" => ' ',
1197 "exclam" => '!',
1198 "quotedbl" => '"',
1199 "numbersign" => '#',
1200 "dollar" => '$',
1201 "percent" => '%',
1202 "ampersand" => '&',
1203 "quotesingle" => '\'',
1204 "parenleft" => '(',
1205 "parenright" => ')',
1206 "asterisk" => '*',
1207 "plus" => '+',
1208 "comma" => ',',
1209 "hyphen" => '-',
1210 "period" => '.',
1211 "slash" => '/',
1212 "zero" => '0',
1213 "one" => '1',
1214 "two" => '2',
1215 "three" => '3',
1216 "four" => '4',
1217 "five" => '5',
1218 "six" => '6',
1219 "seven" => '7',
1220 "eight" => '8',
1221 "nine" => '9',
1222 "colon" => ':',
1223 "semicolon" => ';',
1224 "less" => '<',
1225 "equal" => '=',
1226 "greater" => '>',
1227 "question" => '?',
1228 "at" => '@',
1229 "bracketleft" => '[',
1230 "backslash" => '\\',
1231 "bracketright" => ']',
1232 "asciicircum" => '^',
1233 "underscore" => '_',
1234 "grave" => '`',
1235 "braceleft" => '{',
1236 "bar" => '|',
1237 "braceright" => '}',
1238 "asciitilde" => '~',
1239 name if name.len() == 1 && name.chars().next().unwrap().is_ascii_alphabetic() => {
1241 name.chars().next().unwrap()
1242 }
1243 "bullet" => '\u{2022}',
1245 "Euro" => '\u{20AC}',
1246 "endash" => '\u{2013}',
1247 "emdash" => '\u{2014}',
1248 "quoteleft" => '\u{2018}',
1249 "quoteright" => '\u{2019}',
1250 "quotesinglbase" => '\u{201A}',
1251 "quotedblleft" => '\u{201C}',
1252 "quotedblright" => '\u{201D}',
1253 "quotedblbase" => '\u{201E}',
1254 "ellipsis" => '\u{2026}',
1255 "dagger" => '\u{2020}',
1256 "daggerdbl" => '\u{2021}',
1257 "perthousand" => '\u{2030}',
1258 "guilsinglleft" => '\u{2039}',
1259 "guilsinglright" => '\u{203A}',
1260 "florin" => '\u{0192}',
1261 "trademark" => '\u{2122}',
1262 "circumflex" => '\u{02C6}',
1263 "tilde" => '\u{02DC}',
1264 "macron" => '\u{00AF}',
1265 "breve" => '\u{02D8}',
1266 "dotaccent" => '\u{02D9}',
1267 "ring" => '\u{02DA}',
1268 "ogonek" => '\u{02DB}',
1269 "caron" => '\u{02C7}',
1270 "cedilla" => '\u{00B8}',
1271 "dieresis" => '\u{00A8}',
1272 "acute" => '\u{00B4}',
1273 "exclamdown" => '\u{00A1}',
1275 "cent" => '\u{00A2}',
1276 "sterling" => '\u{00A3}',
1277 "currency" => '\u{00A4}',
1278 "yen" => '\u{00A5}',
1279 "brokenbar" => '\u{00A6}',
1280 "section" => '\u{00A7}',
1281 "copyright" => '\u{00A9}',
1282 "ordfeminine" => '\u{00AA}',
1283 "guillemotleft" => '\u{00AB}',
1284 "logicalnot" => '\u{00AC}',
1285 "registered" => '\u{00AE}',
1286 "degree" => '\u{00B0}',
1287 "plusminus" => '\u{00B1}',
1288 "twosuperior" => '\u{00B2}',
1289 "threesuperior" => '\u{00B3}',
1290 "mu" => '\u{00B5}',
1291 "paragraph" => '\u{00B6}',
1292 "periodcentered" => '\u{00B7}',
1293 "onesuperior" => '\u{00B9}',
1294 "ordmasculine" => '\u{00BA}',
1295 "guillemotright" => '\u{00BB}',
1296 "onequarter" => '\u{00BC}',
1297 "onehalf" => '\u{00BD}',
1298 "threequarters" => '\u{00BE}',
1299 "questiondown" => '\u{00BF}',
1300 "Agrave" => '\u{00C0}',
1302 "Aacute" => '\u{00C1}',
1303 "Acircumflex" => '\u{00C2}',
1304 "Atilde" => '\u{00C3}',
1305 "Adieresis" => '\u{00C4}',
1306 "Aring" => '\u{00C5}',
1307 "AE" => '\u{00C6}',
1308 "Ccedilla" => '\u{00C7}',
1309 "Egrave" => '\u{00C8}',
1310 "Eacute" => '\u{00C9}',
1311 "Ecircumflex" => '\u{00CA}',
1312 "Edieresis" => '\u{00CB}',
1313 "Igrave" => '\u{00CC}',
1314 "Iacute" => '\u{00CD}',
1315 "Icircumflex" => '\u{00CE}',
1316 "Idieresis" => '\u{00CF}',
1317 "Eth" => '\u{00D0}',
1318 "Ntilde" => '\u{00D1}',
1319 "Ograve" => '\u{00D2}',
1320 "Oacute" => '\u{00D3}',
1321 "Ocircumflex" => '\u{00D4}',
1322 "Otilde" => '\u{00D5}',
1323 "Odieresis" => '\u{00D6}',
1324 "multiply" => '\u{00D7}',
1325 "Oslash" => '\u{00D8}',
1326 "Ugrave" => '\u{00D9}',
1327 "Uacute" => '\u{00DA}',
1328 "Ucircumflex" => '\u{00DB}',
1329 "Udieresis" => '\u{00DC}',
1330 "Yacute" => '\u{00DD}',
1331 "Thorn" => '\u{00DE}',
1332 "germandbls" => '\u{00DF}',
1333 "agrave" => '\u{00E0}',
1334 "aacute" => '\u{00E1}',
1335 "acircumflex" => '\u{00E2}',
1336 "atilde" => '\u{00E3}',
1337 "adieresis" => '\u{00E4}',
1338 "aring" => '\u{00E5}',
1339 "ae" => '\u{00E6}',
1340 "ccedilla" => '\u{00E7}',
1341 "egrave" => '\u{00E8}',
1342 "eacute" => '\u{00E9}',
1343 "ecircumflex" => '\u{00EA}',
1344 "edieresis" => '\u{00EB}',
1345 "igrave" => '\u{00EC}',
1346 "iacute" => '\u{00ED}',
1347 "icircumflex" => '\u{00EE}',
1348 "idieresis" => '\u{00EF}',
1349 "eth" => '\u{00F0}',
1350 "ntilde" => '\u{00F1}',
1351 "ograve" => '\u{00F2}',
1352 "oacute" => '\u{00F3}',
1353 "ocircumflex" => '\u{00F4}',
1354 "otilde" => '\u{00F5}',
1355 "odieresis" => '\u{00F6}',
1356 "divide" => '\u{00F7}',
1357 "oslash" => '\u{00F8}',
1358 "ugrave" => '\u{00F9}',
1359 "uacute" => '\u{00FA}',
1360 "ucircumflex" => '\u{00FB}',
1361 "udieresis" => '\u{00FC}',
1362 "yacute" => '\u{00FD}',
1363 "thorn" => '\u{00FE}',
1364 "ydieresis" => '\u{00FF}',
1365 "OE" => '\u{0152}',
1367 "oe" => '\u{0153}',
1368 "Scaron" => '\u{0160}',
1369 "scaron" => '\u{0161}',
1370 "Ydieresis" => '\u{0178}',
1371 "Zcaron" => '\u{017D}',
1372 "zcaron" => '\u{017E}',
1373 "Lslash" => '\u{0141}',
1374 "lslash" => '\u{0142}',
1375 "Idot" | "Idotaccent" => '\u{0130}',
1376 "dotlessi" => '\u{0131}',
1377 "fi" => '\u{FB01}',
1378 "fl" => '\u{FB02}',
1379 "ff" => '\u{FB00}',
1380 "ffi" => '\u{FB03}',
1381 "ffl" => '\u{FB04}',
1382 _ => return None,
1383 };
1384 Some(mapped)
1385}
1386
1387fn winansi_byte_to_char(byte: u8) -> Option<char> {
1394 match byte {
1395 0x20..=0x7E => Some(byte as char),
1396 0x80 => Some('\u{20AC}'),
1397 0x82 => Some('\u{201A}'),
1398 0x83 => Some('\u{0192}'),
1399 0x84 => Some('\u{201E}'),
1400 0x85 => Some('\u{2026}'),
1401 0x86 => Some('\u{2020}'),
1402 0x87 => Some('\u{2021}'),
1403 0x88 => Some('\u{02C6}'),
1404 0x89 => Some('\u{2030}'),
1405 0x8A => Some('\u{0160}'),
1406 0x8B => Some('\u{2039}'),
1407 0x8C => Some('\u{0152}'),
1408 0x8E => Some('\u{017D}'),
1409 0x91 => Some('\u{2018}'),
1410 0x92 => Some('\u{2019}'),
1411 0x93 => Some('\u{201C}'),
1412 0x94 => Some('\u{201D}'),
1413 0x95 => Some('\u{2022}'),
1414 0x96 => Some('\u{2013}'),
1415 0x97 => Some('\u{2014}'),
1416 0x98 => Some('\u{02DC}'),
1417 0x99 => Some('\u{2122}'),
1418 0x9A => Some('\u{0161}'),
1419 0x9B => Some('\u{203A}'),
1420 0x9C => Some('\u{0153}'),
1421 0x9E => Some('\u{017E}'),
1422 0x9F => Some('\u{0178}'),
1423 0xA0..=0xFF => Some(byte as char),
1424 _ => None,
1425 }
1426}
1427
1428fn standard_byte_to_char(byte: u8) -> Option<char> {
1436 match byte {
1437 0x27 => Some('\u{2019}'),
1438 0x60 => Some('\u{2018}'),
1439 0x20..=0x7E => Some(byte as char),
1440 0xA1 => Some('\u{00A1}'),
1441 0xA2 => Some('\u{00A2}'),
1442 0xA3 => Some('\u{00A3}'),
1443 0xA4 => Some('\u{2044}'),
1444 0xA5 => Some('\u{00A5}'),
1445 0xA6 => Some('\u{0192}'),
1446 0xA7 => Some('\u{00A7}'),
1447 0xA8 => Some('\u{00A4}'),
1448 0xA9 => Some('\u{0027}'),
1449 0xAA => Some('\u{201C}'),
1450 0xAB => Some('\u{00AB}'),
1451 0xAC => Some('\u{2039}'),
1452 0xAD => Some('\u{203A}'),
1453 0xAE => Some('\u{FB01}'),
1454 0xAF => Some('\u{FB02}'),
1455 0xB1 => Some('\u{2013}'),
1456 0xB2 => Some('\u{2020}'),
1457 0xB3 => Some('\u{2021}'),
1458 0xB4 => Some('\u{00B7}'),
1459 0xB6 => Some('\u{00B6}'),
1460 0xB7 => Some('\u{2022}'),
1461 0xB8 => Some('\u{201A}'),
1462 0xB9 => Some('\u{201E}'),
1463 0xBA => Some('\u{201D}'),
1464 0xBB => Some('\u{00BB}'),
1465 0xBC => Some('\u{2026}'),
1466 0xBD => Some('\u{2030}'),
1467 0xBF => Some('\u{00BF}'),
1468 0xC1 => Some('\u{0060}'),
1469 0xC2 => Some('\u{00B4}'),
1470 0xC3 => Some('\u{02C6}'),
1471 0xC4 => Some('\u{02DC}'),
1472 0xC5 => Some('\u{00AF}'),
1473 0xC6 => Some('\u{02D8}'),
1474 0xC7 => Some('\u{02D9}'),
1475 0xC8 => Some('\u{00A8}'),
1476 0xCA => Some('\u{02DA}'),
1477 0xCB => Some('\u{00B8}'),
1478 0xCD => Some('\u{02DD}'),
1479 0xCE => Some('\u{02DB}'),
1480 0xCF => Some('\u{02C7}'),
1481 0xE1 => Some('\u{00C6}'),
1482 0xE3 => Some('\u{00AA}'),
1483 0xE8 => Some('\u{0141}'),
1484 0xE9 => Some('\u{00D8}'),
1485 0xEA => Some('\u{0152}'),
1486 0xEB => Some('\u{00BA}'),
1487 0xF1 => Some('\u{00E6}'),
1488 0xF5 => Some('\u{0131}'),
1489 0xF8 => Some('\u{0142}'),
1490 0xF9 => Some('\u{00F8}'),
1491 0xFA => Some('\u{0153}'),
1492 0xFB => Some('\u{00DF}'),
1493 _ => None,
1494 }
1495}
1496
1497fn mac_roman_byte_to_char(byte: u8) -> Option<char> {
1505 match byte {
1506 0x20..=0x7E => Some(byte as char),
1507 0x80 => Some('\u{00C4}'), 0x81 => Some('\u{00C5}'), 0x82 => Some('\u{00C7}'), 0x83 => Some('\u{00C9}'), 0x84 => Some('\u{00D1}'), 0x85 => Some('\u{00D6}'), 0x86 => Some('\u{00DC}'), 0x87 => Some('\u{00E1}'), 0x88 => Some('\u{00E0}'), 0x89 => Some('\u{00E2}'), 0x8A => Some('\u{00E4}'), 0x8B => Some('\u{00E3}'), 0x8C => Some('\u{00E5}'), 0x8D => Some('\u{00E7}'), 0x8E => Some('\u{00E9}'), 0x8F => Some('\u{00E8}'), 0x90 => Some('\u{00EA}'), 0x91 => Some('\u{00EB}'), 0x92 => Some('\u{00ED}'), 0x93 => Some('\u{00EC}'), 0x94 => Some('\u{00EE}'), 0x95 => Some('\u{00EF}'), 0x96 => Some('\u{00F1}'), 0x97 => Some('\u{00F3}'), 0x98 => Some('\u{00F2}'), 0x99 => Some('\u{00F4}'), 0x9A => Some('\u{00F6}'), 0x9B => Some('\u{00F5}'), 0x9C => Some('\u{00FA}'), 0x9D => Some('\u{00F9}'), 0x9E => Some('\u{00FB}'), 0x9F => Some('\u{00FC}'), 0xA0 => Some('\u{2020}'), 0xA1 => Some('\u{00B0}'), 0xA2 => Some('\u{00A2}'), 0xA3 => Some('\u{00A3}'), 0xA4 => Some('\u{00A7}'), 0xA5 => Some('\u{2022}'), 0xA6 => Some('\u{00B6}'), 0xA7 => Some('\u{00DF}'), 0xA8 => Some('\u{00AE}'), 0xA9 => Some('\u{00A9}'), 0xAA => Some('\u{2122}'), 0xAB => Some('\u{00B4}'), 0xAC => Some('\u{00A8}'), 0xAD => Some('\u{2260}'), 0xAE => Some('\u{00C6}'), 0xAF => Some('\u{00D8}'), 0xB0 => Some('\u{221E}'), 0xB1 => Some('\u{00B1}'), 0xB2 => Some('\u{2264}'), 0xB3 => Some('\u{2265}'), 0xB4 => Some('\u{00A5}'), 0xB5 => Some('\u{00B5}'), 0xB6 => Some('\u{2202}'), 0xB7 => Some('\u{2211}'), 0xB8 => Some('\u{220F}'), 0xB9 => Some('\u{03C0}'), 0xBA => Some('\u{222B}'), 0xBB => Some('\u{00AA}'), 0xBC => Some('\u{00BA}'), 0xBD => Some('\u{03A9}'), 0xBE => Some('\u{00E6}'), 0xBF => Some('\u{00F8}'), 0xC0 => Some('\u{00BF}'), 0xC1 => Some('\u{00A1}'), 0xC2 => Some('\u{00AC}'), 0xC3 => Some('\u{221A}'), 0xC4 => Some('\u{0192}'), 0xC5 => Some('\u{2248}'), 0xC6 => Some('\u{2206}'), 0xC7 => Some('\u{00AB}'), 0xC8 => Some('\u{00BB}'), 0xC9 => Some('\u{2026}'), 0xCA => Some('\u{00A0}'), 0xCB => Some('\u{00C0}'), 0xCC => Some('\u{00C3}'), 0xCD => Some('\u{00D5}'), 0xCE => Some('\u{0152}'), 0xCF => Some('\u{0153}'), 0xD0 => Some('\u{2013}'), 0xD1 => Some('\u{2014}'), 0xD2 => Some('\u{201C}'), 0xD3 => Some('\u{201D}'), 0xD4 => Some('\u{2018}'), 0xD5 => Some('\u{2019}'), 0xD6 => Some('\u{00F7}'), 0xD7 => Some('\u{25CA}'), 0xD8 => Some('\u{00FF}'), 0xD9 => Some('\u{0178}'), 0xDA => Some('\u{2044}'), 0xDB => Some('\u{20AC}'), 0xDC => Some('\u{2039}'), 0xDD => Some('\u{203A}'), 0xDE => Some('\u{FB01}'), 0xDF => Some('\u{FB02}'), 0xE0 => Some('\u{2021}'), 0xE1 => Some('\u{00B7}'), 0xE2 => Some('\u{201A}'), 0xE3 => Some('\u{201E}'), 0xE4 => Some('\u{2030}'), 0xE5 => Some('\u{00C2}'), 0xE6 => Some('\u{00CA}'), 0xE7 => Some('\u{00C1}'), 0xE8 => Some('\u{00CB}'), 0xE9 => Some('\u{00C8}'), 0xEA => Some('\u{00CD}'), 0xEB => Some('\u{00CE}'), 0xEC => Some('\u{00CF}'), 0xED => Some('\u{00CC}'), 0xEE => Some('\u{00D3}'), 0xEF => Some('\u{00D4}'), 0xF0 => Some('\u{F8FF}'), 0xF1 => Some('\u{00D2}'), 0xF2 => Some('\u{00DA}'), 0xF3 => Some('\u{00DB}'), 0xF4 => Some('\u{00D9}'), 0xF5 => Some('\u{0131}'), 0xF6 => Some('\u{02C6}'), 0xF7 => Some('\u{02DC}'), 0xF8 => Some('\u{00AF}'), 0xF9 => Some('\u{02D8}'), 0xFA => Some('\u{02D9}'), 0xFB => Some('\u{02DA}'), 0xFC => Some('\u{00B8}'), 0xFD => Some('\u{02DD}'), 0xFE => Some('\u{02DB}'), 0xFF => Some('\u{02C7}'), _ => None,
1636 }
1637}
1638
1639fn parse_simple_encoding(file: &PdfFile, font_dict: &pdf_objects::PdfDictionary) -> SimpleEncoding {
1640 match font_dict.get("Encoding") {
1641 Some(PdfValue::Name(name)) => SimpleEncoding {
1642 base: simple_encoding_base_from_name(name),
1643 differences: BTreeMap::new(),
1644 },
1645 Some(value @ PdfValue::Reference(_)) | Some(value @ PdfValue::Dictionary(_)) => {
1646 match file.resolve_dict(value) {
1647 Ok(dict) => {
1648 let base = dict
1649 .get("BaseEncoding")
1650 .and_then(PdfValue::as_name)
1651 .map(simple_encoding_base_from_name)
1652 .unwrap_or_default();
1653 let differences = dict
1654 .get("Differences")
1655 .and_then(PdfValue::as_array)
1656 .map(parse_differences_array)
1657 .unwrap_or_default();
1658 SimpleEncoding { base, differences }
1659 }
1660 Err(_) => SimpleEncoding::default(),
1661 }
1662 }
1663 _ => SimpleEncoding::default(),
1664 }
1665}
1666
1667fn simple_encoding_base_from_name(name: &str) -> SimpleEncodingBase {
1668 match name {
1669 "WinAnsiEncoding" => SimpleEncodingBase::WinAnsi,
1670 "MacRomanEncoding" => SimpleEncodingBase::MacRoman,
1671 "StandardEncoding" => SimpleEncodingBase::Standard,
1672 _ => SimpleEncodingBase::Identity,
1673 }
1674}
1675
1676fn parse_differences_array(entries: &[PdfValue]) -> BTreeMap<u8, String> {
1677 let mut output = BTreeMap::new();
1678 let mut code: u16 = 0;
1679 let mut have_code = false;
1680 for entry in entries {
1681 match entry {
1682 PdfValue::Integer(value) => {
1683 if *value >= 0 && *value <= 255 {
1684 code = *value as u16;
1685 have_code = true;
1686 } else {
1687 have_code = false;
1688 }
1689 }
1690 PdfValue::Number(value) => {
1691 let rounded = value.round() as i64;
1692 if (0..=255).contains(&rounded) {
1693 code = rounded as u16;
1694 have_code = true;
1695 } else {
1696 have_code = false;
1697 }
1698 }
1699 PdfValue::Name(name) => {
1700 if have_code && code <= 255 {
1701 output.insert(code as u8, name.clone());
1702 code += 1;
1703 if code > 255 {
1704 have_code = false;
1705 }
1706 }
1707 }
1708 _ => {}
1709 }
1710 }
1711 output
1712}
1713
1714fn decode_font_glyphs(font: &LoadedFont, bytes: &[u8]) -> PdfResult<Vec<DecodedGlyph>> {
1715 match font {
1716 LoadedFont::Simple(font) => Ok(bytes
1717 .iter()
1718 .copied()
1719 .enumerate()
1720 .map(|(byte_index, byte)| {
1721 let width_units = font
1722 .widths
1723 .get(u16::from(byte).saturating_sub(font.first_char) as usize)
1724 .copied()
1725 .unwrap_or(600.0);
1726 DecodedGlyph {
1727 text: decode_simple_byte(font, byte),
1728 width_units,
1729 byte_start: byte_index,
1730 byte_end: byte_index + 1,
1731 }
1732 })
1733 .collect()),
1734 LoadedFont::Composite(font) => decode_composite_glyphs(font, bytes),
1735 }
1736}
1737
1738fn reject_unsupported_widths(
1747 encoding: CompositeEncoding,
1748 widths: &BTreeMap<u16, f64>,
1749) -> PdfResult<()> {
1750 if matches!(encoding, CompositeEncoding::IdentityH) {
1751 return Ok(());
1752 }
1753 if !widths.is_empty() {
1754 return Err(PdfError::Unsupported(
1755 "Type0 font with a predefined Unicode CMap and a /W width array is not supported \
1756 (per-CID widths cannot be resolved without real CID computation)"
1757 .to_string(),
1758 ));
1759 }
1760 Ok(())
1761}
1762
1763fn composite_encoding_from_name(name: &str) -> PdfResult<CompositeEncoding> {
1768 match name {
1769 "Identity-H" => Ok(CompositeEncoding::IdentityH),
1770 "UniGB-UCS2-H" | "UniKS-UCS2-H" => Ok(CompositeEncoding::Ucs2H),
1771 "UniJIS-UTF16-H" | "UniCNS-UTF16-H" => Ok(CompositeEncoding::Utf16H),
1772 other => Err(PdfError::Unsupported(format!(
1773 "Type0 font encoding {other} is not supported"
1774 ))),
1775 }
1776}
1777
1778fn load_composite_font(
1779 file: &PdfFile,
1780 font_dict: &pdf_objects::PdfDictionary,
1781) -> PdfResult<CompositeFont> {
1782 let encoding_name = font_dict
1783 .get("Encoding")
1784 .and_then(PdfValue::as_name)
1785 .unwrap_or("Identity-H");
1786 let encoding = composite_encoding_from_name(encoding_name)?;
1787
1788 let descendant = font_dict
1789 .get("DescendantFonts")
1790 .and_then(PdfValue::as_array)
1791 .and_then(|fonts| fonts.first())
1792 .ok_or_else(|| PdfError::Corrupt("Type0 font is missing DescendantFonts".to_string()))?;
1793 let descendant_dict = file.resolve_dict(descendant)?;
1794 let descendant_subtype = descendant_dict
1795 .get("Subtype")
1796 .and_then(PdfValue::as_name)
1797 .unwrap_or("");
1798 if !matches!(descendant_subtype, "CIDFontType0" | "CIDFontType2") {
1799 return Err(PdfError::Unsupported(format!(
1800 "descendant font subtype {descendant_subtype} is not supported"
1801 )));
1802 }
1803
1804 let default_width = descendant_dict
1805 .get("DW")
1806 .and_then(PdfValue::as_number)
1807 .unwrap_or(1000.0);
1808 let widths = descendant_dict
1809 .get("W")
1810 .map(parse_cid_widths)
1811 .transpose()?
1812 .unwrap_or_default();
1813 reject_unsupported_widths(encoding, &widths)?;
1814 let unicode_map = load_to_unicode_map(file, font_dict)?;
1815
1816 Ok(CompositeFont {
1817 encoding,
1818 default_width,
1819 widths,
1820 unicode_map,
1821 })
1822}
1823
1824fn parse_cid_widths(value: &PdfValue) -> PdfResult<BTreeMap<u16, f64>> {
1825 let array = value
1826 .as_array()
1827 .ok_or_else(|| PdfError::Corrupt("CID font W entry must be an array".to_string()))?;
1828 let mut widths = BTreeMap::new();
1829 let mut index = 0usize;
1830 while index < array.len() {
1831 let start_cid = array[index]
1832 .as_integer()
1833 .ok_or_else(|| PdfError::Corrupt("CID width entry is invalid".to_string()))?
1834 as u16;
1835 let next = array
1836 .get(index + 1)
1837 .ok_or_else(|| PdfError::Corrupt("CID width entry is truncated".to_string()))?;
1838 if let Some(width_array) = next.as_array() {
1839 for (offset, width) in width_array.iter().enumerate() {
1840 let cid = start_cid
1841 .checked_add(offset as u16)
1842 .ok_or_else(|| PdfError::Corrupt("CID width index overflow".to_string()))?;
1843 widths.insert(
1844 cid,
1845 width.as_number().ok_or_else(|| {
1846 PdfError::Corrupt("CID width array contains a non-number".to_string())
1847 })?,
1848 );
1849 }
1850 index += 2;
1851 } else {
1852 let end_cid = next
1853 .as_integer()
1854 .ok_or_else(|| PdfError::Corrupt("CID width range is invalid".to_string()))?
1855 as u16;
1856 let width = array
1857 .get(index + 2)
1858 .and_then(PdfValue::as_number)
1859 .ok_or_else(|| PdfError::Corrupt("CID width range is truncated".to_string()))?;
1860 for cid in start_cid..=end_cid {
1861 widths.insert(cid, width);
1862 }
1863 index += 3;
1864 }
1865 }
1866 Ok(widths)
1867}
1868
1869fn load_to_unicode_map(
1870 file: &PdfFile,
1871 font_dict: &pdf_objects::PdfDictionary,
1872) -> PdfResult<BTreeMap<u16, String>> {
1873 let Some(to_unicode_value) = font_dict.get("ToUnicode") else {
1874 return Ok(BTreeMap::new());
1875 };
1876 let to_unicode_ref = match to_unicode_value {
1877 PdfValue::Reference(reference) => *reference,
1878 _ => {
1879 return Err(PdfError::Unsupported(
1880 "direct ToUnicode streams are not supported".to_string(),
1881 ));
1882 }
1883 };
1884 let stream = get_stream(file, to_unicode_ref)?;
1885 let decoded = decode_stream(stream)?;
1886 parse_to_unicode_cmap(&decoded)
1887}
1888
1889fn parse_to_unicode_cmap(data: &[u8]) -> PdfResult<BTreeMap<u16, String>> {
1890 let text = String::from_utf8_lossy(data);
1891 let mut mapping = BTreeMap::new();
1892 enum Mode {
1893 BfChar,
1894 BfRange,
1895 }
1896 let mut mode = None;
1897 for raw_line in text.lines() {
1898 let line = raw_line.trim();
1899 if line.is_empty() {
1900 continue;
1901 }
1902 if line.ends_with("beginbfchar") {
1903 mode = Some(Mode::BfChar);
1904 continue;
1905 }
1906 if line.ends_with("endbfchar") {
1907 mode = None;
1908 continue;
1909 }
1910 if line.ends_with("beginbfrange") {
1911 mode = Some(Mode::BfRange);
1912 continue;
1913 }
1914 if line.ends_with("endbfrange") {
1915 mode = None;
1916 continue;
1917 }
1918 match mode {
1919 Some(Mode::BfChar) => parse_bfchar_line(line, &mut mapping)?,
1920 Some(Mode::BfRange) => parse_bfrange_line(line, &mut mapping)?,
1921 None => {}
1922 }
1923 }
1924 Ok(mapping)
1925}
1926
1927fn parse_bfchar_line(line: &str, mapping: &mut BTreeMap<u16, String>) -> PdfResult<()> {
1928 let tokens = extract_hex_tokens(line);
1929 if tokens.len() < 2 {
1930 return Ok(());
1931 }
1932 let Ok(cid) = parse_cid_token(&tokens[0]) else {
1938 return Ok(());
1939 };
1940 mapping.insert(cid, decode_utf16be_lossy(&tokens[1]));
1941 Ok(())
1942}
1943
1944fn parse_bfrange_line(line: &str, mapping: &mut BTreeMap<u16, String>) -> PdfResult<()> {
1945 let tokens = extract_hex_tokens(line);
1946 if tokens.len() < 3 {
1947 return Ok(());
1948 }
1949 let (Ok(start), Ok(end)) = (parse_cid_token(&tokens[0]), parse_cid_token(&tokens[1])) else {
1952 return Ok(());
1953 };
1954 if line.contains('[') {
1955 for (offset, destination) in tokens.iter().skip(2).enumerate() {
1956 let cid = start.saturating_add(offset as u16);
1957 if cid > end {
1958 break;
1959 }
1960 mapping.insert(cid, decode_utf16be_lossy(destination));
1961 }
1962 return Ok(());
1963 }
1964
1965 let base = parse_unicode_scalar(&tokens[2])?;
1966 for cid in start..=end {
1967 let scalar = base + u32::from(cid - start);
1968 mapping.insert(
1969 cid,
1970 char::from_u32(scalar).unwrap_or('\u{FFFD}').to_string(),
1971 );
1972 }
1973 Ok(())
1974}
1975
1976fn extract_hex_tokens(line: &str) -> Vec<Vec<u8>> {
1977 let mut tokens = Vec::new();
1978 let mut current = String::new();
1979 let mut in_hex = false;
1980 for character in line.chars() {
1981 match character {
1982 '<' => {
1983 current.clear();
1984 in_hex = true;
1985 }
1986 '>' if in_hex => {
1987 if let Ok(bytes) = parse_hex_string_token(¤t) {
1988 tokens.push(bytes);
1989 }
1990 in_hex = false;
1991 }
1992 _ if in_hex => current.push(character),
1993 _ => {}
1994 }
1995 }
1996 tokens
1997}
1998
1999fn parse_hex_string_token(token: &str) -> PdfResult<Vec<u8>> {
2000 let filtered = token
2001 .chars()
2002 .filter(|character| !character.is_whitespace())
2003 .collect::<String>();
2004 let mut chars = filtered.chars().collect::<Vec<_>>();
2005 if chars.len() % 2 != 0 {
2006 chars.push('0');
2007 }
2008 let mut bytes = Vec::with_capacity(chars.len() / 2);
2009 for pair in chars.chunks(2) {
2010 let byte = u8::from_str_radix(&pair.iter().collect::<String>(), 16)
2011 .map_err(|_| PdfError::Corrupt("invalid ToUnicode hex token".to_string()))?;
2012 bytes.push(byte);
2013 }
2014 Ok(bytes)
2015}
2016
2017fn parse_cid_token(bytes: &[u8]) -> PdfResult<u16> {
2018 match bytes {
2019 [single] => Ok(u16::from(*single)),
2020 [high, low] => Ok(u16::from_be_bytes([*high, *low])),
2021 _ => Err(PdfError::Unsupported(
2022 "only one-byte and two-byte CIDs are supported".to_string(),
2023 )),
2024 }
2025}
2026
2027fn parse_unicode_scalar(bytes: &[u8]) -> PdfResult<u32> {
2028 match bytes {
2029 [high, low] => Ok(u32::from(u16::from_be_bytes([*high, *low]))),
2030 [0, 0, high, low] => Ok(u32::from(u16::from_be_bytes([*high, *low]))),
2031 _ => Err(PdfError::Unsupported(
2032 "sequential ToUnicode ranges must use a single UTF-16 code unit".to_string(),
2033 )),
2034 }
2035}
2036
2037fn decode_utf16be_lossy(bytes: &[u8]) -> String {
2038 let mut units = Vec::new();
2039 let mut index = 0usize;
2040 while index < bytes.len() {
2041 let high = bytes[index];
2042 let low = *bytes.get(index + 1).unwrap_or(&0);
2043 units.push(u16::from_be_bytes([high, low]));
2044 index += 2;
2045 }
2046 String::from_utf16_lossy(&units)
2047}
2048
2049fn decode_composite_glyphs(font: &CompositeFont, bytes: &[u8]) -> PdfResult<Vec<DecodedGlyph>> {
2050 if bytes.len() % 2 != 0 {
2051 return Err(PdfError::Corrupt(
2052 "composite font string must contain an even number of bytes".to_string(),
2053 ));
2054 }
2055 let mut glyphs = Vec::new();
2056 let mut byte_index = 0usize;
2057 while byte_index < bytes.len() {
2058 match font.encoding {
2059 CompositeEncoding::IdentityH => {
2060 let cid = u16::from_be_bytes([bytes[byte_index], bytes[byte_index + 1]]);
2061 let text = font
2062 .unicode_map
2063 .get(&cid)
2064 .cloned()
2065 .unwrap_or_else(|| decode_fallback_cid(cid));
2066 let width_units = font.widths.get(&cid).copied().unwrap_or(font.default_width);
2067 glyphs.push(DecodedGlyph {
2068 text,
2069 width_units,
2070 byte_start: byte_index,
2071 byte_end: byte_index + 2,
2072 });
2073 byte_index += 2;
2074 }
2075 CompositeEncoding::Ucs2H => {
2076 let code = u16::from_be_bytes([bytes[byte_index], bytes[byte_index + 1]]);
2077 let text = font.unicode_map.get(&code).cloned().unwrap_or_else(|| {
2079 if (0xD800..=0xDFFF).contains(&code) {
2080 '\u{FFFD}'.to_string()
2082 } else {
2083 char::from_u32(u32::from(code))
2084 .unwrap_or('\u{FFFD}')
2085 .to_string()
2086 }
2087 });
2088 glyphs.push(DecodedGlyph {
2089 text,
2090 width_units: font.default_width,
2091 byte_start: byte_index,
2092 byte_end: byte_index + 2,
2093 });
2094 byte_index += 2;
2095 }
2096 CompositeEncoding::Utf16H => {
2097 let code = u16::from_be_bytes([bytes[byte_index], bytes[byte_index + 1]]);
2098 if (0xD800..=0xDBFF).contains(&code) {
2099 if byte_index + 4 > bytes.len() {
2101 return Err(PdfError::Corrupt(
2102 "truncated UTF-16 surrogate pair in composite font string".to_string(),
2103 ));
2104 }
2105 let low = u16::from_be_bytes([bytes[byte_index + 2], bytes[byte_index + 3]]);
2106 if !(0xDC00..=0xDFFF).contains(&low) {
2107 return Err(PdfError::Corrupt(
2108 "invalid UTF-16 surrogate pair in composite font string".to_string(),
2109 ));
2110 }
2111 let scalar =
2112 0x10000u32 + ((u32::from(code) - 0xD800) << 10) + (u32::from(low) - 0xDC00);
2113 let text = char::from_u32(scalar).unwrap_or('\u{FFFD}').to_string();
2114 glyphs.push(DecodedGlyph {
2115 text,
2116 width_units: font.default_width,
2117 byte_start: byte_index,
2118 byte_end: byte_index + 4,
2119 });
2120 byte_index += 4;
2121 } else if (0xDC00..=0xDFFF).contains(&code) {
2122 glyphs.push(DecodedGlyph {
2124 text: '\u{FFFD}'.to_string(),
2125 width_units: font.default_width,
2126 byte_start: byte_index,
2127 byte_end: byte_index + 2,
2128 });
2129 byte_index += 2;
2130 } else {
2131 let text = font.unicode_map.get(&code).cloned().unwrap_or_else(|| {
2132 char::from_u32(u32::from(code))
2133 .unwrap_or('\u{FFFD}')
2134 .to_string()
2135 });
2136 glyphs.push(DecodedGlyph {
2137 text,
2138 width_units: font.default_width,
2139 byte_start: byte_index,
2140 byte_end: byte_index + 2,
2141 });
2142 byte_index += 2;
2143 }
2144 }
2145 }
2146 }
2147 Ok(glyphs)
2148}
2149
2150fn decode_fallback_cid(cid: u16) -> String {
2151 if cid <= 0x7f {
2152 char::from_u32(u32::from(cid))
2153 .unwrap_or('\u{FFFD}')
2154 .to_string()
2155 } else {
2156 '\u{FFFD}'.to_string()
2157 }
2158}
2159
2160fn matrix_from_operands(operands: &[PdfValue]) -> PdfResult<Matrix> {
2161 if operands.len() != 6 {
2162 return Err(PdfError::Corrupt(
2163 "matrix operator expects six operands".to_string(),
2164 ));
2165 }
2166 Ok(Matrix {
2167 a: operands[0]
2168 .as_number()
2169 .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2170 b: operands[1]
2171 .as_number()
2172 .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2173 c: operands[2]
2174 .as_number()
2175 .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2176 d: operands[3]
2177 .as_number()
2178 .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2179 e: operands[4]
2180 .as_number()
2181 .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2182 f: operands[5]
2183 .as_number()
2184 .ok_or_else(|| PdfError::Corrupt("matrix operand is not numeric".to_string()))?,
2185 })
2186}
2187
2188fn operand_name(operation: &Operation, index: usize) -> PdfResult<&str> {
2189 operation
2190 .operands
2191 .get(index)
2192 .and_then(PdfValue::as_name)
2193 .ok_or_else(|| PdfError::Corrupt(format!("operand {index} is not a name")))
2194}
2195
2196fn operand_number(operation: &Operation, index: usize) -> PdfResult<f64> {
2197 operation
2198 .operands
2199 .get(index)
2200 .and_then(PdfValue::as_number)
2201 .ok_or_else(|| PdfError::Corrupt(format!("operand {index} is not numeric")))
2202}
2203
2204fn operand_string(operation: &Operation, index: usize) -> PdfResult<&pdf_objects::PdfString> {
2205 match operation.operands.get(index) {
2206 Some(PdfValue::String(string)) => Ok(string),
2207 _ => Err(PdfError::Corrupt(format!(
2208 "operand {index} is not a string"
2209 ))),
2210 }
2211}
2212
2213#[cfg(test)]
2214mod tests {
2215 use super::{
2216 CompositeEncoding, CompositeFont, DecodedGlyph, ExtractedPageText, Glyph,
2217 build_search_index, coalesce_match_quads, decode_composite_glyphs, search_page_text,
2218 };
2219 use pdf_graphics::{Point, Quad, Rect};
2220 use std::collections::BTreeMap;
2221
2222 fn empty_composite(encoding: CompositeEncoding) -> CompositeFont {
2223 CompositeFont {
2224 encoding,
2225 default_width: 1000.0,
2226 widths: BTreeMap::new(),
2227 unicode_map: BTreeMap::new(),
2228 }
2229 }
2230
2231 fn glyph_text(g: &DecodedGlyph) -> &str {
2232 g.text.as_str()
2233 }
2234
2235 #[test]
2236 fn ucs2_h_decodes_bmp_codes_directly_to_unicode() {
2237 let font = empty_composite(CompositeEncoding::Ucs2H);
2239 let bytes = [0x4E, 0x2D, 0x65, 0x87];
2240 let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2241 assert_eq!(glyphs.len(), 2, "two glyphs");
2242 assert_eq!(glyph_text(&glyphs[0]), "中");
2243 assert_eq!(glyph_text(&glyphs[1]), "文");
2244 assert_eq!(glyphs[0].byte_start, 0);
2245 assert_eq!(glyphs[0].byte_end, 2);
2246 assert_eq!(glyphs[1].byte_start, 2);
2247 assert_eq!(glyphs[1].byte_end, 4);
2248 assert_eq!(glyphs[0].width_units, 1000.0);
2249 assert_eq!(glyphs[1].width_units, 1000.0);
2250 }
2251
2252 #[test]
2253 fn ucs2_h_emits_replacement_for_surrogate_halves() {
2254 let font = empty_composite(CompositeEncoding::Ucs2H);
2256 let bytes = [0xD8, 0x00];
2257 let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2258 assert_eq!(glyphs.len(), 1);
2259 assert_eq!(glyph_text(&glyphs[0]), "\u{FFFD}");
2260 }
2261
2262 #[test]
2263 fn ucs2_h_to_unicode_overrides_direct_decode() {
2264 let mut font = empty_composite(CompositeEncoding::Ucs2H);
2265 font.unicode_map.insert(0x4E2D, "Z".to_string());
2268 let bytes = [0x4E, 0x2D];
2269 let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2270 assert_eq!(glyph_text(&glyphs[0]), "Z");
2271 }
2272
2273 #[test]
2274 fn utf16_h_decodes_surrogate_pair_then_bmp() {
2275 let font = empty_composite(CompositeEncoding::Utf16H);
2277 let bytes = [0xD8, 0x40, 0xDC, 0x00, 0x4E, 0x2D];
2278 let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2279 assert_eq!(glyphs.len(), 2, "two glyphs");
2280 assert_eq!(glyph_text(&glyphs[0]), "\u{20000}");
2281 assert_eq!(glyphs[0].byte_start, 0);
2282 assert_eq!(glyphs[0].byte_end, 4);
2283 assert_eq!(glyph_text(&glyphs[1]), "中");
2284 assert_eq!(glyphs[1].byte_start, 4);
2285 assert_eq!(glyphs[1].byte_end, 6);
2286 assert_eq!(glyphs[0].width_units, 1000.0);
2287 assert_eq!(glyphs[1].width_units, 1000.0);
2288 }
2289
2290 #[test]
2291 fn utf16_h_orphan_low_surrogate_emits_replacement() {
2292 let font = empty_composite(CompositeEncoding::Utf16H);
2293 let bytes = [0xDC, 0x00];
2294 let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2295 assert_eq!(glyphs.len(), 1);
2296 assert_eq!(glyph_text(&glyphs[0]), "\u{FFFD}");
2297 assert_eq!(glyphs[0].byte_end - glyphs[0].byte_start, 2);
2298 }
2299
2300 #[test]
2301 fn utf16_h_truncated_surrogate_pair_is_corrupt() {
2302 let font = empty_composite(CompositeEncoding::Utf16H);
2303 let bytes = [0xD8, 0x40];
2305 let err = decode_composite_glyphs(&font, &bytes).expect_err("must fail");
2306 let msg = format!("{err:?}");
2307 assert!(
2308 msg.contains("truncated UTF-16 surrogate pair"),
2309 "unexpected error: {msg}"
2310 );
2311 }
2312
2313 #[test]
2314 fn composite_encoding_from_name_accepts_known_cmaps() {
2315 use super::composite_encoding_from_name;
2316 assert_eq!(
2317 composite_encoding_from_name("Identity-H").expect("identity-h"),
2318 CompositeEncoding::IdentityH
2319 );
2320 assert_eq!(
2321 composite_encoding_from_name("UniGB-UCS2-H").expect("gb"),
2322 CompositeEncoding::Ucs2H
2323 );
2324 assert_eq!(
2325 composite_encoding_from_name("UniKS-UCS2-H").expect("ks"),
2326 CompositeEncoding::Ucs2H
2327 );
2328 assert_eq!(
2329 composite_encoding_from_name("UniJIS-UTF16-H").expect("jis"),
2330 CompositeEncoding::Utf16H
2331 );
2332 assert_eq!(
2333 composite_encoding_from_name("UniCNS-UTF16-H").expect("cns"),
2334 CompositeEncoding::Utf16H
2335 );
2336 }
2337
2338 #[test]
2339 fn tounicode_skips_oversize_bfchar_keys_but_keeps_bmp_entries() {
2340 use super::parse_to_unicode_cmap;
2341 let cmap = b"/CIDInit /ProcSet findresource begin\n\
2345 12 dict begin\n\
2346 begincmap\n\
2347 2 beginbfchar\n\
2348 <D840DC00> <D840DC00>\n\
2349 <4E2D> <4E2D>\n\
2350 endbfchar\n\
2351 endcmap\n";
2352 let map = parse_to_unicode_cmap(cmap).expect("parse must succeed");
2353 assert_eq!(map.get(&0x4E2D).map(String::as_str), Some("中"));
2354 assert!(
2355 !map.contains_key(&0xD840),
2356 "surrogate-pair source must not leak into the BMP map"
2357 );
2358 }
2359
2360 #[test]
2361 fn tounicode_skips_oversize_bfrange_keys_but_keeps_bmp_entries() {
2362 use super::parse_to_unicode_cmap;
2363 let cmap = b"begincmap\n\
2364 2 beginbfrange\n\
2365 <D840DC00> <D840DC02> <D840DC00>\n\
2366 <4E2D> <4E2E> <4E2D>\n\
2367 endbfrange\n\
2368 endcmap\n";
2369 let map = parse_to_unicode_cmap(cmap).expect("parse must succeed");
2370 assert_eq!(map.get(&0x4E2D).map(String::as_str), Some("中"));
2371 assert_eq!(map.get(&0x4E2E).map(String::as_str), Some("丮"));
2372 assert!(!map.contains_key(&0xD840));
2373 }
2374
2375 #[test]
2376 fn predefined_cmap_without_widths_is_accepted() {
2377 use super::reject_unsupported_widths;
2378 reject_unsupported_widths(CompositeEncoding::IdentityH, &{
2380 let mut w = BTreeMap::new();
2381 w.insert(1, 1000.0);
2382 w.insert(2, 500.0);
2383 w
2384 })
2385 .expect("identity-h with /W is supported");
2386 reject_unsupported_widths(CompositeEncoding::Ucs2H, &BTreeMap::new())
2389 .expect("ucs2-h without /W is supported");
2390 reject_unsupported_widths(CompositeEncoding::Utf16H, &BTreeMap::new())
2391 .expect("utf16-h without /W is supported");
2392 }
2393
2394 #[test]
2395 fn predefined_cmap_with_any_widths_is_rejected() {
2396 use super::reject_unsupported_widths;
2397 let mut widths = BTreeMap::new();
2398 widths.insert(1, 1000.0);
2399 for encoding in [CompositeEncoding::Ucs2H, CompositeEncoding::Utf16H] {
2400 let err = reject_unsupported_widths(encoding, &widths)
2401 .expect_err("any /W must be rejected for predefined CMaps");
2402 let msg = format!("{err:?}");
2403 assert!(
2404 msg.contains("not supported"),
2405 "expected unsupported error for {encoding:?}, got: {msg}"
2406 );
2407 }
2408 reject_unsupported_widths(CompositeEncoding::IdentityH, &widths)
2410 .expect("identity-h still uses /W per CID");
2411 }
2412
2413 #[test]
2414 fn composite_encoding_from_name_rejects_vertical_and_unknown() {
2415 use super::composite_encoding_from_name;
2416 for name in [
2417 "Identity-V",
2418 "UniJIS-UTF16-V",
2419 "UniGB-UCS2-V",
2420 "90ms-RKSJ-H",
2421 "GBK-EUC-H",
2422 "",
2423 ] {
2424 let err = composite_encoding_from_name(name)
2425 .err()
2426 .unwrap_or_else(|| panic!("expected error for {name}"));
2427 let msg = format!("{err:?}");
2428 assert!(
2429 msg.contains("not supported"),
2430 "expected unsupported error for {name}, got: {msg}"
2431 );
2432 }
2433 }
2434
2435 #[test]
2436 fn utf16_h_to_unicode_consulted_for_bmp_only() {
2437 let mut font = empty_composite(CompositeEncoding::Utf16H);
2438 font.unicode_map.insert(0x4E2D, "Z".to_string());
2440 font.unicode_map.insert(0xD840, "WRONG".to_string());
2443 let bytes = [0xD8, 0x40, 0xDC, 0x00, 0x4E, 0x2D];
2444 let glyphs = decode_composite_glyphs(&font, &bytes).expect("decode");
2445 assert_eq!(
2446 glyph_text(&glyphs[0]),
2447 "\u{20000}",
2448 "SMP must skip ToUnicode"
2449 );
2450 assert_eq!(glyph_text(&glyphs[1]), "Z", "BMP must use ToUnicode");
2451 }
2452
2453 fn make_glyph(text: char, x: f64, y: f64) -> Glyph {
2454 make_glyph_with_height(text, x, y, 12.0)
2455 }
2456
2457 fn make_glyph_with_height(text: char, x: f64, y: f64, height: f64) -> Glyph {
2458 let rect = Rect {
2459 x,
2460 y,
2461 width: 8.0,
2462 height,
2463 };
2464 Glyph {
2465 text,
2466 bbox: rect,
2467 quad: rect.to_quad(),
2468 page_char_index: 0,
2469 operation_index: 0,
2470 location: super::GlyphLocation::Direct {
2471 operand_index: 0,
2472 byte_start: 0,
2473 byte_end: 1,
2474 },
2475 visible: true,
2476 width_units: 600.0,
2477 source_form: None,
2478 }
2479 }
2480
2481 #[test]
2482 fn search_handles_multibyte_utf8_characters() {
2483 let glyphs: Vec<Glyph> = "café and tea"
2486 .chars()
2487 .enumerate()
2488 .map(|(i, c)| make_glyph(c, i as f64 * 10.0, 100.0))
2489 .collect();
2490 let page = ExtractedPageText {
2491 page_index: 0,
2492 text: "café and tea".to_string(),
2493 items: Vec::new(),
2494 glyphs,
2495 };
2496 let matches = search_page_text(&page, "and");
2497 assert_eq!(matches.len(), 1, "should find exactly one 'and'");
2498 assert_eq!(
2499 matches[0].text, "and",
2500 "match text should be 'and', not a shifted substring"
2501 );
2502 }
2503
2504 #[test]
2505 fn search_index_byte_alignment() {
2506 let glyphs: Vec<Glyph> = "aé b"
2508 .chars()
2509 .enumerate()
2510 .map(|(i, c)| make_glyph(c, i as f64 * 10.0, 100.0))
2511 .collect();
2512 let page = ExtractedPageText {
2513 page_index: 0,
2514 text: "aé b".to_string(),
2515 items: Vec::new(),
2516 glyphs,
2517 };
2518 let index = build_search_index(&page);
2519 assert_eq!(
2520 index.normalized_to_display.len(),
2521 index.normalized_text.len(),
2522 "normalized_to_display should have one entry per byte of normalized_text"
2523 );
2524 }
2525
2526 #[test]
2527 fn coalesces_adjacent_glyph_quads_into_match_regions() {
2528 let quads = vec![
2529 Quad {
2530 points: [
2531 Point { x: 10.0, y: 20.0 },
2532 Point { x: 14.0, y: 20.0 },
2533 Point { x: 14.0, y: 30.0 },
2534 Point { x: 10.0, y: 30.0 },
2535 ],
2536 },
2537 Quad {
2538 points: [
2539 Point { x: 14.3, y: 20.0 },
2540 Point { x: 18.5, y: 20.0 },
2541 Point { x: 18.5, y: 30.0 },
2542 Point { x: 14.3, y: 30.0 },
2543 ],
2544 },
2545 Quad {
2546 points: [
2547 Point { x: 50.0, y: 5.0 },
2548 Point { x: 54.0, y: 5.0 },
2549 Point { x: 54.0, y: 15.0 },
2550 Point { x: 50.0, y: 15.0 },
2551 ],
2552 },
2553 ];
2554
2555 let merged = coalesce_match_quads(&quads);
2556 assert_eq!(merged.len(), 2);
2557 let first = merged[0].bounding_rect();
2558 assert_eq!(
2559 first.x, 10.0,
2560 "without padding, rect should start at exact glyph boundary"
2561 );
2562 assert_eq!(first.max_x(), 18.5, "first two glyphs should be merged");
2563 }
2564
2565 #[test]
2566 fn sub_pt_rows_are_not_merged_by_line_grouper() {
2567 let h = 4.8_f64;
2574 let row_a_y = 700.0 - 0.72;
2576 let row_b_y = 699.5 - 0.72;
2577 let row_c_y = 699.0 - 0.72;
2578 let glyphs = vec![
2579 make_glyph_with_height('A', 72.0, row_a_y, h),
2580 make_glyph_with_height('B', 72.0, row_b_y, h),
2581 make_glyph_with_height('C', 72.0, row_c_y, h),
2582 ];
2583 let page = ExtractedPageText {
2584 page_index: 0,
2585 text: "ABC".to_string(),
2586 items: Vec::new(),
2587 glyphs,
2588 };
2589 let matches_ab = search_page_text(&page, "AB");
2590 assert!(
2591 matches_ab.is_empty(),
2592 "rows A and B must be on separate lines (no 'AB' adjacency); got {:?}",
2593 matches_ab
2594 );
2595 for needle in ["A", "B", "C"] {
2596 let hits = search_page_text(&page, needle);
2597 assert_eq!(
2598 hits.len(),
2599 1,
2600 "row {needle} must yield exactly one match; got {hits:?}"
2601 );
2602 }
2603 }
2604
2605 #[test]
2606 fn mixed_font_same_baseline_glyphs_merge_into_one_line() {
2607 let glyphs = vec![
2614 make_glyph_with_height('H', 10.0, -1.2, 8.0),
2617 make_glyph_with_height('W', 18.0, -0.96, 6.4),
2622 ];
2623 let page = ExtractedPageText {
2624 page_index: 0,
2625 text: "HW".to_string(),
2626 items: Vec::new(),
2627 glyphs,
2628 };
2629 let matches = search_page_text(&page, "HW");
2630 assert_eq!(
2631 matches.len(),
2632 1,
2633 "10pt + 8pt same-baseline glyphs must share a visual line; got {matches:?}"
2634 );
2635 }
2636}