mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-14 19:27:04 -04:00
M2.6: draw Highlight annotations that have no appearance stream (#274)
A Highlight annotation (ISO 32000-2 §12.5.6.10) whose /AP is missing, as many older and generated files have, was not drawn at all: the vendored interpreter only draws annotations through their normal appearance. MuPDF, Poppler and PDFium all draw such highlights. When there is no usable /AP /N, the interpreter now fills the /QuadPoints quadrilaterals with /C at /CA opacity, blended with Multiply, the way pdfcraft-annot draws PdfCraft's own highlights. It uses the interpreter's own fill path, so clipping, layers and the device see an ordinary fill. pdfcraft-annot itself can't be reused here: render and annot are both L3, and it builds pdfcraft-cos objects while hayro reads hayro-syntax ones. Nothing changes when an /AP exists. Malformed /QuadPoints (not a non-empty multiple of eight numbers) and an absent, empty or malformed /C draw nothing, as pdfcraft-annot does. Hidden, NoView and "Hide all comments" are checked before this point and still apply. Only Highlight changes; the source PDF is not modified. Test: raster::tests::highlights_without_an_appearance_are_drawn. On main the quad stays white; now it is yellow over white and black over black (Multiply), two quads leave their gap white, /CA 0.5 and gray /C match MuPDF, Poppler and PDFium. An /AP still wins, and ten malformed or hidden variants draw nothing. Vendored patch (13) in vendor/README.md; the parity entry view.render-annotation-appearances cites the test and notes that other comment types without /AP are still not drawn. Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
7965b497ff
commit
8c281b39b2
4 files changed
+144
-6
No files matched your search
@@ -774,6 +774,85 @@ trailer << /Root 1 0 R >>
|
||||
assert_eq!(px(30, 58), vec![255, 255, 255, 255], "above /Rect");
|
||||
}
|
||||
|
||||
/// A Highlight annotation without an appearance stream (common in older and generated files)
|
||||
/// was not drawn at all. Vendored hayro-interpret patch: its /QuadPoints are filled with /C at
|
||||
/// /CA, blended with Multiply, as PdfCraft draws its own highlights (pdfcraft-annot); MuPDF,
|
||||
/// Poppler and PDFium draw these fixtures the same way. An /AP still wins, malformed
|
||||
/// /QuadPoints or no /C draw nothing, and Hidden, NoView and "Hide all comments" still apply.
|
||||
#[test]
|
||||
fn highlights_without_an_appearance_are_drawn() {
|
||||
let render = |annot: &str, config: RenderConfig| {
|
||||
let content = "0 g 100 40 10 20 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 200 100] /Contents 4 0 R /Annots [5 0 R] >> endobj
|
||||
4 0 obj << /Length {} >> stream
|
||||
{content}
|
||||
endstream endobj
|
||||
5 0 obj << /Type /Annot /Subtype /Highlight /Rect [15 35 120 65] {annot} >> endobj
|
||||
6 0 obj << /Type /XObject /Subtype /Form /BBox [15 35 120 65] /Length 24 >> stream
|
||||
0 0 1 rg 15 35 50 30 re f
|
||||
endstream endobj
|
||||
trailer << /Root 1 0 R >>
|
||||
%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), config);
|
||||
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0 });
|
||||
assert!(p.error.is_none(), "{annot}: {:?}", p.error);
|
||||
p
|
||||
};
|
||||
let px = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..4].to_vec();
|
||||
let (white, black, yellow) = (vec![255, 255, 255, 255], vec![0, 0, 0, 255], vec![255, 255, 0, 255]);
|
||||
let quad = "/QuadPoints [15 65 120 65 15 35 120 35]";
|
||||
|
||||
let p = render(&format!("{quad} /C [1 1 0]"), RenderConfig::default());
|
||||
assert_eq!(px(&p, 30, 50), yellow, "the quad is filled with /C");
|
||||
assert_eq!(px(&p, 105, 50), black, "Multiply keeps what is underneath");
|
||||
assert_eq!(px(&p, 150, 50), white, "outside the quad");
|
||||
assert_eq!(px(&p, 30, 30), white, "above the quad");
|
||||
|
||||
// Two quads (one per line of text); the gap between them stays white.
|
||||
let p = render("/QuadPoints [15 65 60 65 15 50 60 50 70 50 120 50 70 35 120 35] /C [0 1 1]", RenderConfig::default());
|
||||
assert_eq!(px(&p, 30, 40), vec![0, 255, 255, 255]);
|
||||
assert_eq!(px(&p, 90, 60), vec![0, 255, 255, 255]);
|
||||
assert_eq!((px(&p, 90, 40), px(&p, 30, 60)), (white.clone(), white.clone()));
|
||||
|
||||
// /CA and gray /C.
|
||||
let p = render(&format!("{quad} /C [1 0 0] /CA 0.5"), RenderConfig::default());
|
||||
let half = px(&p, 30, 50);
|
||||
assert!(half[0] == 255 && (126..=129).contains(&half[1]) && half[1] == half[2], "{half:?}");
|
||||
let p = render(&format!("{quad} /C [0.5]"), RenderConfig::default());
|
||||
let gray = px(&p, 30, 50);
|
||||
assert!((126..=129).contains(&gray[0]) && gray[0] == gray[1] && gray[1] == gray[2], "{gray:?}");
|
||||
|
||||
// An appearance stream wins: the blue form, not a yellow highlight.
|
||||
let p = render(&format!("{quad} /C [1 1 0] /AP << /N 6 0 R >>"), RenderConfig::default());
|
||||
assert_eq!(px(&p, 30, 50), vec![0, 0, 255, 255]);
|
||||
assert_eq!(px(&p, 90, 50), white, "no highlight outside the appearance's own drawing");
|
||||
|
||||
// Nothing is drawn for malformed geometry, no colour, or a hidden annotation.
|
||||
let hide = RenderConfig { hide_comments: true, ..RenderConfig::default() };
|
||||
for (annot, config) in [
|
||||
("/QuadPoints [10 10 20] /C [1 1 0]", RenderConfig::default()),
|
||||
("/QuadPoints [15 65 120 65 15 35 /x 35] /C [1 1 0]", RenderConfig::default()),
|
||||
("/QuadPoints [] /C [1 1 0]", RenderConfig::default()),
|
||||
("/QuadPoints 7 /C [1 1 0]", RenderConfig::default()),
|
||||
(quad, RenderConfig::default()),
|
||||
(&format!("{quad} /C []"), RenderConfig::default()),
|
||||
(&format!("{quad} /C [1 1]"), RenderConfig::default()),
|
||||
(&format!("{quad} /C [1 1 0] /F 2"), RenderConfig::default()),
|
||||
(&format!("{quad} /C [1 1 0] /F 32"), RenderConfig::default()),
|
||||
(&format!("{quad} /C [1 1 0]"), hide),
|
||||
] {
|
||||
let p = render(annot, config);
|
||||
assert_eq!(px(&p, 30, 50), white, "{annot}");
|
||||
assert_eq!(px(&p, 105, 50), black, "{annot}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_comments_keeps_fields() {
|
||||
let pdf = b"%PDF-1.7
|
||||
|
||||
@@ -770,8 +770,8 @@ milestone = "M2"
|
||||
status = "shipped"
|
||||
commands = []
|
||||
tools = []
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states"]
|
||||
notes = ""
|
||||
evidence = ["crates/render/src/raster.rs::widget_appearance_states", "crates/render/src/raster.rs::highlights_without_an_appearance_are_drawn"]
|
||||
notes = "a Highlight without /AP is drawn from /QuadPoints, /C and /CA; other comment types without /AP are not drawn yet"
|
||||
|
||||
[[feature]]
|
||||
id = "view.render-error-isolation"
|
||||
|
||||
Vendored
+1
-1
@@ -7,7 +7,7 @@ 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) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped` |
|
||||
| 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) | (1) `raster::tests::widget_appearance_states`; (2, 3) `xtask text-oracle` on corpus/pdfjs issue918; (4) `raster::tests::layer_override_hides_content`; (5) `raster::tests::soft_mask_without_group_colour_space_is_applied`; (6) `raster::tests::self_referencing_tiling_pattern_terminates`, `raster::tests::self_referencing_type3_glyph_terminates`; (7) `raster::tests::huge_cid_width_ranges_terminate`; (8) `raster::tests::hiding_comments_keeps_fields`; (9) `raster::tests::absurd_mask_and_fax_sizes_are_refused_before_decoding`; (10) `raster::tests::type3_glyphs_that_fan_out_into_themselves_finish`; (11) `raster::tests::type1_font_programs_with_long_runs_of_numbers_load_quickly`; (12) `raster::tests::degenerate_annotation_appearances_are_skipped`; (13) `raster::tests::highlights_without_an_appearance_are_drawn` |
|
||||
|
||||
| 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) | (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` |
|
||||
| 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) | (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 |
|
||||
|
||||
+62
-3
@@ -1,4 +1,4 @@
|
||||
use crate::FillRule;
|
||||
use crate::{BlendMode, FillRule};
|
||||
use crate::color::ColorSpace;
|
||||
use crate::context::Context;
|
||||
use crate::convert::{convert_line_cap, convert_line_join};
|
||||
@@ -17,10 +17,12 @@ use crate::x_object::{
|
||||
};
|
||||
use hayro_syntax::content::TypedIter;
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::dict::keys::{ANNOTS, AP, AS, F, MCID, N, OC, RECT, SUBTYPE};
|
||||
use hayro_syntax::object::dict::keys::{
|
||||
ANNOTS, AP, AS, C, CA, F, MCID, N, OC, QUADPOINTS, RECT, SUBTYPE,
|
||||
};
|
||||
use hayro_syntax::object::{Array, Dict, Object, Rect, Stream, dict_or_stream};
|
||||
use hayro_syntax::page::{Page, Resources};
|
||||
use kurbo::{Affine, Point, Shape};
|
||||
use kurbo::{Affine, BezPath, Point, Shape};
|
||||
use smallvec::smallvec;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -243,11 +245,68 @@ pub fn interpret_page<'a>(
|
||||
draw_form_xobject(resources, &apx, context, device);
|
||||
context.pop_root_transform();
|
||||
context.restore_state(device);
|
||||
} else if annot.get::<hayro_syntax::object::Name<'_>>(SUBTYPE).as_deref() == Some(b"Highlight") {
|
||||
// PdfCraft patch: a Highlight without a usable normal appearance was not drawn.
|
||||
draw_highlight_without_appearance(&annot, context, device);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PdfCraft patch: the normal appearance of a Highlight annotation that has none (ISO 32000-2
|
||||
/// §12.5.6.10), drawn as PdfCraft draws its own highlights (pdfcraft-annot): each /QuadPoints
|
||||
/// quadrilateral filled with /C at /CA opacity, blended with Multiply so the marked-up content
|
||||
/// stays legible. /QuadPoints that aren't groups of eight numbers, or a /C that isn't a gray, RGB
|
||||
/// or CMYK colour (absent or empty means transparent), draw nothing.
|
||||
fn draw_highlight_without_appearance<'a>(
|
||||
annot: &Dict<'a>,
|
||||
context: &mut Context<'a>,
|
||||
device: &mut impl Device<'a>,
|
||||
) {
|
||||
let numbers = |key| -> Option<Vec<f32>> {
|
||||
annot
|
||||
.get::<Array<'_>>(key)?
|
||||
.iter::<Object<'_>>()
|
||||
.map(|o| match o {
|
||||
Object::Number(n) => Some(n.as_f32()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let Some(quad_points) = numbers(QUADPOINTS).filter(|q| !q.is_empty() && q.len() % 8 == 0)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let (color_space, color) = match numbers(C).as_deref() {
|
||||
Some(&[g]) => (ColorSpace::device_gray(), smallvec![g]),
|
||||
Some(&[r, g, b]) => (ColorSpace::device_rgb(), smallvec![r, g, b]),
|
||||
Some(&[c, m, y, k]) => (ColorSpace::device_cmyk(), smallvec![c, m, y, k]),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let mut path = BezPath::new();
|
||||
// Corners in /QuadPoints order: upper left, upper right, lower left, lower right.
|
||||
for &[x1, y1, x2, y2, x3, y3, x4, y4] in quad_points.as_chunks::<8>().0 {
|
||||
path.move_to((x1 as f64, y1 as f64));
|
||||
path.line_to((x2 as f64, y2 as f64));
|
||||
path.line_to((x4 as f64, y4 as f64));
|
||||
path.line_to((x3 as f64, y3 as f64));
|
||||
path.close_path();
|
||||
}
|
||||
|
||||
context.save_state();
|
||||
let state = &mut context.get_mut().graphics_state;
|
||||
state.none_stroke_cs = color_space;
|
||||
state.non_stroke_color = color;
|
||||
state.non_stroke_pattern = None;
|
||||
state.non_stroke_alpha = annot.get::<f32>(CA).unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
state.soft_mask = None;
|
||||
state.blend_mode = BlendMode::Multiply;
|
||||
*context.path_mut() = path;
|
||||
fill_path(context, device, FillRule::NonZero);
|
||||
context.restore_state(device);
|
||||
}
|
||||
|
||||
/// Interpret the instructions from `ops` and render them into the device.
|
||||
pub fn interpret<'a>(
|
||||
mut ops: TypedIter<'_>,
|
||||
|
||||
Reference in new issue
Block a user