mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
InDesign writes fades as luminosity soft masks whose group is one extended axial `sh` over the whole page. hayro 0.7 sampled every such shading pixel by pixel through the shading function, so a report page with 40 of them took 10-15 s at 144 dpi. Backport hayro 0.8.0's `gradient` module: an axial or radial shading that a native gradient draws exactly becomes sRGB stops (within 0.01 per channel, a stop at every stitching bound), painted as a vello_cpu gradient. Upstream's half-pixel shift is left out: it suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres. Page 4 of the reported file: 10.8 s -> 1.0 s. Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
f5cfb33773
commit
7f1b632519
9 files changed
+644
-8
No files matched your search
@@ -4971,6 +4971,159 @@ trailer << /Root 1 0 R >>
|
||||
}
|
||||
}
|
||||
|
||||
/// InDesign fades are luminosity soft masks whose group is one extended axial `sh`: a
|
||||
/// gradient over the whole page. hayro 0.7 sampled each one pixel by pixel through the
|
||||
/// shading function, so a report page with 40 of them took 15 s at 144 dpi. Vendored hayro
|
||||
/// patch (backport of hayro 0.8): axial and radial shadings that a native gradient can
|
||||
/// represent exactly are drawn as one, with stops placed at stitching-function bounds.
|
||||
#[test]
|
||||
fn axial_and_radial_shadings_draw_as_native_gradients() {
|
||||
use hayro::hayro_interpret::font::Glyph;
|
||||
use hayro::hayro_interpret::hayro_syntax::Pdf;
|
||||
use hayro::hayro_interpret::pattern::Pattern;
|
||||
use hayro::hayro_interpret::{
|
||||
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, Paint, PathDrawMode, SoftMask, TransformExt, interpret_page,
|
||||
};
|
||||
use kurbo::{Affine, BezPath, Rect, Shape};
|
||||
|
||||
/// For every shading painted, the offsets of its native gradient stops, or `None` when
|
||||
/// it has to be sampled pixel by pixel.
|
||||
#[derive(Default)]
|
||||
struct Gradients(Vec<Option<Vec<f32>>>);
|
||||
impl<'a> Device<'a> for Gradients {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'a>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &Paint<'a>, _: &PathDrawMode) {
|
||||
if let Paint::Pattern(pattern) = paint
|
||||
&& let Pattern::Shading(s) = pattern.as_ref()
|
||||
{
|
||||
let bbox = (transform * path.clone()).bounding_box();
|
||||
let native = s.encode().as_svg_gradient(bbox, 0.01);
|
||||
self.0.push(native.map(|g| g.stops.iter().map(|stop| stop.offset).collect()));
|
||||
}
|
||||
}
|
||||
fn push_clip_path(&mut self, _: &ClipPath) {}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'a>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'a>, _: Affine, _: Affine, _: &Paint<'a>, _: &GlyphDrawMode) {}
|
||||
fn draw_image(&mut self, _: Image<'a, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
// A 40 × 20 pt page painting `/Sh0 sh`, with optional extra objects from 6 0 obj on.
|
||||
let pdf = |shading: &str, extra: &str| {
|
||||
let content = "/Sh0 sh";
|
||||
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 20] /Contents 4 0 R /Resources << /Shading << /Sh0 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj {shading} endobj
|
||||
{extra}trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
)
|
||||
.into_bytes()
|
||||
};
|
||||
let gradients = |bytes: &[u8]| {
|
||||
let parsed = Pdf::new(Arc::new(bytes.to_vec())).expect("parses");
|
||||
let page = &parsed.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let initial = page.initial_transform(true).to_kurbo();
|
||||
let mut ctx = Context::new(initial, Rect::new(0.0, 0.0, 40.0, 20.0), &cache, page.xref(), InterpreterSettings::default());
|
||||
let mut device = Gradients::default();
|
||||
interpret_page(page, &mut ctx, &mut device);
|
||||
device.0
|
||||
};
|
||||
let render = |bytes: Vec<u8>| {
|
||||
let mut r = PageRenderer::new(Arc::new(bytes), RenderConfig::default());
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
|
||||
assert!(p.error.is_none(), "{:?}", p.error);
|
||||
p
|
||||
};
|
||||
let grey = |p: &RenderedPage, x: u32| i32::from(p.rgba[((10 * p.width + x) * 4) as usize]);
|
||||
let ramp = "/FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1";
|
||||
// A function-based shading (always sampled) that draws the same picture is the reference;
|
||||
// its /Range clamps to 0..1.
|
||||
let sampled = |code: &str| {
|
||||
let extra =
|
||||
format!("6 0 obj << /FunctionType 4 /Domain [0 40 0 20] /Range [0 1] /Length {} >> stream\n{code}\nendstream endobj\n", code.len());
|
||||
pdf("<< /ShadingType 1 /ColorSpace /DeviceGray /Domain [0 40 0 20] /Function 6 0 R >>", &extra)
|
||||
};
|
||||
|
||||
// A steep extended axial ramp, x 10 to 14: one stop at each end, drawn at pixel centres
|
||||
// (a half-pixel shift would move each pixel 32 levels) and padded on both sides.
|
||||
let axial = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Extend [true true] /Function << {ramp} >> >>"), "");
|
||||
assert_eq!(gradients(&axial), vec![Some(vec![0.0, 1.0])], "an extended axial shading is a native gradient");
|
||||
let native = render(axial);
|
||||
let reference = render(sampled("{ pop 10 sub 4 div }"));
|
||||
for (x, expected) in [(2, 0), (9, 0), (10, 32), (11, 96), (12, 159), (13, 223), (14, 255), (30, 255)] {
|
||||
assert!((grey(&native, x) - expected).abs() <= 4, "axial, x {x}: {} instead of {expected}", grey(&native, x));
|
||||
assert!(
|
||||
(grey(&native, x) - grey(&reference, x)).abs() <= 4,
|
||||
"axial, x {x}: native {} vs sampled {}",
|
||||
grey(&native, x),
|
||||
grey(&reference, x)
|
||||
);
|
||||
}
|
||||
|
||||
// An extended radial shading matches the sampled one everywhere.
|
||||
let radial =
|
||||
pdf(&format!("<< /ShadingType 3 /ColorSpace /DeviceGray /Coords [20 10 0 20 10 8] /Extend [true true] /Function << {ramp} >> >>"), "");
|
||||
assert!(matches!(gradients(&radial).as_slice(), [Some(_)]), "an extended radial shading is a native gradient");
|
||||
let native = render(radial);
|
||||
let reference = render(sampled("{ 10 sub dup mul exch 20 sub dup mul add sqrt 8 div }"));
|
||||
let worst = native.rgba.iter().zip(reference.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max();
|
||||
assert!(worst.is_some_and(|d| d <= 4), "radial: native and sampled differ by {worst:?} levels");
|
||||
|
||||
// A stitched function peaking at a quarter of the way gets a stop exactly at its bound.
|
||||
let stitched = pdf(
|
||||
"<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << /FunctionType 3 /Domain [0 1] /Bounds [0.25] /Encode [0 1 0 1] /Functions [<< /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> << /FunctionType 2 /Domain [0 1] /C0 [1] /C1 [0] /N 1 >>] >> >>",
|
||||
"",
|
||||
);
|
||||
let stops = gradients(&stitched);
|
||||
assert!(matches!(stops.as_slice(), [Some(offsets)] if offsets.contains(&0.25)), "a stop at the stitching bound: {stops:?}");
|
||||
let native = render(stitched);
|
||||
for (x, expected) in [(0, 13), (9, 242), (10, 250), (39, 4)] {
|
||||
assert!((grey(&native, x) - expected).abs() <= 4, "stitched, x {x}: {} instead of {expected}", grey(&native, x));
|
||||
}
|
||||
|
||||
// Without /Extend the area beyond the axis keeps the background, which a native gradient
|
||||
// can't leave out: still sampled. Function-based shadings are always sampled.
|
||||
let unextended = pdf(&format!("<< /ShadingType 2 /ColorSpace /DeviceGray /Coords [10 0 14 0] /Function << {ramp} >> >>"), "");
|
||||
assert_eq!(gradients(&unextended), vec![None], "an axial shading without /Extend that covers more than its axis");
|
||||
assert_eq!(gradients(&sampled("{ pop pop 0.5 }")), vec![None], "a function-based shading");
|
||||
|
||||
// The report's case: a luminosity soft mask whose group is one extended axial `sh` over
|
||||
// the page. Red through a mask fading in from x 0 to 40 over white: 255, 255 − t, 255 − t.
|
||||
let content = "/M gs 1 0 0 rg 0 0 40 20 re f";
|
||||
let masked = 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 20] /Contents 4 0 R /Resources << /ExtGState << /M 5 0 R >> >> >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 6 0 R >> >> endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [-32768 -32768 32767 32767] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceGray /Coords [0 0 40 0] /Extend [true true] /Function << {ramp} >> >> >> >> /Length 7 >> stream
|
||||
/Sh0 sh
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let p = render(masked.into_bytes());
|
||||
for x in [2u32, 20, 37] {
|
||||
let px = &p.rgba[((10 * p.width + x) * 4) as usize..][..4];
|
||||
let faded = 255 - (255.0 * (f64::from(x) + 0.5) / 40.0).round() as i32;
|
||||
assert!(px[0] == 255 && (i32::from(px[1]) - faded).abs() <= 4 && px[1] == px[2], "masked, x {x}: {px:?}, green and blue {faded}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An alpha soft mask whose transparency group has no /CS (as Chrome writes gradient text)
|
||||
/// must still mask. Regression test for the vendored hayro-interpret patch.
|
||||
#[test]
|
||||
|
||||
+4
-3
@@ -1,6 +1,6 @@
|
||||
# Where PdfCraft falls short of Acrobat Pro
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha markers removed after the gate was re-judged: no gap blocks alpha) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** patch (gap 2: hayro patch counts after the native-gradient backport) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Every known shortfall, one entry each, ranked by what it costs users. This is the work list: agents
|
||||
choose from the top unless the owner says otherwise, and prefer these over new P2/P3 checklist
|
||||
@@ -66,8 +66,8 @@ update the numbers.
|
||||
|
||||
- **Missing:** our own renderer (M2: `model` crate, font engine, DisplayList devices, ADR-0004), or
|
||||
an owner decision to adopt another engine; and any measurement of rendering against Acrobat.
|
||||
- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 22 local patches to `hayro-interpret`,
|
||||
9 to `hayro`, 3 to `hayro-jbig2` and 13 to `hayro-syntax` (`vendor/README.md`); `lopdf` still
|
||||
- **Evidence:** pages are drawn by vendored `hayro` 0.7 with 23 local patches to `hayro-interpret`,
|
||||
10 to `hayro`, 3 to `hayro-jbig2` and 13 to `hayro-syntax` (`vendor/README.md`); `lopdf` still
|
||||
backs document inspection. All 7 rendering P0 features are `partial`. User reports: TikZ pattern
|
||||
fills drawn wrong ([#794](https://github.com/storytold/pdfcraft/issues/794)), blurry text at
|
||||
1080p/1440p ([#730](https://github.com/storytold/pdfcraft/issues/730)), fixed zoom sizes differ
|
||||
@@ -302,6 +302,7 @@ update the numbers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-11 | patch | Gap 2: 23 hayro-interpret and 10 hayro patches after axial and radial shadings became native gradients (backport of hayro 0.8) |
|
||||
| 2026-10-10 | minor | Removed the alpha markers: with the cross-app gate rule, gaps 1 and 8 are partial sub-cases, not blockers; stage alpha |
|
||||
| 2026-10-10 | minor | Marked the alpha blockers (gap 1 and the Office part of gap 8) after the core-workflow gate put the stage at pre-alpha |
|
||||
| 2026-10-10 | major | Created. Ranked 24 gaps from the 2026-10-10 re-measure, the open GitHub issues and the former ROADMAP.md §Where we're lacking and where we're going (renderer, hardening, fidelity, editing, Pro workflows, 1.0 polish), which this file replaces |
|
||||
Vendored
+2
-2
@@ -7,9 +7,9 @@ the fix. Vendoring copyleft code is never allowed (plan/adr/0001).
|
||||
|
||||
| Crate | Version | Licence | Patches | Test |
|
||||
|---|---|---|---|---|
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
|
||||
| hayro-interpret | 0.7.0 | Apache-2.0 OR MIT | (1) select `/AP /N` appearance states via `/AS` (checkboxes, radios, stateful icons) and honour the NoView flag; (2) Type3 Unicode fallback to `/Encoding` glyph names, then printable ASCII; (3) `Type3Glyph::advance_width`; (4) `InterpreterSettings::ocg_overrides` for viewer layer toggles; (5) soft masks whose group lacks `/CS` were dropped (Chrome gradient text); (6) paint nesting cap `MAX_PAINT_NESTING` for tiling patterns and Type 3 glyphs (self-referencing content overflowed the stack); (7) CID width ranges in `/W` and `/W2` clamped to `MAX_CID` (a `0 4294967295 w` range hung); (8) `InterpreterSettings::hide_comments` skips markup annotations but keeps widgets and links (View ▸ Hide all comments); (9) `image_size_ok`: images and masks over 2^28 pixels are not decoded (a fuzzed `/W 4294967295` stencil mask allocated 4 GiB); (10) `MAX_NESTED_PAINTS`: nested paints (forms, Type 3 glyphs and tiling patterns inside one another) share a per-page budget of 50,000 (a glyph showing eight glyphs of its own font, sixteen deep, hung); (11) depends on skrifa 0.47 instead of 0.42 (upstream read-fonts fixed a Type 1 tokenizer that recursed through runs of numbers: a fuzzed font hung or overflowed the stack), with the one API change (`Charset::glyph_id` returns `Option`); (12) annotation appearances whose `/BBox` or `/Matrix` collapses to a line or a point are skipped (mapping them onto `/Rect` divided by zero and handed the device infinite or NaN matrices); (13) a Highlight annotation without a usable normal appearance is drawn from its `/QuadPoints`, `/C` and `/CA` with Multiply blending, as pdfcraft-annot draws highlights (it was not drawn at all); (14) simple-font `/Widths` (`read_widths`): entries only for codes 0–255 (one entry per code below `/FirstChar` was allocated: `4294967295` asked for 34 GB), an entry that isn't a number leaves its code missing instead of ending the array, and a `/LastChar` below `/FirstChar` gives no widths instead of discarding the font for a standard one; (15) the appearance box is mapped onto `/Rect` corner to corner: the translation now accounts for the scale (an appearance whose box didn't start at the origin and differed in size from `/Rect` was drawn shifted); (16) a clip set by `W`/`W*` takes effect after whichever path-painting operator ends the path (`S`, `f`, `B`, … as well as `n`; ISO 32000-2 §8.5.4): it was applied only on `n`, so `re W* S` clipped nothing and the clip stayed pending for a later, unrelated `n`; (17) `InterpreterSettings::cancelled`: once set, interpretation stops before the next content operator (a render pool sets it for every worker when dropped, after an edit or on closing, and for one worker when the watchdog gives up on its render, so renders whose answers nobody can receive stop instead of running to the end); (18) `MAX_PAGE_CONTENT`: the content a page holds at once (its contents, and the forms, tiling patterns and Type 3 glyph procedures being painted) stays within 512 MiB (`decode_content`; once it runs out, `InterpreterWarning::ContentTruncated` is reported (once per page by pdfcraft-render) so a truncated page is not a clean render; each level gives its bytes back when it finishes): forms nest fifty deep and each level kept its content while the next ran, so a form painting itself around a stream inflating to 256 MiB would have held over 12 GB; and Type 0 sampled functions take at most `MAX_SAMPLE_ENTRIES` (2^21) table points and `MAX_SAMPLE_VALUES` (2^23) samples, read no further than `/Size` asks (256 MiB of 1-bit samples asked for over 8 GB); (19) mesh shadings (types 4–7) are sampled only inside the area being drawn (`ShadingPattern::encode_within`, which the vendored hayro renderer calls with that area plus a pixel), within 8 visits per pixel of it plus 4 per triangle (`TriangleSampler`), and patches are triangulated one at a time: points far off the page were sampled over their whole bounding box, up to 65536 × 65536 entries (a fuzzed 147-byte patch stream; a synthetic page passed 6 GB in six seconds), ten thousand overlapping triangles ran for minutes, and every patch became 722 triangles before any limit; meshes hold at most `MAX_MESH_PATCHES` (2^16) patches and `MAX_MESH_TRIANGLES` (2^20) triangles, a lattice needs two to 2^19 vertices per row (`/VerticesPerRow 0` pushed empty rows for ever; one huge row read the whole stream), and a page samples at most `MAX_PAGE_MESH_TRIANGLES` (2^25) mesh triangles in all (a mesh painted a thousand times was triangulated a thousand times); each cut logs a warning; (20) PDF function construction is limited to 64 levels and 10,000 total nodes with cycle detection; calculator parsing and evaluation share procedure depth/work budgets, integer division/bitshift/rotation/index are panic-free, and fixed-size stack errors propagate; (21) a Type 0 font drawn with a substitute that isn't CID-keyed (it isn't embedded, or its program doesn't load) draws `.nLine truncated
|
||||
|
||||
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel` |
|
||||
| hayro | 0.7.1 | Apache-2.0 OR MIT | (1) `RenderSettings::{x_offset, y_offset}` to render one tile of a large page; (2) images over `MAX_IMAGE_PIXELS` (2^28) are skipped (a fuzzed `/W 4294967295` hung in resampling); (3) `tiling_cell_scale` keeps a tiling pattern's cell pixmap within 3000 px a side whatever its `/XStep`/`/YStep` (a fuzzed step far beyond `/BBox` allocated 2 GB); (4) stroke widths clamped to 4 × (canvas width + height) device pixels (a fuzzed `/LW 9223372036854775807` allocated 10 GB in stroke expansion); (5) `usable_dash_pattern`: a stroke whose dash pattern could cut it into more than `MAX_DASHES_PER_STROKE` (250,000) pieces, counting kurbo's restart of the pattern at every subpath and `ClosePath`, is drawn solid (a fuzzed `/D [[11] 0]` on a line from x = 92234775807 allocated over 5 GB, `[0.000001] 0 d` on a 20 pt line over 10 GB); (6) a dash pattern with a negative or non-finite entry, or a period that isn't positive, is drawn solid (`[-1 -1] 0 d` looped for ever in kurbo); (7) `render_size` and `render_into`: render into a buffer the caller allocates (PdfCraft renders into pixel-aligned memory that the GUI takes over as texture data); `render` is `render_into` on a new pixmap; (8) checked image source/target/RGBA, alpha-premultiplication scratch and intermediate sizes before resampling, finite transforms and exact source/mask buffers; sources wider than u16 may shrink before backend limits, with a 2^20 source-axis cap and conservative 128 MiB filter-planning estimate; an over-limit or failed resize keeps the original image resolution and geometry when the source fits the backend, otherwise skips it, with a logged warning; staging, resampling output/scratch, RGBA conversion and padding buffers use fallible reserves; (9) `may_paint_canvas`: a fill or stroke whose control-point box, grown by how far its paint reaches past the path (half the stroke width times the larger of the miter limit and √2) plus 0.5 user units and 2 pixels, misses the canvas is not drawn, so a tile no longer strokes and fills every path of its page (item 5 of #623); `set_skip_offscreen_paths` turns this off for the regression test; (10) backport of hayro 0.8: shadings that `as_svg_gradient` can represent are painted as a `vello_cpu` linear or two-point radial gradient instead of a texture sampled pixel by pixel through the shading function (InDesign fades are luminosity soft masks of one full-page axial `sh`: 40 on a report page took 15 s at 144 dpi, now under 1 s); without upstream's half-pixel shift, which suits vello_cpu 0.3 but moves 0.0.8's gradients off pixel centres | (1) `raster::tests::tiles_match_full_render`; (2) `raster::tests::absurd_image_dimensions_are_skipped`; (3) `raster::tests::tiling_cells_stay_small_whatever_the_step`; (4) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (5) `raster::tests::dash_patterns_that_cut_a_stroke_into_billions_of_pieces_render`; (6) `raster::tests::invalid_dash_patterns_terminate_and_are_drawn_solid`; (7) `raster::tests::pixels_are_a_plain_renders_bytes_in_a_buffer_a_gui_can_take_over`; (8) `raster::tests::image_resampling_rejects_anisotropic_target_growth`, `raster::tests::image_resampling_bounds_intermediate_before_planning`, `raster::tests::image_resampling_rejects_invalid_sources_and_scales`, `raster::tests::image_resampling_keeps_valid_sizes_and_exact_limits`, `raster::tests::resampling_fallback_preserves_original_geometry_and_pixels`, `raster::tests::image_resampling_wide_sources_shrink_before_backend_side_limits`, `raster::tests::image_resampling_wide_transparent_sources_still_shrink`, `raster::tests::image_resampling_padded_backend_dimensions_stay_checked`, `raster::tests::image_resampling_mismatched_alpha_mask_keeps_pixels_and_placement`; (9) `raster::tests::skipping_paths_off_the_canvas_changes_no_pixel`; (10) `raster::tests::axial_and_radial_shadings_draw_as_native_gradients` |
|
||||
| hayro-jbig2 | 0.3.0 | Apache-2.0 OR MIT | (1) at most 2^28 pixels per bitmap, page bitmap, halftone grid and grayscale image (each side could be 65535: 4.3 billion pixels decoded one by one); (2) at most 2^26 symbol pixels per symbol dictionary (a fuzzed dictionary decoded for minutes); (3) a symbol dictionary's height classes are capped at one per new symbol and its export runs at `2 × symbols + 1`, each plus `EXTRA_EMPTY_STEPS` (1024): an empty height class or a zero-length export run doesn't advance, and past the end of its data the arithmetic decoder can decode them for ever (one changed byte hung the renderer) | (1) `raster::tests::jbig2_regions_of_billions_of_pixels_are_refused`; (2) the nightly `cargo xtask fuzz` finding, which now finishes in about a second; (3) `raster::tests::jbig2_symbol_dictionaries_that_never_advance_terminate` |
|
||||
| lopdf | 0.45.0 | MIT | (1) PNG predictor rows longer than the data are refused before their buffers are allocated (a fuzzed `/Columns 4294967295` allocated two 4 GiB rows), and empty frames return without row buffers; (2) predictor row widths use saturating arithmetic or reject overflow; (3) sub-byte TIFF Predictor 2 scratch is bounded by the samples present in the input instead of the declared `/Colors`, aligned with [lopdf#589](https://github.com/J-F-Liu/lopdf/pull/589) | (1) `inspect::tests::png_predictor_rows_longer_than_the_data_are_refused_before_allocating`, `inspect::tests::empty_png_predictor_frames_do_not_allocate_rows`; (2) `inspect::tests::tiff_subbyte_predictor_row_width_overflow_is_refused`; (3) `inspect::tests::tiff_subbyte_predictor_scratch_is_bounded_by_available_samples`, `inspect::tests::tiff_subbyte_predictors_keep_components_rows_and_padding` |
|
||||
| hayro-syntax | 0.7.2 | Apache-2.0 OR MIT | (1) Page-tree cycle guard in `resolve_pages` (visited set + depth cap): a `/Kids` loop among compressed objects overflowed the stack and aborted the process; (2) `ccitt_size_ok`: CCITT images wider than 2^20 columns or over 2^28 pixels are not decoded (a fuzzed `/Columns 4294967295` allocated 4 GiB); (3) Flate/LZW predictor row widths are saturating `usize`, a zero width is refused and a row longer than the data is not allocated (a fuzzed `/Columns 9223372036854775807` wrapped to a 2^61-byte allocation and aborted); (4) the inline-image end-of-data check (`EI`) doesn't nest inside its own look-ahead and examines at most 256 candidates and steps (50 images with "EI" in their data ran for over ten minutes); (5) a stream whose `/Length` is wrong is recovered only when `stream` is the next token — scanning ahead to a later object's `stream` made an indirect appearance-state dictionary (Quartz checkboxes: `/AP /N` → `<< /Yes … /Off … >>`) render as a non-form, so the widget was skipped; (6) JPX rescaling uses an exact integer sample range for 1–32-bit components (32 shifted past u32, and floating-point maxima at 25–31 bits wrapped to zero), and refuses zero-sized rows; (7) `decode_limit`: Flate (through flate2 and the fallback inflater), LZW and RunLength never write past `MAX_DECODED_STREAM` (256 MiB), or, for the last filter of an image of known size, past twice its pixel bytes (a predictor tag byte per byte), at most 1 GiB; `Stream::decoded_within` for budgeted callers; a page's `/Contents` array shares one `MAX_DECODED_STREAM` (a 2 MB page whose content stream or 8 × 8 image inflated to 2 GB took the renderer past 3 GB in under a second; naming the stream twenty times in `/Contents`, 5 GB); (8) high-precision numbers (jsPDF MediaBox coordinates with over 19 digits) parse without overflowing the u64 mantissa: fractional digits past it are dropped, integer digits past it scale the value (a real, kept finite); (9) large dictionary indexes are built on first use and shared across repeated reads within each PDF (#307); the validated-byte cache is bounded, reader contexts remain separate, repairs and inline/content dictionaries bypass sharing, and small dictionaries stay eager; (10) decoded object-stream slots publish once under contention, cold loading rejects recursive dependencies, and xref repair serializes without holding recursive read guards; this permits one parser per native render pool with private worker caches; (11) `sync.rs`: a poisoned lock is recovered instead of unwrapped (a worker panic no longer cascades into every reader of the same PDF), and xref repair takes a blocking write lock instead of asserting a non-blocking one; (12) `SegmentList` (`util.rs`) returns `None` for an out-of-range index instead of panicking; (13) a page's `/UserUnit` scales `render_dimensions` and `initial_transform`, so the page and everything on it are sized in points, as Acrobat shows them; it is read as Acrobat reads it (the page's own entry, not inherited; a number from 1 up, at most 75,000; anything else counts as 1), and a page without it keeps exactly the transform it had; (14) revision 2–4 passwords are tried as given and then in PDFDocEncoding (`legacy_passwords` in `crypto/mod.rs`, which resolves the upstream `TODO: Convert to PDFDocEncoding`): passwords arrive as UTF-8 text, so a password with characters outside ASCII (`Çok güzel`, `ılık€`) written in PDFDocEncoding, as ISO 32000-2 §7.6.4.3.2 asks and qpdf and Acrobat write it, was always wrong; (15) page content is read a piece at a time (`page::ContentReader`, `Page::content_reader`): plain streams and Flate streams without a predictor are decoded as they are read, with a budget of 64 times their compressed size and never less than `MAX_DECODED_STREAM` (a stream that expands past it is cut and reported); other streams are decoded whole, as before, and share `MAX_DECODED_STREAM`. The content tokenizer reads a window at a time (`UntypedIter::new_window`, `TypedIter::needs_more`, `TypedIter::committed`): an instruction that runs past the window is not decided until more data follows, which `Reader` records by flagging reads and `at_end` past the end of the data, including through the clones the parser makes. The Flate decoder keeps its own 32 KiB history, so it pauses at any output size. `ContentReader::new` reuses raw/decrypted stream bytes for budget accounting and piece creation, and `decoded_raw_within` applies the existing Whole-stream filters and limits to those bytes without decrypting twice. | (1) `cos/tests/writer.rs::page_tree_loops_in_object_streams_do_not_crash_readers`; (2) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (3) `raster::tests::absurd_predictor_columns_and_line_widths_render`; (4) `raster::tests::inline_images_with_ei_in_their_data_parse_in_linear_time`; (5) `raster::tests::indirect_appearance_state_dict_is_not_another_stream`; (6) `raster::testsLine truncated
|
||||
|
||||
+11
@@ -20,6 +20,8 @@ use std::sync::Arc;
|
||||
|
||||
/// The input/output type of functions.
|
||||
pub(crate) type Values = SmallVec<[f32; 4]>;
|
||||
/// PdfCraft patch (backport of hayro 0.8): interior breakpoints of stitching functions.
|
||||
pub(crate) type StitchingBounds = SmallVec<[f32; 3]>;
|
||||
type TupleVec = SmallVec<[(f32, f32); 4]>;
|
||||
|
||||
// PdfCraft patch: PDF functions are untrusted object graphs. A normal color/transfer function
|
||||
@@ -100,6 +102,15 @@ impl Function {
|
||||
FunctionType::Type4(t4) => Some(t4.eval(input)?),
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (backport of hayro 0.8): inputs where a stitching function switches
|
||||
/// sub-function, so a gradient approximation can place a stop exactly there.
|
||||
pub(crate) fn stitching_bounds(&self) -> StitchingBounds {
|
||||
match self.0.as_ref() {
|
||||
FunctionType::Type3(t3) => t3.stitching_bounds(),
|
||||
_ => SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
+46
-1
@@ -1,4 +1,4 @@
|
||||
use crate::function::{Clamper, Function, TupleVec, Values, interpolate};
|
||||
use crate::function::{Clamper, Function, StitchingBounds, TupleVec, Values, interpolate};
|
||||
use hayro_syntax::object::Array;
|
||||
use hayro_syntax::object::Dict;
|
||||
use hayro_syntax::object::Object;
|
||||
@@ -83,6 +83,51 @@ impl Type3 {
|
||||
|
||||
Some(evaluated)
|
||||
}
|
||||
|
||||
// PdfCraft patch (backport of hayro 0.8).
|
||||
pub(crate) fn stitching_bounds(&self) -> StitchingBounds {
|
||||
let mut stitching_bounds = StitchingBounds::new();
|
||||
if self.bounds.len() > 2 {
|
||||
stitching_bounds.extend_from_slice(&self.bounds[1..self.bounds.len() - 1]);
|
||||
}
|
||||
|
||||
for (index, function) in self.functions.iter().enumerate() {
|
||||
let Some(bounds_i_minus_1) = self.bounds.get(index).copied() else {
|
||||
continue;
|
||||
};
|
||||
let Some(bounds_i) = self.bounds.get(index + 1).copied() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some((encode_min, encode_max)) = self.encode.get(index).copied() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if (encode_max - encode_min).abs() <= f32::EPSILON {
|
||||
continue;
|
||||
}
|
||||
|
||||
for child_bound in function.stitching_bounds() {
|
||||
let bound = interpolate(
|
||||
child_bound,
|
||||
encode_min,
|
||||
encode_max,
|
||||
bounds_i_minus_1,
|
||||
bounds_i,
|
||||
);
|
||||
let min_bound = bounds_i_minus_1.min(bounds_i);
|
||||
let max_bound = bounds_i_minus_1.max(bounds_i);
|
||||
if bound > min_bound && bound < max_bound {
|
||||
stitching_bounds.push(bound);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stitching_bounds.sort_by(f32::total_cmp);
|
||||
stitching_bounds.dedup_by(|a, b| (*a - *b).abs() <= f32::EPSILON);
|
||||
|
||||
stitching_bounds
|
||||
}
|
||||
}
|
||||
|
||||
fn find_interval(bounds: &[f32], x: f32) -> Option<usize> {
|
||||
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
//! SVG-like gradient conversion for encoded shadings.
|
||||
//!
|
||||
//! PDF shadings are very expensive to evaluate. Because of this, at least for
|
||||
//! radial-axial shadings it's useful if we can instead approximate them by
|
||||
//! SVG-like sRGB gradients.
|
||||
//!
|
||||
//! PdfCraft patch: backported from hayro-interpret 0.8.0 (Apache-2.0 OR MIT), adapted to this
|
||||
//! version's `EncodedShadingType::RadialAxial` variant. Remove when the vendored copy moves to
|
||||
//! 0.8. A full-page axial soft mask was sampled pixel by pixel (InDesign fades: 15 s per page).
|
||||
|
||||
use crate::color::AlphaColor;
|
||||
use crate::encode::{EncodedShadingPattern, EncodedShadingType, RadialAxialParams};
|
||||
use crate::function::StitchingBounds;
|
||||
use crate::shading::ShadingFunction;
|
||||
use kurbo::{Affine, Circle, Point, Rect, Shape};
|
||||
use smallvec::smallvec;
|
||||
|
||||
const MAX_GRADIENT_SUBDIVISION_DEPTH: u8 = 10;
|
||||
const RADIAL_EPSILON: f64 = 1.0e-6;
|
||||
|
||||
/// An SVG-like gradient.
|
||||
pub struct SvgGradient {
|
||||
/// The kind of the gradient.
|
||||
pub kind: SvgGradientKind,
|
||||
/// The transform from encoded shading coordinates into the renderer's coordinate space.
|
||||
pub transform: Affine,
|
||||
/// The stops of the gradient in the sRGB color space.
|
||||
pub stops: Vec<SvgGradientStop>,
|
||||
}
|
||||
|
||||
/// The kind of the SVG gradient.
|
||||
pub enum SvgGradientKind {
|
||||
/// Linear gradient.
|
||||
Linear {
|
||||
/// The normalized start point of the gradient line.
|
||||
start: Point,
|
||||
/// The normalized end point of the gradient line.
|
||||
end: Point,
|
||||
},
|
||||
/// Radial gradient.
|
||||
Radial {
|
||||
/// The center point of the start circle.
|
||||
start_center: Point,
|
||||
/// The radius of the start circle.
|
||||
start_radius: f32,
|
||||
/// The center point of the end circle.
|
||||
end_center: Point,
|
||||
/// The radius of the end circle.
|
||||
end_radius: f32,
|
||||
},
|
||||
}
|
||||
|
||||
/// A color stop for an SVG-like gradient.
|
||||
pub struct SvgGradientStop {
|
||||
/// Normalized offset.
|
||||
pub offset: f32,
|
||||
/// RGBA color components in sRGB.
|
||||
pub color: [f32; 4],
|
||||
}
|
||||
|
||||
impl EncodedShadingPattern {
|
||||
/// Convert an axial/radial PDF shading into an SVG-like gradient approximation, or `None`
|
||||
/// for other shading types and for cases a native gradient cannot represent exactly.
|
||||
pub fn as_svg_gradient(&self, path_bbox: Rect, tolerance: f32) -> Option<SvgGradient> {
|
||||
let EncodedShadingType::RadialAxial {
|
||||
function,
|
||||
params,
|
||||
domain,
|
||||
extend,
|
||||
} = &self.shading_type
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
|
||||
EncodedRadialAxialShading {
|
||||
function,
|
||||
params,
|
||||
domain: *domain,
|
||||
extend: *extend,
|
||||
}
|
||||
.as_svg_gradient(self, path_bbox, tolerance)
|
||||
}
|
||||
}
|
||||
|
||||
struct EncodedRadialAxialShading<'a> {
|
||||
function: &'a ShadingFunction,
|
||||
params: &'a RadialAxialParams,
|
||||
domain: [f32; 2],
|
||||
extend: [bool; 2],
|
||||
}
|
||||
|
||||
impl EncodedRadialAxialShading<'_> {
|
||||
fn as_svg_gradient(
|
||||
&self,
|
||||
pattern: &EncodedShadingPattern,
|
||||
path_bbox: Rect,
|
||||
tolerance: f32,
|
||||
) -> Option<SvgGradient> {
|
||||
// A couple of cases cannot be losslessly represented by an SVG gradient.
|
||||
match self.params {
|
||||
RadialAxialParams::Axial => {
|
||||
if !self.axial_extend_covers_bbox(pattern, path_bbox) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
RadialAxialParams::Radial { .. } => {
|
||||
if !self.radial_extend_covers_bbox(pattern, path_bbox)
|
||||
|| pattern.background_color.components()[3] > 0.0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(SvgGradient {
|
||||
kind: self.native_gradient_kind(),
|
||||
transform: pattern.base_transform.inverse(),
|
||||
stops: approximate_gradient_stops(
|
||||
|t| self.sample_t(pattern, t),
|
||||
&self.normalized_stitching_bounds(),
|
||||
tolerance,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fn native_gradient_kind(&self) -> SvgGradientKind {
|
||||
match self.params {
|
||||
RadialAxialParams::Axial => SvgGradientKind::Linear {
|
||||
start: Point::ZERO,
|
||||
end: Point::new(1.0, 0.0),
|
||||
},
|
||||
RadialAxialParams::Radial { p1, r } => SvgGradientKind::Radial {
|
||||
start_center: Point::ZERO,
|
||||
start_radius: r.x as f32,
|
||||
end_center: *p1,
|
||||
end_radius: r.y as f32,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_t(&self, pattern: &EncodedShadingPattern, t: f32) -> [f32; 4] {
|
||||
let (t0, t1) = (self.domain[0], self.domain[1]);
|
||||
let t = t0 + (t1 - t0) * t;
|
||||
let Some(out) = self.function.eval(&smallvec![t]) else {
|
||||
return [0.0, 0.0, 0.0, 0.0];
|
||||
};
|
||||
let mut components = pattern.color_space.to_rgba(&out, 1.0, false).components();
|
||||
components[3] *= pattern.opacity;
|
||||
|
||||
if let Some(tf) = &pattern.transfer_function {
|
||||
return tf.apply(&AlphaColor::new(components)).components();
|
||||
}
|
||||
|
||||
components
|
||||
}
|
||||
|
||||
fn axial_extend_covers_bbox(&self, pattern: &EncodedShadingPattern, path_bbox: Rect) -> bool {
|
||||
let path_bbox = pattern.base_transform.transform_rect_bbox(path_bbox);
|
||||
|
||||
let min_x = path_bbox.min_x();
|
||||
let max_x = path_bbox.max_x();
|
||||
|
||||
(self.extend[0] || min_x >= 0.0) && (self.extend[1] || max_x <= 1.0)
|
||||
}
|
||||
|
||||
fn radial_extend_covers_bbox(&self, pattern: &EncodedShadingPattern, path_bbox: Rect) -> bool {
|
||||
let RadialAxialParams::Radial { p1, r } = *self.params else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let path_bbox = pattern.base_transform.transform_rect_bbox(path_bbox);
|
||||
|
||||
if p1.x.abs() <= RADIAL_EPSILON && p1.y.abs() <= RADIAL_EPSILON {
|
||||
return self.same_center_radial_extend_covers_bbox(path_bbox, r.x, r.y);
|
||||
}
|
||||
|
||||
let start_extend_ok = self.extend[0] || r.x.abs() <= RADIAL_EPSILON;
|
||||
let end_extend_ok = self.extend[1] || circle_covers_bbox(Circle::new(p1, r.y), path_bbox);
|
||||
|
||||
start_extend_ok && end_extend_ok
|
||||
}
|
||||
|
||||
fn same_center_radial_extend_covers_bbox(&self, path_bbox: Rect, r0: f64, r1: f64) -> bool {
|
||||
let mut inner_circle = Circle::new(Point::ZERO, r0);
|
||||
let mut outer_circle = Circle::new(Point::ZERO, r1);
|
||||
let mut inner_extend = self.extend[0];
|
||||
let mut outer_extend = self.extend[1];
|
||||
|
||||
if inner_circle.radius > outer_circle.radius {
|
||||
std::mem::swap(&mut inner_circle, &mut outer_circle);
|
||||
std::mem::swap(&mut inner_extend, &mut outer_extend);
|
||||
}
|
||||
|
||||
let inner_extend_ok = inner_extend
|
||||
|| inner_circle.radius <= RADIAL_EPSILON
|
||||
|| circle_outside_bbox(inner_circle, path_bbox);
|
||||
let outer_extend_ok = outer_extend || circle_covers_bbox(outer_circle, path_bbox);
|
||||
|
||||
inner_extend_ok && outer_extend_ok
|
||||
}
|
||||
|
||||
fn normalized_stitching_bounds(&self) -> StitchingBounds {
|
||||
let [t0, t1] = self.domain;
|
||||
let dt = t1 - t0;
|
||||
if dt.abs() <= f32::EPSILON {
|
||||
return StitchingBounds::new();
|
||||
}
|
||||
|
||||
let mut bounds = self
|
||||
.function
|
||||
.stitching_bounds()
|
||||
.into_iter()
|
||||
.map(|bound| (bound - t0) / dt)
|
||||
.filter(|bound| *bound > 0.0 && *bound < 1.0)
|
||||
.collect::<StitchingBounds>();
|
||||
|
||||
if dt < 0.0 {
|
||||
bounds.reverse();
|
||||
}
|
||||
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
fn circle_covers_bbox(circle: Circle, bbox: Rect) -> bool {
|
||||
if circle.radius < 0.0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let radius_squared = circle.radius * circle.radius;
|
||||
[
|
||||
Point::new(bbox.x0, bbox.y0),
|
||||
Point::new(bbox.x1, bbox.y0),
|
||||
Point::new(bbox.x0, bbox.y1),
|
||||
Point::new(bbox.x1, bbox.y1),
|
||||
]
|
||||
.into_iter()
|
||||
.all(|point| {
|
||||
let delta = point - circle.center;
|
||||
delta.hypot2() <= radius_squared
|
||||
})
|
||||
}
|
||||
|
||||
fn circle_outside_bbox(circle: Circle, bbox: Rect) -> bool {
|
||||
if circle.radius <= 0.0 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let closest = Point::new(
|
||||
circle.center.x.clamp(bbox.x0, bbox.x1),
|
||||
circle.center.y.clamp(bbox.y0, bbox.y1),
|
||||
);
|
||||
|
||||
!circle.contains(closest)
|
||||
}
|
||||
|
||||
fn approximate_gradient_stops(
|
||||
mut sample: impl FnMut(f32) -> [f32; 4],
|
||||
breakpoints: &[f32],
|
||||
tolerance: f32,
|
||||
) -> Vec<SvgGradientStop> {
|
||||
let mut interval_bounds = Vec::with_capacity(breakpoints.len() + 2);
|
||||
interval_bounds.push(0.0);
|
||||
interval_bounds.extend(breakpoints.iter().copied());
|
||||
interval_bounds.push(1.0);
|
||||
|
||||
let mut stops = Vec::new();
|
||||
for interval in interval_bounds.windows(2) {
|
||||
let start_offset = interval[0];
|
||||
let end_offset = interval[1];
|
||||
|
||||
let start_color = sample(start_offset);
|
||||
stops.push(SvgGradientStop {
|
||||
offset: start_offset,
|
||||
color: start_color,
|
||||
});
|
||||
|
||||
let end_color = sample(end_offset);
|
||||
|
||||
approximate_gradient_interval(
|
||||
&mut sample,
|
||||
&mut stops,
|
||||
GradientSample {
|
||||
offset: start_offset,
|
||||
color: start_color,
|
||||
},
|
||||
GradientSample {
|
||||
offset: end_offset,
|
||||
color: end_color,
|
||||
},
|
||||
tolerance.max(0.0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
stops
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct GradientSample {
|
||||
offset: f32,
|
||||
color: [f32; 4],
|
||||
}
|
||||
|
||||
fn approximate_gradient_interval(
|
||||
sample: &mut impl FnMut(f32) -> [f32; 4],
|
||||
stops: &mut Vec<SvgGradientStop>,
|
||||
start: GradientSample,
|
||||
end: GradientSample,
|
||||
tolerance: f32,
|
||||
depth: u8,
|
||||
) {
|
||||
let offset_delta = end.offset - start.offset;
|
||||
let quarter_offset = start.offset + offset_delta * 0.25;
|
||||
let mid_offset = start.offset + offset_delta * 0.5;
|
||||
let three_quarter_offset = start.offset + offset_delta * 0.75;
|
||||
|
||||
let quarter_color = sample(quarter_offset);
|
||||
let mid_color = sample(mid_offset);
|
||||
let three_quarter_color = sample(three_quarter_offset);
|
||||
|
||||
if color_error(quarter_color, lerp_color(start.color, end.color, 0.25)) <= tolerance
|
||||
&& color_error(mid_color, lerp_color(start.color, end.color, 0.5)) <= tolerance
|
||||
&& color_error(
|
||||
three_quarter_color,
|
||||
lerp_color(start.color, end.color, 0.75),
|
||||
) <= tolerance
|
||||
|| depth == MAX_GRADIENT_SUBDIVISION_DEPTH
|
||||
{
|
||||
stops.push(SvgGradientStop {
|
||||
offset: end.offset,
|
||||
color: end.color,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let mid = GradientSample {
|
||||
offset: mid_offset,
|
||||
color: mid_color,
|
||||
};
|
||||
|
||||
approximate_gradient_interval(sample, stops, start, mid, tolerance, depth + 1);
|
||||
approximate_gradient_interval(sample, stops, mid, end, tolerance, depth + 1);
|
||||
}
|
||||
|
||||
fn lerp_color(c0: [f32; 4], c1: [f32; 4], t: f32) -> [f32; 4] {
|
||||
[
|
||||
c0[0] + (c1[0] - c0[0]) * t,
|
||||
c0[1] + (c1[1] - c0[1]) * t,
|
||||
c0[2] + (c1[2] - c0[2]) * t,
|
||||
c0[3] + (c1[3] - c0[3]) * t,
|
||||
]
|
||||
}
|
||||
|
||||
fn color_error(c0: [f32; 4], c1: [f32; 4]) -> f32 {
|
||||
c0.iter()
|
||||
.zip(c1)
|
||||
.map(|(a, b)| (a - b).abs())
|
||||
.fold(0.0, f32::max)
|
||||
}
|
||||
Vendored
+1
@@ -47,6 +47,7 @@ pub use x_object::image_size_ok;
|
||||
pub mod color;
|
||||
pub mod encode;
|
||||
pub mod font;
|
||||
pub mod gradient;
|
||||
pub mod pattern;
|
||||
pub mod shading;
|
||||
pub mod util;
|
||||
|
||||
+19
-1
@@ -5,7 +5,7 @@
|
||||
use crate::CacheKey;
|
||||
use crate::cache::Cache;
|
||||
use crate::color::{ColorComponents, ColorSpace};
|
||||
use crate::function::{Function, Values, interpolate};
|
||||
use crate::function::{Function, StitchingBounds, Values, interpolate};
|
||||
use crate::util::{Float32Ext, PointExt, RectExt};
|
||||
use hayro_syntax::bit_reader::BitReader;
|
||||
use hayro_syntax::object::Array;
|
||||
@@ -48,6 +48,24 @@ impl ShadingFunction {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (backport of hayro 0.8): stitching breakpoints of all component functions.
|
||||
pub(crate) fn stitching_bounds(&self) -> StitchingBounds {
|
||||
let mut bounds = StitchingBounds::new();
|
||||
|
||||
match self {
|
||||
Self::Single(function) => bounds.extend(function.stitching_bounds()),
|
||||
Self::Multiple(functions) => {
|
||||
for function in functions {
|
||||
bounds.extend(function.stitching_bounds());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bounds.sort_by(f32::total_cmp);
|
||||
bounds.dedup_by(|a, b| (*a - *b).abs() <= f32::EPSILON);
|
||||
bounds
|
||||
}
|
||||
}
|
||||
|
||||
/// A type of shading.
|
||||
|
||||
Vendored
+47
-1
@@ -14,9 +14,10 @@ use std::collections::HashMap;
|
||||
use std::mem::size_of;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use vello_cpu::color::DynamicColor;
|
||||
use vello_cpu::color::palette::css::BLACK;
|
||||
use vello_cpu::color::{AlphaColor, PremulRgba8, Srgb};
|
||||
use vello_cpu::peniko::{Compose, Fill, ImageQuality, ImageSampler, Mix};
|
||||
use vello_cpu::peniko::{ColorStop, Compose, Fill, Gradient, ImageQuality, ImageSampler, Mix};
|
||||
use vello_cpu::{
|
||||
Image, ImageSource, Mask, PaintType, Pixmap, RenderContext, RenderSettings, peniko,
|
||||
};
|
||||
@@ -861,6 +862,20 @@ impl Renderer {
|
||||
// PdfCraft patch: mesh shadings are sampled only inside `bbox` (and a
|
||||
// pixel around it: texels at a fractional edge look one pixel further).
|
||||
let encoded = s.encode_within(Some(bbox.inflate(1.0, 1.0)));
|
||||
|
||||
// PdfCraft patch (backport of hayro 0.8): axial and radial shadings
|
||||
// become native gradients instead of a texture sampled pixel by pixel.
|
||||
// Upstream also shifts by half a pixel for vello_cpu 0.3; 0.0.8 already
|
||||
// evaluates gradients at pixel centres, like the texture below.
|
||||
if let Some(native) =
|
||||
encoded.as_svg_gradient(bbox, NATIVE_GRADIENT_TOLERANCE)
|
||||
{
|
||||
self.ctx
|
||||
.set_paint_transform(path_transform.inverse() * native.transform);
|
||||
self.ctx.set_paint(native_gradient(native));
|
||||
return clip_path;
|
||||
}
|
||||
|
||||
let (image, width, height, transform, may_have_transparency) =
|
||||
render_shading_texture(bbox, &encoded);
|
||||
let may_have_transparency =
|
||||
@@ -1397,6 +1412,37 @@ impl<'a> Device<'a> for Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch (backport of hayro 0.8): largest per-channel error allowed when approximating
|
||||
/// a shading function by gradient stops.
|
||||
const NATIVE_GRADIENT_TOLERANCE: f32 = 0.01;
|
||||
|
||||
/// PdfCraft patch (backport of hayro 0.8): a `vello_cpu` gradient for an approximated shading.
|
||||
fn native_gradient(native: hayro_interpret::gradient::SvgGradient) -> PaintType {
|
||||
use hayro_interpret::gradient::SvgGradientKind;
|
||||
|
||||
let stops = native
|
||||
.stops
|
||||
.iter()
|
||||
.map(|stop| ColorStop {
|
||||
offset: stop.offset,
|
||||
color: DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new(stop.color)),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
match native.kind {
|
||||
SvgGradientKind::Linear { start, end } => Gradient::new_linear(start, end),
|
||||
SvgGradientKind::Radial {
|
||||
start_center,
|
||||
start_radius,
|
||||
end_center,
|
||||
end_radius,
|
||||
} => Gradient::new_two_point_radial(start_center, start_radius, end_center, end_radius),
|
||||
}
|
||||
.with_extend(peniko::Extend::Pad)
|
||||
.with_stops(stops.as_slice())
|
||||
.into()
|
||||
}
|
||||
|
||||
// TODO: Deduplicate with hayro-svg?
|
||||
fn render_shading_texture(
|
||||
path_bbox: Rect,
|
||||
|
||||
Reference in new issue
Block a user