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#""#;
+ 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]