mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
PDF interpret: a glyph shown in a clipping mode clips even with no outline
* PDF interpret: a glyph shown in a clipping mode clips even with no outline
A glyph shown in a clipping render mode (`4 Tr` to `7 Tr`) adds its outline to
the clipping path, and that clip applies to everything painted after `ET` until
the enclosing `Q`. When the glyphs shown have no outline -- a space, say -- the
clip they contribute is empty, and an empty clip admits nothing.
`interpret_window` applied a clip only once a glyph had contributed an outline,
so such a text object applied *no* clip and what followed painted unmasked.
A text object that shows no glyph at all is a different case and is left alone:
the clipping path is unchanged there, as readers leave it, and a glyph whose
outline is unavailable (Type 3) leaves the real clip unknown, so both stay
unclipped. pdf.js draws the same line in `endText`: it builds the clip from the
glyphs shown, empty outlines and all, and applies none when none were shown.
Measured on a page filled red behind the text object, Preview (CoreGraphics)
and Firefox (pdf.js) agreeing on the case this changes:
a space shown in 7 Tr before: the fill paints after: nothing paints
no glyph shown in 7 Tr unchanged: the fill paints
Companion to storytold/pdfcraft#3451.
The first push of this branch also carried 803 build-artifact files from
`crates/pdf/read-only-contract/target/`, which `.gitignore` did not cover: its
`/target` is anchored to the repository root, so a sub-crate's own build
directory is not ignored. Recommitted without them, and `target/` added so the
next one cannot slip in either (#185 review).
* pdf: test visible glyphs in a clipping mode, and what each clip leaves on the page
---------
Co-authored-by: Hamza <mail.hamzanaveed@gmail.com>
This commit is contained in:
1 parent
638ee75115
commit
75af6f589a
5 files changed
+145
-1
No files matched your search
@@ -2,6 +2,9 @@
|
||||
plan/
|
||||
plans/
|
||||
/target
|
||||
# Also a sub-crate's own build directory: `crates/pdf/read-only-contract/target/` is not covered
|
||||
# by the anchored `/target` above, and 803 of its files reached a commit once (#185 review).
|
||||
target/
|
||||
.DS_Store
|
||||
/.claude/
|
||||
.idea/
|
||||
|
||||
@@ -65,6 +65,88 @@ mod device {
|
||||
execute(&mut fresh).unwrap();
|
||||
assert_eq!(fresh.paths, 1);
|
||||
}
|
||||
|
||||
/// Records the clips the interpreter applies, alongside the paths painted under them.
|
||||
#[derive(Default)]
|
||||
struct ClipDevice {
|
||||
clips: Vec<BezPath>,
|
||||
paths: usize,
|
||||
}
|
||||
|
||||
impl Device<'_> for ClipDevice {
|
||||
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
|
||||
fn set_blend_mode(&mut self, _: BlendMode) {}
|
||||
fn draw_path(&mut self, _: &BezPath, _: Affine, _: &Paint<'_>, _: &PathDrawMode) {
|
||||
self.paths += 1;
|
||||
}
|
||||
fn push_clip_path(&mut self, clip_path: &ClipPath) {
|
||||
self.clips.push(clip_path.path.clone());
|
||||
}
|
||||
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
|
||||
fn draw_glyph(&mut self, _: &Glyph<'_>, _: Affine, _: Affine, _: &Paint<'_>, _: &GlyphDrawMode) {}
|
||||
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
|
||||
fn pop_clip_path(&mut self) {}
|
||||
fn pop_transparency_group(&mut self) {}
|
||||
}
|
||||
|
||||
fn clips_of(content: &str) -> ClipDevice {
|
||||
let bytes = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let pdf = craft_pdf::open_slice(bytes.as_bytes(), None).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
let cache = InterpreterCache::new();
|
||||
let mut context = Context::new(
|
||||
page.initial_transform(true).to_kurbo(),
|
||||
Rect::new(0.0, 0.0, 40.0, 20.0),
|
||||
&cache,
|
||||
page.xref(),
|
||||
InterpreterSettings::default(),
|
||||
);
|
||||
let mut device = ClipDevice::default();
|
||||
craft_pdf::interpret_page(page, &mut context, &mut device).unwrap();
|
||||
device
|
||||
}
|
||||
|
||||
/// A glyph shown in a clipping render mode adds its outline to the clipping path, and that
|
||||
/// clip applies to everything painted after `ET`. When the glyphs shown have no outline -- a
|
||||
/// space, say -- the clip is empty and nothing paints through it. The interpreter applied a
|
||||
/// clip only once a glyph had contributed an outline, so such a text object clipped nothing
|
||||
/// and what followed painted unmasked.
|
||||
///
|
||||
/// A text object that shows no glyph at all is a different case: there the clipping path is
|
||||
/// unchanged, as readers leave it.
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn a_glyph_shown_in_a_clipping_mode_clips_even_with_no_outline() {
|
||||
// `7 Tr` is clip-only, and the glyph shown has no outline: the clip is empty.
|
||||
let empty = clips_of("q BT 7 Tr /F1 12 Tf 4 4 Td ( ) Tj ET 1 0 0 rg 0 0 40 20 re f Q");
|
||||
assert_eq!(empty.clips.len(), 1, "the empty clip is applied");
|
||||
assert!(empty.clips[0].is_empty(), "and it clips everything away: {:?}", empty.clips[0]);
|
||||
|
||||
// Visible glyphs in the same mode clip to their outlines, as they always did: one clip, not
|
||||
// empty, and the fill still painted beneath it (noted in review).
|
||||
let visible = clips_of("q BT 7 Tr /F1 12 Tf 4 4 Td (H) Tj ET 1 0 0 rg 0 0 40 20 re f Q");
|
||||
assert_eq!(visible.clips.len(), 1, "the glyph outline is applied as the clip");
|
||||
assert!(!visible.clips[0].is_empty(), "and it holds the outline");
|
||||
assert_eq!(visible.paths, 1);
|
||||
|
||||
// A clipping mode with no glyph shown at all leaves the clipping path alone.
|
||||
let none = clips_of("q BT 7 Tr /F1 12 Tf 4 4 Td ET 1 0 0 rg 0 0 40 20 re f Q");
|
||||
assert!(none.clips.is_empty(), "nothing was shown, so nothing clips");
|
||||
assert_eq!(none.paths, 1);
|
||||
|
||||
// No text object: the fill is unclipped, as it always was.
|
||||
let plain = clips_of("q 1 0 0 rg 0 0 40 20 re f Q");
|
||||
assert!(plain.clips.is_empty(), "nothing clips the fill");
|
||||
assert_eq!(plain.paths, 1);
|
||||
|
||||
// A painting mode never clips, outline or not.
|
||||
let painting = clips_of("q BT 0 Tr /F1 12 Tf 4 4 Td ( ) Tj ET 1 0 0 rg 0 0 40 20 re f Q");
|
||||
assert!(painting.clips.is_empty(), "a painting text mode never clips");
|
||||
assert_eq!(painting.paths, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
@@ -119,6 +201,37 @@ fn caller_decode_policy_survives_open_fallback() {
|
||||
assert_eq!(normal.decode_limits(), craft_pdf::syntax::DecodeLimits::default());
|
||||
}
|
||||
|
||||
/// What the clip does to the page, rendered: the device test above shows the clip the interpreter
|
||||
/// sets, this shows the pixels it leaves (noted in review).
|
||||
#[cfg(all(feature = "raster", not(target_arch = "wasm32")))]
|
||||
mod rendered {
|
||||
use craft_pdf::raster::{self, Options, RenderCache};
|
||||
|
||||
/// The share of a 40 x 20 pt page painted red (the fill), after `text` and then the fill.
|
||||
fn red_share(text: &str) -> f64 {
|
||||
let content = format!("q {text} 1 0 0 rg 0 0 40 20 re f Q");
|
||||
let bytes = format!(
|
||||
"%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 40 20] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >> endobj\n4 0 obj << /Length {} >> stream\n{content}\nendstream endobj\n5 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF",
|
||||
content.len()
|
||||
);
|
||||
let pdf = craft_pdf::open_slice(bytes.as_bytes(), None).unwrap();
|
||||
let page = raster::render(&pdf.pages()[0], &RenderCache::new(), &Default::default(), &Options { background: [255; 4], ..Default::default() })
|
||||
.unwrap();
|
||||
let pixels = page.bytes().as_chunks::<4>().0;
|
||||
let red = pixels.iter().filter(|p| p[0] > 200 && p[1] < 80 && p[2] < 80).count();
|
||||
red as f64 / pixels.len() as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_text_clip_hides_what_follows_and_a_visible_one_shows_its_glyphs() {
|
||||
assert!(red_share("") > 0.99, "no text object: the fill covers the page");
|
||||
assert_eq!(red_share("BT 7 Tr /F1 12 Tf 4 4 Td ( ) Tj ET"), 0.0, "an outline-less glyph in 7 Tr: nothing paints");
|
||||
let through = red_share("BT 7 Tr /F1 12 Tf 4 4 Td (H) Tj ET");
|
||||
assert!(through > 0.02 && through < 0.5, "visible glyphs in 7 Tr: the fill shows through the H only ({through})");
|
||||
assert!(red_share("BT 7 Tr /F1 12 Tf 4 4 Td ET") > 0.99, "no glyph shown: the clip is unchanged");
|
||||
}
|
||||
}
|
||||
|
||||
/// A path reaches the device as a rectangle only when it is one. A five-point open polyline whose
|
||||
/// points reach every corner of its own bounding box (VectorCraft #2920, a zigzag in an `.ai`
|
||||
/// file) used to be drawn as that box.
|
||||
|
||||
+12
-1
@@ -709,13 +709,24 @@ fn interpret_window<'a>(
|
||||
.next()
|
||||
.is_some();
|
||||
|
||||
if has_outline {
|
||||
// PdfCraft patch: a glyph shown in a clipping mode that contributes no outline
|
||||
// still clips -- the clip is empty, so nothing paints through it, as a glyph with
|
||||
// an outline clips to its shape. Applying nothing instead left what followed
|
||||
// unmasked. A text object that shows no glyph at all is not this case and leaves
|
||||
// the clipping path alone, and a glyph whose outline is unavailable (Type 3)
|
||||
// leaves the real clip unknown, so both stay unclipped.
|
||||
let state = &context.get().text_state;
|
||||
let empty_clip = state.clip_shown && !state.clip_unsupported;
|
||||
|
||||
if has_outline || empty_clip {
|
||||
let clip_path = context.get().ctm * context.get().text_state.clip_paths.clone();
|
||||
|
||||
context.push_clip_path(clip_path, FillRule::NonZero, device);
|
||||
}
|
||||
|
||||
context.get_mut().text_state.clip_paths.truncate(0);
|
||||
context.get_mut().text_state.clip_shown = false;
|
||||
context.get_mut().text_state.clip_unsupported = false;
|
||||
}
|
||||
TypedInstruction::TextFont(t) => {
|
||||
let name = t.0;
|
||||
|
||||
+11
@@ -149,6 +149,15 @@ pub(crate) struct TextState<'a> {
|
||||
// When setting the text rendering mode to `clip`, the glyphs should instead be collected
|
||||
// as paths and then applied as 1 single clip path. This field stores those clip paths.
|
||||
pub(crate) clip_paths: BezPath,
|
||||
/// PdfCraft patch: whether a glyph was shown in a clipping render mode (4 to 7) in this text
|
||||
/// object, so `ET` knows to apply the clip even when none of them contributed an outline: the
|
||||
/// clip is then empty and nothing paints through it. A text object that shows no glyph at all
|
||||
/// leaves the clipping path alone, which is what readers do with it.
|
||||
pub(crate) clip_shown: bool,
|
||||
/// PdfCraft patch: whether a glyph in a clipping mode could not be turned into an outline
|
||||
/// (a Type 3 glyph, which `clip_glyph` does not implement). The real clip is then unknown, so
|
||||
/// `ET` leaves what follows unclipped rather than hiding it.
|
||||
pub(crate) clip_unsupported: bool,
|
||||
}
|
||||
|
||||
impl<'a> TextState<'a> {
|
||||
@@ -245,6 +254,8 @@ impl Default for TextState<'_> {
|
||||
text_line_matrix: Affine::IDENTITY,
|
||||
rise: 0.0,
|
||||
clip_paths: BezPath::default(),
|
||||
clip_shown: false,
|
||||
clip_unsupported: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,9 @@ fn show_glyph<'a>(
|
||||
}
|
||||
|
||||
pub(crate) fn clip_glyph(context: &mut Context<'_>, glyph: &Glyph<'_>, transform: Affine) {
|
||||
// PdfCraft patch: this glyph is shown in a clipping mode, so the text object clips at `ET`
|
||||
// whether or not the glyph has an outline to clip to.
|
||||
context.get_mut().text_state.clip_shown = true;
|
||||
match glyph {
|
||||
Glyph::Outline(o) => {
|
||||
let outline = transform * o.outline();
|
||||
@@ -254,6 +257,9 @@ pub(crate) fn clip_glyph(context: &mut Context<'_>, glyph: &Glyph<'_>, transform
|
||||
}
|
||||
Glyph::Type3(_) => {
|
||||
warn!("text rendering mode clip not implemented for shape glyphs");
|
||||
// PdfCraft patch: the clip this glyph belongs to cannot be built, so `ET` leaves what
|
||||
// follows unclipped rather than applying an empty clip and hiding it.
|
||||
context.get_mut().text_state.clip_unsupported = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user