1use crate::font::Font;
5use crate::{Ruling, TextSpan};
6use pdfboss_core::content::{ContentOps, Op, TextItem};
7use pdfboss_core::{
8 content_stream_data_with, page_content_with, AsyncObjectSource, Dict, FastMap, Matrix, Name,
9 ObjRef, Object, OcState, Page, Point, Rect,
10};
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex};
13
14const MAX_FORM_DEPTH: usize = 16;
16
17const MAX_FORM_INVOCATIONS: usize = 4096;
21
22const RULING_AXIS_EPSILON: f32 = 0.5;
25
26const RULING_MIN_LENGTH: f32 = 8.0;
29
30const RULING_MAX_FILL_THICKNESS: f32 = 3.0;
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct ExtractReport {
40 pub skipped: Vec<SkippedText>,
42 pub hidden: u64,
49}
50
51impl ExtractReport {
52 pub fn is_complete(&self) -> bool {
57 self.skipped.is_empty()
58 }
59
60 fn record(&mut self, kind: SkippedTextKind, cause: SkipCause) {
61 self.skipped.push(SkippedText { kind, cause });
62 }
63}
64
65#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct SkippedText {
68 pub kind: SkippedTextKind,
69 pub cause: SkipCause,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum SkippedTextKind {
75 PageContents,
77 Form,
80 XObject,
83 FontEncoding,
88}
89
90impl std::fmt::Display for SkippedTextKind {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.write_str(match self {
93 SkippedTextKind::PageContents => "the page contents",
94 SkippedTextKind::Form => "a form XObject",
95 SkippedTextKind::XObject => "an XObject",
96 SkippedTextKind::FontEncoding => "a font's CMap encoding",
97 })
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum SkipCause {
104 UnsupportedFilter(String),
110 Unreadable,
112 Parse,
114 Missing,
116 LimitExceeded,
118}
119
120impl std::fmt::Display for SkipCause {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 match self {
123 SkipCause::UnsupportedFilter(name) => write!(f, "unsupported filter /{name}"),
124 SkipCause::Unreadable => f.write_str("stream would not read"),
125 SkipCause::Parse => f.write_str("content would not parse"),
126 SkipCause::Missing => f.write_str("missing resource"),
127 SkipCause::LimitExceeded => f.write_str("form limit exceeded"),
128 }
129 }
130}
131
132#[derive(Default)]
146pub struct FontCache {
147 fonts: Mutex<HashMap<ObjRef, Arc<Font>>>,
148}
149
150impl FontCache {
151 fn get(&self, r: ObjRef) -> Option<Arc<Font>> {
152 self.fonts.lock().unwrap().get(&r).cloned()
153 }
154
155 fn insert(&self, r: ObjRef, font: Arc<Font>) -> Arc<Font> {
159 self.fonts.lock().unwrap().entry(r).or_insert(font).clone()
160 }
161}
162
163fn cause_for(error: &pdfboss_core::Error) -> SkipCause {
166 match error {
167 pdfboss_core::Error::UnsupportedFilter(name) => SkipCause::UnsupportedFilter(name.clone()),
168 _ => SkipCause::Unreadable,
169 }
170}
171
172pub async fn page_spans_and_rulings_with<S: AsyncObjectSource>(
186 src: S,
187 page: &Page,
188 fonts: Option<&FontCache>,
189 oc: Option<&OcState>,
190) -> (Vec<TextSpan>, Vec<Ruling>, ExtractReport) {
191 let mut report = ExtractReport::default();
192 let content = match page_content_with(&src, page).await {
193 Ok(content) => content,
194 Err(e) => {
195 report.record(SkippedTextKind::PageContents, cause_for(&e));
196 Vec::new()
197 }
198 };
199 let mut exec = Executor {
200 src: &src,
201 spans: Vec::new(),
202 rulings: Vec::new(),
203 fallback: Arc::new(Font::fallback()),
204 forms: 0,
205 report,
206 loaded: HashMap::new(),
207 shared: fonts,
208 oc,
209 categories: FastMap::default(),
210 };
211 let root = Frame::new(
212 Arc::new(content),
213 vec![Arc::new(page.resources.clone())],
214 GState::new(),
215 0,
216 (0, 0),
217 );
218 exec.run(root).await;
219 let mut spans = exec.spans;
220 for span in &mut spans {
221 span.page = page.index;
222 }
223 let mut horizontals: Vec<&Ruling> = exec
228 .rulings
229 .iter()
230 .filter(|r| r.start.y == r.end.y)
231 .collect();
232 if !horizontals.is_empty() {
233 horizontals.sort_by(|a, b| a.start.y.total_cmp(&b.start.y));
234 for span in &mut spans {
235 mark_underline_and_strikethrough(span, &horizontals);
236 }
237 }
238 drop(horizontals);
239 (spans, exec.rulings, exec.report)
240}
241
242const UNDERLINE_BELOW: f32 = 0.3;
246const UNDERLINE_ABOVE: f32 = 0.05;
247
248const STRIKETHROUGH_LOW: f32 = 0.15;
251const STRIKETHROUGH_HIGH: f32 = 0.6;
252
253const DECORATED_MIN_OVERLAP: f32 = 0.6;
256
257fn mark_underline_and_strikethrough(span: &mut TextSpan, horizontals: &[&Ruling]) {
264 if span.vertical || span.size <= 0.0 {
265 return;
266 }
267 let width = span.bbox.x1 - span.bbox.x0;
268 if width <= 0.0 {
269 return;
270 }
271 let low = span.y - UNDERLINE_BELOW * span.size;
272 let high = span.y + STRIKETHROUGH_HIGH * span.size;
273 let first = horizontals.partition_point(|r| r.start.y < low);
274 for r in &horizontals[first..] {
275 if r.start.y > high {
276 break;
277 }
278 let overlap = r.end.x.min(span.bbox.x1) - r.start.x.max(span.bbox.x0);
279 if overlap < DECORATED_MIN_OVERLAP * width {
280 continue;
281 }
282 let above = r.start.y - span.y;
283 if above <= UNDERLINE_ABOVE * span.size {
284 span.underline = true;
285 }
286 if above >= STRIKETHROUGH_LOW * span.size {
287 span.strikethrough = true;
288 }
289 }
290}
291
292#[derive(Clone)]
295struct GState {
296 ctm: Matrix,
297 char_spacing: f32,
298 word_spacing: f32,
299 horiz_scale: f32,
301 leading: f32,
302 rise: f32,
303 font: Option<Arc<Font>>,
304 font_name: String,
305 size: f32,
306 render_mode: i32,
308 fill_color: Option<(f32, f32, f32)>,
310 line_width: f32,
312}
313
314impl GState {
315 fn new() -> GState {
316 GState {
317 ctm: Matrix::identity(),
318 char_spacing: 0.0,
319 word_spacing: 0.0,
320 horiz_scale: 1.0,
321 leading: 0.0,
322 rise: 0.0,
323 font: None,
324 font_name: String::new(),
325 size: 0.0,
326 render_mode: 0,
327 fill_color: Some((0.0, 0.0, 0.0)),
328 line_width: 1.0,
329 }
330 }
331}
332
333fn components_color(comps: &[f32]) -> Option<(f32, f32, f32)> {
337 let c = |v: f32| {
338 if v.is_finite() {
339 v.clamp(0.0, 1.0)
340 } else {
341 0.0
342 }
343 };
344 match comps {
345 [v] => Some((c(*v), c(*v), c(*v))),
346 [r, g, b] => Some((c(*r), c(*g), c(*b))),
347 [cy, m, y, k] => Some((
348 (1.0 - c(*cy)) * (1.0 - c(*k)),
349 (1.0 - c(*m)) * (1.0 - c(*k)),
350 (1.0 - c(*y)) * (1.0 - c(*k)),
351 )),
352 _ => None,
353 }
354}
355
356fn finite(m: &Matrix) -> bool {
358 [m.a, m.b, m.c, m.d, m.e, m.f].iter().all(|v| v.is_finite())
359}
360
361fn ctm_scale(m: &Matrix) -> f32 {
364 let det = (m.a * m.d - m.b * m.c).abs();
365 if det.is_finite() && det > 0.0 {
366 return det.sqrt();
367 }
368 1.0
369}
370
371fn ruling_from_segment(a: Point, b: Point, width: f32) -> Option<Ruling> {
374 if [a.x, a.y, b.x, b.y, width].iter().any(|v| !v.is_finite()) {
375 return None;
376 }
377 let dx = (b.x - a.x).abs();
378 let dy = (b.y - a.y).abs();
379 if dy <= RULING_AXIS_EPSILON && dx >= RULING_MIN_LENGTH {
380 let y = (a.y + b.y) / 2.0;
381 return Some(Ruling {
382 start: Point::new(a.x.min(b.x), y),
383 end: Point::new(a.x.max(b.x), y),
384 width,
385 });
386 }
387 if dx <= RULING_AXIS_EPSILON && dy >= RULING_MIN_LENGTH {
388 let x = (a.x + b.x) / 2.0;
389 return Some(Ruling {
390 start: Point::new(x, a.y.min(b.y)),
391 end: Point::new(x, a.y.max(b.y)),
392 width,
393 });
394 }
395 None
396}
397
398fn filled_rect_ruling(device: &[Point]) -> Option<Ruling> {
403 let corners = match device {
404 [a, b, c, d] => [*a, *b, *c, *d],
405 [a, b, c, d, e]
406 if (e.x - a.x).abs() <= RULING_AXIS_EPSILON
407 && (e.y - a.y).abs() <= RULING_AXIS_EPSILON =>
408 {
409 [*a, *b, *c, *d]
410 }
411 _ => return None,
412 };
413 if corners.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
414 return None;
415 }
416 let axis_aligned = |a: Point, b: Point| {
417 (b.x - a.x).abs() <= RULING_AXIS_EPSILON || (b.y - a.y).abs() <= RULING_AXIS_EPSILON
418 };
419 for i in 0..4 {
420 if !axis_aligned(corners[i], corners[(i + 1) % 4]) {
421 return None;
422 }
423 }
424 let x0 = corners.iter().map(|p| p.x).fold(f32::INFINITY, f32::min);
425 let x1 = corners
426 .iter()
427 .map(|p| p.x)
428 .fold(f32::NEG_INFINITY, f32::max);
429 let y0 = corners.iter().map(|p| p.y).fold(f32::INFINITY, f32::min);
430 let y1 = corners
431 .iter()
432 .map(|p| p.y)
433 .fold(f32::NEG_INFINITY, f32::max);
434 let w = x1 - x0;
435 let h = y1 - y0;
436 if w.min(h) > RULING_MAX_FILL_THICKNESS || w.max(h) < RULING_MIN_LENGTH {
437 return None;
438 }
439 if h <= w {
440 let y = (y0 + y1) / 2.0;
441 return Some(Ruling {
442 start: Point::new(x0, y),
443 end: Point::new(x1, y),
444 width: 0.0,
445 });
446 }
447 let x = (x0 + x1) / 2.0;
448 Some(Ruling {
449 start: Point::new(x, y0),
450 end: Point::new(x, y1),
451 width: 0.0,
452 })
453}
454
455struct Subpath {
461 points: Vec<Point>,
462 closed: bool,
463 poisoned: bool,
464}
465
466struct Frame {
477 content: Arc<Vec<u8>>,
482 chain: Vec<Arc<Dict>>,
486 pos: usize,
489 spans_mark: usize,
494 rulings_mark: usize,
495 depth: usize,
497 gs: GState,
498 saved: Vec<GState>,
500 tm: Matrix,
501 tlm: Matrix,
502 subpaths: Vec<Subpath>,
506 fonts: HashMap<String, Arc<Font>>,
509 marks: Vec<bool>,
514}
515
516impl Frame {
517 fn new(
518 content: Arc<Vec<u8>>,
519 chain: Vec<Arc<Dict>>,
520 gs: GState,
521 depth: usize,
522 (spans_mark, rulings_mark): (usize, usize),
523 ) -> Frame {
524 Frame {
525 content,
526 chain,
527 pos: 0,
528 spans_mark,
529 rulings_mark,
530 depth,
531 gs,
532 saved: Vec::new(),
533 tm: Matrix::identity(),
534 tlm: Matrix::identity(),
535 subpaths: Vec::new(),
536 fonts: HashMap::new(),
537 marks: Vec::new(),
538 }
539 }
540
541 fn suppressed(&self) -> bool {
545 self.marks.iter().any(|hidden| *hidden)
546 }
547
548 fn move_to(&mut self, x: f32, y: f32) {
549 self.subpaths.push(Subpath {
550 points: vec![Point::new(x, y)],
551 closed: false,
552 poisoned: false,
553 });
554 }
555
556 fn segment_to(&mut self, x: f32, y: f32, poisons: bool) {
561 let Some(active) = self.subpaths.last_mut() else {
562 return;
563 };
564 if active.closed {
565 let start = active.points[0];
566 self.subpaths.push(Subpath {
567 points: vec![start, Point::new(x, y)],
568 closed: false,
569 poisoned: poisons,
570 });
571 return;
572 }
573 active.points.push(Point::new(x, y));
574 if poisons {
575 active.poisoned = true;
576 }
577 }
578
579 fn close_subpath(&mut self) {
580 if let Some(active) = self.subpaths.last_mut() {
581 active.closed = true;
582 }
583 }
584
585 fn rect_subpath(&mut self, x: f32, y: f32, w: f32, h: f32) {
591 self.subpaths.push(Subpath {
592 points: vec![
593 Point::new(x, y),
594 Point::new(x + w, y),
595 Point::new(x + w, y + h),
596 Point::new(x, y + h),
597 ],
598 closed: true,
599 poisoned: false,
600 });
601 }
602}
603
604struct Executor<'a, S> {
605 src: &'a S,
606 spans: Vec<TextSpan>,
607 rulings: Vec<Ruling>,
608 fallback: Arc<Font>,
609 forms: usize,
612 report: ExtractReport,
614 loaded: HashMap<ObjRef, Arc<Font>>,
619 shared: Option<&'a FontCache>,
622 oc: Option<&'a OcState>,
625 categories: FastMap<(usize, u8), ResolvedCategory>,
634}
635
636type ResolvedCategory = (Arc<Dict>, Option<Arc<Dict>>);
640
641const MAX_CATEGORY_CACHE: usize = 4096;
645
646fn category_slot(category: &str) -> Option<u8> {
649 match category {
650 "ExtGState" => Some(0),
651 "XObject" => Some(1),
652 _ => None,
653 }
654}
655
656impl<S: AsyncObjectSource> Executor<'_, S> {
657 async fn find_res(
666 &mut self,
667 chain: &[Arc<Dict>],
668 category: &str,
669 name: &str,
670 ) -> Option<Object> {
671 let slot = category_slot(category);
672 for res in chain {
673 let key = (Arc::as_ptr(res) as usize, slot.unwrap_or(0));
674 let remembered = slot.and_then(|_| {
675 self.categories
676 .get(&key)
677 .map(|(_, category)| category.clone())
678 });
679 let resolved = match remembered {
680 Some(dict) => dict,
681 None => {
682 let dict = match res.get(category) {
683 Some(cat) => match self.src.resolve(cat).await {
684 Ok(Object::Dict(d)) => Some(Arc::new(d)),
685 _ => None,
686 },
687 None => None,
688 };
689 if slot.is_some() && self.categories.len() < MAX_CATEGORY_CACHE {
690 self.categories.insert(key, (Arc::clone(res), dict.clone()));
691 }
692 dict
693 }
694 };
695 let Some(dict) = resolved else {
696 continue;
697 };
698 if let Some(value) = dict.get(name) {
699 if let Ok(obj) = self.src.resolve(value).await {
700 return Some(obj);
701 }
702 }
703 }
704 None
705 }
706
707 async fn font(
710 &mut self,
711 chain: &[Arc<Dict>],
712 name: &str,
713 cache: &mut HashMap<String, Arc<Font>>,
714 ) -> Arc<Font> {
715 if let Some(f) = cache.get(name) {
716 return f.clone();
717 }
718 let loaded = self.load_font(chain, name).await;
719 if !loaded.simple && !loaded.encoding_known {
720 self.report
721 .record(SkippedTextKind::FontEncoding, SkipCause::Missing);
722 }
723 cache.insert(name.to_string(), loaded.clone());
724 loaded
725 }
726
727 async fn load_font(&mut self, chain: &[Arc<Dict>], name: &str) -> Arc<Font> {
740 for res in chain {
741 let Some(cat) = res.get("Font") else {
742 continue;
743 };
744 let Ok(Object::Dict(dict)) = self.src.resolve(cat).await else {
745 continue;
746 };
747 let Some(value) = dict.get(name) else {
748 continue;
749 };
750 let key = match value {
751 Object::Ref(r) => Some(*r),
752 _ => None,
753 };
754 if let Some(f) = key.and_then(|r| self.hit(r)) {
755 return f;
756 }
757 let Ok(obj) = self.src.resolve(value).await else {
758 continue;
759 };
760 let Some(font_dict) = obj.as_dict() else {
761 return self.fallback.clone();
765 };
766 let loaded = Arc::new(Font::load(self.src, font_dict).await);
767 return match key {
768 Some(r) => self.remember(r, loaded),
769 None => loaded,
770 };
771 }
772 self.fallback.clone()
773 }
774
775 fn hit(&mut self, r: ObjRef) -> Option<Arc<Font>> {
778 if let Some(f) = self.loaded.get(&r) {
779 return Some(f.clone());
780 }
781 let f = self.shared?.get(r)?;
782 self.loaded.insert(r, f.clone());
783 Some(f)
784 }
785
786 fn remember(&mut self, r: ObjRef, font: Arc<Font>) -> Arc<Font> {
789 let font = match self.shared {
790 Some(shared) => shared.insert(r, font),
791 None => font,
792 };
793 self.loaded.insert(r, font.clone());
794 font
795 }
796
797 async fn run(&mut self, root: Frame) {
805 let mut frames = vec![root];
806 'frames: while let Some(mut frame) = frames.pop() {
812 let content = Arc::clone(&frame.content);
815 let mut ops = ContentOps::at(&content, frame.pos);
816 loop {
817 let op = match ops.next_op() {
818 Ok(Some((op, _))) => op,
819 Ok(None) => break,
820 Err(_) => {
821 self.spans.truncate(frame.spans_mark);
825 self.rulings.truncate(frame.rulings_mark);
826 let kind = if frame.depth == 0 {
827 SkippedTextKind::PageContents
828 } else {
829 SkippedTextKind::Form
830 };
831 self.report.record(kind, SkipCause::Parse);
832 continue 'frames;
833 }
834 };
835 match &op {
836 Op::SetFont(name, size) => {
837 let loaded = self.font(&frame.chain, &name.0, &mut frame.fonts).await;
838 frame.gs.font = Some(loaded);
839 frame.gs.font_name = name.0.clone();
840 frame.gs.size = *size;
841 }
842 Op::SetExtGState(name) => {
843 if let Some(lw) = self.ext_gstate_line_width(&frame.chain, &name.0).await {
844 frame.gs.line_width = lw;
845 }
846 }
847 Op::XObject(name) => {
848 if frame.suppressed() {
851 continue;
852 }
853 let entered = self
854 .form_frame(&name.0, &frame.chain, &frame.gs, frame.depth)
855 .await;
856 if let Some(child) = entered {
857 frame.pos = ops.pos();
862 frames.push(frame);
863 frames.push(child);
864 continue 'frames;
865 }
866 }
867 Op::BeginMarkedContentProps(tag, props) => {
868 let hidden = self.marked_hidden(tag, props, &frame.chain).await;
869 if hidden {
870 self.report.hidden += 1;
871 }
872 frame.marks.push(hidden);
873 }
874 op => self.step(&mut frame, op),
875 }
876 }
877 }
878 }
879
880 async fn ext_gstate_line_width(&mut self, chain: &[Arc<Dict>], name: &str) -> Option<f32> {
886 let resolved = self.find_res(chain, "ExtGState", name).await?;
887 let dict = resolved.as_dict()?;
888 let lw = self.src.resolve(dict.get("LW")?).await.ok()?.as_f64()? as f32;
889 (lw.is_finite() && lw >= 0.0).then_some(lw)
890 }
891
892 fn step(&mut self, frame: &mut Frame, op: &Op) {
897 match op {
898 Op::Save => frame.saved.push(frame.gs.clone()),
899 Op::Restore => {
900 if let Some(saved) = frame.saved.pop() {
901 frame.gs = saved;
902 }
903 }
904 Op::Concat(m) if finite(m) => frame.gs.ctm = m.concat(frame.gs.ctm),
905 Op::BeginText => {
906 frame.tm = Matrix::identity();
907 frame.tlm = Matrix::identity();
908 }
909 Op::SetCharSpacing(v) => frame.gs.char_spacing = *v,
910 Op::SetWordSpacing(v) => frame.gs.word_spacing = *v,
911 Op::SetHorizScaling(v) => frame.gs.horiz_scale = v / 100.0,
912 Op::SetLeading(v) => frame.gs.leading = *v,
913 Op::SetTextRise(v) => frame.gs.rise = *v,
914 Op::SetTextRender(mode) => frame.gs.render_mode = *mode,
915 Op::SetFillGray(v) => frame.gs.fill_color = components_color(&[*v]),
916 Op::SetFillRGB(r, g, b) => frame.gs.fill_color = components_color(&[*r, *g, *b]),
917 Op::SetFillCMYK(c, m, y, k) => {
918 frame.gs.fill_color = components_color(&[*c, *m, *y, *k])
919 }
920 Op::SetFillColorSpace(name) => {
924 frame.gs.fill_color = (name.0 != "Pattern").then_some((0.0, 0.0, 0.0));
925 }
926 Op::SetFillColor(comps) => frame.gs.fill_color = components_color(comps),
927 Op::SetFillColorN(comps, pattern) => {
928 frame.gs.fill_color = if pattern.is_some() {
929 None
930 } else {
931 components_color(comps)
932 };
933 }
934 Op::TextMove(tx, ty) => {
935 frame.tlm = Matrix::translate(*tx, *ty).concat(frame.tlm);
936 frame.tm = frame.tlm;
937 }
938 Op::TextMoveSetLeading(tx, ty) => {
939 frame.gs.leading = -ty;
940 frame.tlm = Matrix::translate(*tx, *ty).concat(frame.tlm);
941 frame.tm = frame.tlm;
942 }
943 Op::SetTextMatrix(m) if finite(m) => {
944 frame.tm = *m;
945 frame.tlm = *m;
946 }
947 Op::TextNextLine => {
948 frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
949 frame.tm = frame.tlm;
950 }
951 Op::ShowText(s) => self.emit(frame, s),
952 Op::ShowTextAdjusted(items) => {
953 let vertical = frame.gs.font.as_ref().is_some_and(|f| f.vertical);
956 for item in items {
957 match item {
958 TextItem::Str(s) => self.emit(frame, s),
959 TextItem::Offset(n) => {
960 let (tx, ty) = if vertical {
961 (0.0, -n / 1000.0 * frame.gs.size)
962 } else {
963 (-n / 1000.0 * frame.gs.size * frame.gs.horiz_scale, 0.0)
964 };
965 if tx.is_finite() && ty.is_finite() {
966 frame.tm = Matrix::translate(tx, ty).concat(frame.tm);
967 }
968 }
969 }
970 }
971 }
972 Op::NextLineShowText(s) => {
973 frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
974 frame.tm = frame.tlm;
975 self.emit(frame, s);
976 }
977 Op::NextLineShowTextSpaced(aw, ac, s) => {
978 frame.gs.word_spacing = *aw;
979 frame.gs.char_spacing = *ac;
980 frame.tlm = Matrix::translate(0.0, -frame.gs.leading).concat(frame.tlm);
981 frame.tm = frame.tlm;
982 self.emit(frame, s);
983 }
984 Op::SetLineWidth(w) => {
985 if w.is_finite() && *w >= 0.0 {
986 frame.gs.line_width = *w;
987 }
988 }
989 Op::MoveTo(x, y) => frame.move_to(*x, *y),
990 Op::LineTo(x, y) => frame.segment_to(*x, *y, false),
991 Op::CurveTo(_, _, _, _, x, y) | Op::CurveToV(_, _, x, y) | Op::CurveToY(_, _, x, y) => {
992 frame.segment_to(*x, *y, true)
993 }
994 Op::ClosePath => frame.close_subpath(),
995 Op::Rect(x, y, w, h) => frame.rect_subpath(*x, *y, *w, *h),
996 Op::Stroke => self.commit_rulings(frame, true, false),
997 Op::CloseStroke => {
998 frame.close_subpath();
999 self.commit_rulings(frame, true, false);
1000 }
1001 Op::Fill | Op::FillEvenOdd => self.commit_rulings(frame, false, true),
1002 Op::FillStroke | Op::FillStrokeEvenOdd => self.commit_rulings(frame, true, true),
1003 Op::CloseFillStroke | Op::CloseFillStrokeEvenOdd => {
1004 frame.close_subpath();
1005 self.commit_rulings(frame, true, true);
1006 }
1007 Op::EndPath => frame.subpaths.clear(),
1010 Op::BeginMarkedContent(_) => frame.marks.push(false),
1013 Op::EndMarkedContent => {
1014 frame.marks.pop();
1015 }
1016 _ => {}
1022 }
1023 }
1024
1025 async fn marked_hidden(&self, tag: &Name, props: &Object, chain: &[Arc<Dict>]) -> bool {
1029 let Some(oc) = self.oc else {
1030 return false;
1031 };
1032 if tag.0 != "OC" {
1033 return false;
1034 }
1035 !oc.props_visible_with(self.src, chain, props).await
1036 }
1037
1038 fn commit_rulings(&mut self, frame: &mut Frame, stroke: bool, fill: bool) {
1043 if frame.suppressed() {
1046 frame.subpaths.clear();
1047 return;
1048 }
1049 let ctm = frame.gs.ctm;
1050 let width = frame.gs.line_width * ctm_scale(&ctm);
1051 for sub in frame.subpaths.drain(..) {
1052 if sub.poisoned {
1053 continue;
1054 }
1055 let device: Vec<Point> = sub.points.iter().map(|p| ctm.apply(*p)).collect();
1056 if stroke {
1057 let segments = device.windows(2).map(|pair| (pair[0], pair[1]));
1058 let closing =
1060 (sub.closed && device.len() > 2).then(|| (device[device.len() - 1], device[0]));
1061 for (a, b) in segments.chain(closing) {
1062 if let Some(ruling) = ruling_from_segment(a, b, width) {
1063 self.rulings.push(ruling);
1064 }
1065 }
1066 }
1067 if fill {
1068 if let Some(ruling) = filled_rect_ruling(&device) {
1069 self.rulings.push(ruling);
1070 }
1071 }
1072 }
1073 }
1074
1075 fn emit(&mut self, frame: &mut Frame, bytes: &[u8]) {
1079 let suppressed = frame.suppressed();
1080 if let Some(span) = self.show(&frame.gs, &mut frame.tm, bytes) {
1081 if !suppressed {
1082 self.spans.push(span);
1083 }
1084 }
1085 }
1086
1087 fn show(&self, gs: &GState, tm: &mut Matrix, bytes: &[u8]) -> Option<TextSpan> {
1098 let font: &Font = gs.font.as_deref().unwrap_or(&self.fallback);
1099 let start = tm.concat(gs.ctm);
1100 let origin = start.apply(Point { x: 0.0, y: gs.rise });
1101 let size = gs.size * (start.c * start.c + start.d * start.d).sqrt();
1104 let mut text = String::with_capacity(bytes.len());
1107 for cc in font.codes_in(bytes) {
1108 font.decode_into(cc, &mut text);
1109 let word = if font.is_space(cc) {
1110 gs.word_spacing
1111 } else {
1112 0.0
1113 };
1114 let adv = if font.vertical {
1118 font.vwidth(cc) / 1000.0 * gs.size + gs.char_spacing + word
1119 } else {
1120 (font.width(cc) / 1000.0 * gs.size + gs.char_spacing + word) * gs.horiz_scale
1121 };
1122 if adv.is_finite() {
1123 let (tx, ty) = if font.vertical {
1124 (0.0, adv)
1125 } else {
1126 (adv, 0.0)
1127 };
1128 *tm = Matrix::translate(tx, ty).concat(*tm);
1129 }
1130 }
1131 let end = tm.concat(gs.ctm).apply(Point { x: 0.0, y: gs.rise });
1132 let size = if size.is_finite() { size } else { 0.0 };
1133 let bbox = if font.vertical {
1134 Rect {
1135 x0: origin.x - size / 2.0,
1136 y0: origin.y.min(end.y),
1137 x1: origin.x + size / 2.0,
1138 y1: origin.y.max(end.y),
1139 }
1140 } else {
1141 Rect {
1142 x0: origin.x.min(end.x),
1143 y0: origin.y + font.descent / 1000.0 * size,
1144 x1: origin.x.max(end.x),
1145 y1: origin.y + font.ascent / 1000.0 * size,
1146 }
1147 };
1148 (!text.is_empty() && origin.x.is_finite() && origin.y.is_finite()).then(|| TextSpan {
1149 text,
1150 x: origin.x,
1151 y: origin.y,
1152 end_x: end.x,
1153 size,
1154 bbox,
1155 font: gs.font_name.clone(),
1156 font_name: font.base_name.clone(),
1157 page: 0,
1158 bold: font.bold,
1159 italic: font.italic,
1160 monospace: font.monospace,
1161 serif: font.serif,
1162 rise: gs.rise,
1163 vertical: font.vertical,
1164 invisible: matches!(gs.render_mode, 3 | 7),
1165 color: gs.fill_color,
1166 underline: false,
1167 strikethrough: false,
1168 })
1169 }
1170
1171 async fn form_frame(
1184 &mut self,
1185 name: &str,
1186 chain: &[Arc<Dict>],
1187 gs: &GState,
1188 depth: usize,
1189 ) -> Option<Frame> {
1190 if depth >= MAX_FORM_DEPTH || self.forms >= MAX_FORM_INVOCATIONS {
1191 self.report
1192 .record(SkippedTextKind::Form, SkipCause::LimitExceeded);
1193 return None;
1194 }
1195 self.forms += 1;
1196 let stream = match self.find_res(chain, "XObject", name).await {
1199 Some(Object::Stream(s)) => s,
1200 _ => {
1201 self.report
1202 .record(SkippedTextKind::XObject, SkipCause::Missing);
1203 return None;
1204 }
1205 };
1206 let is_form = match stream.dict.get("Subtype") {
1209 Some(Object::Name(n)) => n.0 == "Form",
1210 Some(indirect @ Object::Ref(_)) => self
1211 .src
1212 .resolve(indirect)
1213 .await
1214 .ok()
1215 .and_then(|o| o.as_name().map(|n| n.0 == "Form"))
1216 .unwrap_or(false),
1217 _ => false,
1218 };
1219 if !is_form {
1220 return None; }
1222 if let (Some(oc), Some(gate)) = (self.oc, stream.dict.get("OC")) {
1225 if !oc.visible_with(self.src, gate).await {
1226 self.report.hidden += 1;
1227 return None;
1228 }
1229 }
1230 let data = match content_stream_data_with(self.src, &stream).await {
1235 Ok(data) => data,
1236 Err(e) => {
1237 self.report.record(SkippedTextKind::Form, cause_for(&e));
1238 return None;
1239 }
1240 };
1241 let mut inner_chain: Vec<Arc<Dict>> = Vec::with_capacity(chain.len() + 1);
1247 if let Some(own) = self.own_resources(&stream.dict).await {
1248 inner_chain.push(Arc::new(own));
1249 }
1250 inner_chain.extend_from_slice(chain);
1251
1252 let mut inner = gs.clone();
1253 if let Some(m) = self.form_matrix(&stream.dict).await {
1254 inner.ctm = m.concat(inner.ctm);
1255 }
1256 Some(Frame::new(
1257 Arc::new(data),
1258 inner_chain,
1259 inner,
1260 depth + 1,
1261 (self.spans.len(), self.rulings.len()),
1262 ))
1263 }
1264
1265 async fn own_resources(&self, dict: &Dict) -> Option<Dict> {
1267 let obj = dict.get("Resources")?;
1268 self.src.resolve(obj).await.ok()?.as_dict().cloned()
1269 }
1270
1271 async fn form_matrix(&self, dict: &Dict) -> Option<Matrix> {
1273 let obj = self.src.resolve(dict.get("Matrix")?).await.ok()?;
1274 let arr = obj.as_array()?;
1275 let mut v = [0.0f32; 6];
1276 for (slot, item) in v.iter_mut().zip(arr.iter()) {
1277 *slot = self.src.resolve(item).await.ok()?.as_f64()? as f32;
1278 }
1279 if arr.len() < 6 {
1280 return None;
1281 }
1282 let m = Matrix {
1283 a: v[0],
1284 b: v[1],
1285 c: v[2],
1286 d: v[3],
1287 e: v[4],
1288 f: v[5],
1289 };
1290 finite(&m).then_some(m)
1291 }
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296 use super::*;
1297 use pdfboss_core::{block_on, Document, Immediate};
1298 use pdfboss_testkit::doc_with_graphics;
1299
1300 fn page_spans(doc: &Document, page: &Page) -> Vec<TextSpan> {
1306 let (spans, _, report) = extract_all(doc, page);
1307 assert!(report.is_complete(), "unexpected skips: {report:?}");
1308 spans
1309 }
1310
1311 fn page_rulings(doc: &Document, page: &Page) -> Vec<Ruling> {
1313 let (_, rulings, report) = extract_all(doc, page);
1314 assert!(report.is_complete(), "unexpected skips: {report:?}");
1315 rulings
1316 }
1317
1318 fn extract_all(doc: &Document, page: &Page) -> (Vec<TextSpan>, Vec<Ruling>, ExtractReport) {
1321 let oc = doc.oc_state();
1322 block_on(page_spans_and_rulings_with(
1323 Immediate(doc),
1324 page,
1325 None,
1326 oc.as_ref(),
1327 ))
1328 }
1329
1330 fn oc_doc(content: &[u8]) -> Document {
1335 use pdfboss_testkit::PdfBuilder;
1336 let mut b = PdfBuilder::new();
1337 b.object(
1338 1,
1339 "<< /Type /Catalog /Pages 2 0 R /OCProperties \
1340 << /OCGs [8 0 R 9 0 R] /D << /OFF [9 0 R] >> >> >>",
1341 );
1342 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1343 b.object(
1344 3,
1345 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1346 /Resources << /Font << /F1 5 0 R >> \
1347 /Properties << /V 8 0 R /H 9 0 R >> \
1348 /XObject << /Fx 6 0 R >> >> /Contents 4 0 R >>",
1349 );
1350 b.stream(4, "", content);
1351 b.object(
1352 5,
1353 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1354 /Encoding /WinAnsiEncoding >>",
1355 );
1356 b.stream(
1357 6,
1358 "/Type /XObject /Subtype /Form /BBox [0 0 612 792] /OC 9 0 R",
1359 b"BT /F1 12 Tf 72 600 Td (formtext) Tj ET",
1360 );
1361 b.object(8, "<< /Type /OCG /Name (shown) >>");
1362 b.object(9, "<< /Type /OCG /Name (hidden) >>");
1363 Document::load(b.build(1)).expect("load")
1364 }
1365
1366 #[test]
1370 fn hidden_layer_text_is_excluded_but_still_advances() {
1371 let doc = oc_doc(
1372 b"BT /F1 12 Tf 72 720 Td /OC /H BDC (wide hidden run) Tj EMC (kept) Tj \
1373 /OC /V BDC ( on) Tj EMC ET",
1374 );
1375 let page = doc.page(0).unwrap();
1376 let (spans, _, report) = extract_all(&doc, &page);
1377 let texts: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1378 assert_eq!(texts, ["kept", " on"]);
1379 assert!(
1380 spans[0].x > 100.0,
1381 "the hidden run must still advance: x = {}",
1382 spans[0].x
1383 );
1384 assert_eq!(report.hidden, 1);
1385 assert!(report.is_complete(), "hidden is not a skip: {report:?}");
1386 }
1387
1388 #[test]
1392 fn hidden_forms_and_rulings_are_excluded() {
1393 let doc = oc_doc(b"/Fx Do /OC /H BDC 72 700 m 272 700 l S EMC 72 650 m 272 650 l S");
1394 let page = doc.page(0).unwrap();
1395 let (spans, rulings, report) = extract_all(&doc, &page);
1396 assert_eq!(spans, vec![], "the gated form must not run");
1397 assert_eq!(rulings.len(), 1, "only the visible line survives");
1398 assert!((rulings[0].start.y - 650.0).abs() < 1e-3);
1399 assert_eq!(report.hidden, 2, "one form, one span");
1400 assert!(report.is_complete());
1401 }
1402
1403 #[test]
1408 fn invisible_render_mode_survives_where_hidden_layers_do_not() {
1409 let doc = oc_doc(
1410 b"BT /F1 12 Tf 3 Tr 72 720 Td (ocr) Tj ET \
1411 /OC /H BDC BT /F1 12 Tf 72 700 Td (gone) Tj ET EMC",
1412 );
1413 let page = doc.page(0).unwrap();
1414 let (spans, _, report) = extract_all(&doc, &page);
1415 let texts: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1416 assert_eq!(texts, ["ocr"]);
1417 assert_eq!(report.hidden, 1);
1418 }
1419
1420 #[test]
1423 fn absent_configuration_extracts_every_layer() {
1424 use pdfboss_testkit::PdfBuilder;
1425 let mut b = PdfBuilder::new();
1426 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1427 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1428 b.object(
1429 3,
1430 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1431 /Resources << /Font << /F1 5 0 R >> \
1432 /Properties << /H 8 0 R >> >> /Contents 4 0 R >>",
1433 );
1434 b.stream(
1435 4,
1436 "",
1437 b"BT /F1 12 Tf 72 720 Td /OC /H BDC (loose) Tj EMC ET",
1438 );
1439 b.object(
1440 5,
1441 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1442 /Encoding /WinAnsiEncoding >>",
1443 );
1444 b.object(8, "<< /Type /OCG /Name (loose) >>");
1445 let doc = Document::load(b.build(1)).expect("load");
1446 let page = doc.page(0).unwrap();
1447 let (spans, _, report) = extract_all(&doc, &page);
1448 assert_eq!(spans.len(), 1);
1449 assert_eq!(spans[0].text, "loose");
1450 assert_eq!(report.hidden, 0);
1451 }
1452
1453 fn spans_of(content: &str) -> Vec<TextSpan> {
1456 let doc = Document::load(doc_with_graphics(content)).unwrap();
1457 let page = doc.page(0).unwrap();
1458 page_spans(&doc, &page)
1459 }
1460
1461 fn rulings_of(content: &str) -> Vec<Ruling> {
1464 let doc = Document::load(doc_with_graphics(content)).unwrap();
1465 let page = doc.page(0).unwrap();
1466 page_rulings(&doc, &page)
1467 }
1468
1469 #[track_caller]
1470 fn assert_ruling(r: &Ruling, x0: f32, y0: f32, x1: f32, y1: f32) {
1471 let close = (r.start.x - x0).abs() < 1e-3
1472 && (r.start.y - y0).abs() < 1e-3
1473 && (r.end.x - x1).abs() < 1e-3
1474 && (r.end.y - y1).abs() < 1e-3;
1475 assert!(close, "{r:?} is not ({x0},{y0})-({x1},{y1})");
1476 }
1477
1478 #[test]
1479 fn word_spacing_applies_to_code_32_only() {
1480 let spans = spans_of("BT /F1 12 Tf 5 Tw 72 720 Td (a b) Tj ET");
1482 assert_eq!(spans.len(), 1);
1483 assert!((spans[0].end_x - 95.0).abs() < 1e-3, "{}", spans[0].end_x);
1484 }
1485
1486 #[test]
1487 fn cm_and_q_q_track_ctm() {
1488 let spans = spans_of(
1489 "q 1 0 0 1 100 0 cm BT /F1 12 Tf 0 720 Td (X) Tj ET Q \
1490 BT /F1 12 Tf 0 700 Td (Y) Tj ET",
1491 );
1492 assert_eq!(spans.len(), 2);
1493 assert!((spans[0].x - 100.0).abs() < 1e-3);
1494 assert!((spans[1].x - 0.0).abs() < 1e-3);
1495 }
1496
1497 #[test]
1498 fn horizontal_scaling_stretches_advances() {
1499 let spans = spans_of("BT /F1 12 Tf 200 Tz 72 720 Td (AB) Tj ET");
1500 assert!((spans[0].end_x - 96.0).abs() < 1e-3, "{}", spans[0].end_x);
1502 }
1503
1504 #[test]
1505 fn text_rise_shifts_baseline() {
1506 let spans = spans_of("BT /F1 12 Tf 72 720 Td 5 Ts (R) Tj ET");
1507 assert!((spans[0].y - 725.0).abs() < 1e-3);
1508 }
1509
1510 #[test]
1513 fn t_star_advances_tlm_by_leading() {
1514 let spans = spans_of("BT /F1 12 Tf 14 TL 72 720 Td (a) Tj T* (b) Tj ET");
1515 assert!((spans[1].y - 706.0).abs() < 1e-3);
1516 }
1517
1518 #[test]
1519 fn tm_positions_directly_and_bt_resets() {
1520 let spans = spans_of("BT /F1 12 Tf 1 0 0 1 300 100 Tm (m) Tj ET BT /F1 12 Tf (o) Tj ET");
1521 assert!((spans[0].x - 300.0).abs() < 1e-3);
1522 assert!((spans[0].y - 100.0).abs() < 1e-3);
1523 assert!((spans[1].x - 0.0).abs() < 1e-3);
1525 assert!((spans[1].y - 0.0).abs() < 1e-3);
1526 }
1527
1528 #[test]
1529 fn tm_scale_sets_device_size() {
1530 let spans = spans_of("BT /F1 1 Tf 12 0 0 12 72 720 Tm (s) Tj ET");
1531 assert!((spans[0].size - 12.0).abs() < 1e-3);
1532 }
1533
1534 #[test]
1535 fn empty_content_yields_no_spans() {
1536 assert!(spans_of("").is_empty());
1537 }
1538
1539 #[test]
1540 fn form_xobject_fanout_is_bounded() {
1541 use pdfboss_testkit::PdfBuilder;
1542 let chain = 6u32;
1547 let mut b = PdfBuilder::new();
1548 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1549 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1550 b.object(
1551 3,
1552 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1553 /Resources << /XObject << /X 10 0 R >> >> /Contents 4 0 R >>",
1554 );
1555 b.stream(4, "", b"/X Do");
1556 for i in 0..chain {
1557 let num = 10 + i;
1558 if i + 1 < chain {
1559 let dict = format!(
1560 "/Type /XObject /Subtype /Form \
1561 /Resources << /XObject << /X {} 0 R >> >>",
1562 num + 1
1563 );
1564 b.stream(num, &dict, "/X Do ".repeat(8).as_bytes());
1565 } else {
1566 b.stream(
1567 num,
1568 "/Type /XObject /Subtype /Form",
1569 b"BT /F1 12 Tf 72 720 Td (L) Tj ET",
1570 );
1571 }
1572 }
1573 let doc = Document::load(b.build(1)).unwrap();
1574 let page = doc.page(0).unwrap();
1575 let (spans, _, report) = block_on(page_spans_and_rulings_with(
1578 Immediate(&doc),
1579 &page,
1580 None,
1581 None,
1582 ));
1583 assert!(!spans.is_empty()); assert!(
1585 spans.len() <= MAX_FORM_INVOCATIONS,
1586 "fan-out not bounded: {} spans",
1587 spans.len()
1588 );
1589 assert!(
1590 report
1591 .skipped
1592 .iter()
1593 .all(|s| s.cause == SkipCause::LimitExceeded),
1594 "only the budget may cut this page short: {report:?}"
1595 );
1596 assert!(!report.is_complete(), "the cut-off must be visible");
1597 }
1598
1599 #[test]
1613 fn form_spans_are_emitted_where_the_do_appears() {
1614 use pdfboss_testkit::PdfBuilder;
1615 let mut b = PdfBuilder::new();
1616 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1617 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1618 b.object(
1619 3,
1620 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1621 /Resources << /Font << /F1 5 0 R >> \
1622 /XObject << /Fa 6 0 R /Fi 7 0 R >> >> /Contents 4 0 R >>",
1623 );
1624 b.stream(
1625 4,
1626 "",
1627 b"BT /F1 12 Tf 72 720 Td (A) Tj ET /Fa Do BT /F1 12 Tf 72 660 Td (E) Tj ET",
1628 );
1629 b.object(
1630 5,
1631 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1632 /Encoding /WinAnsiEncoding >>",
1633 );
1634 b.stream(
1637 6,
1638 "/Type /XObject /Subtype /Form /BBox [0 0 612 792]",
1639 b"BT /F1 12 Tf 72 700 Td (B) Tj ET /Fi Do BT /F1 12 Tf 72 680 Td (D) Tj ET",
1640 );
1641 b.stream(
1642 7,
1643 "/Type /XObject /Subtype /Form /BBox [0 0 612 792]",
1644 b"BT /F1 12 Tf 72 690 Td (C) Tj ET",
1645 );
1646 let doc = Document::load(b.build(1)).unwrap();
1647 let page = doc.page(0).unwrap();
1648 let spans = page_spans(&doc, &page);
1649 let order: Vec<&str> = spans.iter().map(|s| s.text.as_str()).collect();
1650 assert_eq!(order, ["A", "B", "C", "D", "E"]);
1651 }
1652
1653 #[test]
1664 fn loaded_fonts_are_shareable_across_threads() {
1665 fn assert_send_sync<T: Send + Sync>() {}
1666 assert_send_sync::<Font>();
1667 assert_send_sync::<Arc<Font>>();
1668 assert_send_sync::<GState>();
1669 assert_send_sync::<FontCache>();
1670 }
1671
1672 #[test]
1678 fn same_name_binds_a_different_font_per_resource_scope() {
1679 use pdfboss_testkit::PdfBuilder;
1680 let mut b = PdfBuilder::new();
1681 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1682 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1683 b.object(
1684 3,
1685 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1686 /Resources << /Font << /F1 5 0 R >> /XObject << /Fx 6 0 R >> >> \
1687 /Contents 4 0 R >>",
1688 );
1689 b.stream(4, "", b"BT /F1 12 Tf 72 720 Td (aa) Tj ET /Fx Do");
1690 b.object(
1691 5,
1692 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1693 /Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [500] >>",
1694 );
1695 b.stream(
1696 6,
1697 "/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
1698 /Resources << /Font << /F1 7 0 R >> >>",
1699 b"BT /F1 12 Tf 72 700 Td (aa) Tj ET",
1700 );
1701 b.object(
1702 7,
1703 "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica \
1704 /Encoding /WinAnsiEncoding /FirstChar 97 /LastChar 97 /Widths [1000] >>",
1705 );
1706 let doc = Document::load(b.build(1)).unwrap();
1707 let page = doc.page(0).unwrap();
1708 let spans = page_spans(&doc, &page);
1709 assert_eq!(spans.len(), 2);
1710 let advance = |s: &TextSpan| s.end_x - s.x;
1711 assert!(
1712 (advance(&spans[0]) - 12.0).abs() < 1e-3,
1713 "page scope must use the 500-width font: {}",
1714 advance(&spans[0])
1715 );
1716 assert!(
1717 (advance(&spans[1]) - 24.0).abs() < 1e-3,
1718 "form scope must use the 1000-width font: {}",
1719 advance(&spans[1])
1720 );
1721 }
1722
1723 #[test]
1728 fn stroked_grid_yields_rulings_with_correct_endpoints() {
1729 let rulings = rulings_of("72 600 200 100 re S 172 600 m 172 700 l S 72 650 m 272 650 l S");
1730 assert_eq!(rulings.len(), 6, "{rulings:?}");
1731 assert_ruling(&rulings[0], 72.0, 600.0, 272.0, 600.0);
1732 assert_ruling(&rulings[1], 272.0, 600.0, 272.0, 700.0);
1733 assert_ruling(&rulings[2], 72.0, 700.0, 272.0, 700.0);
1734 assert_ruling(&rulings[3], 72.0, 600.0, 72.0, 700.0);
1735 assert_ruling(&rulings[4], 172.0, 600.0, 172.0, 700.0);
1736 assert_ruling(&rulings[5], 72.0, 650.0, 272.0, 650.0);
1737 assert!(rulings.iter().all(|r| (r.width - 1.0).abs() < 1e-3));
1738 }
1739
1740 #[test]
1741 fn w_sets_the_stroke_width() {
1742 let rulings = rulings_of("0.5 w 72 700 m 272 700 l S");
1743 assert_eq!(rulings.len(), 1);
1744 assert!((rulings[0].width - 0.5).abs() < 1e-3);
1745 }
1746
1747 #[test]
1752 fn negative_or_nonfinite_w_is_ignored() {
1753 let rulings = rulings_of("-5 w 72 700 m 272 700 l S");
1754 assert_eq!(rulings.len(), 1, "{rulings:?}");
1755 assert!((rulings[0].width - 1.0).abs() < 1e-3);
1756 let rulings = rulings_of("400000000000000000000000000000000000000 w 72 700 m 272 700 l S");
1757 assert_eq!(rulings.len(), 1, "{rulings:?}");
1758 assert!((rulings[0].width - 1.0).abs() < 1e-3);
1759 }
1760
1761 #[test]
1762 fn ext_gstate_lw_sets_the_stroke_width() {
1763 use pdfboss_testkit::PdfBuilder;
1764 let mut b = PdfBuilder::new();
1765 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1766 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1767 b.object(
1768 3,
1769 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1770 /Resources << /ExtGState << /G1 5 0 R >> >> /Contents 4 0 R >>",
1771 );
1772 b.stream(4, "", b"/G1 gs 72 700 m 272 700 l S");
1773 b.object(5, "<< /Type /ExtGState /LW 2.5 >>");
1774 let doc = Document::load(b.build(1)).unwrap();
1775 let page = doc.page(0).unwrap();
1776 let rulings = page_rulings(&doc, &page);
1777 assert_eq!(rulings.len(), 1);
1778 assert!((rulings[0].width - 2.5).abs() < 1e-3);
1779 }
1780
1781 #[test]
1782 fn thin_filled_rect_yields_its_centerline() {
1783 let rulings = rulings_of("72 700 200 0.8 re f");
1784 assert_eq!(rulings.len(), 1, "{rulings:?}");
1785 assert_ruling(&rulings[0], 72.0, 700.4, 272.0, 700.4);
1786 assert_eq!(rulings[0].width, 0.0, "a fill has no stroke width");
1787 }
1788
1789 #[test]
1790 fn fat_filled_rect_yields_no_rulings() {
1791 assert!(rulings_of("72 600 200 40 re f").is_empty());
1792 }
1793
1794 #[test]
1798 fn cm_rotation_keeps_axis_aligned_segments_only() {
1799 let rotated90 = rulings_of("q 0 1 -1 0 300 100 cm 0 0 m 100 0 l S Q");
1800 assert_eq!(rotated90.len(), 1, "{rotated90:?}");
1801 assert_ruling(&rotated90[0], 300.0, 100.0, 300.0, 200.0);
1802 let rotated30 = rulings_of("q 0.866 0.5 -0.5 0.866 0 0 cm 72 700 m 172 700 l S Q");
1803 assert!(rotated30.is_empty(), "{rotated30:?}");
1804 }
1805
1806 #[test]
1809 fn curves_poison_only_their_own_subpath() {
1810 let rulings =
1811 rulings_of("72 500 m 100 550 150 550 172 500 c 200 500 l 72 700 m 272 700 l S");
1812 assert_eq!(rulings.len(), 1, "{rulings:?}");
1813 assert_ruling(&rulings[0], 72.0, 700.0, 272.0, 700.0);
1814 }
1815
1816 #[test]
1819 fn end_path_discards_the_accumulated_path() {
1820 assert!(rulings_of("72 700 m 272 700 l n").is_empty());
1821 assert!(rulings_of("72 600 200 100 re W n").is_empty());
1822 }
1823
1824 #[test]
1827 fn form_matrix_lands_rulings_in_page_space() {
1828 use pdfboss_testkit::PdfBuilder;
1829 let mut b = PdfBuilder::new();
1830 b.object(1, "<< /Type /Catalog /Pages 2 0 R >>");
1831 b.object(2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
1832 b.object(
1833 3,
1834 "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
1835 /Resources << /XObject << /Fx 5 0 R >> >> /Contents 4 0 R >>",
1836 );
1837 b.stream(4, "", b"/Fx Do");
1838 b.stream(
1839 5,
1840 "/Type /XObject /Subtype /Form /BBox [0 0 612 792] \
1841 /Matrix [1 0 0 1 0 -20]",
1842 b"72 720 m 272 720 l S",
1843 );
1844 let doc = Document::load(b.build(1)).unwrap();
1845 let page = doc.page(0).unwrap();
1846 let rulings = page_rulings(&doc, &page);
1847 assert_eq!(rulings.len(), 1, "{rulings:?}");
1848 assert_ruling(&rulings[0], 72.0, 700.0, 272.0, 700.0);
1849 }
1850}