1use crate::color::{ColorSpace, ColorSpaceCache};
31use crate::function::FunctionCache;
32use crate::image::{ImageCache, RequestedSize, decode_image};
33use crate::names;
34use crate::ops::{FillRule, LineCap, Op, TextItem, TextRenderMode};
35use crate::page::{
36 Content, FormObject, ImageObject, Page, PageObject, PathObject, ShadingObject, TextObject,
37 TextSegment,
38};
39use crate::pattern::{Pattern, TilingPattern};
40use crate::resources::Resources;
41use crate::shading::{Shading, ShadingSource};
42use crate::state::{
43 ClipRule, ContentMarks, GraphicsState, StateStack, TextClipRun, TextCursor, apply_ext_gstate,
44 glyph_matrix, kerning_shift,
45};
46use crate::transparency::Transparency;
47use kurbo::{Affine, BezPath, Point, Rect};
48use pdfrum_common::{DiagKind, Diagnostics, Limits, Operation, Severity};
49use pdfrum_font::{Font, FontCache};
50use pdfrum_object::{Dict, Name, Object, Resolve};
51use std::any::Any;
52use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
53use std::sync::Arc;
54
55pub const MAX_FORM_LEVEL: usize = 40;
60
61#[derive(Debug, Default)]
66pub struct BuildContext {
67 pub colorspaces: ColorSpaceCache,
69 pub functions: FunctionCache,
71 pub images: ImageCache,
73 pub decode_target: RequestedSize,
85 pub fonts: Arc<FontCache>,
94 pub substitution: pdfrum_font::SubstitutionOptions,
103 form_fonts: HashMap<FormFontsKey, Arc<dyn Any + Send + Sync>>,
144 in_flight: HashSet<BufferId>,
146 type3_depth: u32,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
187pub enum FormFontsKey {
188 Form(pdfrum_object::ObjRef),
190 None,
192 DirectResources(pdfrum_object::ObjRef),
194 Direct,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
203struct BufferId {
204 reference: Option<pdfrum_object::ObjRef>,
205 len: usize,
206 fingerprint: u64,
209}
210
211impl BufferId {
212 fn new(reference: Option<pdfrum_object::ObjRef>, data: &[u8]) -> Self {
213 let mut fingerprint = 0xcbf2_9ce4_8422_2325u64;
214 for b in data.iter().take(64).chain(data.iter().rev().take(64)) {
215 fingerprint ^= u64::from(*b);
216 fingerprint = fingerprint.wrapping_mul(0x100_0000_01b3);
217 }
218 Self {
219 reference,
220 len: data.len(),
221 fingerprint,
222 }
223 }
224}
225
226impl BuildContext {
227 #[must_use]
229 pub fn new() -> Self {
230 Self::default()
231 }
232
233 #[must_use]
238 pub fn with_substitution(options: pdfrum_font::SubstitutionOptions) -> Self {
239 Self {
240 substitution: options,
241 ..Self::default()
242 }
243 }
244
245 pub fn form_fonts<T: Any + Send + Sync>(
256 &mut self,
257 key: FormFontsKey,
258 load: impl FnOnce(&mut Self) -> T,
259 ) -> Arc<T> {
260 if key == FormFontsKey::Direct {
261 crate::renderprofile::form_font_miss();
262 return Arc::new(load(self));
263 }
264 if let Some(cached) = self.form_fonts.get(&key)
265 && let Ok(hit) = Arc::clone(cached).downcast::<T>()
266 {
267 return hit;
268 }
269 crate::renderprofile::form_font_miss();
275 let built = Arc::new(load(self));
276 self.form_fonts
277 .insert(key, Arc::clone(&built) as Arc<dyn Any + Send + Sync>);
278 built
279 }
280
281 #[must_use]
283 pub fn forms_in_flight(&self) -> usize {
284 self.in_flight.len()
285 }
286
287 pub(crate) fn enter_type3(&mut self) -> bool {
293 if self.type3_depth >= pdfrum_font::MAX_TYPE3_DEPTH {
294 return false;
295 }
296 self.type3_depth += 1;
297 true
298 }
299
300 pub(crate) fn leave_type3(&mut self) {
302 self.type3_depth = self.type3_depth.saturating_sub(1);
303 }
304}
305
306#[derive(Debug, Clone, Default, PartialEq, Eq)]
318pub struct StreamBounds {
319 starts: Vec<usize>,
320}
321
322impl StreamBounds {
323 #[must_use]
326 pub fn from_counts(counts: impl IntoIterator<Item = usize>) -> Self {
327 let mut starts = Vec::new();
328 let mut at = 0usize;
329 for count in counts {
330 starts.push(at);
331 at = at.saturating_add(count);
332 }
333 Self { starts }
334 }
335
336 #[must_use]
348 pub fn from_joined(bytes: &[u8], total_ops: usize, ends: &[usize], limits: &Limits) -> Self {
349 if ends.len() <= 1 {
350 return Self::default();
351 }
352 let mut counts = Vec::with_capacity(ends.len());
353 let mut start = 0usize;
354 let mut consumed = 0usize;
355 for (index, end) in ends.iter().enumerate() {
356 if index.saturating_add(1) == ends.len() {
357 counts.push(total_ops.saturating_sub(consumed));
358 break;
359 }
360 let element = bytes.get(start..*end).unwrap_or_default();
361 let mut ignored = Diagnostics::default();
364 let count = crate::parse_content(element, limits, &mut ignored).len();
365 consumed = consumed.saturating_add(count);
366 counts.push(count);
367 start = *end;
368 }
369 Self::from_counts(counts)
370 }
371
372 #[must_use]
378 pub fn stream_of(&self, op_index: usize) -> usize {
379 self.starts
380 .partition_point(|start| *start <= op_index)
381 .saturating_sub(1)
382 }
383
384 #[must_use]
386 pub fn len(&self) -> usize {
387 self.starts.len()
388 }
389
390 #[must_use]
392 pub fn is_empty(&self) -> bool {
393 self.starts.is_empty()
394 }
395}
396
397#[must_use]
425pub fn build_page<R: Resolve>(
426 ops: &[Op],
427 resources: &Resources,
428 r: &R,
429 ctx: &mut BuildContext,
430 limits: &Limits,
431 diags: &mut Diagnostics,
432) -> Page {
433 let objects = interpret(
434 ops,
435 resources,
436 &GraphicsState::default(),
437 Affine::IDENTITY,
438 r,
439 ctx,
440 limits,
441 diags,
442 );
443 Page {
444 objects,
445 resources: resources.chosen.clone(),
446 ..Page::empty()
447 }
448}
449
450#[expect(
455 clippy::too_many_arguments,
456 reason = "a page needs its operators, dictionary, inherited attributes, \
457 resources and the usual resolver/context/limits/diagnostics"
458)]
459#[must_use]
460pub fn build_page_from_dict<R: Resolve>(
461 ops: &[Op],
462 dict: &Dict,
463 inherited: impl Fn(&Name) -> Option<Object>,
464 resources: &Resources,
465 r: &R,
466 ctx: &mut BuildContext,
467 limits: &Limits,
468 diags: &mut Diagnostics,
469) -> Page {
470 build_page_streams(
471 ops,
472 &StreamBounds::default(),
473 dict,
474 inherited,
475 resources,
476 r,
477 ctx,
478 limits,
479 diags,
480 )
481}
482
483#[expect(
491 clippy::too_many_arguments,
492 reason = "as `build_page_from_dict`, plus the stream boundaries"
493)]
494#[must_use]
495pub fn build_page_streams<R: Resolve>(
496 ops: &[Op],
497 bounds: &StreamBounds,
498 dict: &Dict,
499 inherited: impl Fn(&Name) -> Option<Object>,
500 resources: &Resources,
501 r: &R,
502 ctx: &mut BuildContext,
503 limits: &Limits,
504 diags: &mut Diagnostics,
505) -> Page {
506 let (media_box, crop_box) = crate::page::derive_boxes(dict, &inherited, r, diags);
507 let rotate = crate::page::Rotation::from_degrees(
508 dict.int(names::ROTATE, r)
509 .or_else(|| inherited(names::ROTATE).and_then(|o| o.as_int()))
510 .unwrap_or(0),
511 );
512 let transparency = Transparency::for_page(dict.dict(names::GROUP, r).as_ref(), r);
513 let (objects, stream_ctms) = interpret_streams(
514 ops,
515 bounds,
516 resources,
517 &GraphicsState::default(),
518 Affine::IDENTITY,
519 r,
520 ctx,
521 limits,
522 diags,
523 );
524 Page {
525 objects,
526 media_box,
527 crop_box,
528 rotate,
529 transparency,
530 resources: resources.chosen.clone(),
531 dirty_streams: BTreeSet::new(),
532 stream_ctms,
533 }
534}
535
536#[must_use]
549pub fn build_form_object<R: Resolve>(
550 stream: &pdfrum_object::Stream,
551 matrix: Affine,
552 resources: &Resources,
553 r: &R,
554 ctx: &mut BuildContext,
555 limits: &Limits,
556 diags: &mut Diagnostics,
557) -> Option<PageObject> {
558 build_form_object_with(stream, matrix, resources, r, ctx, limits, diags, false)
559}
560
561#[must_use]
570#[expect(
571 clippy::too_many_arguments,
572 reason = "one more than `build_form_object`, which is already at the \
573 limit; grouping the resolver, context, limits and sink into a \
574 struct is a change to every builder entry point in this crate \
575 and not this function's to make"
576)]
577pub fn build_form_object_with<R: Resolve>(
578 stream: &pdfrum_object::Stream,
579 matrix: Affine,
580 resources: &Resources,
581 r: &R,
582 ctx: &mut BuildContext,
583 limits: &Limits,
584 diags: &mut Diagnostics,
585 live_edit: bool,
586) -> Option<PageObject> {
587 let content = pdfrum_filters::decode_chain(stream, 0, r, limits, diags).data;
588 let form_matrix = stream.dict.matrix(names::MATRIX, r);
589 let placed = matrix * form_matrix;
590
591 let mut state = GraphicsState {
592 ctm: placed,
593 ..GraphicsState::default()
594 };
595
596 let transparency = Transparency::from_group(stream.dict.dict(names::GROUP, r).as_ref(), r);
597 if transparency.group {
598 state.general.enter_transparency_group();
599 }
600
601 let bbox = stream
602 .dict
603 .array(names::BBOX, r)
604 .filter(|a| a.len() == 4)
605 .map(|a| a.as_rect());
606 if let Some(rect) = bbox {
614 let mut path = kurbo::BezPath::new();
615 path.move_to((rect.x0, rect.y0));
616 path.line_to((rect.x1, rect.y0));
617 path.line_to((rect.x1, rect.y1));
618 path.line_to((rect.x0, rect.y1));
619 path.close_path();
620 state.clip.push_path(placed * path, ClipRule::Winding);
621 }
622
623 let inner = Resources::choose(
624 stream.dict.dict(names::RESOURCES, r),
625 resources.chosen.clone(),
626 resources.page.clone(),
627 );
628
629 let ops = crate::parse_content(&content, limits, diags);
630 let objects = interpret(&ops, &inner, &state, placed, r, ctx, limits, diags);
631
632 Some(PageObject::Form(Box::new(Content {
633 object: FormObject {
634 objects,
635 matrix: placed,
636 bbox,
637 transparency,
638 oc: stream.dict.dict(names::OC, r).map(Arc::new),
639 source: None,
642 live_edit,
643 },
644 state,
645 marks: ContentMarks::default(),
646 content_stream: None,
650 dirty: false,
654 active: true,
655 })))
656}
657
658struct Interp<'a, R: Resolve> {
661 state: GraphicsState,
662 stack: StateStack,
663 marks: ContentMarks,
664 cursor: TextCursor,
665 points: Vec<PathPoint>,
667 pending_clip: FillRule,
670 subpath_start: Point,
672 current: Point,
674 text_clip: Vec<TextClipRun>,
676 resources: &'a Resources,
677 parent_matrix: Affine,
680 resolver: &'a R,
681 objects: Vec<PageObject>,
682 stream: usize,
685 stream_ctms: BTreeMap<usize, Affine>,
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
697enum PointKind {
698 Move,
699 Line,
700 Curve,
701}
702
703#[derive(Debug, Clone, Copy, PartialEq)]
718struct PathPoint {
719 at: Point,
720 kind: PointKind,
721 closes: bool,
724}
725
726impl PathPoint {
727 const fn new(at: Point, kind: PointKind) -> Self {
729 Self {
730 at,
731 kind,
732 closes: false,
733 }
734 }
735
736 const fn closing_line(at: Point) -> Self {
739 Self {
740 at,
741 kind: PointKind::Line,
742 closes: true,
743 }
744 }
745}
746
747#[expect(
753 clippy::too_many_arguments,
754 reason = "the interpreter needs its operators, resources, initial state, \
755 parent matrix, resolver, context, limits and diagnostics"
756)]
757fn interpret<R: Resolve>(
758 ops: &[Op],
759 resources: &Resources,
760 initial: &GraphicsState,
761 parent_matrix: Affine,
762 r: &R,
763 ctx: &mut BuildContext,
764 limits: &Limits,
765 diags: &mut Diagnostics,
766) -> Vec<PageObject> {
767 interpret_streams(
768 ops,
769 &StreamBounds::default(),
770 resources,
771 initial,
772 parent_matrix,
773 r,
774 ctx,
775 limits,
776 diags,
777 )
778 .0
779}
780
781const DEADLINE_STRIDE: usize = 256;
785
786#[expect(
796 clippy::too_many_arguments,
797 reason = "as `interpret`, plus the stream boundaries the editor needs"
798)]
799fn interpret_streams<R: Resolve>(
800 ops: &[Op],
801 bounds: &StreamBounds,
802 resources: &Resources,
803 initial: &GraphicsState,
804 parent_matrix: Affine,
805 r: &R,
806 ctx: &mut BuildContext,
807 limits: &Limits,
808 diags: &mut Diagnostics,
809) -> (Vec<PageObject>, BTreeMap<usize, Affine>) {
810 let mut interp = Interp {
811 state: initial.clone(),
812 stack: StateStack::new(),
813 marks: ContentMarks::new(),
814 cursor: TextCursor::default(),
815 points: Vec::new(),
816 pending_clip: FillRule::None,
817 subpath_start: Point::ZERO,
818 current: Point::ZERO,
819 text_clip: Vec::new(),
820 resources,
821 parent_matrix,
822 resolver: r,
823 objects: Vec::new(),
824 stream: 0,
825 stream_ctms: BTreeMap::new(),
826 };
827 for (index, op) in ops.iter().enumerate() {
828 if index.is_multiple_of(DEADLINE_STRIDE)
829 && limits.check_deadline(Operation::Interpret).is_err()
830 {
831 diags.record(Severity::Suspicious, DiagKind::TimeLimitReached, None);
832 break;
833 }
834 interp.stream = bounds.stream_of(index);
835 interp.apply(op, ctx, limits, diags);
836 }
837 (interp.objects, interp.stream_ctms)
838}
839
840impl<R: Resolve> Interp<'_, R> {
841 #[expect(
843 clippy::too_many_lines,
844 reason = "the operator dispatch is a flat table by design: one arm \
845 per operator, each a few lines, and splitting it would \
846 hide which operator does what"
847 )]
848 fn apply(&mut self, op: &Op, ctx: &mut BuildContext, limits: &Limits, diags: &mut Diagnostics) {
849 match op {
850 Op::SaveState() => self.stack.push(&self.state),
852 Op::RestoreState() => {
853 if !self.stack.pop(&mut self.state) {
854 diags.record(Severity::Suspicious, DiagKind::UnbalancedRestore, None);
855 }
856 self.record_ctm();
859 }
860 Op::Concat(m) => {
862 self.state.ctm *= *m;
863 self.record_ctm();
864 }
865 Op::SetLineWidth(w) => self.state.stroke_params.width = *w,
866 Op::SetLineCap(c) => self.state.stroke_params.cap = *c,
867 Op::SetLineJoin(j) => self.state.stroke_params.join = *j,
868 Op::SetMiterLimit(m) => self.state.stroke_params.miter_limit = *m,
869 Op::SetDash(d) => {
870 if d.valid {
872 self.state.stroke_params.dash.clone_from(&d.array);
873 self.state.stroke_params.dash_phase = d.phase;
874 }
875 }
876 Op::SetFlatness(f) => self.state.general.flatness = *f,
877 Op::SetExtGState(name) => self.apply_ext_gstate(name, ctx, limits, diags),
878
879 Op::MoveTo(p) => {
881 self.add_point(PathPoint::new(*p, PointKind::Move));
882 self.subpath_start = *p;
883 }
884 Op::LineTo(p) => self.add_point(PathPoint::new(*p, PointKind::Line)),
885 Op::CurveTo(a, b, c) => {
886 self.add_point(PathPoint::new(*a, PointKind::Curve));
887 self.add_point(PathPoint::new(*b, PointKind::Curve));
888 self.add_point(PathPoint::new(*c, PointKind::Curve));
889 }
890 Op::CurveToV(b, c) => {
892 let start = self.current;
893 self.add_point(PathPoint::new(start, PointKind::Curve));
894 self.add_point(PathPoint::new(*b, PointKind::Curve));
895 self.add_point(PathPoint::new(*c, PointKind::Curve));
896 }
897 Op::CurveToY(a, c) => {
899 self.add_point(PathPoint::new(*a, PointKind::Curve));
900 self.add_point(PathPoint::new(*c, PointKind::Curve));
901 self.add_point(PathPoint::new(*c, PointKind::Curve));
902 }
903 Op::ClosePath() => self.close_path(),
904 Op::Rectangle(x, y, w, h) => {
905 let (x, y, w, h) = (f64::from(*x), f64::from(*y), f64::from(*w), f64::from(*h));
906 self.add_point(PathPoint::new(Point::new(x, y), PointKind::Move));
907 self.add_point(PathPoint::new(Point::new(x + w, y), PointKind::Line));
908 self.add_point(PathPoint::new(Point::new(x + w, y + h), PointKind::Line));
909 self.add_point(PathPoint::new(Point::new(x, y + h), PointKind::Line));
910 self.add_point(PathPoint::closing_line(Point::new(x, y)));
911 self.subpath_start = Point::new(x, y);
912 }
913
914 Op::Stroke() => self.paint(FillRule::None, true),
916 Op::CloseStroke() => {
917 self.close_path();
918 self.paint(FillRule::None, true);
919 }
920 Op::Fill() | Op::FillObsolete() => self.paint(FillRule::Winding, false),
921 Op::FillEvenOdd() => self.paint(FillRule::EvenOdd, false),
922 Op::FillStroke() => self.paint(FillRule::Winding, true),
923 Op::FillStrokeEvenOdd() => self.paint(FillRule::EvenOdd, true),
924 Op::CloseFillStroke() => {
925 self.close_path();
926 self.paint(FillRule::Winding, true);
927 }
928 Op::CloseFillStrokeEvenOdd() => {
931 let start = self.subpath_start;
932 self.current = start;
933 if !self.points.is_empty() {
934 self.points.push(PathPoint::closing_line(start));
935 }
936 self.paint(FillRule::EvenOdd, true);
937 }
938 Op::EndPath() => self.paint(FillRule::None, false),
939
940 Op::Clip() => self.pending_clip = FillRule::Winding,
942 Op::ClipEvenOdd() => self.pending_clip = FillRule::EvenOdd,
943
944 Op::BeginText() => {
946 self.cursor.set_matrix(Affine::IDENTITY);
949 }
950 Op::EndText() => {
951 let runs = std::mem::take(&mut self.text_clip);
959 if !runs.is_empty() && self.state.text.render_mode.clips() {
960 let _ = self.state.clip.push_text(runs);
961 }
962 }
963 Op::TextMove(tx, ty) => {
964 self.cursor.move_line(f64::from(*tx), f64::from(*ty));
965 }
966 Op::TextMoveSetLeading(tx, ty) => {
967 self.cursor.move_line(f64::from(*tx), f64::from(*ty));
968 self.state.text.leading = -*ty;
970 }
971 Op::SetTextMatrix(m) => self.cursor.set_matrix(*m),
972 Op::TextNextLine() => {
973 self.cursor.next_line(f64::from(self.state.text.leading));
974 }
975 Op::SetLeading(l) => self.state.text.leading = *l,
976 Op::SetTextRise(rise) => self.state.text.rise = *rise,
977 Op::SetHorzScale(z) => self.state.text.horz_scale = *z / 100.0,
979 Op::SetCharSpace(c) => self.state.text.char_space = *c,
980 Op::SetWordSpace(w) => self.state.text.word_space = *w,
981 Op::SetFont(name, size) => {
982 let font = self.find_font(name, ctx, limits, diags);
984 let source = self.resources.find_ref(names::FONT, name, self.resolver);
985 match (font, self.state.text.font.take()) {
986 (Some(f), _) => {
987 self.state.text.font = Some((f, *size));
988 self.state.text.font_source = source;
989 }
990 (None, Some((old, _))) => self.state.text.font = Some((old, *size)),
993 (None, None) => {}
994 }
995 }
996 Op::SetTextRenderMode(mode) => match TextRenderMode::from_int(*mode) {
997 Some(m) => self.state.text.render_mode = m,
998 None => {
1000 diags.record(Severity::Suspicious, DiagKind::BadTextRenderMode, None);
1001 }
1002 },
1003 Op::ShowText(s) => self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags),
1004 Op::NextLineShowText(s) => {
1005 self.cursor.next_line(f64::from(self.state.text.leading));
1006 self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags);
1007 }
1008 Op::SetSpacingShowText(word, char_space, s) => {
1009 self.state.text.word_space = *word;
1010 self.state.text.char_space = *char_space;
1011 self.cursor.next_line(f64::from(self.state.text.leading));
1012 self.show_text(&[(s.bytes.clone(), 0.0)], 0.0, ctx, limits, diags);
1013 }
1014 Op::ShowTextAdjusted(array) => {
1015 if array.valid {
1016 self.show_adjusted(&array.items, ctx, limits, diags);
1017 }
1018 }
1019
1020 Op::SetStrokeColorSpace(name) => {
1022 self.set_color_space(name, true, ctx, limits, diags);
1023 }
1024 Op::SetFillColorSpace(name) => {
1025 self.set_color_space(name, false, ctx, limits, diags);
1026 }
1027 Op::SetStrokeColor(c) => {
1028 let _ = self.state.stroke.set_components(&c.0);
1029 }
1030 Op::SetFillColor(c) => {
1031 let _ = self.state.fill.set_components(&c.0);
1032 }
1033 Op::SetStrokeColorN(c) => self.set_color_n(c, true, ctx, limits, diags),
1034 Op::SetFillColorN(c) => self.set_color_n(c, false, ctx, limits, diags),
1035 Op::SetStrokeGray(g) => {
1036 self.state.stroke.set_stock(ColorSpace::DeviceGray, &[*g]);
1037 }
1038 Op::SetFillGray(g) => self.state.fill.set_stock(ColorSpace::DeviceGray, &[*g]),
1039 Op::SetStrokeRgb(r, g, b) => {
1040 self.state
1041 .stroke
1042 .set_stock(ColorSpace::DeviceRgb, &[*r, *g, *b]);
1043 }
1044 Op::SetFillRgb(r, g, b) => {
1045 self.state
1046 .fill
1047 .set_stock(ColorSpace::DeviceRgb, &[*r, *g, *b]);
1048 }
1049 Op::SetStrokeCmyk(c, m, y, k) => {
1050 self.state
1051 .stroke
1052 .set_stock(ColorSpace::DeviceCmyk, &[*c, *m, *y, *k]);
1053 }
1054 Op::SetFillCmyk(c, m, y, k) => {
1055 self.state
1056 .fill
1057 .set_stock(ColorSpace::DeviceCmyk, &[*c, *m, *y, *k]);
1058 }
1059
1060 Op::DoXObject(name) => self.do_xobject(name, ctx, limits, diags),
1062 Op::ShadeFill(name) => self.shade_fill(name, ctx, limits, diags),
1063 Op::InlineImage(image) => self.inline_image(image, ctx, limits, diags),
1064
1065 Op::BeginMarkedContent(tag) => self.marks.push(tag.clone()),
1067 Op::BeginMarkedContentDict(tag, props) => {
1068 if let Some(properties) = &props.0 {
1069 let resources = self.resources;
1070 let resolver = self.resolver;
1071 self.marks
1072 .push_with_properties(tag.clone(), properties, |name| {
1073 resources
1074 .find(names::PROPERTIES, name, resolver)
1075 .and_then(|o| o.as_dict().cloned())
1076 });
1077 }
1078 }
1080 Op::EndMarkedContent() => {
1081 if !self.marks.pop() {
1082 diags.record(
1083 Severity::Suspicious,
1084 DiagKind::UnbalancedMarkedContent,
1085 None,
1086 );
1087 }
1088 }
1089
1090 Op::Type3Width(..)
1101 | Op::Type3WidthBBox(..)
1102 | Op::SetRenderIntent(_)
1103 | Op::MarkPoint(_)
1104 | Op::MarkPointDict(..)
1105 | Op::BeginInlineImage()
1106 | Op::InlineImageData()
1107 | Op::EndInlineImage()
1108 | Op::BeginCompat()
1109 | Op::EndCompat()
1110 | Op::Unknown(_) => {}
1111 }
1112 }
1113
1114 fn add_point(&mut self, point: PathPoint) {
1116 self.current = point.at;
1117 match self.points.last() {
1118 Some(previous) if previous.kind == PointKind::Move && point.kind == PointKind::Move => {
1120 if previous.at == point.at {
1121 return;
1122 }
1123 if let Some(last) = self.points.last_mut() {
1124 *last = point;
1125 }
1126 return;
1127 }
1128 None if point.kind != PointKind::Move => return,
1130 _ => {}
1131 }
1132 self.points.push(point);
1133 }
1134
1135 fn close_path(&mut self) {
1145 if self.points.is_empty() {
1146 return;
1147 }
1148 if self.current == self.subpath_start {
1149 if let Some(last) = self.points.last_mut() {
1150 last.closes = true;
1151 }
1152 } else {
1153 let start = self.subpath_start;
1154 self.points.push(PathPoint::closing_line(start));
1155 self.current = start;
1156 }
1157 }
1158
1159 fn paint(&mut self, fill_rule: FillRule, stroke: bool) {
1161 let points = std::mem::take(&mut self.points);
1162 let clip_rule = std::mem::replace(&mut self.pending_clip, FillRule::None);
1165
1166 if points.is_empty() {
1167 return;
1169 }
1170 let matrix = self.state.ctm;
1171
1172 if points.len() == 1 {
1174 if clip_rule != FillRule::None {
1175 self.state.clip.push_empty();
1177 return;
1178 }
1179 let point = points
1180 .first()
1181 .copied()
1182 .unwrap_or(PathPoint::new(Point::ZERO, PointKind::Move));
1183 if !point.closes || self.state.stroke_params.cap != LineCap::Round {
1185 return;
1186 }
1187 let mut path = BezPath::new();
1188 path.move_to(point.at);
1189 path.line_to(point.at);
1190 path.close_path();
1191 self.emit_path(path, matrix, fill_rule, stroke, clip_rule);
1192 return;
1193 }
1194
1195 let mut points = points;
1197 if matches!(points.last(), Some(last) if last.kind == PointKind::Move) {
1198 points.pop();
1199 }
1200 if points.is_empty() {
1201 return;
1202 }
1203 let path = build_path(&points);
1204 self.emit_path(path, matrix, fill_rule, stroke, clip_rule);
1205 }
1206
1207 fn emit_path(
1209 &mut self,
1210 path: BezPath,
1211 matrix: Affine,
1212 fill_rule: FillRule,
1213 stroke: bool,
1214 clip_rule: FillRule,
1215 ) {
1216 if stroke || fill_rule != FillRule::None {
1218 let object = PathObject {
1219 path: path.clone(),
1220 matrix,
1221 fill_rule,
1222 stroke,
1223 };
1224 self.push(PageObject::Path(Box::new(self.content(object))));
1225 }
1226 if clip_rule != FillRule::None {
1227 let clipped = if matrix == Affine::IDENTITY {
1230 path
1231 } else {
1232 matrix * path
1233 };
1234 self.state.clip.push_path(
1235 clipped,
1236 match clip_rule {
1237 FillRule::EvenOdd => ClipRule::EvenOdd,
1238 _ => ClipRule::Winding,
1239 },
1240 );
1241 }
1242 }
1243
1244 fn content<T>(&self, object: T) -> Content<T> {
1246 Content {
1247 object,
1248 state: self.state.clone(),
1249 marks: self.marks.clone(),
1250 content_stream: Some(self.stream),
1251 dirty: false,
1254 active: true,
1255 }
1256 }
1257
1258 fn record_ctm(&mut self) {
1261 self.stream_ctms.insert(self.stream, self.state.ctm);
1262 }
1263
1264 fn push(&mut self, object: PageObject) {
1265 self.objects.push(object);
1266 }
1267
1268 fn show_text(
1270 &mut self,
1271 segments: &[(Box<[u8]>, f32)],
1272 initial_kerning: f32,
1273 ctx: &mut BuildContext,
1274 limits: &Limits,
1275 diags: &mut Diagnostics,
1276 ) {
1277 let Some((font, size)) = self.state.text.font.clone() else {
1280 return;
1281 };
1282 let vertical = font.is_vertical();
1283
1284 if initial_kerning != 0.0 {
1287 let shift = -kerning_shift(initial_kerning, size, self.state.text.horz_scale, vertical);
1288 self.cursor.advance(shift, vertical);
1289 }
1290 let segments: Vec<TextSegment> = segments
1291 .iter()
1292 .filter(|(codes, _)| !codes.is_empty())
1293 .map(|(codes, kerning)| TextSegment {
1294 codes: codes.clone(),
1295 kerning: *kerning,
1296 })
1297 .collect();
1298 if segments.is_empty() {
1299 return;
1300 }
1301
1302 let render_mode = if font.type3().is_some() {
1304 TextRenderMode::Fill
1305 } else {
1306 self.state.text.render_mode
1307 };
1308
1309 let position = self
1310 .cursor
1311 .device_position(self.state.text.rise, self.state.ctm);
1312 let matrix = glyph_matrix(
1313 self.state.text.horz_scale,
1314 self.cursor.matrix,
1315 self.state.ctm,
1316 );
1317 let advance = self.advance_for(&segments, &font, size);
1318 let type3_metrics = self.type3_metrics_for(&segments, &font, ctx, limits, diags);
1319
1320 let object = TextObject {
1321 segments: segments.into(),
1322 position,
1323 matrix,
1324 font: Some((Arc::clone(&font), size)),
1325 font_source: self.state.text.font_source,
1326 render_mode,
1327 type3_metrics,
1328 };
1329 if render_mode.clips() {
1337 self.text_clip.push(TextClipRun {
1338 object: object.clone(),
1339 char_space: self.state.text.char_space,
1340 word_space: self.state.text.word_space,
1341 });
1342 }
1343 let mut content = self.content(object);
1348 if render_mode.strokes() {
1349 content.state.text.stroke_ctm = stroke_ctm_of(self.state.ctm);
1350 }
1351 self.push(PageObject::Text(Box::new(content)));
1352 self.cursor.advance(advance, vertical);
1353 }
1354
1355 fn type3_metrics_for(
1361 &self,
1362 segments: &[TextSegment],
1363 font: &Font,
1364 ctx: &mut BuildContext,
1365 limits: &Limits,
1366 diags: &mut Diagnostics,
1367 ) -> std::collections::BTreeMap<u32, crate::type3::Type3Metrics> {
1368 let mut out = std::collections::BTreeMap::new();
1369 let Some(type3) = font.type3() else {
1370 return out;
1371 };
1372 for segment in segments {
1373 for item in font.decode(&segment.codes) {
1374 if out.contains_key(&item.code.0) {
1375 continue;
1376 }
1377 if let Some(m) = crate::type3::metrics(
1378 type3,
1379 item.code,
1380 self.resources.page.as_ref(),
1381 self.resolver,
1382 ctx,
1383 limits,
1384 diags,
1385 ) {
1386 out.insert(item.code.0, m);
1387 }
1388 }
1389 }
1390 out
1391 }
1392
1393 fn advance_for(&self, segments: &[TextSegment], font: &Font, size: f32) -> f64 {
1395 let vertical = font.is_vertical();
1396 let mut total = 0.0f64;
1397 for segment in segments {
1398 for item in font.decode(&segment.codes) {
1399 let mut width = f64::from(item.width) * f64::from(size) / 1000.0;
1400 if item.code.0 == 0x20 && item.cid.is_none() {
1402 width += f64::from(self.state.text.word_space);
1403 }
1404 width += f64::from(self.state.text.char_space);
1405 total += width;
1406 }
1407 total -= kerning_shift(segment.kerning, size, 1.0, vertical);
1408 }
1409 if vertical {
1410 total
1411 } else {
1412 total * f64::from(self.state.text.horz_scale)
1413 }
1414 }
1415
1416 fn show_adjusted(
1418 &mut self,
1419 items: &[TextItem],
1420 ctx: &mut BuildContext,
1421 limits: &Limits,
1422 diags: &mut Diagnostics,
1423 ) {
1424 let strings = items
1425 .iter()
1426 .filter(|i| matches!(i, TextItem::Show(_)))
1427 .count();
1428 let vertical = self
1429 .state
1430 .text
1431 .font
1432 .as_ref()
1433 .is_some_and(|(f, _)| f.is_vertical());
1434
1435 if strings == 0 {
1439 let Some((_, size)) = self.state.text.font.clone() else {
1440 return;
1441 };
1442 for item in items {
1443 let TextItem::Adjust(k) = item else { continue };
1444 if *k != 0.0 {
1445 let shift = -kerning_shift(*k, size, self.state.text.horz_scale, false);
1446 self.cursor.pos.x += shift;
1447 }
1448 }
1449 let _ = vertical;
1450 return;
1451 }
1452
1453 let mut segments: Vec<(Box<[u8]>, f32)> = Vec::new();
1454 let mut initial = 0.0f32;
1455 for item in items {
1456 match item {
1457 TextItem::Show(codes) => {
1458 if !codes.is_empty() {
1459 segments.push((codes.clone(), 0.0));
1460 }
1461 }
1462 TextItem::Adjust(k) => match segments.last_mut() {
1464 Some((_, kerning)) => *kerning += *k,
1465 None => initial += *k,
1466 },
1467 }
1468 }
1469 self.show_text(&segments, initial, ctx, limits, diags);
1470 }
1471
1472 fn find_font(
1475 &self,
1476 name: &Name,
1477 ctx: &mut BuildContext,
1478 limits: &Limits,
1479 diags: &mut Diagnostics,
1480 ) -> Option<Arc<Font>> {
1481 let fonts = Arc::clone(&ctx.fonts);
1487 let substitution = &ctx.substitution;
1488 let mut load = || {
1489 let dict = self
1490 .resources
1491 .find(names::FONT, name, self.resolver)
1492 .and_then(|o| o.as_dict().cloned());
1493 match dict {
1494 Some(d) => pdfrum_font::load_with_options(
1495 &d,
1496 self.resolver,
1497 &fonts,
1498 substitution,
1499 limits,
1500 diags,
1501 ),
1502 None => Some(Font::load_standard(
1505 pdfrum_font::StandardFont::Helvetica,
1506 &fonts,
1507 )),
1508 }
1509 };
1510 match self.resources.find_ref(names::FONT, name, self.resolver) {
1511 Some(reference) => fonts.get_or_load(reference, load),
1512 None => load().map(Arc::new),
1513 }
1514 }
1515
1516 fn set_color_space(
1518 &mut self,
1519 name: &Name,
1520 stroking: bool,
1521 ctx: &mut BuildContext,
1522 limits: &Limits,
1523 diags: &mut Diagnostics,
1524 ) {
1525 let Some(space) = self.load_named_colorspace(name, ctx, limits, diags) else {
1526 return;
1528 };
1529 let target = if stroking {
1530 &mut self.state.stroke
1531 } else {
1532 &mut self.state.fill
1533 };
1534 target.set_space(Arc::new(space));
1535 }
1536
1537 fn load_named_colorspace(
1541 &self,
1542 name: &Name,
1543 ctx: &mut BuildContext,
1544 limits: &Limits,
1545 diags: &mut Diagnostics,
1546 ) -> Option<ColorSpace> {
1547 if name.as_bytes() == b"Pattern" {
1548 return Some(ColorSpace::Pattern(Box::default()));
1549 }
1550 let colorspaces = self.resources.color_spaces(self.resolver);
1551 crate::color::load_colorspace(
1552 &Object::Name(name.clone()),
1553 colorspaces.as_ref(),
1554 self.resolver,
1555 &mut ctx.functions,
1556 limits,
1557 diags,
1558 )
1559 }
1560
1561 fn set_color_n(
1566 &mut self,
1567 c: &crate::ops::PatternComponents,
1568 stroking: bool,
1569 ctx: &mut BuildContext,
1570 limits: &Limits,
1571 diags: &mut Diagnostics,
1572 ) {
1573 if let Some(name) = &c.pattern {
1574 let found = load_pattern(
1575 name,
1576 self.resources,
1577 self.parent_matrix,
1578 &self.state.general,
1579 self.resolver,
1580 ctx,
1581 limits,
1582 diags,
1583 );
1584 let loaded = match found {
1588 FoundPattern::Loaded(p) => Some(p),
1589 FoundPattern::Unusable => None,
1590 FoundPattern::Missing => return,
1591 };
1592 let target = if stroking {
1593 &mut self.state.stroke
1594 } else {
1595 &mut self.state.fill
1596 };
1597 target.set_pattern(name.clone(), &c.values, loaded);
1603 return;
1604 }
1605 let target = if stroking {
1606 &mut self.state.stroke
1607 } else {
1608 &mut self.state.fill
1609 };
1610 let _ = target.set_components(&c.values);
1611 }
1612
1613 fn apply_ext_gstate(
1615 &mut self,
1616 name: &Name,
1617 ctx: &mut BuildContext,
1618 limits: &Limits,
1619 diags: &mut Diagnostics,
1620 ) {
1621 let Some(ext) = self
1622 .resources
1623 .find(names::EXT_G_STATE, name, self.resolver)
1624 .and_then(|o| o.as_dict().cloned())
1625 else {
1626 return;
1627 };
1628 let resources = self.resources;
1629 let resolver = self.resolver;
1630 let fonts = &ctx.fonts;
1631 let substitution = &ctx.substitution;
1632 let find_font = |first: Option<&Object>| -> Option<Arc<Font>> {
1638 let (reference, dict) = match first? {
1643 Object::Ref(reference) => (
1645 Some(*reference),
1646 Object::Ref(*reference)
1647 .resolve(resolver)
1648 .ok()?
1649 .as_dict()
1650 .cloned()?,
1651 ),
1652 Object::Name(name) => (
1654 resources.find_ref(names::FONT, name, resolver),
1655 resources
1656 .find(names::FONT, name, resolver)
1657 .and_then(|o| o.as_dict().cloned())?,
1658 ),
1659 Object::Dict(d) => (None, d.clone()),
1664 _ => return None,
1665 };
1666 let load = || {
1667 pdfrum_font::load_with_options(
1668 &dict,
1669 resolver,
1670 fonts,
1671 substitution,
1672 limits,
1673 &mut Diagnostics::with_limit(0),
1674 )
1675 };
1676 match reference {
1677 Some(reference) => fonts.get_or_load(reference, load),
1678 None => load().map(Arc::new),
1679 }
1680 };
1681 apply_ext_gstate(
1682 &mut self.state,
1683 &ext,
1684 find_font,
1685 self.resolver,
1686 &mut ctx.functions,
1687 limits,
1688 diags,
1689 );
1690 self.expand_soft_mask_group(ctx, limits, diags);
1691 }
1692
1693 fn expand_soft_mask_group(
1707 &mut self,
1708 ctx: &mut BuildContext,
1709 limits: &Limits,
1710 diags: &mut Diagnostics,
1711 ) {
1712 let Some(mask) = self.state.general.soft_mask.as_ref() else {
1713 return;
1714 };
1715 if !mask.objects.is_empty() {
1716 return;
1717 }
1718 let group = mask.group.clone();
1719 let matrix = mask.matrix;
1720 let content = pdfrum_filters::decode_chain(&group, 0, self.resolver, limits, diags).data;
1721 let id = BufferId::new(None, &content);
1722 if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
1723 diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
1724 return;
1725 }
1726 let form_matrix = group.dict.matrix(names::MATRIX, self.resolver);
1729 let inner = GraphicsState {
1730 ctm: matrix * form_matrix,
1731 ..GraphicsState::default()
1732 };
1733 let resources = Resources::choose(
1737 group.dict.dict(names::RESOURCES, self.resolver),
1738 self.resources.page.clone(),
1739 self.resources.page.clone(),
1740 );
1741 ctx.in_flight.insert(id);
1742 let ops = crate::parse_content(&content, limits, diags);
1743 let objects = interpret(
1744 &ops,
1745 &resources,
1746 &inner,
1747 inner.ctm,
1748 self.resolver,
1749 ctx,
1750 limits,
1751 diags,
1752 );
1753 ctx.in_flight.remove(&id);
1754 if let Some(mask) = self.state.general.soft_mask.as_mut() {
1755 Arc::make_mut(mask).objects = objects;
1756 }
1757 }
1758
1759 fn do_xobject(
1761 &mut self,
1762 name: &Name,
1763 ctx: &mut BuildContext,
1764 limits: &Limits,
1765 diags: &mut Diagnostics,
1766 ) {
1767 let Some(object) = self.resources.find(names::XOBJECT, name, self.resolver) else {
1768 return;
1769 };
1770 let Some(stream) = object.as_stream() else {
1772 return;
1773 };
1774 let reference = self
1775 .resources
1776 .holder(names::XOBJECT, self.resolver)
1777 .and_then(|h| h.reference(name));
1778 match stream
1779 .dict
1780 .byte_string(names::SUBTYPE, self.resolver)
1781 .as_deref()
1782 {
1783 Some(b"Form") => self.add_form(stream, reference, ctx, limits, diags),
1784 Some(b"Image") => self.add_image(stream, reference, ctx, limits, diags),
1785 _ => {}
1788 }
1789 }
1790
1791 fn add_form(
1793 &mut self,
1794 stream: &pdfrum_object::Stream,
1795 reference: Option<pdfrum_object::ObjRef>,
1796 ctx: &mut BuildContext,
1797 limits: &Limits,
1798 diags: &mut Diagnostics,
1799 ) {
1800 let content = pdfrum_filters::decode_chain(stream, 0, self.resolver, limits, diags).data;
1801 let id = BufferId::new(reference, &content);
1809 if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
1812 diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
1813 return;
1814 }
1815
1816 let form_matrix = stream.dict.matrix(names::MATRIX, self.resolver);
1818 let matrix = self.state.ctm * form_matrix;
1819
1820 let mut inner = self.state.clone();
1821 inner.ctm = matrix;
1822 inner.clip = crate::state::ClipStack::new();
1825
1826 let transparency = Transparency::from_group(
1827 stream.dict.dict(names::GROUP, self.resolver).as_ref(),
1828 self.resolver,
1829 );
1830 if transparency.group {
1831 inner.general.enter_transparency_group();
1833 }
1834
1835 let bbox = stream
1837 .dict
1838 .array(names::BBOX, self.resolver)
1839 .filter(|a| a.len() == 4)
1840 .map(|a| a.as_rect());
1841
1842 let resources = Resources::choose(
1843 stream.dict.dict(names::RESOURCES, self.resolver),
1844 self.resources.chosen.clone(),
1845 self.resources.page.clone(),
1846 );
1847
1848 ctx.in_flight.insert(id);
1849 let ops = crate::parse_content(&content, limits, diags);
1850 let objects = interpret(
1851 &ops,
1852 &resources,
1853 &inner,
1854 matrix,
1856 self.resolver,
1857 ctx,
1858 limits,
1859 diags,
1860 );
1861 ctx.in_flight.remove(&id);
1862
1863 let object = FormObject {
1864 objects,
1865 matrix,
1866 bbox,
1867 transparency,
1868 oc: stream.dict.dict(names::OC, self.resolver).map(Arc::new),
1869 source: reference,
1870 live_edit: false,
1873 };
1874 self.push(PageObject::Form(Box::new(self.content(object))));
1875 }
1876
1877 fn add_image(
1879 &mut self,
1880 stream: &pdfrum_object::Stream,
1881 reference: Option<pdfrum_object::ObjRef>,
1882 ctx: &mut BuildContext,
1883 limits: &Limits,
1884 diags: &mut Diagnostics,
1885 ) {
1886 let size = ctx.decode_target;
1887 if size == RequestedSize::NoSamples {
1888 return;
1889 }
1890 let cached = reference.and_then(|id| ctx.images.get(id, size));
1891 let image = if let Some(hit) = cached {
1892 hit
1893 } else {
1894 let decoded = decode_image(
1895 stream,
1896 None,
1898 self.resources.page.as_ref(),
1899 size,
1900 self.resolver,
1901 &mut ctx.functions,
1902 limits,
1903 diags,
1904 );
1905 let Ok(image) = decoded else {
1907 return;
1908 };
1909 let image = Arc::new(image);
1910 if let Some(id) = reference {
1911 ctx.images.insert(id, size, Arc::clone(&image));
1912 }
1913 image
1914 };
1915 let is_mask = image.samples.is_stencil();
1916 let object = ImageObject {
1917 image,
1918 matrix: self.state.ctm,
1920 is_mask,
1921 oc: stream.dict.dict(names::OC, self.resolver).map(Arc::new),
1922 source: reference,
1923 };
1924 self.push(PageObject::Image(Box::new(self.content(object))));
1925 }
1926
1927 fn inline_image(
1929 &mut self,
1930 image: &crate::ops::InlineImage,
1931 ctx: &mut BuildContext,
1932 limits: &Limits,
1933 diags: &mut Diagnostics,
1934 ) {
1935 let stream = pdfrum_object::Stream::new(
1936 crate::inline_image::as_xobject_dict(image),
1937 pdfrum_object::ByteSpan::from(image.data.to_vec()),
1938 );
1939 let decoded = decode_image(
1940 &stream,
1941 self.resources.chosen.as_ref(),
1943 self.resources.page.as_ref(),
1944 ctx.decode_target,
1948 self.resolver,
1949 &mut ctx.functions,
1950 limits,
1951 diags,
1952 );
1953 let Ok(data) = decoded else {
1954 return;
1955 };
1956 let is_mask = data.samples.is_stencil();
1957 let object = ImageObject {
1958 image: Arc::new(data),
1959 matrix: self.state.ctm,
1960 is_mask,
1961 oc: None,
1964 source: None,
1967 };
1968 self.push(PageObject::Image(Box::new(self.content(object))));
1969 }
1970
1971 fn shade_fill(
1973 &mut self,
1974 name: &Name,
1975 ctx: &mut BuildContext,
1976 limits: &Limits,
1977 diags: &mut Diagnostics,
1978 ) {
1979 let Some(object) = self.resources.find(names::SHADING, name, self.resolver) else {
1980 return;
1981 };
1982 let colorspaces = self.resources.color_spaces(self.resolver);
1983 let Some(shading) = Shading::load(
1984 &object,
1985 colorspaces.as_ref(),
1986 ShadingSource::ShadingOperator,
1987 self.resolver,
1988 &mut ctx.functions,
1989 limits,
1990 diags,
1991 ) else {
1992 return;
1993 };
1994 let mut bounds = self
1996 .state
1997 .clip
1998 .bounds()
1999 .unwrap_or(crate::page::DEFAULT_MEDIA_BOX);
2000 if let crate::shading::Geometry::Mesh { mesh, .. } = &shading.geometry
2002 && let Some(extent) = mesh.bounds()
2003 {
2004 bounds = bounds.intersect(self.state.ctm.transform_rect_bbox(extent));
2005 }
2006 let object = ShadingObject {
2007 shading: Arc::new(shading),
2008 matrix: self.state.ctm,
2009 bounds,
2010 };
2011 self.push(PageObject::Shading(Box::new(self.content(object))));
2012 }
2013}
2014
2015fn stroke_ctm_of(ctm: Affine) -> [f32; 4] {
2022 let [a, b, c, d, _, _] = ctm.as_coeffs();
2023 #[expect(
2024 clippy::cast_possible_truncation,
2025 reason = "the stored slot is f32, matching the graphics state's other text scalars"
2026 )]
2027 {
2028 [a as f32, c as f32, b as f32, d as f32]
2029 }
2030}
2031
2032fn build_path(points: &[PathPoint]) -> BezPath {
2040 let mut path = BezPath::new();
2041 let mut pending: Vec<Point> = Vec::new();
2042 let mut open = false;
2043 for point in points {
2048 match point.kind {
2049 PointKind::Move => {
2050 pending.clear();
2051 path.move_to(point.at);
2052 open = true;
2053 }
2054 PointKind::Line => {
2055 if open {
2056 path.line_to(point.at);
2057 }
2058 }
2059 PointKind::Curve => {
2060 pending.push(point.at);
2061 if pending.len() == 3 {
2062 if open
2063 && let (Some(a), Some(b), Some(c)) =
2064 (pending.first(), pending.get(1), pending.get(2))
2065 {
2066 path.curve_to(*a, *b, *c);
2067 }
2068 pending.clear();
2069 }
2070 }
2071 }
2072 if point.closes && open {
2076 path.close_path();
2077 pending.clear();
2078 open = false;
2079 }
2080 }
2081 path
2082}
2083
2084#[derive(Debug, Clone)]
2099pub enum FoundPattern {
2100 Loaded(Arc<Pattern>),
2102 Unusable,
2105 Missing,
2108}
2109
2110#[expect(
2123 clippy::too_many_arguments,
2124 reason = "looking a pattern up needs its name, resources, anchor matrix, \
2125 the painting object's general state, and the usual four"
2126)]
2127#[must_use]
2128pub fn load_pattern<R: Resolve>(
2129 name: &Name,
2130 resources: &Resources,
2131 parent_matrix: Affine,
2132 general: &crate::state::GeneralState,
2133 r: &R,
2134 ctx: &mut BuildContext,
2135 limits: &Limits,
2136 diags: &mut Diagnostics,
2137) -> FoundPattern {
2138 let Some(object) = resources.find(names::PATTERN, name, r) else {
2139 return FoundPattern::Missing;
2140 };
2141 if !matches!(object, Object::Dict(_) | Object::Stream(_)) {
2143 return FoundPattern::Missing;
2144 }
2145 let colorspaces = resources.color_spaces(r);
2146 let loaded = Pattern::load(
2147 &object,
2148 parent_matrix,
2149 colorspaces.as_ref(),
2150 r,
2151 &mut ctx.functions,
2152 limits,
2153 diags,
2154 );
2155 let Some(mut pattern) = loaded else {
2156 return FoundPattern::Unusable;
2157 };
2158 if let Pattern::Tiling(tiling) = &mut pattern
2159 && let Some(stream) = object.as_stream()
2160 {
2161 tiling.objects =
2162 expand_tiling_cell(tiling, stream, general, resources, r, ctx, limits, diags);
2163 }
2164 FoundPattern::Loaded(Arc::new(pattern))
2165}
2166
2167#[expect(
2183 clippy::too_many_arguments,
2184 reason = "expanding a cell needs the pattern, its stream, the inherited \
2185 state, resources, resolver and the usual three"
2186)]
2187fn expand_tiling_cell<R: Resolve>(
2188 tiling: &TilingPattern,
2189 stream: &pdfrum_object::Stream,
2190 general: &crate::state::GeneralState,
2191 outer: &Resources,
2192 r: &R,
2193 ctx: &mut BuildContext,
2194 limits: &Limits,
2195 diags: &mut Diagnostics,
2196) -> Vec<PageObject> {
2197 let content = pdfrum_filters::decode_chain(stream, 0, r, limits, diags).data;
2198 let id = BufferId::new(None, &content);
2199 if ctx.in_flight.len() > MAX_FORM_LEVEL || ctx.in_flight.contains(&id) {
2200 diags.record(Severity::Recovered, DiagKind::FormRecursionRefused, None);
2201 return Vec::new();
2202 }
2203 let mut initial = GraphicsState {
2205 general: general.clone(),
2206 ctm: tiling.matrix,
2207 ..GraphicsState::default()
2208 };
2209 if tiling.bbox.width() > 0.0 && tiling.bbox.height() > 0.0 {
2212 initial.clip.push_path(
2213 tiling.matrix * kurbo::Shape::to_path(&tiling.bbox, 0.1),
2214 ClipRule::Winding,
2215 );
2216 }
2217 let resources = Resources::choose(
2218 tiling.resources.clone(),
2219 outer.chosen.clone(),
2220 outer.page.clone(),
2221 );
2222 ctx.in_flight.insert(id);
2223 let ops = crate::parse_content(&content, limits, diags);
2224 let objects = interpret(
2225 &ops,
2226 &resources,
2227 &initial,
2228 tiling.matrix,
2229 r,
2230 ctx,
2231 limits,
2232 diags,
2233 );
2234 ctx.in_flight.remove(&id);
2235 objects
2236}
2237
2238pub fn eliminate_redundant_clips(
2245 objects: &mut [PageObject],
2246 bounds_of: impl Fn(&PageObject) -> Rect,
2247) {
2248 for object in objects.iter_mut() {
2249 if matches!(object, PageObject::Shading(_)) {
2251 continue;
2252 }
2253 let rect = bounds_of(object);
2254 let state = match object {
2255 PageObject::Path(c) => &mut c.state,
2256 PageObject::Text(c) => &mut c.state,
2257 PageObject::Image(c) => &mut c.state,
2258 PageObject::Form(c) => &mut c.state,
2259 PageObject::Shading(_) => continue,
2261 };
2262 if state.clip.len() != 1 {
2263 continue;
2264 }
2265 let Some(crate::state::ClipEntry::Path { path, .. }) = state.clip.entries().first() else {
2266 continue;
2267 };
2268 let clip_rect = kurbo::Shape::bounding_box(path);
2269 if clip_rect.x0 <= rect.x0
2270 && clip_rect.y0 <= rect.y0
2271 && clip_rect.x1 >= rect.x1
2272 && clip_rect.y1 >= rect.y1
2273 {
2274 state.clip = crate::state::ClipStack::new();
2275 }
2276 }
2277}
2278
2279#[cfg(test)]
2280mod tests {
2281 #![allow(
2285 clippy::unreadable_literal,
2286 clippy::float_cmp,
2287 clippy::indexing_slicing,
2288 clippy::cast_precision_loss,
2289 clippy::cast_possible_truncation,
2290 reason = "test fixtures quote oracle vectors verbatim and compare exactly"
2291 )]
2292
2293 use super::{BuildContext, MAX_FORM_LEVEL, build_page};
2294 use crate::color::ColorSpace;
2295 use crate::ops::{FillRule, LineCap};
2296 use crate::page::PageObject;
2297 use crate::resources::Resources;
2298 use crate::state::GraphicsState;
2299 use kurbo::{Affine, Point};
2300 use pdfrum_common::{DiagKind, Diagnostics, Limits};
2301 use pdfrum_object::NoResolve;
2302
2303 fn build(src: &[u8]) -> (crate::page::Page, Diagnostics) {
2304 build_with(src, &Resources::default())
2305 }
2306
2307 fn clip_kinds(page: &crate::page::Page) -> Vec<&'static str> {
2309 page.objects
2310 .last()
2311 .expect("at least one object")
2312 .state()
2313 .clip
2314 .entries()
2315 .iter()
2316 .map(|e| match e {
2317 crate::state::ClipEntry::Path { .. } => "path",
2318 crate::state::ClipEntry::Text { .. } => "text",
2319 })
2320 .collect()
2321 }
2322
2323 #[test]
2324 fn a_clipping_text_mode_reaches_the_clip_stack_at_et() {
2325 let (page, _) = build(b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj ET 0 0 50 50 re f");
2331 assert_eq!(clip_kinds(&page), ["text"]);
2332 }
2333
2334 #[test]
2335 fn a_non_clipping_mode_contributes_nothing() {
2336 let (page, _) = build(b"BT /F1 24 Tf 10 10 Td (Hi) Tj ET 0 0 50 50 re f");
2337 assert!(clip_kinds(&page).is_empty());
2338 }
2339
2340 #[test]
2344 fn the_mode_at_et_decides_whether_the_batch_is_kept() {
2345 let (page, _) = build(b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj 0 Tr ET 0 0 50 50 re f");
2346 assert!(clip_kinds(&page).is_empty(), "the batch is dropped at ET");
2347 let (page, _) = build(
2349 b"BT /F1 24 Tf 7 Tr 10 10 Td (Hi) Tj 0 Tr ET \
2350 BT /F1 24 Tf 7 Tr 10 10 Td (o) Tj ET 0 0 50 50 re f",
2351 );
2352 assert_eq!(
2353 clip_kinds(&page),
2354 ["text"],
2355 "only the second object's own run clips"
2356 );
2357 }
2358
2359 #[test]
2360 fn a_standalone_form_clips_to_its_own_bbox() {
2361 use pdfrum_object::{ByteSpan, Dict, Name, Object, Stream};
2366 let dict = Dict::from_pairs([(
2367 Name::from("BBox"),
2368 Object::Array(pdfrum_object::Array::of([
2369 Object::Int(0),
2370 Object::Int(0),
2371 Object::Int(10),
2372 Object::Int(20),
2373 ])),
2374 )]);
2375 let stream = Stream::new(dict, ByteSpan::from(b"0 0 100 100 re f".to_vec()));
2376 let mut ctx = BuildContext::default();
2377 let mut diags = Diagnostics::default();
2378 let object = super::build_form_object(
2379 &stream,
2380 Affine::IDENTITY,
2381 &Resources::default(),
2382 &NoResolve,
2383 &mut ctx,
2384 &Limits::default(),
2385 &mut diags,
2386 )
2387 .expect("a form");
2388 let PageObject::Form(form) = &object else {
2389 panic!("expected a form");
2390 };
2391 assert_eq!(
2392 form.object.bbox,
2393 Some(kurbo::Rect::new(0.0, 0.0, 10.0, 20.0))
2394 );
2395 let child = form.object.objects.first().expect("one child");
2398 let PageObject::Path(path) = child else {
2399 panic!("expected a path");
2400 };
2401 assert_eq!(path.state.clip.len(), 1, "the bbox is on the child's clip");
2402 assert_eq!(
2403 path.state.clip.bounds(),
2404 Some(kurbo::Rect::new(0.0, 0.0, 10.0, 20.0))
2405 );
2406 }
2407
2408 #[test]
2409 fn a_form_with_no_bbox_is_unclipped() {
2410 use pdfrum_object::{ByteSpan, Dict, Stream};
2411 let stream = Stream::new(Dict::new(), ByteSpan::from(b"0 0 100 100 re f".to_vec()));
2412 let mut ctx = BuildContext::default();
2413 let mut diags = Diagnostics::default();
2414 let object = super::build_form_object(
2415 &stream,
2416 Affine::IDENTITY,
2417 &Resources::default(),
2418 &NoResolve,
2419 &mut ctx,
2420 &Limits::default(),
2421 &mut diags,
2422 )
2423 .expect("a form");
2424 let PageObject::Form(form) = &object else {
2425 panic!("expected a form");
2426 };
2427 assert_eq!(form.object.bbox, None, "a missing /BBox is no clip at all");
2428 let PageObject::Path(path) = form.object.objects.first().expect("one child") else {
2429 panic!("expected a path");
2430 };
2431 assert!(path.state.clip.is_empty());
2432 }
2433
2434 fn build_with(src: &[u8], resources: &Resources) -> (crate::page::Page, Diagnostics) {
2435 build_under(src, resources, &Limits::default())
2436 }
2437
2438 fn build_under(
2439 src: &[u8],
2440 resources: &Resources,
2441 limits: &Limits,
2442 ) -> (crate::page::Page, Diagnostics) {
2443 let mut diags = Diagnostics::default();
2444 let ops = crate::parse_content(src, limits, &mut diags);
2445 let mut ctx = BuildContext::new();
2446 let page = build_page(&ops, resources, &NoResolve, &mut ctx, limits, &mut diags);
2447 (page, diags)
2448 }
2449
2450 #[test]
2453 fn a_spent_deadline_stops_the_interpreter_with_a_diagnostic() {
2454 let spent = Limits {
2455 deadline: Some(pdfrum_common::Deadline::after(std::time::Duration::ZERO)),
2456 ..Limits::default()
2457 };
2458 let (page, diags) = build_under(
2459 b"0 0 10 10 re f 0 0 20 20 re f",
2460 &Resources::default(),
2461 &spent,
2462 );
2463 assert!(page.objects.is_empty());
2464 assert!(diags.contains(&DiagKind::TimeLimitReached));
2465
2466 let generous = Limits {
2467 deadline: Some(pdfrum_common::Deadline::after(
2468 std::time::Duration::from_hours(1),
2469 )),
2470 ..Limits::default()
2471 };
2472 let (page, diags) = build_under(
2473 b"0 0 10 10 re f 0 0 20 20 re f",
2474 &Resources::default(),
2475 &generous,
2476 );
2477 assert_eq!(page.objects.len(), 2);
2478 assert!(!diags.contains(&DiagKind::TimeLimitReached));
2479 }
2480
2481 #[derive(Debug, Default)]
2494 struct Store(std::collections::HashMap<u32, std::sync::Arc<pdfrum_object::Object>>);
2495
2496 impl pdfrum_object::Resolve for Store {
2497 fn fetch(
2498 &self,
2499 r: pdfrum_object::ObjRef,
2500 ) -> Result<std::sync::Arc<pdfrum_object::Object>, pdfrum_object::Error> {
2501 self.0
2502 .get(&r.num)
2503 .map(std::sync::Arc::clone)
2504 .ok_or(pdfrum_object::Error::UnresolvedRef(r))
2505 }
2506 }
2507
2508 fn helvetica() -> pdfrum_object::Dict {
2511 use pdfrum_object::{Dict, Name, Object};
2512 Dict::from_pairs([
2513 (Name::from("Type"), Object::Name(Name::from("Font"))),
2514 (Name::from("Subtype"), Object::Name(Name::from("Type1"))),
2515 (
2516 Name::from("BaseFont"),
2517 Object::Name(Name::from("Helvetica")),
2518 ),
2519 ])
2520 }
2521
2522 fn font_from_ext_gstate(first: pdfrum_object::Object) -> Option<f32> {
2525 use pdfrum_object::{Array, Dict, Name, Object};
2526 let store = Store(
2527 [(7u32, std::sync::Arc::new(Object::Dict(helvetica())))]
2528 .into_iter()
2529 .collect(),
2530 );
2531 let gs = Dict::from_pairs([(
2532 Name::from("Font"),
2533 Object::Array(Array::of([first, Object::Int(12)])),
2534 )]);
2535 let resources = Resources {
2536 chosen: Some(Dict::from_pairs([
2537 (
2538 Name::from("ExtGState"),
2539 Object::Dict(Dict::from_pairs([(Name::from("GS"), Object::Dict(gs))])),
2540 ),
2541 (
2542 Name::from("Font"),
2543 Object::Dict(Dict::from_pairs([(
2544 Name::from("F1"),
2545 Object::Dict(helvetica()),
2546 )])),
2547 ),
2548 ])),
2549 page: None,
2550 };
2551 let limits = Limits::default();
2552 let mut diags = Diagnostics::default();
2553 let ops = crate::parse_content(b"/GS gs BT (x) Tj ET", &limits, &mut diags);
2554 let mut ctx = BuildContext::new();
2555 let mut state = GraphicsState::default();
2556 let page = build_page(&ops, &resources, &store, &mut ctx, &limits, &mut diags);
2560 let _ = &mut state;
2561 page.objects
2562 .first()
2563 .and_then(|o| o.state().text.font.as_ref())
2564 .map(|(_, size)| *size)
2565 }
2566
2567 #[test]
2570 fn an_ext_gstate_font_resolves_the_specs_indirect_reference() {
2571 let size =
2572 font_from_ext_gstate(pdfrum_object::Object::Ref(pdfrum_object::ObjRef::new(7, 0)));
2573 assert_eq!(size, Some(12.0));
2574 }
2575
2576 #[test]
2578 fn an_ext_gstate_font_still_takes_the_oracles_resource_name() {
2579 let size =
2580 font_from_ext_gstate(pdfrum_object::Object::Name(pdfrum_object::Name::from("F1")));
2581 assert_eq!(size, Some(12.0));
2582 }
2583
2584 #[test]
2588 fn an_ext_gstate_font_reference_to_nothing_installs_nothing() {
2589 let size = font_from_ext_gstate(pdfrum_object::Object::Ref(pdfrum_object::ObjRef::new(
2590 99, 0,
2591 )));
2592 assert_eq!(size, None);
2593 }
2594
2595 #[test]
2596 fn two_form_objects_with_identical_bytes_are_two_forms() {
2597 use super::BufferId;
2605 use pdfrum_object::ObjRef;
2606 let body = b"/X1 Do";
2607 let five = BufferId::new(
2608 Some(ObjRef {
2609 num: 5,
2610 generation: 0,
2611 }),
2612 body,
2613 );
2614 let six = BufferId::new(
2615 Some(ObjRef {
2616 num: 6,
2617 generation: 0,
2618 }),
2619 body,
2620 );
2621 assert_ne!(five, six, "same bytes, different objects, different ids");
2622 assert_eq!(
2623 five,
2624 BufferId::new(
2625 Some(ObjRef {
2626 num: 5,
2627 generation: 0
2628 }),
2629 body
2630 ),
2631 "the same object really is the same id, which is what catches a \
2632 form that draws itself"
2633 );
2634 assert_ne!(
2637 BufferId::new(None, b"a"),
2638 BufferId::new(None, b"b"),
2639 "content still distinguishes two unreferenced buffers"
2640 );
2641 }
2642
2643 #[test]
2644 fn a_rectangle_fill_produces_one_path_object() {
2645 let (page, _) = build(b"0 0 100 50 re f");
2646 assert_eq!(page.objects.len(), 1);
2647 let PageObject::Path(path) = &page.objects[0] else {
2648 panic!("expected a path, got {:?}", page.objects[0]);
2649 };
2650 assert_eq!(path.object.fill_rule, FillRule::Winding);
2651 assert!(!path.object.stroke);
2652 }
2653
2654 #[test]
2655 fn n_with_no_clip_produces_nothing() {
2656 let (page, _) = build(b"0 0 100 50 re n");
2657 assert!(page.objects.is_empty());
2658 }
2659
2660 #[test]
2661 fn n_with_a_pending_clip_clips_but_paints_nothing() {
2662 let (page, _) = build(b"0 0 100 50 re W n 0 0 10 10 re f");
2663 assert_eq!(page.objects.len(), 1);
2665 let PageObject::Path(path) = &page.objects[0] else {
2666 panic!("expected a path");
2667 };
2668 assert_eq!(path.state.clip.len(), 1);
2669 }
2670
2671 #[test]
2684 fn a_curve_that_closes_its_subpath_stays_a_curve_and_leaks_nothing() {
2685 let (page, _) = build(
2686 b"10 10 m 12 14 16 14 18 10 c 14 6 12 6 10 10 c h \
2687 50 10 m 52 14 56 14 58 10 c 54 6 52 6 50 10 c h S",
2688 );
2689 let PageObject::Path(path) = &page.objects[0] else {
2690 panic!("expected a path");
2691 };
2692 let elements: Vec<_> = path.object.path.elements().to_vec();
2693 let kinds: Vec<&str> = elements
2695 .iter()
2696 .map(|e| match e {
2697 kurbo::PathEl::MoveTo(_) => "M",
2698 kurbo::PathEl::LineTo(_) => "L",
2699 kurbo::PathEl::CurveTo(..) => "C",
2700 kurbo::PathEl::QuadTo(..) => "Q",
2701 kurbo::PathEl::ClosePath => "Z",
2702 })
2703 .collect();
2704 assert_eq!(kinds, ["M", "C", "C", "Z", "M", "C", "C", "Z"], "{kinds:?}");
2705 let kurbo::PathEl::CurveTo(a, b, c) = elements[5] else {
2708 panic!("expected the second subpath's first curve");
2709 };
2710 for p in [a, b, c] {
2711 assert!(
2712 p.x >= 49.0,
2713 "control point {p:?} leaked from the first glyph"
2714 );
2715 }
2716 }
2717
2718 #[test]
2719 fn a_line_before_any_move_is_discarded() {
2720 let (page, _) = build(b"5 5 l 10 10 l S");
2721 assert!(page.objects.is_empty());
2723 }
2724
2725 #[test]
2726 fn consecutive_moves_collapse_to_the_last() {
2727 let (page, _) = build(b"1 1 m 2 2 m 3 3 m 9 9 l S");
2728 let PageObject::Path(path) = &page.objects[0] else {
2729 panic!("expected a path");
2730 };
2731 let start = path.object.path.elements().first().copied();
2733 assert!(
2734 matches!(start, Some(kurbo::PathEl::MoveTo(p)) if (p.x - 3.0).abs() < 1e-6),
2735 "got {start:?}"
2736 );
2737 }
2738
2739 #[test]
2740 fn a_single_point_paints_nothing_unless_the_cap_is_round() {
2741 let (page, _) = build(b"5 5 m h S");
2743 assert!(page.objects.is_empty());
2744 let (page, _) = build(b"1 J 5 5 m h S");
2746 assert_eq!(page.objects.len(), 1);
2747 }
2748
2749 #[test]
2750 fn a_single_point_with_a_pending_clip_blanks_everything() {
2751 let (page, _) = build(b"5 5 m W n 0 0 10 10 re f");
2752 let PageObject::Path(path) = &page.objects[0] else {
2753 panic!("expected a path");
2754 };
2755 let bounds = path.state.clip.bounds().expect("an empty clip");
2756 assert!(bounds.area() < 1e-6, "got {bounds:?}");
2757 }
2758
2759 #[test]
2760 fn q_and_restore_round_trip_the_state() {
2761 let (page, _) = build(b"q 5 w 1 0 0 rg Q 0 0 10 10 re f");
2762 let PageObject::Path(path) = &page.objects[0] else {
2763 panic!("expected a path");
2764 };
2765 assert!((path.state.stroke_params.width - 1.0).abs() < 1e-6);
2767 assert_eq!(&path.state.fill.components[..], &[0.0]);
2768 }
2769
2770 #[test]
2771 fn an_unbalanced_restore_is_harmless() {
2772 let (page, diags) = build(b"Q Q 0 0 10 10 re f");
2773 assert_eq!(page.objects.len(), 1);
2774 assert!(diags.contains(&DiagKind::UnbalancedRestore));
2775 }
2776
2777 #[test]
2778 fn cm_pre_concatenates() {
2779 let (page, _) = build(b"2 0 0 2 0 0 cm 1 0 0 1 10 0 cm 0 0 1 1 re f");
2780 let PageObject::Path(path) = &page.objects[0] else {
2781 panic!("expected a path");
2782 };
2783 let origin = path.object.matrix * Point::ZERO;
2785 assert!((origin.x - 20.0).abs() < 1e-6, "got {origin:?}");
2786 }
2787
2788 #[test]
2789 fn tz_is_stored_as_a_fraction() {
2790 let (page, _) = build(b"150 Tz 0 0 10 10 re f");
2791 let PageObject::Path(path) = &page.objects[0] else {
2792 panic!("expected a path");
2793 };
2794 assert!((path.state.text.horz_scale - 1.5).abs() < 1e-6);
2795 }
2796
2797 #[test]
2798 fn td_sets_the_leading_to_the_negated_offset() {
2799 let (page, _) = build(b"BT 0 -14 TD ET 0 0 1 1 re f");
2800 let PageObject::Path(path) = &page.objects[0] else {
2801 panic!("expected a path");
2802 };
2803 assert!((path.state.text.leading - 14.0).abs() < 1e-6);
2804 }
2805
2806 fn first_text_state(page: &crate::page::Page) -> &crate::state::TextState {
2807 let PageObject::Text(text) = &page.objects[0] else {
2808 panic!("expected text, got {:?}", page.objects[0]);
2809 };
2810 &text.state.text
2811 }
2812
2813 #[test]
2814 fn a_stroked_tj_under_a_scaling_ctm_records_the_transposed_linear_part() {
2815 let (page, _) = build(b"2 0 0 3 0 0 cm BT /F1 24 Tf 1 Tr (x) Tj ET");
2818 assert_eq!(first_text_state(&page).stroke_ctm, [2.0, 0.0, 0.0, 3.0]);
2819 assert_eq!(
2820 first_text_state(&page).render_mode,
2821 crate::ops::TextRenderMode::Stroke
2822 );
2823
2824 let (page, _) = build(b"1 2 3 4 0 0 cm BT /F1 24 Tf 1 Tr (x) Tj ET");
2826 assert_eq!(first_text_state(&page).stroke_ctm, [1.0, 3.0, 2.0, 4.0]);
2827 }
2828
2829 #[test]
2830 fn a_filled_tj_under_a_scaling_ctm_keeps_the_identity_stroke_ctm() {
2831 let (page, _) = build(b"2 0 0 3 0 0 cm BT /F1 24 Tf 0 Tr (x) Tj ET");
2832 assert_eq!(first_text_state(&page).stroke_ctm, [1.0, 0.0, 0.0, 1.0]);
2833 assert_eq!(
2834 first_text_state(&page).render_mode,
2835 crate::ops::TextRenderMode::Fill
2836 );
2837 }
2838
2839 #[test]
2840 fn an_out_of_range_text_render_mode_leaves_the_mode_alone() {
2841 let (page, diags) = build(b"2 Tr 9 Tr 0 0 1 1 re f");
2842 let PageObject::Path(path) = &page.objects[0] else {
2843 panic!("expected a path");
2844 };
2845 assert_eq!(
2846 path.state.text.render_mode,
2847 crate::ops::TextRenderMode::FillStroke,
2848 "the 9 should have been refused"
2849 );
2850 assert!(diags.contains(&DiagKind::BadTextRenderMode));
2851 }
2852
2853 #[test]
2854 fn a_colorspace_operator_resets_the_colour_to_the_default() {
2855 let (page, _) = build(b"1 0 0 rg /DeviceGray cs 0 0 1 1 re f");
2856 let PageObject::Path(path) = &page.objects[0] else {
2857 panic!("expected a path");
2858 };
2859 assert_eq!(&path.state.fill.components[..], &[0.0]);
2860 assert_eq!(
2861 path.state.fill.space.as_deref(),
2862 Some(&ColorSpace::DeviceGray)
2863 );
2864 }
2865
2866 #[test]
2867 fn too_few_colour_operands_leave_the_colour_standing() {
2868 let (page, _) = build(b"0 0 1 rg /DeviceCMYK cs 0.5 0.5 sc 0 0 1 1 re f");
2869 let PageObject::Path(path) = &page.objects[0] else {
2870 panic!("expected a path");
2871 };
2872 assert_eq!(&path.state.fill.components[..], &[0.0, 0.0, 0.0, 0.0]);
2874 }
2875
2876 #[test]
2877 fn marked_content_is_snapshotted_onto_each_object() {
2878 let (page, _) = build(b"/Span BMC 0 0 1 1 re f EMC 0 0 1 1 re f");
2879 assert_eq!(page.objects.len(), 2);
2880 assert_eq!(page.objects[0].marks().len(), 1);
2881 assert_eq!(page.objects[1].marks().len(), 0);
2882 }
2883
2884 #[test]
2885 fn an_unbalanced_emc_is_harmless() {
2886 let (page, diags) = build(b"EMC EMC 0 0 1 1 re f");
2887 assert_eq!(page.objects.len(), 1);
2888 assert!(diags.contains(&DiagKind::UnbalancedMarkedContent));
2889 }
2890
2891 #[test]
2892 fn b_star_appends_its_closing_segment_unconditionally() {
2893 let (with_b, _) = build(b"0 0 m 10 0 l 0 0 l b");
2895 let (with_b_star, _) = build(b"0 0 m 10 0 l 0 0 l b*");
2896 let PageObject::Path(a) = &with_b.objects[0] else {
2897 panic!("expected a path");
2898 };
2899 let PageObject::Path(b) = &with_b_star.objects[0] else {
2900 panic!("expected a path");
2901 };
2902 assert!(
2903 b.object.path.elements().len() >= a.object.path.elements().len(),
2904 "b* should not produce fewer elements than b"
2905 );
2906 }
2907
2908 #[test]
2909 fn the_form_guard_allows_forty_one_and_refuses_the_forty_second() {
2910 assert_eq!(MAX_FORM_LEVEL, 40);
2912 let ctx = BuildContext::new();
2913 assert_eq!(ctx.forms_in_flight(), 0);
2914 }
2915
2916 #[test]
2917 fn a_dash_operand_that_is_not_an_array_is_a_no_op() {
2918 let (page, _) = build(b"[3 3] 0 d 5 0 d 0 0 1 1 re f");
2919 let PageObject::Path(path) = &page.objects[0] else {
2920 panic!("expected a path");
2921 };
2922 assert_eq!(&path.state.stroke_params.dash[..], &[3.0, 3.0]);
2924 }
2925
2926 #[test]
2927 fn the_default_state_is_what_a_page_starts_with() {
2928 let state = GraphicsState::default();
2929 assert_eq!(state.ctm, Affine::IDENTITY);
2930 assert_eq!(state.stroke_params.cap, LineCap::Butt);
2931 }
2932
2933 #[test]
2934 fn an_appearance_is_not_a_live_edit_unless_it_is_built_as_one() {
2935 let stream = pdfrum_object::Stream::new(
2940 pdfrum_object::Dict::new(),
2941 pdfrum_object::ByteSpan::from(b"0 0 10 10 re f".to_vec()),
2942 );
2943 let build = |live_edit| {
2944 let mut ctx = BuildContext::default();
2945 let mut diags = Diagnostics::default();
2946 let object = super::build_form_object_with(
2947 &stream,
2948 Affine::IDENTITY,
2949 &Resources::default(),
2950 &NoResolve,
2951 &mut ctx,
2952 &Limits::default(),
2953 &mut diags,
2954 live_edit,
2955 )
2956 .expect("a form");
2957 let PageObject::Form(form) = object else {
2958 panic!("expected a form");
2959 };
2960 form.object.live_edit
2961 };
2962 assert!(!build(false));
2963 assert!(build(true));
2964 }
2965
2966 #[test]
2967 fn the_plain_entry_point_never_marks_a_live_edit() {
2968 let stream = pdfrum_object::Stream::new(
2971 pdfrum_object::Dict::new(),
2972 pdfrum_object::ByteSpan::from(b"0 0 10 10 re f".to_vec()),
2973 );
2974 let mut ctx = BuildContext::default();
2975 let mut diags = Diagnostics::default();
2976 let object = super::build_form_object(
2977 &stream,
2978 Affine::IDENTITY,
2979 &Resources::default(),
2980 &NoResolve,
2981 &mut ctx,
2982 &Limits::default(),
2983 &mut diags,
2984 )
2985 .expect("a form");
2986 let PageObject::Form(form) = object else {
2987 panic!("expected a form");
2988 };
2989 assert!(!form.object.live_edit);
2990 }
2991}