mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 10:08:00 -06:00
* M2: a tiling pattern drawn once shows its cell at full scale (#3349) PowerPoint writes a picture fill as a tiling pattern with /XStep 2147480100. Fitting that step into the 3000 px cell pixmap shrank the picture below a pixel. When what is painted can only show the first cell, the cell's /BBox is drawn at full scale with a transparent border and padded instead of tiled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q * M2: a tiling cell whose scale isn't finite keeps the old path (#3349) Review follow-up on #3356: a /BBox 1e-40 wide passes the zero-area check and gives an infinite cell scale, which would send non-finite geometry to the cell renderer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q * M2: a stroke's miter counts in the one-cell check (#3349) Review follow-up on #3356: shows_one_cell took the line width as a stroke's reach; a miter join reaches half the width times the miter limit. The reach formula patch (9) uses is now one function, stroke_reach, used by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q * M2: a skewed stroke's reach uses the transform's largest stretch (#3349) Review on #3356: under a skew, a direction between the axes stretches further than either axis, so the larger axis stretch could place a miter tip short of the next cell when it reached it, and the one-cell path left that cell unpainted. The reach now uses the spectral norm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Gv2WwdgjmBz33GWkwgNn3Q --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4d42b48d20
commit
b955c2eefb
@@ -3462,6 +3462,141 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
/// One page `w` × `h` points whose content `content` paints with pattern `/P1`: a 10 × 10 cell
|
||||
/// of blue with `/XStep step /YStep step` and pattern matrix `matrix`. Its RGBA pixels.
|
||||
fn tiling_page(w: usize, h: usize, content: &str, step: &str, matrix: &str) -> Vec<u8> {
|
||||
let cell = "0 0 1 rg 0 0 10 10 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 {w} {h}] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 10 10] /XStep {step} /YStep {step} /Matrix [{matrix}] /Length {} >> stream
|
||||
{cell}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len(),
|
||||
cell.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
page.rgba.to_vec()
|
||||
}
|
||||
|
||||
/// #3349: PowerPoint (PDFMaker) writes a picture fill as a tiling pattern drawn once, with
|
||||
/// `/XStep 2147480100 /YStep 2147480100` and a matrix that flips y. Fitting such a step into
|
||||
/// a cell pixmap shrank the picture below a pixel, so it vanished. The cell's `/BBox` is drawn
|
||||
/// at full scale, once, filled or stroked: where it is it shows, and around it (left, right,
|
||||
/// above, below) nothing repeats or smears.
|
||||
#[test]
|
||||
fn a_tiling_pattern_drawn_once_shows_its_cell() {
|
||||
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
|
||||
let (blue, white) = (vec![0, 0, 255], vec![255, 255, 255]);
|
||||
// The cell at user x 10..20 and, flipped, y 10..20: device rows 20..30.
|
||||
let once = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "2147480100", "1 0 0 -1 10 20");
|
||||
assert_eq!(at(&once, 40, 15, 25), blue, "the cell");
|
||||
for (x, y, side) in [(5, 25, "left"), (25, 25, "right"), (15, 15, "above"), (15, 35, "below")] {
|
||||
assert_eq!(at(&once, 40, x, y), white, "nothing {side} of it");
|
||||
}
|
||||
// Stroked: a wide line across the cell shows it too.
|
||||
let stroked = tiling_page(40, 40, "/Pattern CS /P1 SCN 20 w 0 15 m 40 15 l S", "2147480100", "1 0 0 -1 10 20");
|
||||
assert_eq!(at(&stroked, 40, 15, 25), blue, "a stroke with the pattern");
|
||||
assert_eq!(at(&stroked, 40, 25, 25), white);
|
||||
// An ordinary step still tiles.
|
||||
let tiled = tiling_page(40, 40, "/Pattern cs /P1 scn 0 0 40 40 re f", "20", "1 0 0 1 0 0");
|
||||
assert_eq!(at(&tiled, 40, 25, 35), blue, "a step of 20 repeats the cell");
|
||||
}
|
||||
|
||||
/// #3356 review: a `/BBox` too thin to divide by (not zero, which is refused, but 1e-40
|
||||
/// wide) makes the cell scale infinite; it keeps the old path, so no infinite or NaN
|
||||
/// geometry reaches the cell renderer, the page renders and what follows still draws.
|
||||
#[test]
|
||||
fn a_tiling_cell_too_thin_to_scale_keeps_the_old_path() {
|
||||
assert!(hayro::usable_cell_scale(2.0, 1.0));
|
||||
for (xs, ys) in [(f32::INFINITY, 1.0), (1.0, f32::NAN), (0.0, 1.0), (1.0, -2.0)] {
|
||||
assert!(!hayro::usable_cell_scale(xs, ys), "{xs} {ys}");
|
||||
}
|
||||
let thin = "0.0000000000000000000000000000000000000001";
|
||||
let content = "/Pattern cs /P1 scn 0 0 40 40 re f 1 0 0 rg 0 0 4 4 re f";
|
||||
let cell = "0 0 1 rg 0 0 10 10 re f";
|
||||
let pdf = format!(
|
||||
"%PDF-1.7
|
||||
1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources << /Pattern << /P1 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 3 /BBox [0 0 {thin} 10] /XStep 2147480100 /YStep 2147480100 /Length {} >> stream
|
||||
{cell}
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len(),
|
||||
cell.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
|
||||
let page = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
|
||||
assert!(page.error.is_none(), "{:?}", page.error);
|
||||
assert_eq!(&page.rgba[((38 * 40 + 1) * 4)..][..4], &[255, 0, 0, 255], "what follows still draws");
|
||||
assert_eq!(&page.rgba[((20 * 40 + 20) * 4)..][..3], &[255, 255, 255], "the degenerate cell draws nothing");
|
||||
}
|
||||
|
||||
/// #3349: a step too large for a cell pixmap is drawn as one cell only when the painted area
|
||||
/// can't reach another: a page wide (or tall) enough to show the next cell, or the one before,
|
||||
/// still tiles them.
|
||||
#[test]
|
||||
fn a_large_step_still_tiles_where_the_next_cell_shows() {
|
||||
let at = |px: &[u8], w: usize, x: usize, y: usize| px[(y * w + x) * 4..][..3].to_vec();
|
||||
let blue = vec![0, 0, 255];
|
||||
// Steps of 3100 points: over 3000 pixels at this scale, so the cell is shrunk to fit,
|
||||
// by 3000/3100; a pixel 4 points into a cell is blue either way.
|
||||
let wide = "/Pattern cs /P1 scn 0 0 3200 20 re f";
|
||||
let next = tiling_page(3200, 20, wide, "3100", "1 0 0 1 0 0");
|
||||
assert_eq!(at(&next, 3200, 3104, 15), blue, "the cell to the right");
|
||||
let before = tiling_page(3200, 20, wide, "3100", "1 0 0 1 3100 0");
|
||||
assert_eq!(at(&before, 3200, 4, 15), blue, "the cell to the left");
|
||||
let tall = "/Pattern cs /P1 scn 0 0 20 3200 re f";
|
||||
let above = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 0");
|
||||
assert_eq!(at(&above, 20, 4, 3200 - 3104), blue, "the cell above");
|
||||
let below = tiling_page(20, 3200, tall, "3100", "1 0 0 1 0 3100");
|
||||
assert_eq!(at(&below, 20, 4, 3200 - 4), blue, "the cell below");
|
||||
// A stroke counts with its width: this line ends at x 3080, short of the next cell at
|
||||
// 3100, but its square cap reaches 3110.
|
||||
let stroked = tiling_page(3200, 20, "/Pattern CS /P1 SCN 60 w 2 J 0 10 m 3080 10 l S", "3100", "1 0 0 1 0 0");
|
||||
assert_eq!(at(&stroked, 3200, 3104, 15), blue, "the next cell, under the stroke's cap");
|
||||
// A miter reaches further than the line width (review on #3356): this join's vertex is
|
||||
// at x 3085, 15 short of the next cell with a 10-wide line, but its miter tip, 45 long
|
||||
// under the default limit of 10, reaches 3130.
|
||||
let mitered = tiling_page(3200, 20, "/Pattern CS /P1 SCN 10 w 0 j 3000 0 m 3085 10 l 3000 20 l S", "3100", "1 0 0 1 0 0");
|
||||
// The tip narrows to a point, so this pixel is partly covered: blue, not white.
|
||||
let tip = at(&mitered, 3200, 3104, 10);
|
||||
assert!(tip[0] < 160 && tip[1] < 160 && tip[2] > 200, "the next cell, under the miter's tip: {tip:?}");
|
||||
// Under a skew, a direction between the axes stretches more than either axis (review on
|
||||
// #3356). This join's vertex is at device x 2980 and its miter tip, along that direction,
|
||||
// at 3107: the larger axis stretch (1.118) put the reach at 3092, short of the next cell;
|
||||
// the most any direction stretches (1.461) puts it at 3126.
|
||||
let skewed = tiling_page(
|
||||
3200,
|
||||
20,
|
||||
"/Pattern CS /P1 SCN 1 0 1 0.5 0 0 cm 20 w 0 j 2783.936 -366.118 m 3033.6 -53.6 l 2721.082 -303.264 l S",
|
||||
"3100",
|
||||
"1 0 0 1 0 0",
|
||||
);
|
||||
// The tip narrows to a point, so its pixels are partly covered.
|
||||
let under_tip = (3100..3108).flat_map(|x| (10..20).map(move |y| (x, y))).map(|(x, y)| at(&skewed, 3200, x, y));
|
||||
assert!(
|
||||
under_tip.clone().any(|px| px[0] < 200 && px[1] < 200 && px[2] > 240),
|
||||
"the next cell, under the skewed miter's tip: {:?}",
|
||||
under_tip.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
/// Contributor-original JPEG 2000: one unsigned 8-bit pixel per component, one tile,
|
||||
/// no decomposition, reversible wavelet, and one empty packet per component. Empty
|
||||
/// coefficients decode to the midpoint (128) after the unsigned DC level shift.
|
||||
|
||||
Vendored
+2
-2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -61,7 +61,7 @@ use vello_cpu::{Level, Pixmap, RenderMode};
|
||||
|
||||
mod renderer;
|
||||
/// PdfCraft patch: exported for its regression test.
|
||||
pub use renderer::{image_resampling_size, tiling_cell_scale};
|
||||
pub use renderer::{image_resampling_size, tiling_cell_scale, usable_cell_scale};
|
||||
/// PdfCraft patch: exported for its regression test.
|
||||
#[doc(hidden)]
|
||||
pub use renderer::set_skip_offscreen_paths;
|
||||
|
||||
Vendored
+100
-8
@@ -526,14 +526,7 @@ impl Renderer {
|
||||
},
|
||||
};
|
||||
|
||||
// A miter limit that isn't finite leaves miters unbounded: never skipped.
|
||||
let miter = stroke.miter_limit.abs();
|
||||
let reach = stroke.width / 2.0
|
||||
* if miter.is_finite() {
|
||||
miter.max(std::f64::consts::SQRT_2)
|
||||
} else {
|
||||
f64::INFINITY
|
||||
};
|
||||
let reach = stroke_reach(&stroke);
|
||||
self.ctx.set_stroke(stroke);
|
||||
reach
|
||||
}
|
||||
@@ -1142,6 +1135,65 @@ impl Renderer {
|
||||
};
|
||||
xs = xs.max(min_x_scale).min(max_x_scale);
|
||||
ys = ys.max(min_y_scale).min(max_y_scale);
|
||||
// PdfCraft patch (20): a step too large for a cell pixmap, where what is
|
||||
// painted can only show the first cell (PowerPoint writes `/XStep
|
||||
// 2147480100` for a picture fill drawn once, #3349): the cell's `/BBox` is
|
||||
// drawn at full scale with a transparent border and padded, not tiled.
|
||||
// Shrinking the cell to fit the step left a picture under one pixel.
|
||||
// How far a stroke's paint reaches past the path, miters included, in
|
||||
// device pixels: the most the transform stretches any direction (a skew
|
||||
// stretches some further than either axis), times the reach.
|
||||
let reach = if is_stroke {
|
||||
path_transform.spectral_norm() * stroke_reach(self.ctx.stroke())
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
if usable_cell_scale(xs, ys)
|
||||
&& (xs * t.x_step.abs() > MAX_PIXMAP_SIZE || ys * t.y_step.abs() > MAX_PIXMAP_SIZE)
|
||||
&& shows_one_cell(&t.matrix, path_transform, path, reach, bbox, t.x_step, t.y_step)
|
||||
{
|
||||
const BORDER: f64 = 1.0;
|
||||
let pix_width = ((bbox.width() as f32 * xs).ceil() as u16).saturating_add(2);
|
||||
let pix_height = ((bbox.height() as f32 * ys).ceil() as u16).saturating_add(2);
|
||||
let mut renderer = Self {
|
||||
ctx: RenderContext::new_with(
|
||||
pix_width,
|
||||
pix_height,
|
||||
derive_settings(self.ctx.render_settings()),
|
||||
),
|
||||
color_replacement: None,
|
||||
in_annotations: false,
|
||||
cur_mask: None,
|
||||
inside_pattern: true,
|
||||
soft_mask_cache: HashMap::default(),
|
||||
outline_cache: self.outline_cache.clone(),
|
||||
in_type3_glyph: false,
|
||||
scaler: self.scaler,
|
||||
preview: self.preview,
|
||||
image_transparency_stack: Vec::new(),
|
||||
};
|
||||
let initial_transform = Affine::translate((BORDER, BORDER))
|
||||
* Affine::scale_non_uniform(xs as f64, ys as f64)
|
||||
* Affine::translate((-bbox.x0, -bbox.y0));
|
||||
t.interpret(&mut renderer, initial_transform, is_stroke);
|
||||
let mut pix = Pixmap::new(pix_width, pix_height);
|
||||
renderer.ctx.flush();
|
||||
let mut resources = vello_cpu::Resources::default();
|
||||
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);
|
||||
paint_transform = path_transform.inverse() * t.matrix * initial_transform.inverse();
|
||||
let image = Image {
|
||||
image: ImageSource::Pixmap(Arc::new(pix)),
|
||||
sampler: ImageSampler {
|
||||
x_extend: peniko::Extend::Pad,
|
||||
y_extend: peniko::Extend::Pad,
|
||||
quality: ImageQuality::Medium,
|
||||
alpha: 1.0,
|
||||
},
|
||||
};
|
||||
self.ctx.set_paint_transform(paint_transform);
|
||||
self.ctx.set_paint(PaintType::Image(image));
|
||||
return clip_path;
|
||||
}
|
||||
// PdfCraft patch: the cell pixmap is `step × scale` pixels, which only the
|
||||
// u16 cast below bounded (a fuzzed /XStep far beyond /BBox gave 65535² cells).
|
||||
xs = tiling_cell_scale(xs, t.x_step, MAX_PIXMAP_SIZE);
|
||||
@@ -1894,6 +1946,46 @@ fn convert_blend_mode(blend_mode: BlendMode) -> peniko::BlendMode {
|
||||
peniko::BlendMode::new(mix, Compose::SrcOver)
|
||||
}
|
||||
|
||||
/// PdfCraft patch: how far a stroke's paint reaches past its path, in user units: half the width
|
||||
/// times the larger of the miter limit and √2 (a miter join or a square cap). A miter limit that
|
||||
/// isn't finite leaves miters unbounded, so the reach is too.
|
||||
fn stroke_reach(stroke: &kurbo::Stroke) -> f64 {
|
||||
let miter = stroke.miter_limit.abs();
|
||||
stroke.width / 2.0
|
||||
* if miter.is_finite() {
|
||||
miter.max(std::f64::consts::SQRT_2)
|
||||
} else {
|
||||
f64::INFINITY
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (20): whether a cell's scale can draw it at full size: finite and positive. A
|
||||
/// `/BBox` too thin to divide by (1e-40 wide passes the zero-area check) gives an infinite scale,
|
||||
/// which would send non-finite geometry to the cell renderer; such a cell keeps the old path.
|
||||
pub fn usable_cell_scale(xs: f32, ys: f32) -> bool {
|
||||
xs.is_finite() && ys.is_finite() && xs > 0.0 && ys > 0.0
|
||||
}
|
||||
|
||||
/// PdfCraft patch (20): whether `path` (drawn with `path_transform`, its paint reaching `reach`
|
||||
/// device pixels past it), mapped into the space of a tiling pattern with matrix `matrix`, can
|
||||
/// only meet the first cell's `/BBox`: every other cell's content lies a whole step or more
|
||||
/// away. False when the matrix can't be inverted or the bounds aren't finite.
|
||||
pub(crate) fn shows_one_cell(matrix: &Affine, path_transform: &Affine, path: &BezPath, reach: f64, bbox: Rect, x_step: f32, y_step: f32) -> bool {
|
||||
if matrix.determinant().abs() < 1e-12 || !matrix.determinant().is_finite() {
|
||||
return false;
|
||||
}
|
||||
let device = (*path_transform * path.clone()).bounding_box().inflate(reach, reach);
|
||||
let bounds = (matrix.inverse() * device.to_path(0.1)).bounding_box();
|
||||
let (sx, sy) = (f64::from(x_step.abs()), f64::from(y_step.abs()));
|
||||
let finite = [bounds.x0, bounds.y0, bounds.x1, bounds.y1, sx, sy].iter().all(|v| v.is_finite());
|
||||
// Cell k's content spans `bbox + k × step`; with k = ±1 out of reach, so is every other.
|
||||
finite
|
||||
&& bounds.x1 < bbox.x0 + sx
|
||||
&& bounds.x0 > bbox.x1 - sx
|
||||
&& bounds.y1 < bbox.y0 + sy
|
||||
&& bounds.y0 > bbox.y1 - sy
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the scale for a tiling cell, lowered so that the cell pixmap (`|step| ×
|
||||
/// scale` pixels a side) stays within `max_pixels`. Geometry is unchanged (the pattern transform
|
||||
/// is derived from the scale); an over-large step only renders at lower resolution.
|
||||
|
||||
Reference in New Issue
Block a user