diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 4d7ed045..ea0fa08d 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -485,6 +485,15 @@ fn mutate_text(src: &str, cut: usize, edits: &[(usize, char)]) -> String { chars.into_iter().collect() } +/// Type shifted two billion points down exports to PDF: an input of +/// `svg_hostile_markup_never_panics`, cut down to its type. +#[test] +fn svg_type_shifted_two_billion_points_exports_to_pdf() { + let svg = r#"f"#; + let d = vectorcraft_svg::import(svg).unwrap(); + assert!(vectorcraft_engine::export_pdf(&d, &vectorcraft_pdf::PdfOptions::default()).is_ok()); +} + proptest! { #![proptest_config(config())] diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 990fc3cb..4b0484a7 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -14,7 +14,7 @@ use krilla::num::NormalizedF32; use krilla::page::PageSettings; use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash}; use krilla::surface::Surface; -use krilla::text::{GlyphId, KrillaGlyph}; +use krilla::text::{Glyph, GlyphId, KrillaGlyph}; use kurbo::{PathEl, Shape, Vec2}; use vectorcraft_color::{BlendMode, Color, GradientKind, Paint}; use vectorcraft_doc::{AppearanceItem, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject}; @@ -229,8 +229,10 @@ impl Writer { if sheet.negative { cover(&mut s, paper, 255, 1.0); } + // A page whose art or marks would be placed beyond [`MAX_COORD`] is left blank. + let fits = writable(&s, sheet.view, Rect::ZERO) && writable(&s, sheet.view * sheet.place, Rect::ZERO); s.push_transform(&xf(sheet.view)); - let window = sheet.window.and_then(|r| to_path(&r.to_path(0.1))); + let window = sheet.window.and_then(|r| to_path(&s, &r.to_path(0.1))); if let Some(clip) = &window { s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); } @@ -239,7 +241,7 @@ impl Writer { s.push_transform(&xf(sheet.place)); } let r = sheet.area; - let clip = sheet.clip.then(|| to_path(&r.to_path(0.1))).flatten(); + let clip = sheet.clip.then(|| to_path(&s, &r.to_path(0.1))).flatten(); if let Some(clip) = &clip { s.push_clip_path(clip, &krilla::paint::FillRule::NonZero); } @@ -248,15 +250,17 @@ impl Writer { // at the top of a page is put in a non-isolated group of its own, whose blending space // `cmyk_blending` rewrites. let doc = ex.doc; - let page_group = !self.pdf13 && (doc.page_isolate || doc.page_knockout); - let cmyk_page_group = self.cmyk && !page_group && ex.shows_transparency(); + let page_group = fits && !self.pdf13 && (doc.page_isolate || doc.page_knockout); + let cmyk_page_group = fits && self.cmyk && !page_group && ex.shows_transparency(); if page_group { s.push_isolated(); } else if cmyk_page_group { let all = constant_mask(&mut s, r, 1.0); s.push_mask(all); } - ex.children(&mut s, &doc.layers, r); + if fits { + ex.children(&mut s, &doc.layers, r); + } if page_group || cmyk_page_group { s.pop(); } @@ -266,7 +270,7 @@ impl Writer { if placed { s.pop(); } - if let Some(art) = sheet.marks { + if fits && let Some(art) = sheet.marks { // The drawing space the page shows (the marks' area). let shown = sheet.view.inverse().transform_rect_bbox(paper); let knockout = std::mem::replace(&mut ex.knockout, false); @@ -525,12 +529,43 @@ struct GlyphLine { /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). const MAX_FIELD_PX: f64 = 512.0; +/// The largest magnitude a coordinate, length or transform entry may have in the content stream it +/// is drawn into (for glyph spacing, in thousandths of an em). Art that would go beyond it is left +/// out. A PDF/A-2 file may hold only integers from -2,147,483,648 to 2,147,483,647 (ISO 19005-2, +/// 6.1.13). The PDF writer writes a whole number in the `i32` range as an integer, and in builds +/// with overflow checks it panics on -2,147,483,648. +const MAX_COORD: f64 = 1e9; + fn xf(a: Affine) -> Transform { let c = a.as_coeffs(); Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32) } -fn to_path(bp: &BezPath) -> Option { +/// The current transform of surface `s` in the content stream it draws into, without that stream's +/// root transform (a page's y flip). +fn ctm(s: &Surface) -> Affine { + let t = s.ctm(); + Affine::new([t.sx(), t.ky(), t.kx(), t.sy(), t.tx(), t.ty()].map(f64::from)) +} + +/// Are `values` finite and within [`MAX_COORD`]? +fn in_range(values: &[f64]) -> bool { + values.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) +} + +/// Can art spanning `b`, in the space `space` maps into the one `s` draws in, be written: `b`, the +/// transform of `s` followed by `space`, and `b` through that transform all within [`MAX_COORD`]? +fn writable(s: &Surface, space: Affine, b: Rect) -> bool { + let t = ctm(s) * space; + let to = t.transform_rect_bbox(b); + in_range(&t.as_coeffs()) && in_range(&[b.x0, b.y0, b.x1, b.y1, to.x0, to.y0, to.x1, to.y1]) +} + +/// `bp` as a path to draw on `s`; `None` when it is empty or reaches beyond [`MAX_COORD`] there. +fn to_path(s: &Surface, bp: &BezPath) -> Option { + if !writable(s, Affine::IDENTITY, bp.control_box()) { + return None; + } let mut pb = PathBuilder::new(); for el in bp.elements() { match *el { @@ -597,7 +632,7 @@ fn color(c: &Color) -> krilla::color::Color { /// Fill the page (with a margin) with a grey level (0 = black, 255 = white) at `opacity`: mask /// backdrops. fn cover(s: &mut Surface, page: Rect, level: u8, opacity: f32) { - let Some(p) = to_path(&page.inflate(1.0, 1.0).to_path(0.1)) else { return }; + let Some(p) = to_path(s, &page.inflate(1.0, 1.0).to_path(0.1)) else { return }; s.set_stroke(None); s.set_fill(Some(Fill { paint: rgb::Color::new(level, level, level).into(), opacity: norm(opacity), rule: krilla::paint::FillRule::NonZero })); s.draw_path(&p); @@ -718,14 +753,15 @@ impl Exporter<'_> { } } - /// Convert a paint. `bounds` resolves unset gradient geometry (like the renderer). - fn paint(&mut self, p: &Paint, bounds: Rect) -> Option { - self.paint_in(p, bounds, Affine::IDENTITY) + /// Convert a paint to draw on `s`. `bounds` resolves unset gradient geometry (like the + /// renderer). A gradient placed beyond [`MAX_COORD`] there gives no paint. + fn paint(&mut self, s: &Surface, p: &Paint, bounds: Rect) -> Option { + self.paint_in(s, p, bounds, Affine::IDENTITY) } /// [`Self::paint`] drawn in another space: `space` maps the paint's space (where `bounds` /// is) to the one drawn in. - fn paint_in(&mut self, p: &Paint, bounds: Rect, space: Affine) -> Option { + fn paint_in(&mut self, s: &Surface, p: &Paint, bounds: Rect, space: Affine) -> Option { match p { Paint::None => None, Paint::Solid { color: c, swatch, tint } => Some(self.solid(c, swatch.as_deref(), *tint).into()), @@ -761,6 +797,9 @@ impl Exporter<'_> { } match g.gradient.kind { GradientKind::Linear => { + if !writable(s, space, Rect::from_points(geom.start, geom.end)) { + return None; + } let (s, mut e) = (geom.start, geom.end); if s.distance(e) < 1e-9 { e = s + Vec2::new(1.0, 0.0); @@ -785,6 +824,10 @@ impl Exporter<'_> { let (cx, cy) = (geom.start.x as f32, geom.start.y as f32); // An off-centre focal point: a two-point radial shading from it. let f = t.inverse() * geom.focal_point(); + let circles = Rect::from_center_size(geom.start, (2.0 * f64::from(r), 2.0 * f64::from(r))).union_pt(f); + if !writable(s, space * t, circles) { + return None; + } Some( RadialGradient { fx: f.x as f32, @@ -968,7 +1011,7 @@ impl Exporter<'_> { // The region every output clips to; nothing to clip by hides the clipped art. The // clipping path's fill paints behind the clipped art and its stroke over it, unclipped. if let Some((clip, rest)) = children.split_first() - && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) + && let Some((p, r)) = vectorcraft_effects::clip_outline(clip).and_then(|(bp, r)| to_path(s, &bp).map(|p| (p, r))) { let paint = clip.clip_paint(); s.push_clip_path(&p, &rule(r)); @@ -1020,7 +1063,7 @@ impl Exporter<'_> { } fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule, page: Rect) { - let Some(path) = to_path(bp) else { return }; + let Some(path) = to_path(s, bp) else { return }; let bounds = bp.bounding_box(); for item in &n.appearance.items { match item { @@ -1033,7 +1076,7 @@ impl Exporter<'_> { if ex.area(s, &path, rule(r), &fl.paint, (fl.opacity, fl.blend), bounds, bounds) { return; } - let Some(paint) = ex.paint(&fl.paint, bounds) else { return }; + let Some(paint) = ex.paint(s, &fl.paint, bounds) else { return }; let bl = fl.blend != BlendMode::Normal; if bl { s.push_blend_mode(blend(fl.blend)); @@ -1166,14 +1209,18 @@ impl Exporter<'_> { // The area the stroke paints (dashes, caps, joins, profile, arrowheads, alignment), as // Outline Stroke makes it, painted with the pattern's tiles or the freeform's image. let region = stroke::outline_region(&vectorcraft_geom::PathData::from_bezpath(bp), r, st).to_bezpath(); - if let Some(clip) = to_path(®ion) { + if let Some(clip) = to_path(s, ®ion) { let area = region.bounding_box(); self.area(s, &clip, krilla::paint::FillRule::NonZero, &st.paint, (st.opacity, st.blend), st.paint_bounds(bounds), area); } return; } - let Some(paint) = self.paint(&st.paint, st.paint_bounds(bounds)) else { return }; + let Some(paint) = self.paint(s, &st.paint, st.paint_bounds(bounds)) else { return }; let w = stroke::for_writer(bp, st); + // A stroke whose width, caps or miter spikes reach beyond [`MAX_COORD`] is left out. + if !writable(s, Affine::IDENTITY, w.reach(st, bp.control_box()).inflate(1.0, 1.0)) { + return; + } let mut pushes = 0; if st.blend != BlendMode::Normal { s.push_blend_mode(blend(st.blend)); @@ -1189,7 +1236,7 @@ impl Exporter<'_> { // (miter spikes included) plus the path, even-odd. let mut outside = w.reach(st, bounds).inflate(1.0, 1.0).to_path(0.1); outside.extend(bp.iter()); - if let Some(p) = to_path(&outside) { + if let Some(p) = to_path(s, &outside) { s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd); pushes += 1; } @@ -1203,7 +1250,11 @@ impl Exporter<'_> { if pat.len() % 2 == 1 { pat.extend(pat.clone()); } - StrokeDash { array: pat, offset: d.offset as f32 } + // An offset beyond [`MAX_COORD`] is written as its remainder after whole + // periods of the pattern. + let period: f64 = pat.iter().copied().map(f64::from).sum(); + let offset = if in_range(&[d.offset]) { d.offset } else { d.offset.rem_euclid(period) }; + StrokeDash { array: pat, offset: if offset.is_finite() { offset as f32 } else { 0.0 } } }); s.set_fill(None); s.set_stroke(Some(Stroke { @@ -1230,7 +1281,7 @@ impl Exporter<'_> { // A gradient along or across the stroke: slices clipped to its outlines, under // the stroke's opacity as a group. if let Some(ws) = stroke::written_slices(bp, r, st, outlines) - && let Some(clip) = to_path(&ws.clip) + && let Some(clip) = to_path(s, &ws.clip) { self.warn("gradients along or across strokes are exported as slices of linear gradients"); if st.opacity < 1.0 { @@ -1241,7 +1292,7 @@ impl Exporter<'_> { s.set_stroke(None); for (shape, paint) in &ws.slices { let b = shape.bounding_box(); - if let (Some(paint), Some(p)) = (self.paint(paint, b), to_path(shape)) { + if let (Some(paint), Some(p)) = (self.paint(s, paint, b), to_path(s, shape)) { s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&p); } @@ -1260,8 +1311,10 @@ impl Exporter<'_> { let opacity = if grouped { NormalizedF32::ONE } else { norm(st.opacity) }; s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity, rule: krilla::paint::FillRule::NonZero })); - for p in outlines.iter().filter_map(to_path) { - s.draw_path(&p); + for outline in outlines { + if let Some(p) = to_path(s, outline) { + s.draw_path(&p); + } } s.set_fill(None); } @@ -1315,14 +1368,16 @@ impl Exporter<'_> { all.extend(bar.iter()); } all.apply_affine(t.xf); - to_path(&all).map(|p| (all, p)) + to_path(s, &all).map(|p| (all, p)) }); let all = all.flatten(); if let Some((bp, path)) = &all { self.text_items(s, below, bp, path, page, tb); } - s.push_transform(&xf(t.xf)); - // Real text: the characters' fills as text in embedded fonts. + // Real text: the characters' fills as text in embedded fonts. The type's transform is + // pushed for each fill and stroke of the characters, inside its form when the fill or + // stroke overprints. + let text_space = xf(t.xf); let plain = (!self.outline_text).then(|| t.plain_text()); let bars = vectorcraft_text::decorations(&layout, vectorcraft_text::FontDb::global(), t); for (i, run) in t.runs.iter().enumerate() { @@ -1338,6 +1393,7 @@ impl Exporter<'_> { bp.extend(run_bars.iter()); let fill = &run.style.fill; self.overprinting(s, run.style.overprint_fill, fill, |ex, s| { + s.push_transform(&text_space); // The outlines left to fill as paths: all of them, or those real text leaves. let outlines = match plain.as_deref().filter(|_| !fill.is_none() && !ex.area_paint(fill)) { Some(text) => { @@ -1348,26 +1404,31 @@ impl Exporter<'_> { None => Cow::Borrowed(&bp), }; let opaque = (1.0, BlendMode::Normal); - if let Some(path) = to_path(&outlines) + if let Some(path) = to_path(s, &outlines) && !ex.area(s, &path, krilla::paint::FillRule::NonZero, fill, opaque, layout.bounds, outlines.bounding_box()) - && let Some(paint) = ex.paint(fill, layout.bounds) + && let Some(paint) = ex.paint(s, fill, layout.bounds) { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); s.draw_path(&path); } + s.pop(); }); - if run.style.has_stroke() - && let Some(path) = to_path(&bp) - { + s.push_transform(&text_space); + let path = run.style.has_stroke().then(|| to_path(s, &bp)).flatten(); + s.pop(); + if let Some(path) = path { // Character strokes are drawn in text space, with their cap, join and dashes. let st = run.style.stroke_layer(); - self.overprinting(s, st.overprint, &st.paint, |ex, s| ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds)); + self.overprinting(s, st.overprint, &st.paint, |ex, s| { + s.push_transform(&text_space); + ex.stroke(s, &bp, &path, FillRule::NonZero, &st, page, layout.bounds); + s.pop(); + }); } } s.set_fill(None); s.set_stroke(None); - s.pop(); if self.inline_depth < MAX_INLINE_DEPTH { self.inline_depth += 1; for ig in &layout.inlines { @@ -1393,7 +1454,7 @@ impl Exporter<'_> { match item { AppearanceItem::Fill(fl) if fl.visible => self.overprinting(s, fl.overprint, &fl.paint, |ex, s| { if !ex.area(s, path, krilla::paint::FillRule::NonZero, &fl.paint, (fl.opacity, fl.blend), tb, bp.bounding_box()) - && let Some(paint) = ex.paint(&fl.paint, tb) + && let Some(paint) = ex.paint(s, &fl.paint, tb) { s.set_stroke(None); s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero })); @@ -1468,9 +1529,9 @@ impl Exporter<'_> { } /// Draw glyphs `line` (of `text`), filled with `fill` (whose space is text space, where - /// `bounds` is). + /// `bounds` is). A line that would be written with a pen position, size or spacing (in + /// thousandths of an em) beyond [`MAX_COORD`] is left out. fn draw_glyph_line(&mut self, s: &mut Surface, line: GlyphLine, text: &str, fill: &Paint, bounds: Rect) { - let Some(paint) = self.paint_in(fill, bounds, line.frame.inverse()) else { return }; let size = line.size; let glyphs: Vec = line .glyphs @@ -1481,10 +1542,18 @@ impl Exporter<'_> { KrillaGlyph::new(GlyphId::new(*gid), ((next - x) / size) as f32, 0.0, 0.0, 0.0, range.clone(), None) }) .collect(); + let pens = + line.glyphs.iter().fold(Rect::new(0.0, -size, 0.0, size), |r, (_, x, advance, _)| r.union_pt((*x, 0.0)).union_pt((x + advance, 0.0))); + let spacing: Vec = glyphs.iter().map(|g| f64::from(g.x_advance(1.0)) * 1000.0).collect(); + if !(writable(s, line.frame, pens) && in_range(&spacing)) { + return; + } s.push_transform(&xf(line.frame)); - s.set_stroke(None); - s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); - s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false); + if let Some(paint) = self.paint_in(s, fill, bounds, line.frame.inverse()) { + s.set_stroke(None); + s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero })); + s.draw_glyphs(krilla::geom::Point::from_xy(0.0, 0.0), &glyphs, line.font, text, size as f32, false); + } s.pop(); } @@ -1558,6 +1627,10 @@ impl Exporter<'_> { } fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject) { + // An image placed or sized beyond [`MAX_COORD`] is left out. + if !writable(s, im.xf, Rect::new(0.0, 0.0, im.width.max(1).into(), im.height.max(1).into())) { + return; + } // The size it is placed at: its pixel grid's sides through its transform. let [a, b, c, d, ..] = im.xf.as_coeffs(); let size = (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)); @@ -1586,3 +1659,29 @@ fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option Some(Image::from_rgba8(rgba.into_raw(), w, h)) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writable_bounds_values_the_transform_and_values_through_it() { + let mut pdf = krilla::Document::new(); + let mut page = pdf.start_page_with(PageSettings::new(Size::from_wh(100.0, 100.0).unwrap())); + let mut s = page.surface(); + let r = Rect::new(0.0, 0.0, 10.0, 10.0); + assert!(writable(&s, Affine::IDENTITY, r)); + assert!(!writable(&s, Affine::IDENTITY, Rect::new(-2_147_483_649.0, 0.0, 10.0, 10.0))); + assert!(!writable(&s, Affine::IDENTITY, Rect::new(f64::NAN, 0.0, 10.0, 10.0))); + assert!(!writable(&s, Affine::translate((0.0, 2e9)), r)); + assert!(!writable(&s, Affine::scale(2e9), Rect::ZERO)); + // `writable` also checks the surface's own transform: with the surface moved to + // x = MAX_COORD, a point at the origin fits and a 10 by 10 box does not. + s.push_transform(&xf(Affine::translate((MAX_COORD, 0.0)))); + assert!(writable(&s, Affine::IDENTITY, Rect::ZERO)); + assert!(!writable(&s, Affine::IDENTITY, r)); + s.pop(); + s.finish(); + page.finish(); + } +} diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index f80b7dd8..443fda0a 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use vectorcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint}; +use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, Paint}; use vectorcraft_doc::{ Appearance, AppearanceItem, Artboard, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject, }; @@ -268,6 +268,79 @@ fn objects_outside_artboard_are_culled() { assert_eq!(leaves(&roundtrip(&d)).len(), 1); } +/// A line from (10, `y`) to (90, `y`). +fn line(y: f64) -> PathData { + let mut bp = vectorcraft_geom::BezPath::new(); + bp.move_to((10.0, y)); + bp.line_to((90.0, y)); + PathData::from_bezpath(&bp) +} + +/// Art that the PDF would hold a value beyond a billion points for is left out: a path that far, +/// a gradient placed that far, a stroke so wide that its group's box reaches that far, and an +/// image placed that far. Each would be written with -2,147,483,648, which the PDF writer panics +/// on in builds with overflow checks. +#[test] +fn art_reaching_beyond_a_billion_points_is_left_out() { + let far = -2_147_483_649.0; + let shaded = |kind, start, end| { + let mut n = rect_node(Rect::new(60.0, 10.0, 90.0, 50.0), Color::BLACK); + let geom = GradientGeom { start, end, aspect: 1.0, focal: None }; + let g = GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient { kind, ..Gradient::default() }) }; + n.appearance.set_fill(Paint::Gradient(Box::new(g))); + n + }; + let mut wide = Node::path(NodeId(0), line(0.0), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 4_294_967_296.0)); + wide.opacity = 0.5; + let image = ImageObject { + key: "img1".into(), + width: 4, + height: 2, + xf: Affine::new([5.5e8, 0.0, 0.0, 10.0, far, 60.0]), + link: None, + placement: Default::default(), + }; + for (what, n) in [ + ("path", rect_node(Rect::new(far, 60.0, 90.0, 90.0), Color::BLACK)), + ("linear gradient", shaded(GradientKind::Linear, Point::new(far, 0.0), Point::new(90.0, 0.0))), + ("radial gradient", shaded(GradientKind::Radial, Point::new(far, 30.0), Point::new(far + 100.0, 30.0))), + ("stroke", wide), + ("image", Node::new(NodeId(0), NodeKind::Image(Box::new(image)))), + ] { + let mut d = doc(100.0, 100.0); + d.images.insert("img1".into(), ImageBlob::new("image/png", tiny_png())); + add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK)); + add(&mut d, n); + assert_eq!(leaves(&roundtrip(&d)).len(), 1, "{what}"); + } +} + +/// A dash offset beyond a billion points is written as its remainder after whole periods of the +/// pattern: -2,147,483,649 (-2,147,483,648 as an f32, which the PDF writer panics on in builds +/// with overflow checks) is 1 in a pattern of 5 and 5. +#[test] +fn dash_offsets_beyond_a_billion_points_wrap_into_their_pattern() { + let mut d = doc(100.0, 100.0); + let mut n = Node::path(NodeId(0), line(50.0), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)); + n.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![5.0, 5.0], offset: -2_147_483_649.0, align_corners: false }); + add(&mut d, n); + assert!(uncompressed(&d).contains("[5 5]1 d")); +} + +/// The page of an artboard beyond a billion points is left blank: what is on it would be placed +/// through -2,147,483,648, which the PDF writer panics on in builds with overflow checks. +#[test] +fn artboards_beyond_a_billion_points_export_blank_pages() { + let mut d = doc(100.0, 100.0); + d.artboards[0].rect = Rect::new(2_147_483_649.0, 0.0, 2_147_483_749.0, 100.0); + let mut n = rect_node(Rect::new(2_147_483_659.0, 10.0, 2_147_483_699.0, 50.0), Color::BLACK); + n.opacity = 0.5; + add(&mut d, n); + let bytes = export(&d, &PdfOptions::default()).unwrap(); + assert_eq!(page_count(&bytes), 1); + assert!(leaves(&import(&bytes).unwrap()).is_empty()); +} + #[test] fn opacity_and_blend_modes() { let mut d = doc(200.0, 200.0); diff --git a/crates/pdf/src/tests_realtext.rs b/crates/pdf/src/tests_realtext.rs index c7b23365..f7e83f84 100644 --- a/crates/pdf/src/tests_realtext.rs +++ b/crates/pdf/src/tests_realtext.rs @@ -110,6 +110,58 @@ fn real_text_lands_within_half_a_point_of_the_outlines() { } } +/// A run shifted two billion points off its baseline is left out, as real text or outlines, and +/// the rest of the type is written. On a page 50 points high, the page's y flip puts the shifted +/// run's baseline at -2,147,483,648, which the PDF writer panics on in builds with overflow checks. +#[test] +fn a_run_shifted_two_billion_points_is_left_out() { + let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0)); + t.runs = vec![ + TextRun { text: "in".into(), style: style(12.0), inline: None }, + TextRun { text: "out".into(), style: CharStyle { baseline_shift: -2_147_483_649.0, ..style(12.0) }, inline: None }, + ]; + let mut d = doc(vec![t]); + d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0); + assert_eq!(texts(&import_as(&pdf(&d, false).bytes, TextAs::Text)), ["in"]); + let b = ink(&import_as(&pdf(&d, true).bytes, TextAs::Outlines)); + assert!(b.y0 > 0.0 && b.y1 < 50.0, "{b:?}"); +} + +/// An overprinting run is written, or left out, as the same run is without overprint: the form +/// marked to overprint the run holds the type's transform. For type moved 2,147,483,689 points down +/// with its run shifted back onto a page 50 points high, a form placed through that transform would +/// sit at y = -2,147,483,648, which the PDF writer panics on in builds with overflow checks. For +/// type scaled 3 times, a form without that transform would hold a run shifted 500,000,000 points +/// up, and the export would write the run 1,500,000,000 points up. +#[test] +fn overprinting_runs_are_written_or_left_out_as_other_runs_are() { + let moved = |overprint_fill| { + let back = CharStyle { baseline_shift: 2_147_483_649.0, overprint_fill, ..style(12.0) }; + TextObject::point(Point::new(5.0, 2_147_483_689.0), "moved", back) + }; + let scaled = |overprint_fill| { + let mut t = TextObject::point(Point::new(5.0, 40.0), "", style(12.0)); + t.xf *= Affine::scale(3.0); + let up = CharStyle { baseline_shift: 500_000_000.0, overprint_fill, ..style(12.0) }; + t.runs = vec![TextRun { text: "in".into(), style: style(12.0), inline: None }, TextRun { text: "out".into(), style: up, inline: None }]; + t + }; + let written = |mut t: TextObject| { + // The export culls type by the laid-out bounds that the engine keeps in `cached_bounds`. + t.cached_bounds = Some(vectorcraft_text::layout(FontDb::global(), &t).bounds); + let mut d = doc(vec![t]); + d.artboards[0].rect = Rect::new(0.0, 0.0, 50.0, 50.0); + pdf(&d, false).bytes + }; + let text_of = |bytes: &[u8]| texts(&import_as(bytes, TextAs::Text)); + let (on, off) = (written(moved(true)), written(moved(false))); + assert!(String::from_utf8_lossy(&on).contains("/VCop gs"), "an overprinting form"); + assert_eq!(text_of(&on), ["moved"]); + assert_eq!(text_of(&off), ["moved"]); + assert_eq!(text_of(&written(scaled(true))), ["in"]); + assert_eq!(text_of(&written(scaled(false))), ["in"]); +} + /// A named instance of a variable font is real text in that instance: its embedded subset draws /// the instance's glyphs where the outlines would be, not the default instance's (#296). #[test]