mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #1360 from dotCooCoo/feat/pdf-export-huge-text-offsets
PDF export: art beyond 1,000,000,000 pt is left out, so dev and test builds no longer abort
This commit is contained in:
4 files changed
+274
-41
No files matched your search
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg"><text baseline-shift="-2147483649">f</text></svg>"#;
|
||||
let d = vectorcraft_svg::import(svg).unwrap();
|
||||
assert!(vectorcraft_engine::export_pdf(&d, &vectorcraft_pdf::PdfOptions::default()).is_ok());
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
|
||||
+139
-40
@@ -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<Path> {
|
||||
/// 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<Path> {
|
||||
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<krilla::paint::Paint> {
|
||||
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<krilla::paint::Paint> {
|
||||
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<krilla::paint::Paint> {
|
||||
fn paint_in(&mut self, s: &Surface, p: &Paint, bounds: Rect, space: Affine) -> Option<krilla::paint::Paint> {
|
||||
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<KrillaGlyph> = 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<f64> = 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<Image>
|
||||
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();
|
||||
}
|
||||
}
|
||||
+74
-1
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user