Files
craft-libs/crates/pdf/tests/upstream_compatibility.rs

513 lines
25 KiB
Rust

//! Observable resource, object-depth and text-paint compatibility contracts.
#![cfg(feature = "read")]
#![allow(clippy::unwrap_used, clippy::panic)]
use craft_pdf::syntax::object::{FromBytes, ObjRef, Object};
fn stream(dictionary: &str, content: &[u8]) -> Vec<u8> {
let mut bytes = format!("<< {dictionary} /Length {} >>\nstream\n", content.len()).into_bytes();
bytes.extend_from_slice(content);
bytes.extend_from_slice(b"\nendstream");
bytes
}
fn object(bytes: &mut Vec<u8>, number: usize, body: &[u8]) -> usize {
let offset = bytes.len();
bytes.extend_from_slice(format!("{number} 0 obj\n").as_bytes());
bytes.extend_from_slice(body);
bytes.extend_from_slice(b"\nendobj\n");
offset
}
fn assemble(objects: &[Vec<u8>]) -> Vec<u8> {
let mut bytes = b"%PDF-1.7\n".to_vec();
let offsets: Vec<_> = objects.iter().enumerate().map(|(i, body)| object(&mut bytes, i + 1, body)).collect();
let start = bytes.len();
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", offsets.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{start}\n%%EOF\n", objects.len() + 1).as_bytes());
bytes
}
fn nested_object(depth: usize, kind: usize) -> Vec<u8> {
let mut value = "42".to_string();
for level in 0..depth {
value = if kind == 0 || kind == 2 && level % 2 == 0 { format!("[{value}]") } else { format!("<< /V {value} >>") };
}
value.into_bytes()
}
fn assert_nested_leaf(mut value: Object<'_>, depth: usize) {
for _ in 0..depth {
value = match value {
Object::Array(array) => array.iter::<Object<'_>>().next().unwrap(),
Object::Dict(dict) => dict.get::<Object<'_>>(b"V").unwrap(),
_ => panic!("expected another direct container"),
};
}
assert_eq!(value.into_i32(), Some(42));
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn direct_objects_accept_64_containers_and_reject_65() {
for kind in 0..3 {
let accepted = nested_object(64, kind);
assert_nested_leaf(Object::from_bytes(&accepted).unwrap(), 64);
assert!(Object::from_bytes(&nested_object(65, kind)).is_none(), "container kind {kind}");
}
}
// Object 6 exists only inside an ASCIIHex-decoded object stream. A real xref stream
// selects the compressed object, exercising its parser rather than a direct-byte helper.
fn compressed_object(value: &[u8]) -> Vec<u8> {
let mut payload = b"6 0 ".to_vec();
payload.extend_from_slice(value);
let encoded = payload.iter().map(|b| format!("{b:02X}")).collect::<String>() + ">";
let objects = [
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R >>".to_vec(),
stream("", b""),
stream("/Type /ObjStm /N 1 /First 4 /Filter /ASCIIHexDecode", encoded.as_bytes()),
];
let mut bytes = b"%PDF-1.7\n".to_vec();
let mut offsets = vec![0];
for (i, body) in objects.iter().enumerate() {
offsets.push(object(&mut bytes, i + 1, body));
}
let xref = bytes.len();
let mut rows = Vec::new();
for number in 0..8 {
let (kind, first, second): (u8, u32, u16) = match number {
0 => (0, 0, 65535),
6 => (2, 5, 0),
7 => (1, u32::try_from(xref).unwrap(), 0),
n => (1, u32::try_from(offsets[n]).unwrap(), 0),
};
rows.push(kind);
rows.extend_from_slice(&first.to_be_bytes());
rows.extend_from_slice(&second.to_be_bytes());
}
object(&mut bytes, 7, &stream("/Type /XRef /Size 8 /Root 1 0 R /W [1 4 2]", &rows));
bytes.extend_from_slice(format!("startxref\n{xref}\n%%EOF\n").as_bytes());
bytes
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn decoded_object_streams_keep_the_same_64_container_boundary() {
for kind in 0..3 {
let accepted = craft_pdf::open_slice(&compressed_object(&nested_object(64, kind)), None).unwrap();
assert_nested_leaf(accepted.xref().get::<Object<'_>>(ObjRef::new(6, 0).into()).unwrap(), 64);
let rejected = craft_pdf::open_slice(&compressed_object(&nested_object(65, kind)), None).unwrap();
assert!(rejected.xref().get::<Object<'_>>(ObjRef::new(6, 0).into()).is_none(), "container kind {kind}");
}
}
mod page_trees {
use super::assemble;
use craft_pdf::syntax::object::{Array, FromBytes, MaybeRef, Name, Number};
use craft_pdf::syntax::page::{Page, Rotation};
fn assert_page(page: &Page<'_>, id: i32, media: [f64; 4], crop: [f64; 4], rotation: i32, user_unit: f32) {
assert_eq!(page.raw().get::<i32>(b"ContractPage"), Some(id));
let actual = page.media_box();
assert_eq!([actual.x0, actual.y0, actual.x1, actual.y1], media);
let actual = page.crop_box();
assert_eq!([actual.x0, actual.y0, actual.x1, actual.y1], crop);
let actual = match page.rotation() {
Rotation::None => 0,
Rotation::Horizontal => 90,
Rotation::Flipped => 180,
Rotation::FlippedHorizontal => 270,
};
assert_eq!(actual, rotation);
assert_eq!(page.user_unit(), user_unit);
}
fn deep_page_tree_contract() {
const DEPTH: usize = 1024;
let mut objects = vec![b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()];
for level in 0..DEPTH {
let number = level + 2;
let parent = if level == 0 { String::new() } else { format!("/Parent {} 0 R", number - 1) };
let attributes = match level {
0 => "/MediaBox [0 0 200 300] /CropBox [1 2 190 290] /Rotate 90 /Resources << /Font << /Root << /BaseFont /Helvetica >> >> >>",
512 => "/MediaBox [0 0 300 400] /Rotate 180 /Resources << /Font << /Middle << /BaseFont /Courier >> >> >>",
_ => "",
};
objects.push(format!("<< /Type /Pages /Count 1 /Kids [{} 0 R] {parent} {attributes} >>", number + 1).into_bytes());
}
// An empty page is valid. Omitting Contents also prevents a brute-force scan
// from satisfying this contract after losing the actual page-tree ancestry.
objects.push(format!("<< /Type /Page /Parent {} 0 R /ContractPage 1024 /UserUnit 2 >>", DEPTH + 1).into_bytes());
let pdf = craft_pdf::open_slice(&assemble(&objects), None).unwrap();
assert_eq!(pdf.pages().len(), 1, "the valid leaf must survive all 1024 ancestors");
let page = &pdf.pages()[0];
assert_page(page, 1024, [0.0, 0.0, 300.0, 400.0], [1.0, 2.0, 190.0, 290.0], 180, 2.0);
let middle = Name::from_bytes(b"/Middle").unwrap();
let root = Name::from_bytes(b"/Root").unwrap();
let font = page.resources().get_font(&middle).unwrap();
assert_eq!(font.get::<Name<'_>>(b"BaseFont").unwrap().as_str(), "Courier");
assert!(page.resources().get_font(&root).is_none(), "the middle dictionary replaces the root scope");
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn deep_page_trees_preserve_leaf_attributes_and_resources() {
// Assert the observable contract before the small-stack check, so an old
// recursive backend fails on missing pages before it can overflow that stack.
deep_page_tree_contract();
#[cfg(not(target_arch = "wasm32"))]
std::thread::Builder::new().stack_size(128 * 1024).spawn(deep_page_tree_contract).unwrap().join().unwrap();
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn cyclic_page_trees_keep_valid_sibling_order_and_deduplicate_leaves() {
let pdf = craft_pdf::open_slice(
&assemble(&[
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Count 3 /Kids [3 0 R 6 0 R 5 0 R 4 0 R 6 0 R] /MediaBox [0 0 100 200] /CropBox [1 2 90 180] /Rotate 90 /Resources << /Font << /Root << /BaseFont /Helvetica >> >> >> >>".to_vec(),
b"<< /Type /Pages /Parent 2 0 R /Count 2 /Kids [2 0 R 5 0 R 4 0 R 5 0 R] /MediaBox [0 0 300 400] /CropBox [10 20 280 370] /Rotate 180 /Resources << /Font << /Local << /BaseFont /Courier >> >> >> >>".to_vec(),
b"<< /Type /Page /Parent 3 0 R /ContractPage 4 /Rotate 270 /UserUnit 2 /Resources << >> >>".to_vec(),
b"<< /Type /Page /Parent 3 0 R /ContractPage 5 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /ContractPage 6 >>".to_vec(),
]),
None,
)
.unwrap();
assert_eq!(pdf.pages().len(), 3, "cycles and repeated references must not hide or duplicate valid pages");
assert_page(&pdf.pages()[0], 5, [0.0, 0.0, 300.0, 400.0], [10.0, 20.0, 280.0, 370.0], 180, 1.0);
assert_page(&pdf.pages()[1], 4, [0.0, 0.0, 300.0, 400.0], [10.0, 20.0, 280.0, 370.0], 270, 2.0);
assert_page(&pdf.pages()[2], 6, [0.0, 0.0, 100.0, 200.0], [1.0, 2.0, 90.0, 180.0], 90, 1.0);
let root = Name::from_bytes(b"/Root").unwrap();
let local = Name::from_bytes(b"/Local").unwrap();
assert!(pdf.pages()[0].resources().get_font(&local).is_some());
assert!(pdf.pages()[0].resources().get_font(&root).is_none());
assert!(pdf.pages()[1].resources().get_font(&local).is_none());
assert!(pdf.pages()[1].resources().get_font(&root).is_none());
assert!(pdf.pages()[2].resources().get_font(&root).is_some());
assert!(pdf.pages()[2].resources().get_font(&local).is_none());
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn sign_only_numeric_children_preserve_later_valid_pages() {
// Exercise the parser invariant directly: the old raw iterator panicked
// after array validation accepted '+' but parsing could not read it.
let array = Array::from_bytes(b"[+ 3]").unwrap();
let values: Vec<_> = array
.raw_iter()
.map(|value| match value {
MaybeRef::NotRef(value) => value.into_i32(),
MaybeRef::Ref(_) => None,
})
.collect();
assert_eq!(values, [Some(0), Some(3)]);
for token in ["+", "+.", "-"] {
let pdf = craft_pdf::open_slice(
&assemble(&[
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
format!("<< /Type /Pages /Count 1 /Kids [{token} 3 0 R] /MediaBox [0 0 100 200] /CropBox [1 2 90 180] /Rotate 90 >>")
.into_bytes(),
b"<< /Type /Page /Parent 2 0 R /ContractPage 3 >>".to_vec(),
]),
None,
)
.unwrap();
assert_eq!(pdf.pages().len(), 1, "{token}: an ignored numeric child must not hide the valid sibling");
assert_page(&pdf.pages()[0], 3, [0.0, 0.0, 100.0, 200.0], [1.0, 2.0, 90.0, 180.0], 90, 1.0);
let bytes = format!("{token} ");
assert_eq!(Number::from_bytes(bytes.as_bytes()).unwrap().as_f64(), 0.0, "{token}: keep the reader's zero tolerance");
}
}
}
#[cfg(feature = "interpret")]
mod painting {
use super::{assemble, stream};
use craft_pdf::interpret::{
BlendMode, ClipPath, Context, Device, GlyphDrawMode, Image, InterpreterCache, InterpreterSettings, Paint, PathDrawMode, SoftMask,
TransformExt, font::Glyph,
};
use kurbo::{Affine, BezPath, Point, Rect};
#[cfg(craft_pdf_unicode_fixtures)]
const INTER: &[u8] = include_bytes!(concat!(env!("CRAFT_PDF_TEST_FONT_DIR"), "/Inter-Regular.ttf"));
#[derive(Debug)]
struct GlyphEvent {
text: String,
mode: char,
rgba: [f32; 4],
origin: Point,
}
#[derive(Default)]
struct Events {
paths: Vec<[f32; 4]>,
glyphs: Vec<GlyphEvent>,
clips: Vec<String>,
}
fn rgba(paint: &Paint<'_>) -> [f32; 4] {
let Paint::Color(color) = paint else { panic!("solid paint expected") };
color.to_rgba().components()
}
impl Device<'_> for Events {
fn set_soft_mask(&mut self, _: Option<SoftMask<'_>>) {}
fn set_blend_mode(&mut self, _: BlendMode) {}
fn draw_path(&mut self, _: &BezPath, _: Affine, paint: &Paint<'_>, _: &PathDrawMode) {
self.paths.push(rgba(paint));
}
fn push_clip_path(&mut self, clip: &ClipPath) {
self.clips.push(clip.path.to_svg());
}
fn push_transparency_group(&mut self, _: f32, _: Option<SoftMask<'_>>, _: BlendMode) {}
fn draw_glyph(&mut self, glyph: &Glyph<'_>, _: Affine, glyph_transform: Affine, paint: &Paint<'_>, mode: &GlyphDrawMode) {
let Glyph::Outline(outline) = glyph else { panic!("embedded outline glyph expected") };
assert!(outline.font_data().unwrap().data.as_ref().as_ref() == fixture_font(), "fallback font cannot satisfy the contract");
let text = match glyph.as_unicode().unwrap() {
hayro_cmap::BfString::Char(c) => c.to_string(),
hayro_cmap::BfString::String(s) => s,
};
let mode = match mode {
GlyphDrawMode::Fill => 'F',
GlyphDrawMode::Stroke(_) => 'S',
GlyphDrawMode::Invisible => 'I',
};
self.glyphs.push(GlyphEvent { text, mode, rgba: rgba(paint), origin: glyph_transform * Point::ORIGIN });
}
fn draw_image(&mut self, _: Image<'_, '_>, _: Affine) {}
fn pop_clip_path(&mut self) {}
fn pop_transparency_group(&mut self) {}
}
fn events(bytes: &[u8]) -> Events {
let pdf = craft_pdf::open_slice(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, 100.0, 60.0),
&cache,
page.xref(),
InterpreterSettings::default(),
);
let mut device = Events::default();
craft_pdf::interpret_page(page, &mut context, &mut device).unwrap();
device
}
fn page(resources: &str, content: &[u8], extra: &[Vec<u8>]) -> Vec<u8> {
let mut objects = vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>".to_vec(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 40 40] /Contents 4 0 R /Resources {resources} >>").into_bytes(),
stream("", content),
];
objects.extend_from_slice(extra);
assemble(&objects)
}
fn painted_form(color: &str, side: u32) -> Vec<u8> {
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 {side} {side}]"), format!("{color} rg 0 0 {side} {side} re f").as_bytes())
}
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
const GREEN: [f32; 4] = [0.0, 1.0, 0.0, 1.0];
const GREEN_SCOPE: &str = "/Resources << /XObject << /Target 7 0 R >> >>";
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn nearest_page_resource_dictionary_replaces_the_whole_ancestor_dictionary() {
for (middle, leaf, expected) in [
("", "", vec![RED]),
("/Resources << >>", "", vec![]),
("/Resources << /ProcSet [/PDF] >>", "", vec![]),
(GREEN_SCOPE, "", vec![GREEN]),
(GREEN_SCOPE, "/Resources << >>", vec![]),
("", "/Resources << /XObject << /Unused 7 0 R >> >>", vec![]),
] {
let bytes = assemble(&[
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Count 1 /Kids [3 0 R] /Resources << /XObject << /Target 6 0 R >> >> >>".to_vec(),
format!("<< /Type /Pages /Parent 2 0 R /Count 1 /Kids [4 0 R] {middle} >>").into_bytes(),
format!("<< /Type /Page /Parent 3 0 R /MediaBox [0 0 40 40] /Contents 5 0 R {leaf} >>").into_bytes(),
stream("", b"/Target Do"),
painted_form("1 0 0", 40),
painted_form("0 1 0", 40),
]);
assert_eq!(events(&bytes).paths, expected, "middle={middle}, page={leaf}");
}
}
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn forms_inherit_only_when_resources_are_absent_and_restore_the_caller_scope() {
for (scope, first) in [("", Some(RED)), ("/Resources << >>", None), ("/Resources << /ProcSet [/PDF] >>", None), (GREEN_SCOPE, Some(GREEN))] {
let bytes = page(
"<< /XObject << /Outer 5 0 R /Target 6 0 R >> >>",
b"/Outer Do /Target Do",
&[
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 40 40] {scope}"), b"/Target Do"),
painted_form("1 0 0", 40),
painted_form("0 1 0", 40),
],
);
let expected: Vec<_> = first.into_iter().chain([RED]).collect();
assert_eq!(events(&bytes).paths, expected, "{scope}");
}
}
#[cfg(feature = "raster")]
fn pixels(bytes: &[u8]) -> Vec<u8> {
use craft_pdf::raster::{self, Options, RenderCache};
let pdf = craft_pdf::open_slice(bytes, None).unwrap();
let image = raster::render(&pdf.pages()[0], &RenderCache::new(), &InterpreterSettings::default(), &Options::default()).unwrap();
assert!(image.diagnostics.is_empty(), "{:?}", image.diagnostics);
image.bytes().to_vec()
}
#[cfg(feature = "raster")]
fn center(bytes: &[u8]) -> [u8; 4] {
pixels(bytes)[(20 * 40 + 20) * 4..(20 * 40 + 20) * 4 + 4].try_into().unwrap()
}
#[cfg(feature = "raster")]
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn type3_procedure_and_font_resources_replace_the_enclosing_scope() {
for (font_scope, procedure_scope, expected) in [
("", "", [255, 0, 0, 255]),
("/Resources << >>", "", [0; 4]),
(GREEN_SCOPE, "", [0, 255, 0, 255]),
(GREEN_SCOPE, "/Resources << >>", [0; 4]),
(GREEN_SCOPE, "/Resources << /ProcSet [/PDF] >>", [0; 4]),
("/Resources << >>", GREEN_SCOPE, [0, 255, 0, 255]),
] {
let bytes = page(
"<< /Font << /F1 5 0 R >> /XObject << /Target 6 0 R >> >>",
b"BT /F1 20 Tf 10 10 Td (A) Tj ET",
&[
format!("<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1 1] /FontMatrix [1 0 0 1 0 0] /FirstChar 65 /LastChar 65 /Widths [1] /Encoding << /Differences [65 /a] >> /CharProcs << /a 8 0 R >> {font_scope} >>").into_bytes(),
painted_form("1 0 0", 1),
painted_form("0 1 0", 1),
stream(procedure_scope, b"1 0 d0 /Target Do"),
],
);
assert_eq!(center(&bytes), expected, "font={font_scope}, procedure={procedure_scope}");
}
}
#[cfg(feature = "raster")]
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn tiling_patterns_do_not_import_missing_resources_from_the_page() {
for (scope, expected) in
[("", [0; 4]), ("/Resources << >>", [0; 4]), ("/Resources << /ProcSet [/PDF] >>", [0; 4]), (GREEN_SCOPE, [0, 255, 0, 255])]
{
let bytes = page(
"<< /Pattern << /P 5 0 R >> /XObject << /Target 6 0 R >> >>",
b"/Pattern cs /P scn 0 0 40 40 re f",
&[
stream(
&format!("/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 40 40] /XStep 40 /YStep 40 {scope}"),
b"/Target Do",
),
painted_form("1 0 0", 40),
painted_form("0 1 0", 40),
],
);
assert_eq!(center(&bytes), expected, "{scope}");
}
}
fn fixture_font() -> &'static [u8] {
#[cfg(craft_pdf_unicode_fixtures)]
{
INTER
}
#[cfg(not(craft_pdf_unicode_fixtures))]
{
panic!("enable the hash-verified fonts.json fixtures")
}
}
fn text_pdf(content: &str) -> Vec<u8> {
assemble(&[
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 60] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> /ExtGState << /G << /ca 0.25 /CA 0.75 >> >> >> >>".to_vec(),
stream("", content.as_bytes()),
b"<< /Type /Font /Subtype /TrueType /BaseFont /Inter-Regular /Encoding /WinAnsiEncoding /FirstChar 65 /LastChar 67 /Widths [600 600 600] /FontDescriptor 6 0 R >>".to_vec(),
b"<< /Type /FontDescriptor /FontName /Inter-Regular /Flags 32 /FontFile2 7 0 R >>".to_vec(),
stream("", fixture_font()),
])
}
#[cfg_attr(not(craft_pdf_unicode_fixtures), ignore = "requires hash-verified external fonts and craft_pdf_unicode_fixtures cfg")]
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn filled_stroked_tj_runs_preserve_paint_order_alpha_and_text_advances() {
let content = |mode| format!("/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf {mode} Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ (C) Tj ET");
for mode in [2, 6] {
let result = events(&text_pdf(&content(mode)));
let expected = [('F', "A", 10.0), ('F', "B", 14.0), ('S', "A", 10.0), ('S', "B", 14.0), ('F', "C", 26.0), ('S', "C", 26.0)];
assert_eq!(result.glyphs.len(), expected.len());
for (glyph, (paint, text, x)) in result.glyphs.iter().zip(expected) {
assert_eq!((glyph.mode, glyph.text.as_str()), (paint, text), "{mode} Tr: {:?}", result.glyphs);
assert!((glyph.origin.x - x).abs() < 1e-6 && (glyph.origin.y - 20.0).abs() < 1e-6, "{glyph:?}");
assert_eq!(glyph.rgba, if paint == 'F' { [1.0, 0.0, 0.0, 64.0 / 255.0] } else { [0.0, 0.0, 1.0, 191.0 / 255.0] });
}
if mode == 6 {
assert_eq!(result.clips, events(&text_pdf(&content(4))).clips, "stroking must not duplicate text clipping outlines");
}
}
}
#[cfg(feature = "raster")]
#[cfg_attr(not(craft_pdf_unicode_fixtures), ignore = "requires hash-verified external fonts and craft_pdf_unicode_fixtures cfg")]
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn overlapping_translucent_glyphs_fill_the_whole_run_before_stroking() {
let reference = pixels(&text_pdf(
"/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf 0 Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ 1 Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ ET",
));
assert!(reference.as_chunks::<4>().0.iter().any(|p| p[0] > 0 && p[2] > 0 && p[3] < 255));
for mode in [2, 6] {
let actual = pixels(&text_pdf(&format!("/G gs 1 0 0 rg 0 0 1 RG 2 w BT /F1 20 Tf {mode} Tr 1 0 0 1 10 20 Tm [(A) 400 (B)] TJ ET")));
assert!(actual == reference, "{mode} Tr must match explicit whole-run fill followed by whole-run stroke");
}
}
#[cfg(feature = "raster")]
#[cfg_attr(not(target_arch = "wasm32"), test)]
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
fn soft_mask_transfer_endpoints_use_rounded_output_bytes() {
for (low, high, left, right) in [("0", "0", 0, 0), ("0.5", "0.5", 128, 128), ("1", "1", 255, 255), ("0.5001", "0.5", 128, 128)] {
let bytes = page(
"<< /ExtGState << /G 5 0 R >> >>",
b"/G gs 0 0 1 rg 0 0 40 40 re f",
&[
b"<< /SMask << /S /Luminosity /G 6 0 R /TR 7 0 R >> >>".to_vec(),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 40 40] /Group << /S /Transparency /CS /DeviceGray >>",
b"0 g 0 0 20 40 re f 1 g 20 0 20 40 re f",
),
format!("<< /FunctionType 2 /Domain [0 1] /C0 [{low}] /C1 [{high}] /N 1 >>").into_bytes(),
],
);
let rendered = pixels(&bytes);
for (x, alpha) in [(10, left), (30, right)] {
assert_eq!(&rendered[(20 * 40 + x) * 4..(20 * 40 + x) * 4 + 4], &[0, 0, alpha, alpha], "C0={low}, C1={high}, x={x}");
}
}
}
}