Integrate KenM76 PR #4913

# Conflicts:
#	crates/render/tests/it/main.rs
This commit is contained in:
FredApps
2026-10-29 02:36:20 +01:00
5 changed files with 180 additions and 5 deletions
+1
View File
@@ -9,6 +9,7 @@ mod named_form_properties;
mod search;
mod separation_all;
mod stray_q_in_a_form;
mod stroke_degenerate_cubic;
mod substitute_widths;
mod tiling_cell_placement;
mod type3_text_clip;
@@ -0,0 +1,51 @@
//! A contributor-original synthetic page reproducing #4907: an outline that ends in a cubic whose
//! control points all sit 0.001 pt from its start was not drawn at all, because the stroker put
//! NaN into its outline and the rasterizer dropped the whole path.
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest};
use std::sync::Arc;
/// A one-page 100 × 20 pt PDF drawing `content`, with a cross-reference table.
fn pdf(content: &str) -> Vec<u8> {
let objects = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 20] /Contents 4 0 R >>".to_string(),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = Vec::new();
for (i, object) in objects.iter().enumerate() {
offsets.push(bytes.len());
bytes.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", i + 1).as_bytes());
}
let xref = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
for offset in offsets {
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
}
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
/// The red channel of the pixel at (`x`, `y`) pt from the top left of `content` rendered at 1×.
fn red_at(content: &str, x: usize, y: usize) -> u8 {
let mut renderer = PageRenderer::new(Arc::new(pdf(content)), RenderConfig::default());
let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
assert!(out.error.is_none(), "{:?}", out.error);
let at = (y * out.width as usize + x) * 4;
*out.rgba.get(at).expect("the pixel lies on the page")
}
#[test]
fn a_stroke_ending_in_a_tiny_degenerate_cubic_is_drawn() {
// The line runs from (10, 10) to about (84, 9.5) in page space; (50, 10) from the top left is
// inside its 2 pt width. The cubic's exact coordinates are what trigger the NaN. The control,
// without the cubic, is the baseline: it draws with or without the fix.
let path = "0 0 m 73.763 -0.536 l 73.764 -0.536 73.764 -0.536 73.764 -0.536 c S";
let control = "0 0 m 73.763 -0.536 l S";
for content in [control, path] {
let content = format!("0 G 2 w 1 0 0 1 10 10 cm {content}");
let red = red_at(&content, 50, 10);
assert!(red < 64, "`{content}` left the line's pixel at red {red}, want black");
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+125 -3
View File
@@ -5,7 +5,7 @@
use crate::flatten_simd::{Callback, LinePathEl};
use crate::geometry::RectU16;
use crate::kurbo::{self, Affine, PathEl, Stroke, StrokeCtx, StrokeOpts};
use crate::kurbo::{self, Affine, BezPath, PathEl, Stroke, StrokeCtx, StrokeOpts};
use alloc::vec::Vec;
use fearless_simd::{Level, Simd, dispatch};
use log::warn;
@@ -220,7 +220,8 @@ pub fn fill_impl<S: Simd>(
/// See the note about open subpaths and culling on [`fill`].
pub fn stroke(
level: Level,
path: impl IntoIterator<Item = PathEl>,
// PdfCraft patch: `Clone` so the path can be stroked a second time.
path: impl IntoIterator<Item = PathEl> + Clone,
style: &Stroke,
affine: Affine,
line_buf: &mut Vec<Line>,
@@ -235,7 +236,14 @@ pub fn stroke(
.max(affine.as_coeffs()[3].abs())
.max(1.);
expand_stroke(path, style, tolerance, stroke_ctx);
expand_stroke(path.clone(), style, tolerance, stroke_ctx);
// PdfCraft patch: kurbo's stroker can put NaN into the outline of a curve that is nearly a
// line (all control points within a rounding error of the chord), and `fill` then drops the
// whole outline. Stroke it again with such curves as lines. A finite outline is unchanged.
if !stroke_ctx.output().is_finite() {
let path = near_line_curves_as_lines(path, tolerance);
expand_stroke(&path, style, tolerance, stroke_ctx);
}
fill(
level,
stroke_ctx.output(),
@@ -246,6 +254,49 @@ pub fn stroke(
);
}
// PdfCraft patch.
/// `path` with each quadratic or cubic whose control points all lie within
/// `tolerance` of its chord replaced by a line to its end point. One element out per element in.
fn near_line_curves_as_lines(path: impl IntoIterator<Item = PathEl>, tolerance: f64) -> BezPath {
let mut out = BezPath::new();
let mut start = kurbo::Point::ZERO;
let mut last = start;
for el in path {
let el = match el {
PathEl::QuadTo(p1, p2) if near_segment(p1, last, p2, tolerance) => PathEl::LineTo(p2),
PathEl::CurveTo(p1, p2, p3)
if near_segment(p1, last, p3, tolerance)
&& near_segment(p2, last, p3, tolerance) =>
{
PathEl::LineTo(p3)
}
el => el,
};
match el {
PathEl::MoveTo(p) => {
start = p;
last = p;
}
PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => last = p,
PathEl::ClosePath => last = start,
}
out.push(el);
}
out
}
/// Whether `p` is within `tolerance` of the segment from `a` to `b`. False for non-finite input.
fn near_segment(p: kurbo::Point, a: kurbo::Point, b: kurbo::Point, tolerance: f64) -> bool {
let ab = b - a;
let len2 = ab.hypot2();
let t = if len2 > 0.0 {
((p - a).dot(ab) / len2).clamp(0.0, 1.0)
} else {
0.0
};
(p - (a + ab * t)).hypot() <= tolerance
}
/// Expand a stroked path to a filled path.
pub fn expand_stroke(
path: impl IntoIterator<Item = PathEl>,
@@ -284,3 +335,74 @@ impl Callback for FlattenerCallback<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::{FlattenCtx, Line, TOL, expand_stroke, stroke};
use crate::geometry::RectU16;
use crate::kurbo::{Affine, BezPath, Point, Stroke, StrokeCtx};
use alloc::vec::Vec;
use fearless_simd::Level;
fn stroke_lines(path: &BezPath, width: f64, ctx: &mut StrokeCtx) -> Vec<Line> {
let mut lines = Vec::new();
stroke(
Level::baseline(),
path,
&Stroke::new(width),
Affine::IDENTITY,
&mut lines,
&mut FlattenCtx::default(),
ctx,
RectU16::new(0, 0, 200, 200),
);
lines
}
// An outline in an Illustrator file (#4907) has a cubic whose control points are all equal
// and 0.001 from its start. kurbo's stroker gives that curve a NaN join, which made the whole
// outline disappear. The trigger depends on these exact coordinates.
#[test]
fn a_tiny_degenerate_cubic_does_not_drop_the_stroke() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
path.line_to((73.763, -0.536));
let p = Point::new(73.764, -0.536);
path.curve_to(p, p, p);
let mut ctx = StrokeCtx::default();
let lines = stroke_lines(&path, 0.34, &mut ctx);
assert!(!lines.is_empty(), "the stroke was dropped");
assert!(ctx.output().is_finite());
let max_x = lines
.iter()
.map(|l| l.p0.x.max(l.p1.x))
.fold(f32::MIN, f32::max);
assert!(max_x > 73.0, "the stroke ends at x = {max_x}");
}
#[test]
fn a_path_with_nan_is_still_dropped() {
let mut path = BezPath::new();
path.move_to((10.0, 10.0));
path.curve_to((f64::NAN, 10.0), (10.0, 60.0), (60.0, 60.0));
let lines = stroke_lines(&path, 2.0, &mut StrokeCtx::default());
assert!(lines.is_empty());
}
// A regression guard: it passes with or without the patch.
#[test]
fn a_finite_outline_is_stroked_as_before() {
let mut path = BezPath::new();
path.move_to((10.0, 10.0));
path.curve_to((60.0, 10.0), (10.0, 60.0), (60.0, 60.0));
let style = Stroke::new(2.0);
let mut expected = StrokeCtx::default();
expand_stroke(&path, &style, TOL, &mut expected);
let mut ctx = StrokeCtx::default();
let lines = stroke_lines(&path, 2.0, &mut ctx);
assert!(!lines.is_empty());
assert_eq!(ctx.output(), expected.output());
}
}
+2 -1
View File
@@ -144,7 +144,8 @@ impl StripGenerator {
/// Generate the strips for a stroked path.
pub fn generate_stroked_path(
&mut self,
path: impl IntoIterator<Item = PathEl>,
// PdfCraft patch: `Clone` for `flatten::stroke`.
path: impl IntoIterator<Item = PathEl> + Clone,
stroke: &Stroke,
transform: Affine,
aliasing_threshold: Option<u8>,